内存缓存最近三个字体,避免高频的文件io

This commit is contained in:
崮生(子虚) 2026-04-08 22:31:53 +08:00
parent b902b4e1cc
commit 79ee7dd3f0
5 changed files with 445 additions and 2 deletions

View File

@ -252,6 +252,29 @@ async function handleUpload(req: Request, res: Response) {
return { req, res: jsonResponse(result, result.success ? 200 : 400) };
}
/** 字体文件 LRU 缓存,最多保留 3 个最近使用的字体 buffer */
const fontBufferCache = new Map<string, ArrayBuffer>();
const FONT_CACHE_MAX = 3;
/** 从缓存或磁盘读取字体 buffer */
async function readFontBuffer(fontPath: string): Promise<ArrayBuffer> {
const cached = fontBufferCache.get(fontPath);
if (cached) {
/** LRU命中时移到末尾最近使用 */
fontBufferCache.delete(fontPath);
fontBufferCache.set(fontPath, cached);
return cached;
}
const buffer = new Uint8Array(await readFile(fontPath)).buffer;
if (fontBufferCache.size >= FONT_CACHE_MAX) {
/** 淘汰最久未使用的条目 */
const oldest = fontBufferCache.keys().next().value!;
fontBufferCache.delete(oldest);
}
fontBufferCache.set(fontPath, buffer);
return buffer;
}
/** GET /api?font=...&text=... — 字体裁剪 */
async function handleFontSubset(req: Request, res: Response) {
const url = parseUrl(req);
@ -276,7 +299,7 @@ async function handleFontSubset(req: Request, res: Response) {
const fontType = fontPath.split(".").pop() as "ttf";
let oldFontBuffer: ArrayBuffer;
try {
oldFontBuffer = new Uint8Array(await readFile(fontPath)).buffer;
oldFontBuffer = await readFontBuffer(fontPath);
} catch {
return {
req,

View File

@ -1,7 +1,7 @@
{
"name": "webfont",
"private": true,
"version": "1.2.0",
"version": "1.2.1",
"type": "module",
"scripts": {
"dev": "pnpx tsx scripts/dev-all.ts",
@ -21,6 +21,7 @@
},
"devDependencies": {
"@types/node": "^25.5.2",
"skia-canvas": "^3.0.8",
"tsup": "^8.5.1",
"typescript": "^6.0.2",
"undici": "^8.0.2",

56
pnpm-lock.yaml generated
View File

@ -21,6 +21,9 @@ importers:
'@types/node':
specifier: ^25.5.2
version: 25.5.2
skia-canvas:
specifier: ^3.0.8
version: 3.0.8
tsup:
specifier: ^8.5.1
version: 8.5.1(postcss@8.5.9)(typescript@6.0.2)
@ -579,6 +582,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
@ -681,6 +688,15 @@ packages:
fix-dts-default-cjs-exports@1.0.1:
resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==}
follow-redirects@1.15.11:
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
fonteditor-core@file:vendor/fonteditor-core:
resolution: {directory: vendor/fonteditor-core, type: directory}
@ -696,6 +712,10 @@ packages:
html-entities@2.3.3:
resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==}
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
is-what@4.1.16:
resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
engines: {node: '>=12.13'}
@ -833,6 +853,9 @@ packages:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
parenthesis@3.1.8:
resolution: {integrity: sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==}
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
@ -907,6 +930,9 @@ packages:
resolution: {integrity: sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==}
engines: {node: '>=10'}
skia-canvas@3.0.8:
resolution: {integrity: sha512-FSYKxp8Ng2vOeeOBiyPhnn6ui6FirPJXMyjk4PKl8N/OWzVrkMawUgY9zubIWHMdYtyWFn0gfX3QlRwg6HBmdg==}
solid-js@1.9.12:
resolution: {integrity: sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw==}
@ -923,6 +949,9 @@ packages:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'}
string-split-by@1.0.0:
resolution: {integrity: sha512-KaJKY+hfpzNyet/emP81PJA9hTVSfxNLS9SFTWxdCnnW1/zOOwiV248+EfoX7IQFcBaOp4G5YE6xTJMF+pLg6A==}
sucrase@3.35.1:
resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
engines: {node: '>=16 || 14 >=14.17'}
@ -1471,6 +1500,8 @@ snapshots:
acorn@8.16.0: {}
agent-base@7.1.4: {}
any-promise@1.3.0: {}
babel-plugin-jsx-dom-expressions@0.40.6(@babel/core@7.29.0):
@ -1573,6 +1604,8 @@ snapshots:
mlly: 1.8.2
rollup: 4.60.1
follow-redirects@1.15.11: {}
fonteditor-core@file:vendor/fonteditor-core:
dependencies:
'@xmldom/xmldom': 0.8.12
@ -1584,6 +1617,13 @@ snapshots:
html-entities@2.3.3: {}
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
debug: 4.4.3
transitivePeerDependencies:
- supports-color
is-what@4.1.16: {}
joycon@3.1.1: {}
@ -1682,6 +1722,8 @@ snapshots:
object-assign@4.1.1: {}
parenthesis@3.1.8: {}
parse5@7.3.0:
dependencies:
entities: 6.0.1
@ -1776,6 +1818,16 @@ snapshots:
seroval@1.5.2: {}
skia-canvas@3.0.8:
dependencies:
detect-libc: 2.1.2
follow-redirects: 1.15.11
https-proxy-agent: 7.0.6
string-split-by: 1.0.0
transitivePeerDependencies:
- debug
- supports-color
solid-js@1.9.12:
dependencies:
csstype: 3.2.3
@ -1795,6 +1847,10 @@ snapshots:
source-map@0.7.6: {}
string-split-by@1.0.0:
dependencies:
parenthesis: 3.1.8
sucrase@3.35.1:
dependencies:
'@jridgewell/gen-mapping': 0.3.13

230
verify_font.ts Normal file
View File

@ -0,0 +1,230 @@
/**
*
*
* :
* pnpm tsx verify_font.ts baseline +
* pnpm tsx verify_font.ts
*
* :
* 1. glyf contours/pts/unicode/advanceWidth
* 2. skia-canvas SSIM
*/
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { Font } from "fonteditor-core";
import { Canvas, FontLibrary } from "skia-canvas";
const isBaseline = process.argv[2] === "baseline";
const FONT_PATH = "font/令东齐伋复刻体.ttf";
const FONT_NAME = "令东齐伋复刻体";
const SIMILARITY_THRESHOLD = 0.98;
const raw = await readFile(FONT_PATH);
const fontBuffer = new Uint8Array(raw).buffer;
FontLibrary.use(FONT_NAME, FONT_PATH);
const testCases = [
{ text: "天地玄黄宇宙洪荒", label: "8个汉字" },
{ text: "Hello World 123", label: "拉丁+数字" },
{ text: "天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈剑号巨阙珠称夜光果珍李柰菜重芥姜海咸河淡鳞潜羽翔", label: "千字文前段" },
];
/** 子集字体注册计数器 */
let subsetFontCounter = 0;
/** 渲染文字到纯图片数据(不保存文件),返回 Uint8Array */
function renderText(fontFamily: string, text: string, fontSize: number): Uint8Array {
const charWidth = Math.ceil(fontSize * 1.5);
const width = text.length * charWidth + 20;
const height = Math.ceil(fontSize * 1.5);
const canvas = new Canvas(width, height);
const ctx = canvas.getContext("2d");
ctx.fillStyle = "white";
ctx.fillRect(0, 0, width, height);
ctx.font = `${fontSize}px ${fontFamily}`;
ctx.fillStyle = "black";
ctx.fillText(text, 10, Math.ceil(fontSize * 1.2));
const imgData = ctx.getImageData(0, 0, width, height);
return new Uint8Array(imgData.data.buffer);
}
/**
* SSIM
* 0~1 1
*/
function calculateSSIM(a: Uint8Array, b: Uint8Array): number {
if (a.length !== b.length) return 0;
/** 转灰度 */
const toGray = (data: Uint8Array, offset: number) =>
0.299 * data[offset] + 0.587 * data[offset + 1] + 0.114 * data[offset + 2];
const pixelCount = a.length / 4;
let sumA = 0, sumB = 0, sumA2 = 0, sumB2 = 0, sumAB = 0;
for (let i = 0; i < pixelCount; i++) {
const idx = i * 4;
const ga = toGray(a, idx);
const gb = toGray(b, idx);
sumA += ga;
sumB += gb;
sumA2 += ga * ga;
sumB2 += gb * gb;
sumAB += ga * gb;
}
const meanA = sumA / pixelCount;
const meanB = sumB / pixelCount;
const varA = sumA2 / pixelCount - meanA * meanA;
const varB = sumB2 / pixelCount - meanB * meanB;
const covAB = sumAB / pixelCount - meanA * meanB;
const C1 = 6.5025;
const C2 = 58.5225;
const ssim =
(2 * meanA * meanB + C1) * (2 * covAB + C2) /
((meanA * meanA + meanB * meanB + C1) * (varA + varB + C2));
return ssim;
}
/** 从 Font 对象中提取可对比的结构数据 */
function extractFontData(font: any) {
const d = font.data;
const glyf = d.glyf.map((g: any, i: number) => {
const contourHeads = g.contours
? g.contours.map((c: any[]) =>
c.slice(0, 3).map((p: any) => [p.x, p.y, !!p.onCurve])
)
: [];
return {
index: i,
contours: g.contours?.length || 0,
pts: g.contours ? g.contours.reduce((s: number, c: any[]) => s + c.length, 0) : 0,
compound: !!g.compound,
unicode: g.unicode ? [...g.unicode].sort((a: number, b: number) => a - b) : [],
advanceWidth: g.advanceWidth,
leftSideBearing: g.leftSideBearing,
contourHeads,
};
});
const out = font.write({ type: "ttf" });
const buf = out instanceof ArrayBuffer ? out : new Uint8Array(out as any).buffer;
const view = new DataView(buf);
const numTables = view.getUint16(4, false);
const tables: Record<string, { offset: number; length: number; checksum: number }> = {};
for (let i = 0; i < numTables; i++) {
const base = 12 + i * 16;
const tag = String.fromCharCode(
view.getUint8(base), view.getUint8(base + 1),
view.getUint8(base + 2), view.getUint8(base + 3)
);
tables[tag] = {
offset: view.getUint32(base + 8, false),
length: view.getUint32(base + 12, false),
checksum: view.getUint32(base + 4, false),
};
}
return { glyfCount: d.glyf.length, glyf, outputSize: buf.byteLength, tables, buffer: buf };
}
/** 注册子集字体并返回 fontFamily 名 */
async function registerSubsetFont(ttfBuffer: ArrayBuffer, counter: number): Promise<string> {
const fontPath = `verify_font_baseline/_verify_${counter}.ttf`;
await writeFile(fontPath, Buffer.from(ttfBuffer));
const familyName = `SubsetFont_${counter}`;
FontLibrary.use(familyName, [fontPath]);
return familyName;
}
if (isBaseline) {
await mkdir("verify_font_baseline", { recursive: true });
const baseline: Record<string, any> = {};
for (const { text, label } of testCases) {
const subset = [...text].map((c) => c.codePointAt(0)!);
const font = Font.create(fontBuffer, { type: "ttf", subset });
const data = extractFontData(font);
await writeFile(`verify_font_baseline/${label}.ttf`, Buffer.from(data.buffer));
/** 用完整字体和子集字体分别渲染,保存像素数据 */
const fullPixels = renderText(FONT_NAME, text, 48);
subsetFontCounter++;
const familyName = await registerSubsetFont(data.buffer, subsetFontCounter);
const subsetPixels = renderText(familyName, text, 48);
const ssim = calculateSSIM(fullPixels, subsetPixels);
const { buffer: _, ...structData } = data;
baseline[label] = {
...structData,
fullPixels: Array.from(fullPixels),
subsetPixels: Array.from(subsetPixels),
ssim,
};
console.log(` ${label}: glyf=${data.glyfCount}, output=${data.outputSize} bytes, ssim=${ssim.toFixed(4)}`);
}
await writeFile("verify_font_baseline.json", JSON.stringify(baseline, null, 2));
console.log("\n基准已生成含完整字体+子集字体渲染像素数据及相似度)");
} else {
const baselineRaw = await readFile("verify_font_baseline.json", "utf-8");
const baseline = JSON.parse(baselineRaw);
let allPassed = true;
for (const { text, label } of testCases) {
const expected = baseline[label];
if (!expected) { console.log(`? ${label}: 无基准数据`); continue; }
const subset = [...text].map((c) => c.codePointAt(0)!);
const font = Font.create(fontBuffer, { type: "ttf", subset });
const actual = extractFontData(font);
const errors: string[] = [];
/** 1. 结构化对比 */
if (actual.glyfCount !== expected.glyfCount) {
errors.push(`glyfCount: ${actual.glyfCount} != ${expected.glyfCount}`);
}
for (let i = 0; i < Math.max(actual.glyf.length, expected.glyf.length); i++) {
const a = actual.glyf[i];
const e = expected.glyf[i];
if (!a || !e) { errors.push(`glyf[${i}]: 缺失`); continue; }
if (a.contours !== e.contours) errors.push(`glyf[${i}].contours: ${a.contours} != ${e.contours}`);
if (a.pts !== e.pts) errors.push(`glyf[${i}].pts: ${a.pts} != ${e.pts}`);
if (a.compound !== e.compound) errors.push(`glyf[${i}].compound: ${a.compound} != ${e.compound}`);
if (JSON.stringify(a.unicode) !== JSON.stringify(e.unicode)) errors.push(`glyf[${i}].unicode: ${JSON.stringify(a.unicode)} != ${JSON.stringify(e.unicode)}`);
if (a.advanceWidth !== e.advanceWidth) errors.push(`glyf[${i}].advanceWidth: ${a.advanceWidth} != ${e.advanceWidth}`);
if (JSON.stringify(a.contourHeads) !== JSON.stringify(e.contourHeads)) errors.push(`glyf[${i}].contourHeads: 不一致`);
}
/** 2. 渲染相似度对比:当前子集字体 vs 基准完整字体 */
const fullPixels = new Uint8Array(expected.fullPixels);
subsetFontCounter++;
const familyName = await registerSubsetFont(actual.buffer, subsetFontCounter);
const currentPixels = renderText(familyName, text, 48);
const ssim = calculateSSIM(fullPixels, currentPixels);
if (ssim < SIMILARITY_THRESHOLD) {
errors.push(`渲染相似度: ${ssim.toFixed(4)} < 阈值 ${SIMILARITY_THRESHOLD}`);
}
if (errors.length === 0) {
console.log(`${label}: PASS (glyf=${actual.glyfCount}, output=${actual.outputSize} bytes, ssim=${ssim.toFixed(4)})`);
} else {
allPassed = false;
console.log(`${label}: FAIL (${errors.length} errors)`);
for (const err of errors) console.log(` ${err}`);
}
}
console.log(`\n${allPassed ? "ALL PASSED ✓" : "SOME CHECKS FAILED ✗"}`);
if (!allPassed) process.exit(1);
}

133
基准测试.test.ts Normal file
View File

@ -0,0 +1,133 @@
/**
*
* 运行: pnpm tsx .test.ts
*
* :
* 1. Font.create optimize sort write
* 2. vs SSIM
*/
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { performance } from "node:perf_hooks";
import { Font } from "fonteditor-core";
import { Canvas, FontLibrary } from "skia-canvas";
const FONT_PATH = "font/令东齐伋复刻体.ttf";
const FONT_NAME = "令东齐伋复刻体";
const raw = await readFile(FONT_PATH);
const fontBuffer = new Uint8Array(raw).buffer;
FontLibrary.use(FONT_NAME, FONT_PATH);
const testCases = [
{ label: "8个汉字", text: "天地玄黄宇宙洪荒" },
{ label: "拉丁+数字", text: "Hello World 123" },
{
label: "千字文前段",
text: "天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈剑号巨阙珠称夜光果珍李柰菜重芥姜海咸河淡鳞潜羽翔",
},
];
const ROUNDS = 10;
/** 子集字体临时文件计数器 */
let subsetFontCounter = 0;
/** 渲染文字到像素数据 */
function renderText(fontFamily: string, text: string, fontSize: number): Uint8Array {
const charWidth = Math.ceil(fontSize * 1.5);
const width = text.length * charWidth + 20;
const height = Math.ceil(fontSize * 1.5);
const canvas = new Canvas(width, height);
const ctx = canvas.getContext("2d");
ctx.fillStyle = "white";
ctx.fillRect(0, 0, width, height);
ctx.font = `${fontSize}px ${fontFamily}`;
ctx.fillStyle = "black";
ctx.fillText(text, 10, Math.ceil(fontSize * 1.2));
const imgData = ctx.getImageData(0, 0, width, height);
return new Uint8Array(imgData.data.buffer);
}
/** 计算两张图片的结构相似度(简化版 SSIM返回 0~1 */
function calculateSSIM(a: Uint8Array, b: Uint8Array): number {
if (a.length !== b.length) return 0;
const toGray = (data: Uint8Array, offset: number) =>
0.299 * data[offset] + 0.587 * data[offset + 1] + 0.114 * data[offset + 2];
const pixelCount = a.length / 4;
let sumA = 0, sumB = 0, sumA2 = 0, sumB2 = 0, sumAB = 0;
for (let i = 0; i < pixelCount; i++) {
const idx = i * 4;
const ga = toGray(a, idx);
const gb = toGray(b, idx);
sumA += ga;
sumB += gb;
sumA2 += ga * ga;
sumB2 += gb * gb;
sumAB += ga * gb;
}
const meanA = sumA / pixelCount;
const meanB = sumB / pixelCount;
const varA = sumA2 / pixelCount - meanA * meanA;
const varB = sumB2 / pixelCount - meanB * meanB;
const covAB = sumAB / pixelCount - meanA * meanB;
const C1 = 6.5025;
const C2 = 58.5225;
return (2 * meanA * meanB + C1) * (2 * covAB + C2) /
((meanA * meanA + meanB * meanB + C1) * (varA + varB + C2));
}
/** 注册子集字体用于渲染 */
async function registerSubsetFont(ttfBuffer: ArrayBuffer, counter: number): Promise<string> {
await mkdir("verify_font_baseline", { recursive: true });
const fontPath = `verify_font_baseline/_bench_${counter}.ttf`;
await writeFile(fontPath, Buffer.from(ttfBuffer));
const familyName = `BenchSubset_${counter}`;
FontLibrary.use(familyName, [fontPath]);
return familyName;
}
console.log("\n=== 字体裁剪基准测试 ===\n");
for (const { label, text } of testCases) {
const subset = [...text].map((c) => c.codePointAt(0)!);
const times: number[] = [];
let lastOutputSize = 0;
let lastTtfBuffer: ArrayBuffer | null = null;
for (let i = 0; i < ROUNDS; i++) {
const t0 = performance.now();
const font = Font.create(fontBuffer, { type: "ttf", subset });
const optimized = font.optimize().sort();
const result = optimized.write({ type: "ttf" });
const t1 = performance.now();
times.push(t1 - t0);
lastOutputSize = typeof result === "string" ? result.length : result.byteLength;
if (i === 0) {
lastTtfBuffer = result instanceof ArrayBuffer ? result : new Uint8Array(result as any).buffer;
}
}
const avg = times.reduce((a, b) => a + b, 0) / times.length;
const min = Math.min(...times);
const max = Math.max(...times);
/** 计算渲染相似度 */
let ssim = 0;
if (lastTtfBuffer) {
subsetFontCounter++;
const familyName = await registerSubsetFont(lastTtfBuffer, subsetFontCounter);
const fullPixels = renderText(FONT_NAME, text, 48);
const subsetPixels = renderText(familyName, text, 48);
ssim = calculateSSIM(fullPixels, subsetPixels);
}
console.log(` ${label}: avg=${avg.toFixed(1)}ms min=${min.toFixed(1)}ms max=${max.toFixed(1)}ms 输出=${lastOutputSize.toLocaleString()} bytes ssim=${ssim.toFixed(4)}`);
}
console.log("");