From 19b14f58eaf5509bfb5441eca0d247995264c02a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B4=AE=E7=94=9F=EF=BC=88=E5=AD=90=E8=99=9A=EF=BC=89?= <2234839456@qq.com> Date: Thu, 30 Jul 2026 21:19:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AD=97=E4=BD=93=E5=85=83=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E4=B8=89=E5=B1=82=E6=9E=B6=E6=9E=84=20+=20=E4=B8=B4?= =?UTF-8?q?=E6=97=B6=E5=AD=97=E4=BD=93=E4=BF=9D=E7=95=99=E6=9C=BA=E5=88=B6?= =?UTF-8?q?=20+=20=E5=88=97=E8=A1=A8=E9=A1=B5=E8=A6=86=E7=9B=96=E7=8E=87?= =?UTF-8?q?=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - font_meta.ts: cmap 解析覆盖率 + name 表提取字体信息(版权/作者/许可) + 人工配置(font-config.json) - 三层缓存: 进程内存 → .meta.json 磁盘 → font-config.json stat mtime 热更新 - 临时字体保留机制: TEMP_RETENTION_HOURS(默认3h), 超时未使用自动删除 - 列表页: 覆盖率标签 + 按字符集覆盖率排序 - 详情页: 标签/开源链接/简介 + 字体信息面板(设计师/版权/许可) - 首页: 红色警告提示切勿上传非商用字体 + 显示保留时限 - SSG noindex bug 修复 --- backend/app.ts | 8 + backend/config.ts | 3 + backend/font_util/font_meta.ts | 431 +++++++++++++++++++++++++++++++++ backend/interface.ts | 2 + backend/routes/config.ts | 4 +- backend/routes/font_detail.ts | 6 +- backend/routes/font_meta.ts | 122 ++++++++++ backend/routes/subset.ts | 4 + backend/temp_cleaner.ts | 85 +++++++ src/UploadSection.vue | 18 +- src/api.ts | 61 +++++ src/i18n.ts | 14 +- src/pages/FontDetail.vue | 164 ++++++++++++- src/pages/FontList.vue | 140 +++++++++-- src/pages/Home.vue | 4 +- 15 files changed, 1025 insertions(+), 41 deletions(-) create mode 100644 backend/font_util/font_meta.ts create mode 100644 backend/routes/font_meta.ts create mode 100644 backend/temp_cleaner.ts diff --git a/backend/app.ts b/backend/app.ts index 28b5c2c..68d62dc 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -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(); diff --git a/backend/config.ts b/backend/config.ts index 4f16573..687e4c7 100644 --- a/backend/config.ts +++ b/backend/config.ts @@ -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; diff --git a/backend/font_util/font_meta.ts b/backend/font_util/font_meta.ts new file mode 100644 index 0000000..00a5916 --- /dev/null +++ b/backend/font_util/font_meta.ts @@ -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): 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): 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 { + const buf = fontBuffer instanceof Uint8Array ? fontBuffer.buffer : fontBuffer; + const dv = new DataView(buf); + const cmapEntry = readTableEntry(dv, "cmap"); + const cps = new Set(); + 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, ranges: ReadonlyArray): { 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 }> = [ + { 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): 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): 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(); + + 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), + }; +} diff --git a/backend/interface.ts b/backend/interface.ts index c55eaf8..fbd5ea5 100644 --- a/backend/interface.ts +++ b/backend/interface.ts @@ -1,6 +1,8 @@ export let stat: (path: string) => Promise<{ isFile: () => boolean; size: number; + /** 最后修改时间戳(毫秒),用于文件变更检测 */ + mtimeMs: number; }>; export let readFile: (path: string) => Promise; diff --git a/backend/routes/config.ts b/backend/routes/config.ts index fca2273..fd0b0dc 100644 --- a/backend/routes/config.ts +++ b/backend/routes/config.ts @@ -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, }), }; } diff --git a/backend/routes/font_detail.ts b/backend/routes/font_detail.ts index aa705a6..fb79a36 100644 --- a/backend/routes/font_detail.ts +++ b/backend/routes/font_detail.ts @@ -79,9 +79,9 @@ export async function handleFontDetail(pathname: string): Promise", ''); + /** 字体不存在时,在 head 注入 noindex 标签防止搜索引擎收录 */ + if (!meta?.exists) { + html = html.replace("", '\n'); } const encoder = new TextEncoder(); diff --git a/backend/routes/font_meta.ts b/backend/routes/font_meta.ts new file mode 100644 index 0000000..ac0ab1e --- /dev/null +++ b/backend/routes/font_meta.ts @@ -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>(); + +/** 人工配置缓存状态 */ +const CONFIG_PATH = "font/font-config.json"; +let userConfigMap: Record = {}; +let configMtime = 0; + +/** + * 检查并刷新人工配置。 + * 通过 stat mtime 检测文件变更——mtime 不变则直接跳过(零 IO 读取), + * mtime 变了才重新 readFile。这样编辑 font-config.json 后下个请求即生效。 + */ +async function refreshUserConfig(): Promise { + 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 | 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): Promise { + 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 }) }; +} diff --git a/backend/routes/subset.ts b/backend/routes/subset.ts index ee13888..8c6bd94 100644 --- a/backend/routes/subset.ts +++ b/backend/routes/subset.ts @@ -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}`; diff --git a/backend/temp_cleaner.ts b/backend/temp_cleaner.ts new file mode 100644 index 0000000..ae85fc2 --- /dev/null +++ b/backend/temp_cleaner.ts @@ -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(); + +/** + * 记录字体被使用(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 { + 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); +} diff --git a/src/UploadSection.vue b/src/UploadSection.vue index b8f7c8e..6da611c 100644 --- a/src/UploadSection.vue +++ b/src/UploadSection.vue @@ -52,7 +52,16 @@ function onFileSelect(e: Event, target: ReturnType) {