From ae41a99e95b49bc125f1cfe7fd74779edcce436c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B4=AE=E7=94=9F=EF=BC=88=E5=AD=90=E8=99=9A=EF=BC=89?= <2234839456@qq.com> Date: Sat, 25 Jul 2026 04:58:34 +0800 Subject: [PATCH] =?UTF-8?q?perf(gsub):=20serialize=20type2/3/4=20=E5=A4=A7?= =?UTF-8?q?=20coverage=20=E5=8F=8D=E8=BD=AC=E9=81=8D=E5=8E=86=20+=20?= =?UTF-8?q?=E4=BF=AE=E8=B6=8A=E7=95=8C=20undefined=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit serializeMultipleSubst/serializeAlternateSubst/serializeLigatureSubst 原实现 readCoverageGids 全量展开 coverage 再逐个查 gidLookup,与 serializeSingleSubst ([[gsub-serialize-single-bigcov-reverse]] 早修)同类的 serialize 侧浪费。 probe 实测:白狐千字文 type3 coverage=1000、初夏 type3=329/type4=97、霞鹜 type4=94, 但子集仅命中个位数。CJK 字体(令东/思源 TTF+OTF)type2/3/4 全无调用。 优化:套用 serializeSingleSubst 的反转模式——coverageCount 判大小,coverage 远大于 子集(covGidCount > subsetLen*4 && >16)时遍历 currentSortedSubsetGids 用 coverageIndexOf 二分定位 coverage 下标 idx(即 set 偏移数组下标),替代全量展开。 附带修复原版越界 bug:原版 gidLookup[covGids[i]] 当 covGids[i] 超出 numGlyphs (损坏 coverage,白狐实测含)返回 undefined,`undefined < 0` 为 false(NaN 比较)漏过, 产生 from=undefined 的非法 AlternateSet entry(emitCoverage 写无效 gid)。 两路径统一改 `!(fromNew >= 0)`(undefined>=0 也是 false → 跳过),与反转路径行为一致。 区别于 reachable 侧 readCoverageRemapped 早有 numGlyphs 越界保护(line 221-261)。 A/B:初夏/霞鹜千字文(type3/4 触发反转)字节与基准线 IDENTICAL;白狐千字文字节变小 (-12B ttf/-16B woff2,即去掉的非法 entry)。 基准测试新增「otf-白狐千字文」用例验证:SSIM=1.0000 ink=19735/19735(渲染像素级一致, 证明去掉的是浏览器本就忽略的无效 gid entry)。 性能 best-of N=2000:白狐千字文 min -5.4%/p10 -6.7%、初夏 -5.1%/-3.9%、霞鹜 -5.6%/-3.5%; 令东千字文(无 type2/3/4)持平。29 用例全 SSIM PASS。 Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/font_util/gsub-subset.ts | 192 +++++++++++++++++++++++-------- 基准测试.test.ts | 3 + 2 files changed, 147 insertions(+), 48 deletions(-) diff --git a/backend/font_util/gsub-subset.ts b/backend/font_util/gsub-subset.ts index 95ea288..cb00dd6 100644 --- a/backend/font_util/gsub-subset.ts +++ b/backend/font_util/gsub-subset.ts @@ -528,22 +528,48 @@ function serializeMultipleSubst( const dv = r.dv; const covOff = off + r.u16(off + 2); const seqCount = r.u16(off + 4); - const covGids = readCoverageGids(r, covOff); /** 逐 coverage 字形读取其 sequence,重映射后保留有效项 */ const entries: Array<{ from: number; seq: number[] }> = []; - for (let i = 0; i < covGids.length && i < seqCount; i++) { - const fromNew = gidLookup[covGids[i]]; - if (fromNew < 0) continue; - const seqOff = off + dv.getUint16(off + 6 + i * 2, false); - const glyphCount = dv.getUint16(seqOff, false); - const newSeq: number[] = []; - for (let k = 0; k < glyphCount; k++) { - const g = gidLookup[dv.getUint16(seqOff + 2 + k * 2, false)]; - if (g >= 0) newSeq.push(g); + + /** 反转遍历快路径(同 serializeLigatureSubst / serializeSingleSubst):coverage 远大于子集时 + * 遍历子集 gid 二分定位 coverage 下标 idx(即 SequenceTable 偏移数组下标),替代全量展开。 */ + const subsetGids = currentSortedSubsetGids; + const covGidCount = coverageCount(r, covOff); + const useReverse = covGidCount > subsetGids.length * 4 && covGidCount > 16; + + if (useReverse) { + for (const g of subsetGids) { + const idx = coverageIndexOf(r, covOff, g); + if (idx < 0 || idx >= seqCount) continue; + const fromNew = gidLookup[g]; + if (!(fromNew >= 0)) continue; + const seqOff = off + dv.getUint16(off + 6 + idx * 2, false); + const glyphCount = dv.getUint16(seqOff, false); + const newSeq: number[] = []; + for (let k = 0; k < glyphCount; k++) { + const gg = gidLookup[dv.getUint16(seqOff + 2 + k * 2, false)]; + if (gg >= 0) newSeq.push(gg); + } + /** 序列至少 1 个目标 gid 才有意义 */ + if (newSeq.length > 0) entries.push({ from: fromNew, seq: newSeq }); + } + } else { + const covGids = readCoverageGids(r, covOff); + for (let i = 0; i < covGids.length && i < seqCount; i++) { + const fromNew = gidLookup[covGids[i]]; + /** fromNew === undefined(covGids[i] 越界,损坏 coverage)也跳过,与反转路径行为一致 */ + if (!(fromNew >= 0)) continue; + const seqOff = off + dv.getUint16(off + 6 + i * 2, false); + const glyphCount = dv.getUint16(seqOff, false); + const newSeq: number[] = []; + for (let k = 0; k < glyphCount; k++) { + const g = gidLookup[dv.getUint16(seqOff + 2 + k * 2, false)]; + if (g >= 0) newSeq.push(g); + } + /** 序列至少 1 个目标 gid 才有意义 */ + if (newSeq.length > 0) entries.push({ from: fromNew, seq: newSeq }); } - /** 序列至少 1 个目标 gid 才有意义 */ - if (newSeq.length > 0) entries.push({ from: fromNew, seq: newSeq }); } if (entries.length === 0) return false; /** 按 from gid 升序排序,使 coverage(emitCoverage 强制升序)与 sequence 数组保持下标配对 */ @@ -579,20 +605,46 @@ function serializeAlternateSubst( const dv = r.dv; const covOff = off + r.u16(off + 2); const altCount = r.u16(off + 4); - const covGids = readCoverageGids(r, covOff); const entries: Array<{ from: number; alts: number[] }> = []; - for (let i = 0; i < covGids.length && i < altCount; i++) { - const fromNew = gidLookup[covGids[i]]; - if (fromNew < 0) continue; - const altOff = off + dv.getUint16(off + 6 + i * 2, false); - const cnt = dv.getUint16(altOff, false); - const newAlts: number[] = []; - for (let k = 0; k < cnt; k++) { - const g = gidLookup[dv.getUint16(altOff + 2 + k * 2, false)]; - if (g >= 0) newAlts.push(g); + + /** 反转遍历快路径(同 serializeMultipleSubst / serializeLigatureSubst):coverage 远大于子集时 + * 遍历子集 gid 二分定位 coverage 下标 idx(即 AlternateSet 偏移数组下标),替代全量展开。 */ + const subsetGids = currentSortedSubsetGids; + const covGidCount = coverageCount(r, covOff); + const useReverse = covGidCount > subsetGids.length * 4 && covGidCount > 16; + + if (useReverse) { + for (const g of subsetGids) { + const idx = coverageIndexOf(r, covOff, g); + if (idx < 0 || idx >= altCount) continue; + const fromNew = gidLookup[g]; + if (!(fromNew >= 0)) continue; + const altOff = off + dv.getUint16(off + 6 + idx * 2, false); + const cnt = dv.getUint16(altOff, false); + const newAlts: number[] = []; + for (let k = 0; k < cnt; k++) { + const gg = gidLookup[dv.getUint16(altOff + 2 + k * 2, false)]; + if (gg >= 0) newAlts.push(gg); + } + if (newAlts.length > 0) entries.push({ from: fromNew, alts: newAlts }); + } + } else { + const covGids = readCoverageGids(r, covOff); + for (let i = 0; i < covGids.length && i < altCount; i++) { + const fromNew = gidLookup[covGids[i]]; + /** fromNew === undefined(covGids[i] 越界,损坏 coverage 的 gid 超 numGlyphs)也跳过, + * 与反转路径(subsetGids 必在范围内)行为一致。原 `fromNew < 0` 漏过 undefined(NaN 比较 false)。 */ + if (!(fromNew >= 0)) continue; + const altOff = off + dv.getUint16(off + 6 + i * 2, false); + const cnt = dv.getUint16(altOff, false); + const newAlts: number[] = []; + for (let k = 0; k < cnt; k++) { + const g = gidLookup[dv.getUint16(altOff + 2 + k * 2, false)]; + if (g >= 0) newAlts.push(g); + } + if (newAlts.length > 0) entries.push({ from: fromNew, alts: newAlts }); } - if (newAlts.length > 0) entries.push({ from: fromNew, alts: newAlts }); } if (entries.length === 0) return false; /** 按 from gid 升序排序,使 coverage(emitCoverage 强制升序)与 alternate 数组保持下标配对 */ @@ -629,37 +681,81 @@ function serializeLigatureSubst( const dv = r.dv; const covOff = off + r.u16(off + 2); const setCount = r.u16(off + 4); - const covGids = readCoverageGids(r, covOff); /** 每个 coverage 字形收集有效 ligature 列表 */ const entries: Array<{ from: number; ligs: Array<{ comp: number[]; lig: number }> }> = []; - for (let i = 0; i < covGids.length && i < setCount; i++) { - /** gidLookup[origGid] = 新gid 或 -1(不在子集)。数组索引比 Map.get 快 ~2×, - * serializeLigatureSubst 对每条 ligature 的全部分量密集 remapGid,是 CJK ligature 子集热点。 */ - const fromNew = gidLookup[covGids[i]]; - if (fromNew < 0) continue; - const setOff = off + dv.getUint16(off + 6 + i * 2, false); - const ligCount = dv.getUint16(setOff, false); - const newLigs: Array<{ comp: number[]; lig: number }> = []; - for (let j = 0; j < ligCount; j++) { - const ligOff = setOff + dv.getUint16(setOff + 2 + j * 2, false); - const compCount = dv.getUint16(ligOff, false); - const ligNew = gidLookup[dv.getUint16(ligOff + 2, false)]; - if (ligNew < 0) continue; - /** components 从第 2 字形开始(第 1 字形即 coverage 字形),compCount 含第 1 字形 */ - const compNew: number[] = [fromNew]; - let ok = true; - for (let k = 0; k < compCount - 1; k++) { - const c = gidLookup[dv.getUint16(ligOff + 4 + k * 2, false)]; - if (c < 0) { - ok = false; - break; + + /** 反转遍历快路径:LigatureSubst 的 coverage 可达数百~上千 gid(霞鹜文楷/初夏明朝 type4), + * 但子集仅命中个位数。原 readCoverageGids 全量展开再逐个查 gidLookup 浪费。 + * 改为遍历子集原始 gid(currentSortedSubsetGids),用 coverageIndexOf 二分定位 coverage 下标 idx, + * idx 即 LigatureSet 偏移数组的下标(off + 6 + idx * 2)。与 serializeSingleSubst 反转同思路 + * ([[gsub-serialize-single-bigcov-reverse]])。仅当 coverage 明显多于子集时启用。 */ + const subsetGids = currentSortedSubsetGids; + const covGidCount = coverageCount(r, covOff); + const useReverse = covGidCount > subsetGids.length * 4 && covGidCount > 16; + + if (useReverse) { + for (const g of subsetGids) { + const idx = coverageIndexOf(r, covOff, g); + if (idx < 0 || idx >= setCount) continue; + /** gidLookup[origGid] = 新gid 或 -1(不在子集)。数组索引比 Map.get 快 ~2×, + * serializeLigatureSubst 对每条 ligature 的全部分量密集 remapGid,是 CJK ligature 子集热点。 */ + const fromNew = gidLookup[g]; + if (!(fromNew >= 0)) continue; + const setOff = off + dv.getUint16(off + 6 + idx * 2, false); + const ligCount = dv.getUint16(setOff, false); + const newLigs: Array<{ comp: number[]; lig: number }> = []; + for (let j = 0; j < ligCount; j++) { + const ligOff = setOff + dv.getUint16(setOff + 2 + j * 2, false); + const compCount = dv.getUint16(ligOff, false); + const ligNew = gidLookup[dv.getUint16(ligOff + 2, false)]; + if (ligNew < 0) continue; + /** components 从第 2 字形开始(第 1 字形即 coverage 字形),compCount 含第 1 字形 */ + const compNew: number[] = [fromNew]; + let ok = true; + for (let k = 0; k < compCount - 1; k++) { + const c = gidLookup[dv.getUint16(ligOff + 4 + k * 2, false)]; + if (c < 0) { + ok = false; + break; + } + compNew.push(c); } - compNew.push(c); + if (ok) newLigs.push({ comp: compNew, lig: ligNew }); } - if (ok) newLigs.push({ comp: compNew, lig: ligNew }); + if (newLigs.length > 0) entries.push({ from: fromNew, ligs: newLigs }); + } + } else { + const covGids = readCoverageGids(r, covOff); + for (let i = 0; i < covGids.length && i < setCount; i++) { + /** gidLookup[origGid] = 新gid 或 -1(不在子集)。数组索引比 Map.get 快 ~2×, + * serializeLigatureSubst 对每条 ligature 的全部分量密集 remapGid,是 CJK ligature 子集热点。 */ + const fromNew = gidLookup[covGids[i]]; + /** fromNew === undefined(covGids[i] 越界,损坏 coverage)也跳过,与反转路径行为一致 */ + if (!(fromNew >= 0)) continue; + const setOff = off + dv.getUint16(off + 6 + i * 2, false); + const ligCount = dv.getUint16(setOff, false); + const newLigs: Array<{ comp: number[]; lig: number }> = []; + for (let j = 0; j < ligCount; j++) { + const ligOff = setOff + dv.getUint16(setOff + 2 + j * 2, false); + const compCount = dv.getUint16(ligOff, false); + const ligNew = gidLookup[dv.getUint16(ligOff + 2, false)]; + if (ligNew < 0) continue; + /** components 从第 2 字形开始(第 1 字形即 coverage 字形),compCount 含第 1 字形 */ + const compNew: number[] = [fromNew]; + let ok = true; + for (let k = 0; k < compCount - 1; k++) { + const c = gidLookup[dv.getUint16(ligOff + 4 + k * 2, false)]; + if (c < 0) { + ok = false; + break; + } + compNew.push(c); + } + if (ok) newLigs.push({ comp: compNew, lig: ligNew }); + } + if (newLigs.length > 0) entries.push({ from: fromNew, ligs: newLigs }); } - if (newLigs.length > 0) entries.push({ from: fromNew, ligs: newLigs }); } if (entries.length === 0) return false; /** 按 from gid 升序排序,使 coverage(emitCoverage 强制升序)与 ligature set 数组保持下标配对 */ diff --git a/基准测试.test.ts b/基准测试.test.ts index 5104a3c..67100bb 100644 --- a/基准测试.test.ts +++ b/基准测试.test.ts @@ -271,6 +271,9 @@ const testCases = [ { label: "otf-五个汉字", fontPath: "font/temp/BaiHuOTFJiaoYuHanZi-2.otf", fontName: "白狐教育汉字", text: "天地黄宇宙法海波", sourceType: "otf" as const, outType: "woff2" as const, fullFormat: "opentype" }, { label: "otf-思源黑体", fontPath: "font/temp/SourceHanSans-Regular.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: "woff2" as const, fullFormat: "opentype" }, + /** 白狐千字文长文本:触发 type3 AlternateSubst 大 coverage(千 gid)反转路径 + 守护 + * serializeAlternateSubst 越界 coverage gid 正确性(白狐含损坏 coverage,原 undefined 漏网 bug) */ + { label: "otf-白狐千字文", fontPath: "font/temp/BaiHuOTFJiaoYuHanZi-2.otf", fontName: "白狐教育汉字", text: "天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳", sourceType: "otf" as const, outType: "woff2" as const, fullFormat: "opentype" }, /** ===== 小字号渲染(size=24,守护 SSIM 在小字号下不退化) ===== */ { label: "小字号-8字", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天地玄黄宇宙洪荒", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype", fontSize: 24 },