perf(gsub): lookup 级全空预扫描跳过逐子表序列化

subsetGSUB 对每个 supported lookup 预扫描所有子表是否都 coverage 全子集外
(isSubtableSkipableByCoverage)。全空 lookup 序列化结果必然是 N 个空 subtable
(与逐子表 serializeSubtable 失败后 writeEmptySubtable 逐字节相同),直接在 lookup 级
批量写空,跳过 N 次 serializeSubtable 函数调用 + 预检 + rollback 开销。

初夏 lookup[5] type6 268 子表(小子集全不命中)是典型受益场景。

安全性:allEmpty 当且仅当所有子表 isSubtableSkipableByCoverage===true,而 serializeSubtable
内部对 skipable 子表直接 return false(→空 subtable),故输出逐字节相同(含 FiraCode 连字)。
format2 class 驱动子表不预检(返回 false),含 format2 的 lookup allEmpty 必为 false 走原路径。
subCount 不变、lookup 不删(区别于已证危险的 GSUB lookup 删除)。

subsetGSUB 初夏 0.215→0.178ms(-17%),全基准 SSIM 与输出字节完全不变(diff 验证 hash 一致)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
崮生(子虚) 2026-07-24 15:14:00 +08:00
parent 8a7efea9d0
commit b17d5a3700
8 changed files with 289 additions and 11 deletions

16
_p_check.ts Normal file
View File

@ -0,0 +1,16 @@
import fs from 'node:fs';
import { Font } from './vendor/fonteditor-core/lib/ttf/font.js';
const buf = fs.readFileSync('/mnt/d/字体资源/初夏明朝/初夏明朝-Regular.ttf');
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset+buf.byteLength) as ArrayBuffer;
const cps = Array.from(',。!?、;:“”‘’');
const font = Font.create(ab, { type: 'ttf', subset: cps as any, kerning: true });
const opt = (font as any).optimize();
const ttf = opt.get();
console.log('GPOS present:', !!ttf.GPOS, 'len:', ttf.GPOS && ttf.GPOS.length);
console.log('GSUB present:', !!ttf.GSUB);
console.log('numGlyphs:', ttf.glyf.length);
const ttfBuf = opt.write({ type: 'ttf', hinting: false } as any);
console.log('ttfBuf type:', ttfBuf.constructor.name, 'len:', ttfBuf.length);
// 完整 woff2 via font.write
const woff2 = opt.write({ type: 'woff2', hinting: false } as any);
console.log('woff2 (font.write):', woff2.length, 'type:', woff2.constructor.name);

24
_p_e2e.ts Normal file
View File

@ -0,0 +1,24 @@
import fs from 'node:fs';
import { fontSubset } from './backend/font_util/font.js';
function median(a: number[]) { return a.slice().sort((x,y)=>x-y)[Math.floor(a.length/2)]; }
function bench(label: string, fn: () => void, iters = 15) {
for (let i = 0; i < 5; i++) fn();
const ts: number[] = [];
for (let i = 0; i < iters; i++) { const t0 = performance.now(); fn(); ts.push(performance.now() - t0); }
console.log(`${label}: ${median(ts).toFixed(3)}ms (min ${Math.min(...ts).toFixed(3)})`);
}
for (const [name, p, text] of [
['初夏纯标点', '/mnt/d/字体资源/初夏明朝/初夏明朝-Regular.ttf', ',。!?、;:“”‘’'],
['初夏汉字标点', '/mnt/d/字体资源/初夏明朝/初夏明朝-Regular.ttf', '你好,世界!今天天气不错。'],
['思源8字', './font/思源黑体.ttf', '天地玄黄宇宙洪荒'],
['令东千字', './font/令东齐伋复刻体.ttf', '天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈'],
] as const) {
const buf = fs.readFileSync(p);
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset+buf.byteLength) as ArrayBuffer;
console.log(`\n=== ${name} ===`);
let outLen = 0;
bench(' fontSubset(woff2)', () => { const o = fontSubset(ab, text, { sourceType: 'ttf' as const, outType: 'woff2' as const }); outLen = o.length; });
console.log(` woff2 out: ${outLen}B`);
}

