diff --git a/backend/routes/subset.ts b/backend/routes/subset.ts index cb0a55f..5289338 100644 --- a/backend/routes/subset.ts +++ b/backend/routes/subset.ts @@ -1,7 +1,20 @@ +import { createRequire } from "node:module"; import { fontSubset } from "../font_util/font"; import type { FontEditor } from "../../vendor/fonteditor-core/lib/ttf/font.js"; import { parseUrl, jsonResponse, stats, subsetCache, findFontPath, readFontBuffer } from "../shared"; +/** + * 子集化版本指纹 + * + * 纳入 subsetCache 的 key,让旧缓存条目在以下两种场景自动失效,无需手动清缓存: + * - 生产:发版 bump package.json 的 version,旧缓存自然过期(语义版本失效) + * - 开发:pnpm dev 重启进程时进程启动时间戳变化,内存缓存整体重置 + * + * 杜绝「子集化代码已修但缓存返回旧错误结果」的陷阱。 + */ +const packageVersion: string = createRequire(import.meta.url)("../../package.json").version; +const SUBSET_CACHE_KEY = `${packageVersion}:${process.uptime()}`; + /** GET /api?font=...&text=... — 字体裁剪 */ export async function handleFontSubset(req: Request, res: Response) { const url = parseUrl(req); @@ -28,7 +41,8 @@ export async function handleFontSubset(req: Request, res: Response) { const outType = (outTypeParam === "woff2" || outTypeParam === "ttf") ? outTypeParam : "ttf"; /** 查询裁剪结果缓存 */ - const cacheKey = `${fontPath}:${outType}:${text}`; + /** 版本指纹纳入 key:代码变更后旧缓存自动失效 */ + const cacheKey = `${SUBSET_CACHE_KEY}:${fontPath}:${outType}:${text}`; stats.subsetRequests++; stats.totalChars += text.length; const cached = subsetCache.get(cacheKey); diff --git a/package.json b/package.json index 7c6fa85..9be98f9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "webfont", "private": true, - "version": "1.8.0", + "version": "1.9.0", "type": "module", "scripts": { "dev": "pnpx tsx scripts/dev-all.ts", diff --git a/vendor/fonteditor-core/lib/ttf/otf2ttfobject.js b/vendor/fonteditor-core/lib/ttf/otf2ttfobject.js index 80ef4dd..698a1bc 100644 --- a/vendor/fonteditor-core/lib/ttf/otf2ttfobject.js +++ b/vendor/fonteditor-core/lib/ttf/otf2ttfobject.js @@ -21,7 +21,6 @@ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { de * @return {Object} ttfObject对象 */ function otf2ttfobject(otfBuffer, options) { - var __t0 = process.hrtime.bigint(); var otfObject; if (otfBuffer instanceof ArrayBuffer) { var otfReader = new _otfreader.default(options); @@ -57,7 +56,5 @@ function otf2ttfobject(otfBuffer, options) { /** 优化245: delete → null 赋值,避免 V8 隐藏类转换 */ otfObject.CFF = null; otfObject.VORG = null; - var __t1 = process.hrtime.bigint(); - console.error('otf2ttfobject: ' + Number(__t1 - __t0) / 1e6 + 'ms'); return otfObject; } \ No newline at end of file diff --git a/vendor/fonteditor-core/lib/ttf/otfreader.js b/vendor/fonteditor-core/lib/ttf/otfreader.js index 5da8411..4d377c9 100644 --- a/vendor/fonteditor-core/lib/ttf/otfreader.js +++ b/vendor/fonteditor-core/lib/ttf/otfreader.js @@ -41,7 +41,6 @@ var OTFReader = exports.default = /*#__PURE__*/function () { return _createClass(OTFReader, [{ key: "readBuffer", value: function readBuffer(buffer) { - var __t0 = process.hrtime.bigint(); var reader = new _reader.default(buffer, 0, buffer.byteLength, false); var font = {}; @@ -85,8 +84,6 @@ var OTFReader = exports.default = /*#__PURE__*/function () { _error.default.raise(10303); } reader.dispose(); - var __t1 = process.hrtime.bigint(); - console.error('OTFREADER.readBuffer: ' + Number(__t1 - __t0) / 1e6 + 'ms'); return font; } @@ -98,7 +95,6 @@ var OTFReader = exports.default = /*#__PURE__*/function () { }, { key: "resolveGlyf", value: function resolveGlyf(font) { - var __t0 = process.hrtime.bigint(); var codes = font.cmap; var glyf = font.CFF.glyf; var subsetMap = font.readOptions.subset ? font.subsetMap : null; @@ -207,8 +203,6 @@ var OTFReader = exports.default = /*#__PURE__*/function () { glyf = subGlyf; } font.glyf = glyf; - var __t1 = process.hrtime.bigint(); - console.error('OTFREADER.resolveGlyf: ' + Number(__t1 - __t0) / 1e6 + 'ms'); } /** diff --git a/vendor/fonteditor-core/lib/ttf/table/CFF.js b/vendor/fonteditor-core/lib/ttf/table/CFF.js index 9aa8111..f049746 100644 --- a/vendor/fonteditor-core/lib/ttf/table/CFF.js +++ b/vendor/fonteditor-core/lib/ttf/table/CFF.js @@ -264,8 +264,10 @@ function readCFFIndexObject(reader, indexInfo, idx) { /** * 优化303+307: 子集模式下 off[idx]/off[idx+1] 可能为 undefined(未预读), * 直接用 DataView 读取(绕过 reader 原型方法 + seek 边界检查)。命中槽位直接复用。 + * 注意:必须同时检查 off[idx+1],因为 off[idx] 可能被上一次 idx-1 的顺带读取填充, + * 此时若仅判 off[idx] 会跳过 off[idx+1] 的读取,导致切片 end 为 undefined。 */ - if (off && off[idx] === undefined) { + if (off && (off[idx] === undefined || off[idx + 1] === undefined)) { var base = indexInfo._offsetArrayBase; var os = indexInfo._offsetSize; var dv = reader.view; diff --git a/vendor/fonteditor-core/lib/ttf/table/cmap/parse.js b/vendor/fonteditor-core/lib/ttf/table/cmap/parse.js index bed1b79..57b891a 100644 --- a/vendor/fonteditor-core/lib/ttf/table/cmap/parse.js +++ b/vendor/fonteditor-core/lib/ttf/table/cmap/parse.js @@ -165,7 +165,7 @@ function readSubTable(reader, ttf, subTable, cmapOffset) { * 思源等大 CID 字体 format12 有 1.5 万+ group,全量展开需 4.5 万次 getUint32。 * subset 仅查找少数 cp,lookupFormat12 直接从 view 二分查找(group 已升序、每项 12 字节)。 */ - var isSubset12 = ttf.readOptions && ttf.readOptions.subset; + var isSubset12 = ttf.readOptions && ttf.readOptions.subset && ttf.readOptions.subset.length > 0; if (isSubset12) { format12._cmapView = view; format12._groupsOffset = vOffset; diff --git a/基准测试.test.ts b/基准测试.test.ts index 4d8dcf4..f9d1df8 100644 --- a/基准测试.test.ts +++ b/基准测试.test.ts @@ -106,7 +106,7 @@ async function renderTextViaBrowser( text: string, fontSize: number, fontFormat: string = "truetype", -): Promise<{ pixels: Uint8Array; screenshot: Buffer; inkPixels: number }> { +): Promise<{ pixels: Uint8Array; screenshot: Buffer; inkPixels: number; width: number; height: number }> { const charWidth = Math.ceil(fontSize * 1.5); const width = text.length * charWidth + 20; const height = Math.ceil(fontSize * 1.5); @@ -139,17 +139,21 @@ async function renderTextViaBrowser( throw new Error(`字体渲染无墨水像素 (${fontFamily}),字体可能未正确加载`); } - return { pixels, screenshot: Buffer.from(screenshot), inkPixels }; + return { pixels, screenshot: Buffer.from(screenshot), inkPixels, width, height }; } /** * 计算两张图片的标准 SSIM (Wang et al. 2004) * 使用 11x11 均匀滑动窗口 + 积分图加速,返回 0~1 + * + * 修复:原实现用 Math.sqrt(像素数) 推断 width,假设图片为正方形。 + * 但渲染截图是宽长条(如 740×72),sqrt 会得到错误 width(230), + * 导致像素坐标错位、SSIM 系统性偏低(尤其 OTF 用例被放大偏差)。 + * 改为由调用方传入真实 width/height。 */ -function calculateSSIM(a: Uint8Array, b: Uint8Array): number { +function calculateSSIM(a: Uint8Array, b: Uint8Array, width: number, height: number): number { if (a.length !== b.length) return 0; - const width = Math.sqrt(a.length / 4) | 0; - const height = (a.length / 4 / width) | 0; + if (width * height * 4 !== a.length) return 0; if (width === 0 || height === 0) return 0; /** 转灰度并提取到独立数组 */ @@ -238,9 +242,9 @@ const testCases = [ { label: "拉丁+数字", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "Hello World 123", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" }, { label: "千字文前段", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈剑号巨阙珠称夜光果珍李柰菜重芥姜海咸河淡鳞潜羽翔", sourceType: "ttf" as const, outType: "ttf" as const, fullFormat: "truetype" }, { label: "千字文前段", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈剑号巨阙珠称夜光果珍李柰菜重芥姜海咸河淡鳞潜羽翔", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" }, - /** OTF 字体 */ - { label: "otf-五个汉字", fontPath: "font/temp/BaiHuOTFJiaoYuHanZi-2.otf", fontName: "白狐教育汉字", text: "天地黄宇宙", sourceType: "otf" as const, outType: "ttf" as const, fullFormat: "opentype" }, - { label: "otf-思源黑体", fontPath: "font/temp/SourceHanSans-Regular.otf", fontName: "思源黑体", text: "天地玄黄宇宙", sourceType: "otf" as const, outType: "ttf" as const, fullFormat: "opentype" }, + /** OTF 字体(含三点水等复杂笔画字,守护 OTF→TTF 转换正确性) */ + { label: "otf-五个汉字", fontPath: "font/temp/BaiHuOTFJiaoYuHanZi-2.otf", fontName: "白狐教育汉字", text: "天地黄宇宙法海波", sourceType: "otf" as const, outType: "ttf" as const, fullFormat: "opentype" }, + { label: "otf-思源黑体", fontPath: "font/temp/SourceHanSans-Regular.otf", fontName: "思源黑体", text: "天地玄黄宇宙洪法海波", sourceType: "otf" as const, outType: "ttf" as const, fullFormat: "opentype" }, ]; // ======== 主测试 ======== @@ -370,7 +374,7 @@ for (const tc of testCases) { fullInk = fullResult.inkPixels; subsetInk = subsetResult.inkPixels; - ssim = calculateSSIM(fullResult.pixels, subsetResult.pixels); + ssim = calculateSSIM(fullResult.pixels, subsetResult.pixels, fullResult.width, fullResult.height); } results.push({ label: tc.label, sourceType: tc.sourceType, outType: tc.outType, avg, min, max, outputSize: lastSize, ssim, fullInk, subsetInk });