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:
崮生(子虚) 2026-07-30 21:19:09 +08:00
parent 5b82dec19a
commit 19b14f58ea
15 changed files with 1025 additions and 41 deletions

View File

@ -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();

View File

@ -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;

View File

@ -0,0 +1,431 @@
/**
* cmap codepoint
*
*
* cmap format4BMP 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 gid0 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) & 0xFFFFgid=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广 format4BMP
*/
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+9FFF20992
* - 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-16BEplatformID=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),
};
}

View File

@ -1,6 +1,8 @@
export let stat: (path: string) => Promise<{
isFile: () => boolean;
size: number;
/** 最后修改时间戳(毫秒),用于文件变更检测 */
mtimeMs: number;
}>;
export let readFile: (path: string) => Promise<Uint8Array>;

View File

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

View File

@ -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
View 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 cmapname
* .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 }) };
}

View File

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

View File

@ -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') }}

View File

@ -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();

View File

@ -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",

View 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="

View File

@ -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">名称 AZ</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>

View File

@ -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">