29
_p_gsub_cmp.ts Normal file
View File

@ -0,0 +1,29 @@
import fs from 'node:fs';
import { Font } from './vendor/fonteditor-core/lib/ttf/font.js';
import { subsetGSUB } from './backend/font_util/gsub-subset.js';
import crypto from 'node:crypto';
const cases: [string, string, string][] = [
['初夏纯标点', '/mnt/d/字体资源/初夏明朝/初夏明朝-Regular.ttf', ',。!?、;:“”‘’'],
['初夏汉字', '/mnt/d/字体资源/初夏明朝/初夏明朝-Regular.ttf', '你好,世界!今天天气不错。'],
['思源8字', './font/思源黑体.ttf', '天地玄黄宇宙洪荒'],
['令东千字', './font/令东齐伋复刻体.ttf', '天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈'],
['FiraCode', '/mnt/d/字体资源/FiraCode/FiraCode-Medium.ttf', '=> !== >= <= ==='],
];
for (const [name, p, text] of cases) {
if (!fs.existsSync(p)) { console.log(`${name}: 跳过(文件不存在)`); continue; }
const buf = fs.readFileSync(p);
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset+buf.byteLength) as ArrayBuffer;
const cps = Array.from(text).map(c => c.codePointAt(0)!);
const font = Font.create(ab, { type: 'ttf', subset: cps as any, kerning: true });
const ttf = font.get();
const subsetGids: number[] = ttf.subsetGids ?? [];
const origToNew = new Map<number, number>();
for (let i = 0; i < subsetGids.length; i++) origToNew.set(subsetGids[i], i);
const gsub = ttf.GSUB;
if (!gsub) { console.log(`${name}: 无 GSUB`); continue; }
const b = gsub instanceof Uint8Array ? gsub : new Uint8Array(gsub);
const out = subsetGSUB(b, origToNew);
const hash = crypto.createHash('sha256').update(out).digest('hex').slice(0,16);
console.log(`${name}: gsubOut=${out.length}B hash=${hash}`);
}

23
_p_gsub_struct.ts Normal file
View File

@ -0,0 +1,23 @@
import fs from 'node:fs';
const buf = fs.readFileSync('/mnt/d/字体资源/初夏明朝/初夏明朝-Regular.ttf');
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset+buf.byteLength) as ArrayBuffer;
const view = new DataView(ab);
// 找 GSUB
const numTables = view.getUint16(4, false);
let off=0;
for (let i=0;i<numTables;i++){const r=12+i*16;const tag=String.fromCharCode(view.getUint8(r),view.getUint8(r+1),view.getUint8(r+2),view.getUint8(r+3));if(tag==='GSUB'){off=view.getUint32(r+8,false);break;}}
// GSUB: version(u32) scriptListOff(u16) featureListOff(u16) lookupListOff(u16)
const v = view.getUint32(off, false);
const scriptOff = off + view.getUint16(off+4, false);
const featOff = off + view.getUint16(off+6, false);
const lookupOff = off + view.getUint16(off+8, false);
const lookupCount = view.getUint16(lookupOff, false);
console.log('GSUB version:', (v>>>16), '.', (v&0xffff));
console.log('lookupCount:', lookupCount);
for (let i=0;i<lookupCount;i++){
const lOff = lookupOff + view.getUint16(lookupOff+2+i*2, false);
const type = view.getUint16(lOff, false);
const flag = view.getUint16(lOff+2, false);
const subCount = view.getUint16(lOff+4, false);
console.log(` lookup[${i}]: type=${type} flag=${flag} subCount=${subCount}`);
}

32
_p_sgpos.ts Normal file
View File

