web-font/backend/routes/font_meta.ts
崮生(子虚) 19b14f58ea feat: 字体元数据三层架构 + 临时字体保留机制 + 列表页覆盖率排序
- font_meta.ts: cmap 解析覆盖率 + name 表提取字体信息(版权/作者/许可) + 人工配置(font-config.json)
- 三层缓存: 进程内存 → .meta.json 磁盘 → font-config.json stat mtime 热更新
- 临时字体保留机制: TEMP_RETENTION_HOURS(默认3h), 超时未使用自动删除
- 列表页: 覆盖率标签 + 按字符集覆盖率排序
- 详情页: 标签/开源链接/简介 + 字体信息面板(设计师/版权/许可)
- 首页: 红色警告提示切勿上传非商用字体 + 显示保留时限
- SSG noindex bug 修复
2026-07-30 21:19:09 +08:00

123 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 }) };
}