mirror of
https://github.com/2234839/web-font.git
synced 2026-09-04 14:53:32 +08:00
feat: 字体元数据三层架构 + 临时字体保留机制 + 列表页覆盖率排序
- font_meta.ts: cmap 解析覆盖率 + name 表提取字体信息(版权/作者/许可) + 人工配置(font-config.json) - 三层缓存: 进程内存 → .meta.json 磁盘 → font-config.json stat mtime 热更新 - 临时字体保留机制: TEMP_RETENTION_HOURS(默认3h), 超时未使用自动删除 - 列表页: 覆盖率标签 + 按字符集覆盖率排序 - 详情页: 标签/开源链接/简介 + 字体信息面板(设计师/版权/许可) - 首页: 红色警告提示切勿上传非商用字体 + 显示保留时限 - SSG noindex bug 修复
This commit is contained in:
parent
5b82dec19a
commit
19b14f58ea
@ -11,6 +11,8 @@ import { handleStats } from "./routes/stats";
|
||||
import { handleUpload } from "./routes/upload";
|
||||
import { handleFontSubset } from "./routes/subset";
|
||||
import { handleFontDetail } from "./routes/font_detail";
|
||||
import { handleFontMeta } from "./routes/font_meta";
|
||||
import { startTempCleaner } from "./temp_cleaner";
|
||||
import "./server/node";
|
||||
import "./server/llrt";
|
||||
|
||||
@ -156,6 +158,9 @@ const fontApiMiddleware: cMiddleware = async (req, res, next) => {
|
||||
if (url.pathname === "/api/upload" && req.method === "POST") {
|
||||
return handleUpload(req, res);
|
||||
}
|
||||
if (url.pathname === "/api/font-meta" && req.method === "GET") {
|
||||
return handleFontMeta(req, res);
|
||||
}
|
||||
if (url.pathname === "/api" && req.method === "GET") {
|
||||
return handleFontSubset(req, res);
|
||||
}
|
||||
@ -201,6 +206,9 @@ async function main() {
|
||||
);
|
||||
console.log("[config] temp upload:", enableTempUpload);
|
||||
console.log("[config] admin upload:", !!adminApiKey);
|
||||
|
||||
/** 启动临时字体定时清理器 */
|
||||
startTempCleaner();
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@ -15,6 +15,9 @@ export const tempMaxFiles = parseInt(env.TEMP_MAX_FILES ?? "10", 10) || 10;
|
||||
/** 临时上传目录总体积上限(字节),默认 200MB */
|
||||
export const tempMaxTotalSize = parseInt(env.TEMP_MAX_TOTAL_SIZE ?? `${200 * 1024 * 1024}`, 10) || 200 * 1024 * 1024;
|
||||
|
||||
/** 临时字体保留时限(小时),超过后若无人使用则自动删除 */
|
||||
export const tempRetentionHours = parseFloat(env.TEMP_RETENTION_HOURS ?? "3") || 3;
|
||||
|
||||
/** 字体裁剪结果内存缓存容量上限(字节),默认 10MB */
|
||||
export const subsetCacheMaxSize = parseInt(env.SUBSET_CACHE_MAX_SIZE ?? `${10 * 1024 * 1024}`, 10) || 10 * 1024 * 1024;
|
||||
|
||||
|
||||
431
backend/font_util/font_meta.ts
Normal file
431
backend/font_util/font_meta.ts
Normal file
@ -0,0 +1,431 @@
|
||||
/**
|
||||
* 字体元数据提取 —— 从字体字节解析 cmap,提取完整 codepoint 集合,
|
||||
* 并对照标准字符集计算覆盖率。
|
||||
*
|
||||
* 直接解析 cmap 表的 format4(BMP)和 format12(含补充平面)的 segment/group,
|
||||
* 遍历所有区间收集完整 codepoint 集合。不依赖 fonteditor-core 的 Font.create,
|
||||
* 避免解析 glyf 轮廓(对大字体可省数十毫秒)。
|
||||
*/
|
||||
|
||||
/** ttf/otf 表目录条目 */
|
||||
interface TableEntry {
|
||||
offset: number;
|
||||
length: number;
|
||||
}
|
||||
|
||||
/** 解析表目录,返回指定 tag 的 (offset, length) */
|
||||
function readTableEntry(dv: DataView, tag: string): TableEntry | null {
|
||||
if (dv.byteLength < 12) return null;
|
||||
const numTables = dv.getUint16(4, false);
|
||||
if (numTables <= 0 || numTables > 100) return null;
|
||||
let off = 12;
|
||||
for (let i = 0; i < numTables; i++) {
|
||||
const recOff = off + i * 16;
|
||||
if (recOff + 16 > dv.byteLength) return null;
|
||||
const t0 = dv.getUint8(recOff);
|
||||
const t1 = dv.getUint8(recOff + 1);
|
||||
const t2 = dv.getUint8(recOff + 2);
|
||||
const t3 = dv.getUint8(recOff + 3);
|
||||
if (t0 === tag.charCodeAt(0) && t1 === tag.charCodeAt(1) && t2 === tag.charCodeAt(2) && t3 === tag.charCodeAt(3)) {
|
||||
return { offset: dv.getUint32(recOff + 8, false), length: dv.getUint32(recOff + 12, false) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 cmap 的 format4 subtable 遍历所有 segment,收集每个 segment 中 gid≠0 的 codepoint。
|
||||
* format4 表布局见 gsub-probe.ts 的 lookupFormat4 注释。
|
||||
*/
|
||||
function collectFormat4(dv: DataView, subOff: number, cps: Set<number>): void {
|
||||
if (subOff + 14 > dv.byteLength) return;
|
||||
const segCountX2 = dv.getUint16(subOff + 6, false);
|
||||
const segCount = segCountX2 / 2;
|
||||
const endCodeBase = subOff + 14;
|
||||
const startCodeBase = endCodeBase + segCount * 2 + 2;
|
||||
const idDeltaBase = startCodeBase + segCount * 2;
|
||||
const idRangeOffsetBase = idDeltaBase + segCount * 2;
|
||||
|
||||
for (let i = 0; i < segCount; i++) {
|
||||
const start = dv.getUint16(startCodeBase + i * 2, false);
|
||||
const end = dv.getUint16(endCodeBase + i * 2, false);
|
||||
/** 0xFFFF 的 segment 是 format4 必有的哨兵,跳过 */
|
||||
if (start === 0xFFFF) continue;
|
||||
const idDelta = dv.getInt16(idDeltaBase + i * 2, false);
|
||||
const idRangeOffset = dv.getUint16(idRangeOffsetBase + i * 2, false);
|
||||
|
||||
if (idRangeOffset === 0) {
|
||||
/** 线性映射:gid = (cp + idDelta) & 0xFFFF,gid=0 表示缺失 */
|
||||
for (let cp = start; cp <= end; cp++) {
|
||||
if (((cp + idDelta) & 0xFFFF) !== 0) cps.add(cp);
|
||||
}
|
||||
} else {
|
||||
/** 逐个查 glyphIdArray */
|
||||
for (let cp = start; cp <= end; cp++) {
|
||||
const glyphOff = idRangeOffsetBase + i * 2 + idRangeOffset + (cp - start) * 2;
|
||||
if (glyphOff + 2 > dv.byteLength) break;
|
||||
const glyphId = dv.getUint16(glyphOff, false);
|
||||
if (glyphId !== 0 && ((glyphId + idDelta) & 0xFFFF) !== 0) cps.add(cp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 cmap 的 format12 subtable 遍历所有 group,收集 gid 在范围内的 codepoint。
|
||||
* format12 表布局见 gsub-probe.ts 的 lookupFormat12 注释。
|
||||
*/
|
||||
function collectFormat12(dv: DataView, subOff: number, cps: Set<number>): void {
|
||||
if (subOff + 16 > dv.byteLength) return;
|
||||
const nGroups = dv.getUint32(subOff + 12, false);
|
||||
const groupsBase = subOff + 16;
|
||||
for (let i = 0; i < nGroups; i++) {
|
||||
const gOff = groupsBase + i * 12;
|
||||
if (gOff + 12 > dv.byteLength) break;
|
||||
const gStart = dv.getUint32(gOff, false);
|
||||
const gEnd = dv.getUint32(gOff + 4, false);
|
||||
/** startGlyphID 无需检查——gid 从 startGlyphID 连续递增,0 才表示缺失,
|
||||
* 但 format12 的 group 本身就是连续有效区间,整个 [gStart, gEnd] 都有字形 */
|
||||
for (let cp = gStart; cp <= gEnd; cp++) {
|
||||
cps.add(cp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从字体字节提取所有支持的 Unicode codepoint 集合。
|
||||
* 优先解析 format12(覆盖更广),再补 format4(BMP 兜底)。
|
||||
*/
|
||||
export function extractCodePoints(fontBuffer: ArrayBuffer | Uint8Array): Set<number> {
|
||||
const buf = fontBuffer instanceof Uint8Array ? fontBuffer.buffer : fontBuffer;
|
||||
const dv = new DataView(buf);
|
||||
const cmapEntry = readTableEntry(dv, "cmap");
|
||||
const cps = new Set<number>();
|
||||
if (cmapEntry === null) return cps;
|
||||
|
||||
/** 选择 format4 和 format12 的 subtable 偏移 */
|
||||
const numberSubtables = dv.getUint16(cmapEntry.offset + 2, false);
|
||||
let fmt4Off = -1;
|
||||
let fmt12Off = -1;
|
||||
let dirOff = cmapEntry.offset + 4;
|
||||
for (let i = 0; i < numberSubtables; i++) {
|
||||
if (dirOff + 8 > dv.byteLength) break;
|
||||
const platformID = dv.getUint16(dirOff, false);
|
||||
const encodingID = dv.getUint16(dirOff + 2, false);
|
||||
const subRelOff = dv.getUint32(dirOff + 4, false);
|
||||
const subOff = cmapEntry.offset + subRelOff;
|
||||
if (subOff + 2 <= dv.byteLength) {
|
||||
const format = dv.getUint16(subOff, false);
|
||||
if (format === 12 && platformID === 3 && encodingID === 10 && fmt12Off < 0) {
|
||||
fmt12Off = subOff;
|
||||
} else if (format === 4 && platformID === 3 && encodingID === 1 && fmt4Off < 0) {
|
||||
fmt4Off = subOff;
|
||||
}
|
||||
}
|
||||
dirOff += 8;
|
||||
}
|
||||
|
||||
/** format12 优先(含补充平面),format4 补充 BMP */
|
||||
if (fmt12Off >= 0) collectFormat12(dv, fmt12Off, cps);
|
||||
if (fmt4Off >= 0) collectFormat4(dv, fmt4Off, cps);
|
||||
|
||||
return cps;
|
||||
}
|
||||
|
||||
// ────────────────────── 标准字符集定义 ─────────────────────-
|
||||
|
||||
/** 字符集覆盖率结果 */
|
||||
export interface CharsetCoverage {
|
||||
/** 字符集标识 */
|
||||
key: string;
|
||||
/** 字符集名称(中文) */
|
||||
name: string;
|
||||
/** 该字符集的总字符数 */
|
||||
total: number;
|
||||
/** 字体支持的字符数 */
|
||||
covered: number;
|
||||
/** 覆盖率百分比 (0~100,保留一位小数) */
|
||||
percent: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算给定 Unicode 区间数组中,字体覆盖了多少 codepoint。
|
||||
* 区间数组格式:[[start, end], ...],每个区间连续。
|
||||
*/
|
||||
function countCoverage(cps: Set<number>, ranges: ReadonlyArray<readonly [number, number]>): { total: number; covered: number } {
|
||||
let total = 0;
|
||||
let covered = 0;
|
||||
for (const [start, end] of ranges) {
|
||||
for (let cp = start; cp <= end; cp++) {
|
||||
total++;
|
||||
if (cps.has(cp)) covered++;
|
||||
}
|
||||
}
|
||||
return { total, covered };
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准字符集列表 —— 全部用 Unicode 区间表示,基于 Unicode 官方区块定义。
|
||||
* 覆盖率是近似值(区间内含少量非目标字符),但对字体选型参考足够精确。
|
||||
*
|
||||
* - ascii: ASCII 可打印字符,含字母、数字、常见标点(U+0020~U+007E)
|
||||
* - commonHanzi: 常用汉字(U+4E00~U+5535,约 3500 字),覆盖日常中文 99.8%
|
||||
* - cjkBasic: CJK 统一汉字基本区(U+4E00~U+9FFF,20992 码位),含简繁体
|
||||
* - cjkExtA: CJK 扩展A 区(U+3400~U+4DBF),罕见字/古字
|
||||
* - punctuation: CJK 标点符号(U+3000~U+303F)
|
||||
* - fullwidth: 全角字符(U+FF00~U+FFEF)
|
||||
* - cyrillic: 西里尔字母/俄文(U+0400~U+04FF)
|
||||
*/
|
||||
const CHARSETS: Array<{ key: string; name: string; ranges: ReadonlyArray<readonly [number, number]> }> = [
|
||||
{ key: "ascii", name: "英文字母数字", ranges: [[0x20, 0x7e]] },
|
||||
{ key: "commonHanzi", name: "常用汉字(约3500)", ranges: [[0x4e00, 0x5bad]] },
|
||||
{ key: "cjkBasic", name: "CJK 基本汉字(20992)", ranges: [[0x4e00, 0x9fff]] },
|
||||
{ key: "cjkExtA", name: "CJK 扩展A(罕用字)", ranges: [[0x3400, 0x4dbf]] },
|
||||
{ key: "punctuation", name: "中文标点", ranges: [[0x3000, 0x303f]] },
|
||||
{ key: "fullwidth", name: "全角字符", ranges: [[0xff00, 0xffef]] },
|
||||
{ key: "cyrillic", name: "西里尔字母/俄文", ranges: [[0x400, 0x4ff]] },
|
||||
];
|
||||
|
||||
/**
|
||||
* 计算字体对各类标准字符集的覆盖率。
|
||||
*/
|
||||
export function calcCoverage(cps: Set<number>): CharsetCoverage[] {
|
||||
return CHARSETS.map(({ key, name, ranges }) => {
|
||||
const { total, covered } = countCoverage(cps, ranges);
|
||||
return {
|
||||
key,
|
||||
name,
|
||||
total,
|
||||
covered,
|
||||
percent: total > 0 ? Math.round((covered / total) * 1000) / 10 : 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 codepoint 集合编码为紧凑的区间数组(用于传输/存储)。
|
||||
* 每个区间 [start, end] 表示连续的 codepoint 范围。
|
||||
*/
|
||||
export function codePointsToRanges(cps: Set<number>): Array<[number, number]> {
|
||||
const sorted = [...cps].sort((a, b) => a - b);
|
||||
const ranges: Array<[number, number]> = [];
|
||||
let i = 0;
|
||||
while (i < sorted.length) {
|
||||
const start = sorted[i];
|
||||
let end = start;
|
||||
while (i + 1 < sorted.length && sorted[i + 1] === end + 1) {
|
||||
end = sorted[++i];
|
||||
}
|
||||
ranges.push([start, end]);
|
||||
i++;
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/** OpenType name 表中的标准名称 ID */
|
||||
const NAME_ID = {
|
||||
COPYRIGHT: 0,
|
||||
FAMILY: 1,
|
||||
SUBFAMILY: 2,
|
||||
UNIQUE_ID: 3,
|
||||
FULL_NAME: 4,
|
||||
VERSION: 5,
|
||||
POSTSCRIPT: 6,
|
||||
TRADEMARK: 7,
|
||||
MANUFACTURER: 8,
|
||||
DESIGNER: 9,
|
||||
DESCRIPTION: 10,
|
||||
VENDOR_URL: 11,
|
||||
DESIGNER_URL: 12,
|
||||
LICENSE: 13,
|
||||
LICENSE_URL: 14,
|
||||
} as const;
|
||||
|
||||
/** 需要提取的 nameID 列表 */
|
||||
const EXTRACT_NAME_IDS = [
|
||||
NAME_ID.COPYRIGHT,
|
||||
NAME_ID.FAMILY,
|
||||
NAME_ID.SUBFAMILY,
|
||||
NAME_ID.FULL_NAME,
|
||||
NAME_ID.VERSION,
|
||||
NAME_ID.POSTSCRIPT,
|
||||
NAME_ID.TRADEMARK,
|
||||
NAME_ID.MANUFACTURER,
|
||||
NAME_ID.DESIGNER,
|
||||
NAME_ID.DESCRIPTION,
|
||||
NAME_ID.VENDOR_URL,
|
||||
NAME_ID.DESIGNER_URL,
|
||||
NAME_ID.LICENSE,
|
||||
NAME_ID.LICENSE_URL,
|
||||
] as const;
|
||||
|
||||
/** 字体基本信息(从 name 表提取) */
|
||||
export interface FontInfo {
|
||||
/** 版权声明 */
|
||||
copyright?: string;
|
||||
/** 字体族名 */
|
||||
family?: string;
|
||||
/** 字体子族名(如 Regular、Bold) */
|
||||
subfamily?: string;
|
||||
/** 唯一标识 */
|
||||
uniqueId?: string;
|
||||
/** 完整名称 */
|
||||
fullName?: string;
|
||||
/** 版本号 */
|
||||
version?: string;
|
||||
/** PostScript 名称 */
|
||||
postScript?: string;
|
||||
/** 商标声明 */
|
||||
trademark?: string;
|
||||
/** 制造商/出版商 */
|
||||
manufacturer?: string;
|
||||
/** 设计师 */
|
||||
designer?: string;
|
||||
/** 描述 */
|
||||
description?: string;
|
||||
/** 厂商 URL */
|
||||
vendorUrl?: string;
|
||||
/** 设计师 URL */
|
||||
designerUrl?: string;
|
||||
/** 许可声明 */
|
||||
license?: string;
|
||||
/** 许可 URL */
|
||||
licenseUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码 name record 的字符串。
|
||||
* platformID=3 (Windows) 使用 UTF-16BE;platformID=1 (Mac) 使用 Latin-1/ASCII。
|
||||
*/
|
||||
function decodeNameString(dv: DataView, offset: number, length: number, platformID: number): string {
|
||||
if (offset + length > dv.byteLength) return "";
|
||||
if (platformID === 3) {
|
||||
/** UTF-16BE */
|
||||
const codes: number[] = [];
|
||||
for (let i = 0; i < length; i += 2) {
|
||||
codes.push(dv.getUint16(offset + i, false));
|
||||
}
|
||||
return String.fromCodePoint(...codes);
|
||||
}
|
||||
/** Mac Roman / ASCII 近似 */
|
||||
const bytes = new Uint8Array(dv.buffer, offset, length);
|
||||
return new TextDecoder("utf-8").decode(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 name 表提取字体基本信息。
|
||||
* 优先取 platformID=3 (Windows) 的英文记录,其次 platformID=0/1。
|
||||
*/
|
||||
export function extractFontInfo(fontBuffer: ArrayBuffer | Uint8Array): FontInfo {
|
||||
const buf = fontBuffer instanceof Uint8Array ? fontBuffer.buffer : fontBuffer;
|
||||
const dv = new DataView(buf);
|
||||
const nameEntry = readTableEntry(dv, "name");
|
||||
if (nameEntry === null) return {};
|
||||
|
||||
const base = nameEntry.offset;
|
||||
if (base + 6 > dv.byteLength) return {};
|
||||
|
||||
const format = dv.getUint16(base, false);
|
||||
const count = dv.getUint16(base + 2, false);
|
||||
/** stringOffset:相对 name 表起始的偏移,指向字符串存储区 */
|
||||
const stringOffset = dv.getUint16(base + 4, false);
|
||||
const storageBase = base + stringOffset;
|
||||
|
||||
/** 收集每个 nameID 的最佳字符串,优先级:Windows(3) > Mac(1) */
|
||||
const collected = new Map<number, { text: string; priority: number }>();
|
||||
|
||||
const recordBase = base + 6;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const recOff = recordBase + i * 12;
|
||||
if (recOff + 12 > dv.byteLength) break;
|
||||
const platformID = dv.getUint16(recOff, false);
|
||||
const encodingID = dv.getUint16(recOff + 2, false);
|
||||
const languageID = dv.getUint16(recOff + 4, false);
|
||||
const nameID = dv.getUint16(recOff + 6, false);
|
||||
const length = dv.getUint16(recOff + 8, false);
|
||||
const offset = dv.getUint16(recOff + 10, false);
|
||||
|
||||
if (!EXTRACT_NAME_IDS.includes(nameID as (typeof EXTRACT_NAME_IDS)[number])) continue;
|
||||
|
||||
/** 优先级:Windows 英文(3,1,0x409) > Windows 中文(3,1,*) > Mac(1,*) > 其他 */
|
||||
let priority = 0;
|
||||
if (platformID === 3 && languageID === 0x0409) priority = 4;
|
||||
else if (platformID === 3 && encodingID === 1) priority = 3;
|
||||
else if (platformID === 3) priority = 2;
|
||||
else if (platformID === 1) priority = 1;
|
||||
|
||||
const existing = collected.get(nameID);
|
||||
if (existing && existing.priority >= priority) continue;
|
||||
|
||||
const text = decodeNameString(dv, storageBase + offset, length, platformID);
|
||||
if (text) collected.set(nameID, { text, priority });
|
||||
}
|
||||
|
||||
/** format 1 有 lang-tag record,跳过 */
|
||||
void format;
|
||||
|
||||
const info: FontInfo = {};
|
||||
const get = (id: number): string | undefined => collected.get(id)?.text;
|
||||
info.copyright = get(NAME_ID.COPYRIGHT);
|
||||
info.family = get(NAME_ID.FAMILY);
|
||||
info.subfamily = get(NAME_ID.SUBFAMILY);
|
||||
info.uniqueId = get(NAME_ID.UNIQUE_ID);
|
||||
info.fullName = get(NAME_ID.FULL_NAME);
|
||||
info.version = get(NAME_ID.VERSION);
|
||||
info.postScript = get(NAME_ID.POSTSCRIPT);
|
||||
info.trademark = get(NAME_ID.TRADEMARK);
|
||||
info.manufacturer = get(NAME_ID.MANUFACTURER);
|
||||
info.designer = get(NAME_ID.DESIGNER);
|
||||
info.description = get(NAME_ID.DESCRIPTION);
|
||||
info.vendorUrl = get(NAME_ID.VENDOR_URL);
|
||||
info.designerUrl = get(NAME_ID.DESIGNER_URL);
|
||||
info.license = get(NAME_ID.LICENSE);
|
||||
info.licenseUrl = get(NAME_ID.LICENSE_URL);
|
||||
return info;
|
||||
}
|
||||
|
||||
/** 人工配置项(来自 font/font-config.json,由用户维护) */
|
||||
export interface FontUserConfig {
|
||||
/** 显示名称(优先于文件名) */
|
||||
displayName?: string;
|
||||
/** 描述/简介 */
|
||||
description?: string;
|
||||
/** 标签列表 */
|
||||
tags?: string[];
|
||||
/** 开源仓库地址(如 GitHub URL) */
|
||||
homepage?: string;
|
||||
/** 默认预览文字 */
|
||||
previewText?: string;
|
||||
}
|
||||
|
||||
/** 字体元数据结果 */
|
||||
export interface FontMeta {
|
||||
/** 字体支持的 codepoint 总数 */
|
||||
totalCodePoints: number;
|
||||
/** 各字符集覆盖率 */
|
||||
coverage: CharsetCoverage[];
|
||||
/**
|
||||
* 字体支持的所有 codepoint 区间(紧凑表示)。
|
||||
* 前端可据此判断任意字符是否被支持,无需请求完整字符列表。
|
||||
* 例如 [[0x20, 0x7e], [0x4e00, 0x9fff]] 表示支持 ASCII 和全部基本汉字。
|
||||
*/
|
||||
ranges: Array<[number, number]>;
|
||||
/** 字体基本信息(版权、作者等,来自 name 表) */
|
||||
info: FontInfo;
|
||||
/** 人工配置(来自 font-config.json,由路由层合并) */
|
||||
config?: FontUserConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取字体元数据:codepoint 总数、覆盖率、支持的区间、字体基本信息。
|
||||
* 注意:config 字段由路由层从 font-config.json 填充,此处不包含。
|
||||
*/
|
||||
export function extractFontMeta(fontBuffer: ArrayBuffer | Uint8Array): FontMeta {
|
||||
const cps = extractCodePoints(fontBuffer);
|
||||
return {
|
||||
totalCodePoints: cps.size,
|
||||
coverage: calcCoverage(cps),
|
||||
ranges: codePointsToRanges(cps),
|
||||
info: extractFontInfo(fontBuffer),
|
||||
};
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
export let stat: (path: string) => Promise<{
|
||||
isFile: () => boolean;
|
||||
size: number;
|
||||
/** 最后修改时间戳(毫秒),用于文件变更检测 */
|
||||
mtimeMs: number;
|
||||
}>;
|
||||
|
||||
export let readFile: (path: string) => Promise<Uint8Array>;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { jsonResponse } from "../shared";
|
||||
import { enableTempUpload, adminApiKey } from "../config";
|
||||
import { enableTempUpload, adminApiKey, tempRetentionHours } from "../config";
|
||||
|
||||
/** GET /api/config — 返回公开配置 */
|
||||
export async function handleGetConfig(req: Request, _res: Response) {
|
||||
@ -9,6 +9,8 @@ export async function handleGetConfig(req: Request, _res: Response) {
|
||||
enableTempUpload,
|
||||
adminUploadEnabled: !!adminApiKey,
|
||||
supportedOutTypes: ["woff2", "ttf"],
|
||||
/** 临时字体保留时限(小时) */
|
||||
tempRetentionHours,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -79,9 +79,9 @@ export async function handleFontDetail(pathname: string): Promise<Response | nul
|
||||
html = html.replaceAll(placeholder, value);
|
||||
}
|
||||
|
||||
/** 字体不存在时,在 body 开头注入 noindex 标签防止搜索引擎收录 */
|
||||
if (meta && !meta.exists) {
|
||||
html = html.replace("</head>", '<meta name="robots" content="noindex"></head>');
|
||||
/** 字体不存在时,在 head 注入 noindex 标签防止搜索引擎收录 */
|
||||
if (!meta?.exists) {
|
||||
html = html.replace("</head>", '<meta name="robots" content="noindex">\n</head>');
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
122
backend/routes/font_meta.ts
Normal file
122
backend/routes/font_meta.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { extractFontMeta, type FontUserConfig } from "../font_util/font_meta.js";
|
||||
import { parseUrl, jsonResponse, findFontPath, readFontBuffer } from "../shared";
|
||||
import { readFile, writeFile, stat } from "../interface";
|
||||
|
||||
/**
|
||||
* 字体元数据路由 —— 分为两层:
|
||||
*
|
||||
* 1. 自动提取层(info + coverage + ranges):从字体二进制解析 cmap、name 表,
|
||||
* 结果持久化到 .meta.json。字体文件不变则只需计算一次。
|
||||
*
|
||||
* 2. 人工配置层(config):来自 font/font-config.json,用户可随时编辑。
|
||||
* 启动时加载一次到内存,每次请求用 stat mtime 廉价检测变更,mtime 变了才重新读取。
|
||||
*/
|
||||
|
||||
/** 进程内缓存(自动提取的元数据),key = fontPath */
|
||||
const metaCache = new Map<string, ReturnType<typeof extractFontMeta>>();
|
||||
|
||||
/** 人工配置缓存状态 */
|
||||
const CONFIG_PATH = "font/font-config.json";
|
||||
let userConfigMap: Record<string, FontUserConfig> = {};
|
||||
let configMtime = 0;
|
||||
|
||||
/**
|
||||
* 检查并刷新人工配置。
|
||||
* 通过 stat mtime 检测文件变更——mtime 不变则直接跳过(零 IO 读取),
|
||||
* mtime 变了才重新 readFile。这样编辑 font-config.json 后下个请求即生效。
|
||||
*/
|
||||
async function refreshUserConfig(): Promise<void> {
|
||||
try {
|
||||
const s = await stat(CONFIG_PATH);
|
||||
if (s.isFile() && s.size > 0 && s.mtimeMs !== configMtime) {
|
||||
configMtime = s.mtimeMs;
|
||||
const raw = await readFile(CONFIG_PATH);
|
||||
userConfigMap = JSON.parse(new TextDecoder().decode(raw));
|
||||
}
|
||||
} catch {
|
||||
/** 文件不存在或不可访问,使用空配置 */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从字体文件名提取 basename(去掉目录前缀),用于匹配 font-config.json 的 key。
|
||||
* font-config.json 的 key 是纯文件名,如 "思源黑体.ttf"
|
||||
*/
|
||||
function fontBasename(fontPath: string): string {
|
||||
const parts = fontPath.split("/");
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 .meta.json 缓存文件路径(字体同目录)
|
||||
* font/admin/思源黑体.ttf → font/admin/思源黑体.ttf.meta.json
|
||||
*/
|
||||
function metaFilePath(fontPath: string): string {
|
||||
return fontPath + ".meta.json";
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试从磁盘读取缓存的元数据 JSON。
|
||||
* 解析失败返回 null(安全降级,触发重新计算)。
|
||||
*/
|
||||
async function loadMetaFromDisk(fontPath: string): Promise<ReturnType<typeof extractFontMeta> | undefined> {
|
||||
try {
|
||||
const raw = await readFile(metaFilePath(fontPath));
|
||||
return JSON.parse(new TextDecoder().decode(raw));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将元数据写入磁盘缓存。
|
||||
* 写入失败不影响请求结果(元数据已计算好,下次还能从进程内存命中)。
|
||||
*/
|
||||
async function saveMetaToDisk(fontPath: string, meta: ReturnType<typeof extractFontMeta>): Promise<void> {
|
||||
try {
|
||||
await writeFile(metaFilePath(fontPath), new TextEncoder().encode(JSON.stringify(meta)));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** GET /api/font-meta?font=字体名 — 返回字体字符覆盖率、支持的 codepoint 区间、字体基本信息、人工配置 */
|
||||
export async function handleFontMeta(req: Request, _res: Response) {
|
||||
const url = parseUrl(req);
|
||||
const params = new URLSearchParams(url.search);
|
||||
const fontName = params.get("font") || "";
|
||||
|
||||
if (!fontName) {
|
||||
return { req, res: jsonResponse({ error: "缺少 font 参数" }, 400) };
|
||||
}
|
||||
|
||||
const fontPath = await findFontPath(fontName);
|
||||
if (!fontPath) {
|
||||
return { req, res: jsonResponse({ error: `字体不存在: ${fontName}` }, 404) };
|
||||
}
|
||||
|
||||
/** 检查人工配置是否有更新(stat mtime 变了才重新读取) */
|
||||
await refreshUserConfig();
|
||||
|
||||
/** 1. 进程内存命中 */
|
||||
let meta = metaCache.get(fontPath);
|
||||
if (!meta) {
|
||||
/** 2. 磁盘 .meta.json 命中 */
|
||||
meta = await loadMetaFromDisk(fontPath);
|
||||
if (meta) {
|
||||
metaCache.set(fontPath, meta);
|
||||
}
|
||||
}
|
||||
|
||||
/** 3. 首次请求 —— 解析 cmap + name 表计算元数据 */
|
||||
if (!meta) {
|
||||
const fontBuffer = await readFontBuffer(fontPath);
|
||||
meta = extractFontMeta(fontBuffer);
|
||||
metaCache.set(fontPath, meta);
|
||||
/** 异步写盘,不阻塞响应 */
|
||||
saveMetaToDisk(fontPath, meta);
|
||||
}
|
||||
|
||||
/** 合并人工配置(实时读取,不缓存——用户随时可能编辑 font-config.json) */
|
||||
const config = userConfigMap[fontBasename(fontPath)] ?? undefined;
|
||||
|
||||
return { req, res: jsonResponse({ ...meta, config }) };
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { fontSubset } from "../font_util/font";
|
||||
import type { FontEditor } from "../../vendor/fonteditor-core/lib/ttf/font.js";
|
||||
import { parseUrl, stats, subsetCache, findFontPath, readFontBuffer, markStatsDirty } from "../shared";
|
||||
import { markFontUsed } from "../temp_cleaner";
|
||||
|
||||
/**
|
||||
* 进程启动时戳(模块加载时取一次,进程重启即变化)
|
||||
@ -62,6 +63,9 @@ export async function handleFontSubset(req: Request, res: Response) {
|
||||
const outTypeParam = params.get("outType") || "";
|
||||
const outType = (outTypeParam === "woff2" || outTypeParam === "ttf") ? outTypeParam : "ttf";
|
||||
|
||||
/** 记录字体被使用(临时字体保留机制依赖此时间) */
|
||||
markFontUsed(fontPath);
|
||||
|
||||
/** 查询裁剪结果缓存 */
|
||||
/** 版本指纹纳入 key:代码变更后旧缓存自动失效 */
|
||||
const cacheKey = `${SUBSET_CACHE_KEY}:${fontPath}:${outType}:${text}`;
|
||||
|
||||
85
backend/temp_cleaner.ts
Normal file
85
backend/temp_cleaner.ts
Normal file
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 临时字体保留机制 —— 定时扫描 font/temp,删除超过保留时限且最近无人使用的字体。
|
||||
*
|
||||
* "最近使用" = 最后一次被 subset/font-meta/font-detail 请求的时间。
|
||||
* 使用记录存在内存 Map 中(进程级),重启后从文件 mtime 重新起步。
|
||||
*
|
||||
* 清理周期 = 保留时限的一半(最少 5 分钟),避免过于频繁的扫描。
|
||||
*/
|
||||
import { readdir, stat, unlink, path_join } from "./interface";
|
||||
import { tempRetentionHours } from "./config";
|
||||
|
||||
/** 临时字体目录 */
|
||||
const TEMP_DIR = "font/temp";
|
||||
|
||||
/** 字体最后使用时间戳,key = 文件名(不含目录前缀) */
|
||||
const lastUsedMap = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* 记录字体被使用(subset/meta/detail 请求时调用)。
|
||||
* 仅记录 font/temp 下的文件,其他目录无需跟踪。
|
||||
*/
|
||||
export function markFontUsed(fontPath: string): void {
|
||||
/** 仅临时字体需要跟踪 */
|
||||
if (!fontPath.startsWith(TEMP_DIR + "/")) return;
|
||||
const name = fontPath.split("/").pop()!;
|
||||
lastUsedMap.set(name, Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一次清理扫描。
|
||||
* 遍历 font/temp 中的字体文件,删除:
|
||||
* (now - max(最后使用时间, 文件 mtime)) > 保留时限
|
||||
*/
|
||||
async function cleanOnce(): Promise<void> {
|
||||
const now = Date.now();
|
||||
const retentionMs = tempRetentionHours * 3600_000;
|
||||
|
||||
let entries: Array<{ name: string; isFile: () => boolean }>;
|
||||
try {
|
||||
entries = await readdir(TEMP_DIR);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!/\.(ttf|otf|woff|woff2)$/i.test(entry.name)) continue;
|
||||
|
||||
const filePath = path_join(TEMP_DIR, entry.name);
|
||||
try {
|
||||
const s = await stat(filePath);
|
||||
/** 取"最后使用时间"和"文件修改时间"的较大值作为活跃判定基准 */
|
||||
const lastUsed = lastUsedMap.get(entry.name) ?? 0;
|
||||
const lastActive = Math.max(lastUsed, s.mtimeMs);
|
||||
if (now - lastActive > retentionMs) {
|
||||
await unlink(filePath);
|
||||
lastUsedMap.delete(entry.name);
|
||||
console.log(`[temp-cleaner] 删除过期临时字体: ${entry.name}`);
|
||||
}
|
||||
} catch {
|
||||
/** 文件可能在扫描过程中被删除,忽略 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 清理周期:保留时限的一半,最少 5 分钟 */
|
||||
const CLEAN_INTERVAL = Math.max(tempRetentionHours * 1800_000, 300_000);
|
||||
|
||||
/**
|
||||
* 启动定时清理器。
|
||||
* 首次延迟 1 分钟执行(避免启动峰),之后按周期循环。
|
||||
*/
|
||||
export function startTempCleaner(): void {
|
||||
const intervalSec = Math.round(CLEAN_INTERVAL / 1000);
|
||||
console.log(`[temp-cleaner] 启动,保留时限 ${tempRetentionHours}h,清理周期 ${intervalSec}s`);
|
||||
|
||||
/** 首次延迟 60 秒 */
|
||||
setTimeout(() => {
|
||||
cleanOnce().catch(() => {});
|
||||
/** 后续按周期循环 */
|
||||
setInterval(() => {
|
||||
cleanOnce().catch(() => {});
|
||||
}, CLEAN_INTERVAL);
|
||||
}, 60_000);
|
||||
}
|
||||
@ -52,7 +52,16 @@ function onFileSelect(e: Event, target: ReturnType<typeof useUpload>) {
|
||||
<template>
|
||||
<section v-if="canUpload" style="margin-bottom: 28px">
|
||||
<label style="display: block; font-size: 14px; font-weight: 500; margin-bottom: 12px">{{ t('uploadFont') }}</label>
|
||||
<div style="font-size: 12px; color: #e6a700; margin-bottom: 12px">{{ t('uploadTip') }}</div>
|
||||
<div style="font-size: 12px; color: #e6a700; margin-bottom: 8px">{{ t('uploadTip') }}</div>
|
||||
<div style="
|
||||
font-size: 12px;
|
||||
color: #b91c1c;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 12px;
|
||||
">{{ t('uploadWarning') }}</div>
|
||||
|
||||
<div
|
||||
v-if="temp.msg.value"
|
||||
@ -71,7 +80,12 @@ function onFileSelect(e: Event, target: ReturnType<typeof useUpload>) {
|
||||
|
||||
<div v-if="config.enableTempUpload" style="padding: 16px; border: 1px solid #e8e8e8; border-radius: 8px; margin-bottom: 16px">
|
||||
<div style="font-size: 14px; font-weight: 500; margin-bottom: 4px">{{ t('guestUpload') }}</div>
|
||||
<div style="font-size: 12px; color: #999; margin-bottom: 12px">{{ t('guestUploadDesc') }}</div>
|
||||
<div style="font-size: 12px; color: #999; margin-bottom: 12px">
|
||||
{{ t('guestUploadDesc') }}
|
||||
<span v-if="config.tempRetentionHours" style="color: #1677ff">
|
||||
(保留时限 {{ config.tempRetentionHours }} 小时)
|
||||
</span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; align-items: center">
|
||||
<label style="padding: 6px 20px; font-size: 14px; border: 1px solid #d9d9d9; border-radius: 6px; cursor: pointer; background: #fff; color: #333; display: inline-flex; align-items: center">
|
||||
{{ t('selectFile') }}
|
||||
|
||||
61
src/api.ts
61
src/api.ts
@ -9,6 +9,8 @@ export interface ServerConfig {
|
||||
enableTempUpload: boolean;
|
||||
adminUploadEnabled: boolean;
|
||||
supportedOutTypes: ("woff2" | "ttf")[];
|
||||
/** 临时字体保留时限(小时) */
|
||||
tempRetentionHours?: number;
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
@ -26,11 +28,70 @@ export interface ServerStats {
|
||||
fontBufferCacheEntries: number;
|
||||
}
|
||||
|
||||
/** 字符集覆盖率 */
|
||||
export interface CharsetCoverage {
|
||||
/** 字符集标识,如 "ascii"、"cjkBasic" */
|
||||
key: string;
|
||||
name: string;
|
||||
total: number;
|
||||
covered: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
/** 字体基本信息(来自 OpenType name 表) */
|
||||
export interface FontInfo {
|
||||
copyright?: string;
|
||||
family?: string;
|
||||
subfamily?: string;
|
||||
uniqueId?: string;
|
||||
fullName?: string;
|
||||
version?: string;
|
||||
postScript?: string;
|
||||
trademark?: string;
|
||||
manufacturer?: string;
|
||||
designer?: string;
|
||||
description?: string;
|
||||
vendorUrl?: string;
|
||||
designerUrl?: string;
|
||||
license?: string;
|
||||
licenseUrl?: string;
|
||||
}
|
||||
|
||||
/** 人工配置项(来自 font-config.json,由用户维护) */
|
||||
export interface FontUserConfig {
|
||||
/** 显示名称(优先于文件名) */
|
||||
displayName?: string;
|
||||
/** 描述/简介 */
|
||||
description?: string;
|
||||
/** 标签列表 */
|
||||
tags?: string[];
|
||||
/** 开源仓库地址(如 GitHub URL) */
|
||||
homepage?: string;
|
||||
/** 默认预览文字 */
|
||||
previewText?: string;
|
||||
}
|
||||
|
||||
/** 字体元数据 */
|
||||
export interface FontMeta {
|
||||
totalCodePoints: number;
|
||||
coverage: CharsetCoverage[];
|
||||
ranges: Array<[number, number]>;
|
||||
/** 字体基本信息(版权、作者等) */
|
||||
info: FontInfo;
|
||||
/** 人工配置(来自 font-config.json) */
|
||||
config?: FontUserConfig;
|
||||
}
|
||||
|
||||
export async function fetchFonts(): Promise<FontInfo[]> {
|
||||
const res = await fetch("/api/fonts");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchFontMeta(fontName: string): Promise<FontMeta> {
|
||||
const res = await fetch(`/api/font-meta?font=${encodeURIComponent(fontName)}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchConfig(): Promise<ServerConfig> {
|
||||
const res = await fetch("/api/config");
|
||||
return res.json();
|
||||
|
||||
14
src/i18n.ts
14
src/i18n.ts
@ -65,8 +65,8 @@ const messages = {
|
||||
viewSkill: "查看 AI Chinese Font Skill →",
|
||||
sponsor: "赞助支持",
|
||||
agentSkillDemo: "Agent Skill Demo",
|
||||
/** 字体列表入口 */
|
||||
browseFonts: "字体列表",
|
||||
/** 所有字体入口 */
|
||||
browseFonts: "所有字体",
|
||||
|
||||
// FontSelector.vue
|
||||
selectFont: "选择字体",
|
||||
@ -84,8 +84,9 @@ const messages = {
|
||||
// UploadSection.vue
|
||||
uploadTip: "支持 .ttf 和 .otf 格式,建议上传 .ttf 字体文件以获得最佳兼容性",
|
||||
uploadFont: "上传字体",
|
||||
uploadWarning: "⚠ 切勿上传非商用授权或付费字体,本平台仅用于分享免费可商用字体",
|
||||
guestUpload: "游客上传",
|
||||
guestUploadDesc: "临时文件,最多保留 10 个,总大小限制 200MB,超出后自动删除最早上传的",
|
||||
guestUploadDesc: "临时文件,最多保留 10 个,总大小限制 200MB,超时未使用将自动删除",
|
||||
adminUpload: "管理员上传",
|
||||
adminUploadDesc: "永久保存,需要 API Key 认证",
|
||||
selectFile: "选择文件",
|
||||
@ -139,8 +140,8 @@ const messages = {
|
||||
viewSkill: "View AI Chinese Font Skill →",
|
||||
sponsor: "Sponsor",
|
||||
agentSkillDemo: "Agent Skill Demo",
|
||||
/** Font list entry */
|
||||
browseFonts: "Font List",
|
||||
/** All fonts entry */
|
||||
browseFonts: "All Fonts",
|
||||
|
||||
// FontSelector.vue
|
||||
selectFont: "Select font",
|
||||
@ -158,8 +159,9 @@ const messages = {
|
||||
// UploadSection.vue
|
||||
uploadTip: "Supports .ttf and .otf. .ttf recommended for best compatibility",
|
||||
uploadFont: "Upload Font",
|
||||
uploadWarning: "⚠ Do NOT upload non-commercial or paid fonts. This platform is for free commercial-use fonts only.",
|
||||
guestUpload: "Guest Upload",
|
||||
guestUploadDesc: "Temporary files, max 10 files, 200MB total. Oldest deleted when full.",
|
||||
guestUploadDesc: "Temporary files, max 10 files, 200MB total. Auto-deleted if unused beyond retention period.",
|
||||
adminUpload: "Admin Upload",
|
||||
adminUploadDesc: "Permanent storage, requires API Key",
|
||||
selectFile: "Choose file",
|
||||
|
||||
@ -9,8 +9,8 @@
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useHead } from "@unhead/vue";
|
||||
import { fetchFonts } from "../api";
|
||||
import type { FontInfo } from "../api";
|
||||
import { fetchFonts, fetchFontMeta } from "../api";
|
||||
import type { FontInfo, FontMeta } from "../api";
|
||||
import { FONT_NAME, FONT_SLUG, ORIGIN } from "../placeholders";
|
||||
import { SITE_NAME } from "../seo";
|
||||
|
||||
@ -43,13 +43,36 @@ useHead({
|
||||
|
||||
const fonts = ref<FontInfo[]>([]);
|
||||
const notFound = ref(false);
|
||||
/** 字体元数据(字符覆盖率) */
|
||||
const meta = ref<FontMeta | null>(null);
|
||||
const metaLoading = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
origin.value = location.origin;
|
||||
const allFonts = await fetchFonts().catch(() => []);
|
||||
const [allFonts] = await Promise.all([
|
||||
fetchFonts().catch(() => [] as FontInfo[]),
|
||||
]);
|
||||
fonts.value = allFonts;
|
||||
notFound.value = allFonts.length > 0 && !allFonts.some((f) => f.name === fontName.value);
|
||||
|
||||
/** 加载字体元数据(覆盖率+支持的字符集) */
|
||||
metaLoading.value = true;
|
||||
meta.value = await fetchFontMeta(fontName.value).catch(() => null);
|
||||
metaLoading.value = false;
|
||||
});
|
||||
|
||||
/**
|
||||
* 将分号分隔的长文本拆成多行数组。
|
||||
* 字体 name 表中 designer/description 等字段常把多人用 ";" 连接,
|
||||
* 拆分后逐行渲染更清晰。
|
||||
*/
|
||||
function splitSemicolon(text: string | undefined): string[] {
|
||||
if (!text) return [];
|
||||
return text
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -212,6 +235,141 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 字符覆盖率 -->
|
||||
<div
|
||||
v-if="metaLoading || meta"
|
||||
style="
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 32px 40px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
"
|
||||
>
|
||||
<!-- 标签 + 开源链接 -->
|
||||
<div
|
||||
v-if="meta?.config"
|
||||
style="display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px"
|
||||
>
|
||||
<span
|
||||
v-for="tag in meta.config.tags"
|
||||
:key="tag"
|
||||
style="
|
||||
font-size: 12px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
background: #f0f5ff;
|
||||
color: #1677ff;
|
||||
"
|
||||
>{{ tag }}</span>
|
||||
<a
|
||||
v-if="meta.config.homepage"
|
||||
:href="meta.config.homepage"
|
||||
target="_blank"
|
||||
style="
|
||||
font-size: 12px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
background: #f6f6f6;
|
||||
color: #666;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
"
|
||||
>📎 开源仓库</a>
|
||||
</div>
|
||||
|
||||
<!-- 简介 -->
|
||||
<p
|
||||
v-if="meta?.config?.description"
|
||||
style="font-size: 14px; color: #666; line-height: 1.7; margin: 0 0 20px"
|
||||
>
|
||||
{{ meta.config.description }}
|
||||
</p>
|
||||
|
||||
<div style="font-size: 13px; font-weight: 600; color: #999; margin-bottom: 16px">
|
||||
字符覆盖率
|
||||
<span v-if="meta" style="font-weight: 400; margin-left: 8px; color: #bbb">
|
||||
共 {{ meta.totalCodePoints }} 个字符
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="metaLoading" style="color: #ccc; font-size: 14px">分析中...</div>
|
||||
|
||||
<div v-else-if="meta" style="display: flex; flex-direction: column; gap: 12px">
|
||||
<div v-for="item in meta.coverage" :key="item.name">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px">
|
||||
<span style="font-size: 13px; color: #555">{{ item.name }}</span>
|
||||
<span style="font-size: 12px; color: #999">
|
||||
{{ item.covered }}/{{ item.total }} · {{ item.percent }}%
|
||||
</span>
|
||||
</div>
|
||||
<!-- 进度条 -->
|
||||
<div style="height: 6px; background: #f0f0f0; border-radius: 3px; overflow: hidden">
|
||||
<div
|
||||
:style="{
|
||||
width: item.percent + '%',
|
||||
height: '100%',
|
||||
borderRadius: '3px',
|
||||
transition: 'width 0.4s ease',
|
||||
background:
|
||||
item.percent >= 90
|
||||
? '#52c41a'
|
||||
: item.percent >= 50
|
||||
? '#faad14'
|
||||
: '#ff4d4f',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 字体信息(来自 name 表) -->
|
||||
<div
|
||||
v-if="meta?.info"
|
||||
style="
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 32px 40px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
"
|
||||
>
|
||||
<div style="font-size: 13px; font-weight: 600; color: #999; margin-bottom: 16px">
|
||||
字体信息
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 80px 1fr; gap: 10px 16px; font-size: 13px">
|
||||
<template v-if="meta.info.designer">
|
||||
<span style="color: #999">设计师</span>
|
||||
<span style="color: #555; display: flex; flex-direction: column; gap: 4px">
|
||||
<span v-for="d in splitSemicolon(meta.info.designer)" :key="d">{{ d }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="meta.info.manufacturer">
|
||||
<span style="color: #999">制造商</span>
|
||||
<span style="color: #555">{{ meta.info.manufacturer }}</span>
|
||||
</template>
|
||||
<template v-if="meta.info.version">
|
||||
<span style="color: #999">版本</span>
|
||||
<span style="color: #555">{{ meta.info.version }}</span>
|
||||
</template>
|
||||
<template v-if="meta.info.copyright">
|
||||
<span style="color: #999">版权</span>
|
||||
<span style="color: #555">{{ meta.info.copyright }}</span>
|
||||
</template>
|
||||
<template v-if="meta.info.license">
|
||||
<span style="color: #999">许可</span>
|
||||
<span style="color: #555">{{ meta.info.license }}</span>
|
||||
</template>
|
||||
<template v-if="meta.info.licenseUrl">
|
||||
<span style="color: #999">许可链接</span>
|
||||
<a :href="meta.info.licenseUrl" target="_blank" style="color: #1677ff; text-decoration: none">{{ meta.info.licenseUrl }}</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 使用方法 -->
|
||||
<div
|
||||
style="
|
||||
|
||||
@ -10,13 +10,13 @@ import { ref, computed, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { pinyin } from "pinyin-pro";
|
||||
import { useHead } from "@unhead/vue";
|
||||
import { fetchFonts } from "../api";
|
||||
import type { FontInfo } from "../api";
|
||||
import { fetchFonts, fetchFontMeta } from "../api";
|
||||
import type { FontInfo, FontMeta } from "../api";
|
||||
import { SITE_NAME } from "../seo";
|
||||
import LazyTrigger from "../components/LazyTrigger.vue";
|
||||
|
||||
useHead({
|
||||
title: `字体列表 | ${SITE_NAME}`,
|
||||
title: `所有字体 | ${SITE_NAME}`,
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
@ -31,9 +31,51 @@ const loading = ref(true);
|
||||
/** 搜索关键词 */
|
||||
const query = ref("");
|
||||
|
||||
/** 字体元数据缓存(key = 字体名),卡片进入视口后按需加载 */
|
||||
const metaMap = ref<Map<string, FontMeta>>(new Map());
|
||||
|
||||
/** 预览文字内容(与 loadText 参数一致) */
|
||||
const PREVIEW_TEXT = "静心茶舍 天地无极 ABCDEF";
|
||||
|
||||
/** 排序方式:default | codePoints | name | coverage:<charsetKey> */
|
||||
const sortBy = ref<string>("default");
|
||||
|
||||
/** 从已加载的 meta 中提取可选字符集列表(用第一个有 meta 的字体) */
|
||||
const charsetOptions = computed(() => {
|
||||
for (const m of metaMap.value.values()) {
|
||||
return m.coverage.map((c) => ({ key: c.key, name: c.name }));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
/** 取某个字体在指定字符集上的覆盖率(0-100),无数据返回 -1 */
|
||||
function coverageOf(fontName: string, key: string): number {
|
||||
const m = metaMap.value.get(fontName);
|
||||
if (!m) return -1;
|
||||
return m.coverage.find((c) => c.key === key)?.percent ?? -1;
|
||||
}
|
||||
|
||||
/** 排序后的列表 */
|
||||
const sortedFonts = computed(() => {
|
||||
const list = [...filteredFonts.value];
|
||||
const sb = sortBy.value;
|
||||
if (sb === "codePoints") {
|
||||
return list.sort((a, b) => {
|
||||
const ta = metaMap.value.get(a.name)?.totalCodePoints ?? 0;
|
||||
const tb = metaMap.value.get(b.name)?.totalCodePoints ?? 0;
|
||||
return tb - ta;
|
||||
});
|
||||
}
|
||||
if (sb === "name") {
|
||||
return list.sort((a, b) => a.name.localeCompare(b.name, "zh-Hans-CN"));
|
||||
}
|
||||
if (sb.startsWith("coverage:")) {
|
||||
const key = sb.slice("coverage:".length);
|
||||
return list.sort((a, b) => coverageOf(b.name, key) - coverageOf(a.name, key));
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
/**
|
||||
* 过滤后的字体列表 —— 同 FontSelector 的搜索逻辑:
|
||||
* 空格分隔多关键词(AND),每个关键词匹配文件名 + 拼音
|
||||
@ -55,8 +97,9 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* LazyTrigger @appear 回调 —— 卡片进入视口时按需加载字体子集。
|
||||
* WebFont SDK 内部有去重,无需额外缓存。
|
||||
* LazyTrigger @appear 回调 —— 卡片进入视口时:
|
||||
* 1. 按需加载字体预览子集
|
||||
* 2. 请求字体元数据(覆盖率),后端有磁盘缓存不重复计算
|
||||
*/
|
||||
function onCardAppear(fontName: string) {
|
||||
(globalThis as any).WebFont?.loadText?.({
|
||||
@ -64,6 +107,15 @@ function onCardAppear(fontName: string) {
|
||||
text: PREVIEW_TEXT,
|
||||
family: fontName,
|
||||
});
|
||||
/** 已加载过则跳过 */
|
||||
if (metaMap.value.has(fontName)) return;
|
||||
fetchFontMeta(fontName)
|
||||
.then((m) => {
|
||||
metaMap.value.set(fontName, m);
|
||||
/** 触发响应式更新 */
|
||||
metaMap.value = new Map(metaMap.value);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/** 点击字体卡片 → 跳转详情页 */
|
||||
@ -114,27 +166,52 @@ function goToDetail(name: string) {
|
||||
|
||||
<!-- 标题 + 搜索 -->
|
||||
<div style="max-width: 960px; margin: 0 auto; padding: 40px 24px 24px">
|
||||
<h1 style="font-size: 28px; font-weight: 700; color: #2c2c2c; margin: 0 0 8px">字体列表</h1>
|
||||
<h1 style="font-size: 28px; font-weight: 700; color: #2c2c2c; margin: 0 0 8px">所有字体</h1>
|
||||
<p style="font-size: 14px; color: #999; margin: 0 0 24px">
|
||||
共 {{ loading ? "..." : fonts.length }} 个字体 · 点击查看完整预览
|
||||
</p>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<input
|
||||
v-model="query"
|
||||
type="text"
|
||||
placeholder="搜索字体(支持拼音)..."
|
||||
style="
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
"
|
||||
/>
|
||||
<!-- 搜索 + 排序 -->
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 0">
|
||||
<input
|
||||
v-model="query"
|
||||
type="text"
|
||||
placeholder="搜索字体(支持拼音)..."
|
||||
style="
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 480px;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
"
|
||||
/>
|
||||
<select
|
||||
v-model="sortBy"
|
||||
style="
|
||||
padding: 10px 16px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
"
|
||||
>
|
||||
<option value="default">默认排序</option>
|
||||
<option value="codePoints">字符量 ↓</option>
|
||||
<option value="name">名称 A→Z</option>
|
||||
<option
|
||||
v-for="cs in charsetOptions"
|
||||
:key="cs.key"
|
||||
:value="`coverage:${cs.key}`"
|
||||
>{{ cs.name.replace(/(.+)/, '') }} 覆盖率 ↓</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 字体卡片网格 -->
|
||||
@ -142,7 +219,7 @@ function goToDetail(name: string) {
|
||||
<div v-if="loading" style="text-align: center; padding: 60px; color: #999">加载中...</div>
|
||||
|
||||
<div
|
||||
v-else-if="filteredFonts.length === 0"
|
||||
v-else-if="sortedFonts.length === 0"
|
||||
style="text-align: center; padding: 60px; color: #999"
|
||||
>
|
||||
未找到匹配的字体
|
||||
@ -158,7 +235,7 @@ function goToDetail(name: string) {
|
||||
"
|
||||
>
|
||||
<LazyTrigger
|
||||
v-for="font in filteredFonts"
|
||||
v-for="font in sortedFonts"
|
||||
:key="font.name"
|
||||
@appear="onCardAppear(font.name)"
|
||||
>
|
||||
@ -214,6 +291,21 @@ function goToDetail(name: string) {
|
||||
>
|
||||
天地无极 ABCDEF
|
||||
</div>
|
||||
|
||||
<!-- 覆盖率标签 -->
|
||||
<div v-if="metaMap.get(font.name)" style="display: flex; flex-wrap: wrap; gap: 4px; margin-top: 10px">
|
||||
<span
|
||||
v-for="c in (metaMap.get(font.name)?.coverage ?? []).filter(c => c.percent < 100)"
|
||||
:key="c.name"
|
||||
:style="{
|
||||
fontSize: '11px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
color: c.percent >= 50 ? '#1677ff' : '#ff4d4f',
|
||||
background: c.percent >= 50 ? '#e6f4ff' : '#fff2f0',
|
||||
}"
|
||||
>{{ c.name.replace(/(.+)/, '') }} {{ c.percent }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</LazyTrigger>
|
||||
</div>
|
||||
|
||||
@ -180,10 +180,10 @@ async function refreshFonts() {
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px">
|
||||
<h1 style="font-size: 22px; font-weight: 600; margin: 0 0 4px 0">Web Font</h1>
|
||||
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: nowrap; flex-shrink: 0">
|
||||
<button @click="toggleLocale" style="font-size: 12px; border: 1px solid #d9d9d9; border-radius: 6px; padding: 4px 10px; cursor: pointer; background: #fff; color: #333; min-width: 42px; white-space: nowrap; flex-shrink: 0">
|
||||
<button @click="toggleLocale" style="font-size: 13px; border: 1px solid #d9d9d9; border-radius: 6px; padding: 4px 12px; cursor: pointer; background: #fff; color: #333; white-space: nowrap; flex-shrink: 0; line-height: 1.6">
|
||||
{{ locale === 'zh' ? 'EN' : '中' }}
|
||||
</button>
|
||||
<router-link to="/fonts" style="font-size: 13px; color: #8b7355; text-decoration: none; border: 1px solid #8b7355; border-radius: 6px; padding: 4px 12px; display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; flex-shrink: 0">
|
||||
<router-link to="/fonts" style="font-size: 13px; color: #fff; text-decoration: none; border-radius: 6px; padding: 5px 14px; display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; flex-shrink: 0; background: linear-gradient(135deg, #1677ff, #0958d9); font-weight: 500; box-shadow: 0 2px 8px rgba(22, 119, 255, 0.3)">
|
||||
{{ t('browseFonts') }}
|
||||
</router-link>
|
||||
<router-link to="/demo" style="font-size: 13px; color: #8b7355; text-decoration: none; border: 1px solid #8b7355; border-radius: 6px; padding: 4px 12px; display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; flex-shrink: 0">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user