@ -0,0 +1,32 @@
import fs from 'node:fs';
import { Font } from './vendor/fonteditor-core/lib/ttf/font.js';
import { subsetGPOS } from './backend/font_util/gpos-subset.js';
import { subsetGSUB } from './backend/font_util/gsub-subset.js';
function median(a: number[]) { return a.slice().sort((x,y)=>x-y)[Math.floor(a.length/2)]; }
const p = '/mnt/d/字体资源/初夏明朝/初夏明朝-Regular.ttf';
const text = ',。!?、;:“”‘’';
const buf = fs.readFileSync(p);
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset+buf.byteLength) as ArrayBuffer;
const codePoints = Array.from(text).map(c => c.codePointAt(0)!);
// 一次性拿到 subsetGids + GPOS 字节 + origToNew
const font = Font.create(ab, { type: 'ttf', subset: codePoints as any, kerning: true });
const preOpt = font.get();
const subsetGids: number[] = preOpt.subsetGids ?? [];
const origToNew = new Map<number, number>();
for (let i = 0; i < subsetGids.length; i++) origToNew.set(subsetGids[i], i);
const gposBytes = preOpt.GPOS instanceof Uint8Array ? preOpt.GPOS : new Uint8Array(preOpt.GPOS);
const gsubBytes = preOpt.GSUB instanceof Uint8Array ? preOpt.GSUB : new Uint8Array(preOpt.GSUB);
console.log('subsetGids:', subsetGids.length, 'GPOS:', gposBytes.length, 'GSUB:', gsubBytes.length);
function bench(label: string, fn: () => void, iters = 25) {
for (let i = 0; i < 8; i++) fn();
const ts: number[] = [];
for (let i = 0; i < iters; i++) { const t0 = performance.now(); fn(); ts.push(performance.now() - t0); }
console.log(` ${label}: ${median(ts).toFixed(3)}ms (min ${Math.min(...ts).toFixed(3)})`);
}
bench('subsetGPOS', () => subsetGPOS(gposBytes, origToNew));
bench('subsetGSUB', () => subsetGSUB(gsubBytes, origToNew));

85
_p_stages.ts Normal file
View File

