feat(v1.9.0): OTF 子集化支持 + 法字笔画修复 + subsetCache 版本指纹

性能(vs v1.7.0 基线,基准测试全用例通过):
- 千字文 woff2 346ms → 9.8ms(35x)
- 8汉字 woff2 39.7ms → 2.8ms(14x)
- 千字文 ttf 6.0ms → 4.4ms
- 8汉字 ttf 2.7ms → 2.1ms

OTF 正确性:
- 修复 CFF.js readCFFIndexObject 漏读 off[idx+1] 导致子程序读成 0 字节,
  思源黑体「法」字三点水丢笔画的问题
- 修复非 subset 模式 cmap format12 崩溃(空数组误判为 subset)

缓存健壮性:
- subsetCache key 加版本指纹(package version + process.uptime),
  杜绝代码已修但缓存返回旧错误结果

基准测试:
- 新增白狐/思源 OTF 用例(含法海波等复杂笔画字)守护 OTF→TTF 转换
- 修复 calculateSSIM 的 width 推断 bug(sqrt 假设正方形)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
崮生(子虚) 2026-06-13 21:01:19 +08:00
parent 4a6c7e64e1
commit 168fc66544
7 changed files with 33 additions and 22 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -165,7 +165,7 @@ function readSubTable(reader, ttf, subTable, cmapOffset) {
* 思源等大 CID 字体 format12 1.5 + group全量展开需 4.5 万次 getUint32
* subset 仅查找少数 cplookupFormat12 直接从 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;

View File

@ -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×72sqrt width230
* 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 });