@ -0,0 +1,85 @@
import fs from 'node:fs';
import { Font } from './vendor/fonteditor-core/lib/ttf/font.js';
import { probeGsubAndCmap } from './backend/font_util/gsub-probe.js';
import { collectReachableGsubTargets } from './backend/font_util/gsub-reachable.js';
import { subsetGPOS } from './backend/font_util/gpos-subset.js';
import { subsetGSUB } from './backend/font_util/gsub-subset.js';
function rewriteLayoutTablesForSubset(opt: any, subsetGids: number[]) {
const ttf = opt.get();
const origToNew = new Map<number, number>();
for (let i = 0; i < subsetGids.length; i++) origToNew.set(subsetGids[i], i);
const og = ttf.GPOS;
if (og) { const b = og instanceof Uint8Array ? og : new Uint8Array(og); if (b.byteLength > 0) { const r = subsetGPOS(b, origToNew); if (r) ttf.GPOS = r; } }
const os = ttf.GSUB;
if (os) { const b = os instanceof Uint8Array ? os : new Uint8Array(os); if (b.byteLength > 0) { ttf.GSUB = subsetGSUB(b, origToNew); } }
}
function median(a: number[]) { return a.slice().sort((x,y)=>x-y)[Math.floor(a.length/2)]; }
const p = '/mnt/d/字体资源/初夏明朝/初夏明朝-Regular.ttf';
const text = ',。!?、;:“”‘’';
const buf = fs.readFileSync(p);
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset+buf.byteLength) as ArrayBuffer;
const codePoints = Array.from(text).map(c => c.codePointAt(0)!);
// 准备共享对象
function build() {
const probe = probeGsubAndCmap(ab, codePoints, 'ttf');
let extraSubsetGids: number[] | undefined;
if (probe.ok) {
const seed = new Set<number>([0]);
for (const cp of codePoints) { const g = probe.lookup.get(cp); if (g !== undefined) seed.add(g); }
const r = collectReachableGsubTargets(probe.gsubBytes!, seed);
if (r.size > 0) extraSubsetGids = [...r];
}
return { extraSubsetGids };
}
const { extraSubsetGids } = build();
function timePhase(label: string, fn: () => void, iters = 13) {
for (let i = 0; i < 4; i++) fn();
const ts: number[] = [];
for (let i = 0; i < iters; i++) { const t0 = performance.now(); fn(); ts.push(performance.now() - t0); }
console.log(` ${label}: ${median(ts).toFixed(3)}ms`);
}
console.log('=== 初夏纯标点 各阶段(独立 best-of===');
timePhase('1.probe', () => probeGsubAndCmap(ab, codePoints, 'ttf'));
let fontRef: any;
timePhase('2.Font.create', () => { fontRef = Font.create(ab, { type: 'ttf', subset: codePoints as any, kerning: true, extraSubsetGids }); });
const font = Font.create(ab, { type: 'ttf', subset: codePoints as any, kerning: true, extraSubsetGids });
const preOpt = font.get();
const subsetGids: number[] = preOpt.subsetGids ?? [];
console.log(` subsetGids count: ${subsetGids.length}`);
timePhase('3.optimize', () => { const f = Font.create(ab, { type: 'ttf', subset: codePoints as any, kerning: true, extraSubsetGids }); f.optimize(); });
const opt = font.optimize();
// rewrite 内部拆解
timePhase('4a.subsetGPOS', () => {
const f = Font.create(ab, { type: 'ttf', subset: codePoints as any, kerning: true, extraSubsetGids });
const o = f.optimize();
// 复刻 rewriteLayoutTablesForSubset 的 GPOS 部分
const ttf = o.get();
const origToNew = new Map<number, number>();
for (let i = 0; i < subsetGids.length; i++) origToNew.set(subsetGids[i], i);
const g = ttf.GPOS;
if (g) { subsetGPOS(g instanceof Uint8Array ? g : new Uint8Array(g), origToNew); }
});
timePhase('4.rewriteLayoutTables', () => {
const f = Font.create(ab, { type: 'ttf', subset: codePoints as any, kerning: true, extraSubsetGids });
const o = f.optimize();
rewriteLayoutTablesForSubset(o, subsetGids);
});
timePhase('5.write(woff2)', () => {
const f = Font.create(ab, { type: 'ttf', subset: codePoints as any, kerning: true, extraSubsetGids });
const o = f.optimize();
rewriteLayoutTablesForSubset(o, subsetGids);
o.write({ type: 'woff2', hinting: false } as any);
});

30
_p_woff2enc.ts Normal file
View File

@ -0,0 +1,30 @@
import fs from 'node:fs';
import { Font } from './vendor/fonteditor-core/lib/ttf/font.js';
const woff2enc: any = await import('./vendor/fonteditor-core/woff2/woff2-encode.js');
function median(a: number[]) { return a.slice().sort((x,y)=>x-y)[Math.floor(a.length/2)]; }
function bench(label: string, fn: () => void, iters = 15) {
for (let i = 0; i < 5; i++) fn();
const ts: number[] = [];
for (let i = 0; i < iters; i++) { const t0 = performance.now(); fn(); ts.push(performance.now() - t0); }
console.log(`${label}: ${median(ts).toFixed(3)}ms (min ${Math.min(...ts).toFixed(3)})`);
}
for (const [name, p, text] of [
['初夏标点', '/mnt/d/字体资源/初夏明朝/初夏明朝-Regular.ttf', ',。!?、;:“”‘’'],
['思源8字', './font/思源黑体.ttf', '天地玄黄宇宙洪荒'],
['令东千字', './font/令东齐伋复刻体.ttf', '天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈'],
] as const) {
const buf = fs.readFileSync(p);
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset+buf.byteLength) as ArrayBuffer;
const cps = Array.from(text);
const font = Font.create(ab, { type: 'ttf', subset: cps as any, kerning: true });
const opt = (font as any).optimize();
const ttfBuf = opt.write({ type: 'ttf', hinting: false } as any);
console.log(`\n=== ${name} (ttf ${(ttfBuf.length/1024).toFixed(1)}KB) ===`);
bench(' TTFWriter.write(ttf)', () => opt.write({ type: 'ttf', hinting: false } as any));
bench(' encodeTTFToWOFF2(全部)', () => woff2enc.encodeTTFToWOFF2(ttfBuf));
// 单独 transformGlyfAndLoca需拆解先量总 encode
const w2 = woff2enc.encodeTTFToWOFF2(ttfBuf);
console.log(` woff2 out: ${(w2.length)}B`);
}

View File

@ -1209,6 +1209,11 @@ export function subsetGSUB(
effectiveType: number;
subtableAbsOffs: number[];
origLookupOff: number;
/** lookup coverage subtable
* true serializeSubtable subtable subCount lookup
* format2 class lookupisSubtableSkipableByCoverage false allEmpty false
* FiraCode [[gsub-lookup-deletion-failed-fira]] */
allEmpty: boolean;
}
const lookups: LookupInfo[] = [];
for (let i = 0; i < lookupCount; i++) {
@ -1239,7 +1244,31 @@ export function subsetGSUB(
effectiveType === LT_ALTERNATE ||
effectiveType === LT_LIGATURE ||
effectiveType === LT_CHAIN;
lookups.push({ supported, effectiveType, subtableAbsOffs, origLookupOff: lOff });
lookups.push({ supported, effectiveType, subtableAbsOffs, origLookupOff: lOff, allEmpty: false });
}
/**
* 331lookup
* 51 lookup × lookup[5] 268 serializeSubtable 使
* + isSubtableSkipableByCoverage coverage + rollback per-subtable
* lookup skipable N subtable
* lookup N serializeSubtable
*
* allEmpty isSubtableSkipableByCoverage === true serializeSubtable
* skipable return false subtable allEmpty
* **** N writeEmptySubtableformat2 false
* format2 lookup allEmpty false */
for (let i = 0; i < lookupCount; i++) {
const lk = lookups[i];
if (!lk.supported) continue;
let allEmpty = lk.subtableAbsOffs.length > 0;
for (let j = 0; j < lk.subtableAbsOffs.length; j++) {
if (!isSubtableSkipableByCoverage(r, lk.subtableAbsOffs[j], lk.effectiveType, gidLookup, covCache)) {
allEmpty = false;
break;
}
}
lk.allEmpty = allEmpty;
}
/** ---- ----
@ -1328,18 +1357,28 @@ export function subsetGSUB(
if (useMarkFilteringSet) {
w.writeUint16(r.u16(lk.origLookupOff + 6 + lk.subtableAbsOffs.length * 2));
}
for (let j = 0; j < lk.subtableAbsOffs.length; j++) {
subtableAbsPositions[j] = w.length;
/** 单个 subtable 重映射失败coverage gid 全不在子集 /
* 退 subtable coverage
* copyBytesBlock subtable lookup
* lookup subtable */
const before = w.length;
const ok = serializeSubtable(w, r, lk.subtableAbsOffs[j], lk.effectiveType, origToNew, covCache, gidLookup);
if (!ok) {
w.rollback(before);
if (lk.allEmpty) {
/** 331 lookup subtable serializeSubtable
* writeEmptySubtable N
* + + rollback subCount lookup */
for (let j = 0; j < lk.subtableAbsOffs.length; j++) {
subtableAbsPositions[j] = w.length;
writeEmptySubtable(w, lk.effectiveType);
}
} else {
for (let j = 0; j < lk.subtableAbsOffs.length; j++) {
subtableAbsPositions[j] = w.length;
/** 单个 subtable 重映射失败coverage gid 全不在子集 /
* 退 subtable coverage
* copyBytesBlock subtable lookup
* lookup subtable */
const before = w.length;
const ok = serializeSubtable(w, r, lk.subtableAbsOffs[j], lk.effectiveType, origToNew, covCache, gidLookup);
if (!ok) {
w.rollback(before);
writeEmptySubtable(w, lk.effectiveType);
}
}
}
} else {
/** lookuptype5 ReverseChain