From 5c118121ff2da8b11ce33099bb331ca5204fbb5a 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: Thu, 27 Aug 2026 07:57:32 +0800 Subject: [PATCH] =?UTF-8?q?v1.14.0:=20GSUB/GPOS=20=E5=85=B1=E4=BA=AB=20gid?= =?UTF-8?q?Lookup=20=E6=9F=A5=E6=89=BE=E8=A1=A8=20+=20remapGid=20=E6=95=B0?= =?UTF-8?q?=E7=BB=84=E5=8C=96=20+=20WF2=5FNOTRANSFORM=20=E5=AE=9E=E9=AA=8C?= =?UTF-8?q?=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优化340:buildGidLookup 提取导出,rewriteLayoutTablesForSubset 构造一次共享传入 subsetGPOS/subsetGSUB(省每侧独立的 maxOrigGid 扫描+fill+Map 遍历,big3500 -4.2%) - 优化342:remapGid/parseChainRuleFormat1or2 从 Map.get 改为 gidLookup 数组索引 (~9ns→~1ns,语义等价:越界/-1 均映射为 null),GSUB 查询路径 Map 残余清零 - 实验341:woff2-encode.js 增加 WF2_NOTRANSFORM=1 环境变量开关,跳过 glyf/loca transform(规范合法 untransformed 路径,-13% CPU 但 +10% 体积,默认关闭不影响生产) 验证:tsc 通过;基准 30/30 PASS min ssim 0.9876 与基线一致;输出字节总量 449,440B 与基线完全一致;7 用例 sha256 字节级 IDENTICAL;leafer 渲染 7 用例 SSIM≥0.99; 24 字体结构扫描通过(6 个缺字均为原字体本身无此字形);40/40 边界输入扫描通过。 --- backend/font_util/font.ts | 9 ++-- backend/font_util/gpos-subset.ts | 10 ++--- backend/font_util/gsub-subset.ts | 43 ++++++++++++++------ package.json | 2 +- vendor/fonteditor-core/woff2/woff2-encode.js | 31 +++++++++++++- 5 files changed, 72 insertions(+), 23 deletions(-) diff --git a/backend/font_util/font.ts b/backend/font_util/font.ts index 64109f4..6c503e1 100644 --- a/backend/font_util/font.ts +++ b/backend/font_util/font.ts @@ -1,7 +1,7 @@ import { Font } from "../../vendor/fonteditor-core/lib/ttf/font.js"; import type { FontEditor } from "../../vendor/fonteditor-core/lib/ttf/font.js"; import { subsetGPOS } from "./gpos-subset.js"; -import { subsetGSUB } from "./gsub-subset.js"; +import { subsetGSUB, buildGidLookup } from "./gsub-subset.js"; import { collectReachableGsubTargets } from "./gsub-reachable.js"; import { probeGsubAndCmap } from "./gsub-probe.js"; import { subsetOTF } from "./otf-subset.js"; @@ -80,12 +80,15 @@ const rewriteLayoutTablesForSubset = ( const origToNew = new Map(); for (let i = 0; i < subsetGids.length; i++) origToNew.set(subsetGids[i], i); + /** GPOS/GSUB 的 origToNew 相同,gidLookup 只构造一次共享传入(省一次 maxOrigGid 扫描+fill+Map 遍历) */ + const gidLookup = buildGidLookup(origToNew); + /** GPOS 子集化 */ const origGPOS = ttf.GPOS; if (origGPOS) { const gposBytes = origGPOS instanceof Uint8Array ? origGPOS : new Uint8Array(origGPOS); if (gposBytes.byteLength > 0) { - const rewritten = subsetGPOS(gposBytes, origToNew); + const rewritten = subsetGPOS(gposBytes, origToNew, gidLookup); if (rewritten) ttf.GPOS = rewritten; /** rewritten === null 表示含不支持的版本,保留原始 GPOS 字节(安全降级) */ } @@ -96,7 +99,7 @@ const rewriteLayoutTablesForSubset = ( if (origGSUB) { const gsubBytes = origGSUB instanceof Uint8Array ? origGSUB : new Uint8Array(origGSUB); if (gsubBytes.byteLength > 0) { - ttf.GSUB = subsetGSUB(gsubBytes, origToNew); + ttf.GSUB = subsetGSUB(gsubBytes, origToNew, gidLookup); } } }; diff --git a/backend/font_util/gpos-subset.ts b/backend/font_util/gpos-subset.ts index 410644f..9b0432f 100644 --- a/backend/font_util/gpos-subset.ts +++ b/backend/font_util/gpos-subset.ts @@ -15,6 +15,7 @@ */ import { OTWriter as Writer, OTReader as Reader, serializeScriptList, serializeFeatureList, scriptListSpan, featureListSpan } from "./ot-bytes.js"; +import { buildGidLookup } from "./gsub-subset.js"; /** ValueFormat 位掩码 */ const VF_X_PLACEMENT = 0x0001; @@ -118,6 +119,7 @@ function writeEmptyCoverage(w: Writer): void { export function subsetGPOS( gposBytes: Uint8Array, origToNew: Map, + sharedGidLookup?: GidLookup, ): Uint8Array | null { const dv = new DataView(gposBytes.buffer, gposBytes.byteOffset, gposBytes.byteLength); const r = new Reader(dv); @@ -127,11 +129,9 @@ export function subsetGPOS( * 替代全模块的 origToNew.get/has(Map 查询 ~9ns vs 数组索引 ~1ns)。 * coverage/ClassDef/PairPos 序列化每字形都查,CJK 标点字体调用密集。 * 容量按 subsetGids 的最大原 gid 分配(远小于 65536,避免大数组 fill 抵消收益—— - * GPOS coverage 常引用大于子集 maxGid 的 gid,remapGid 内做越界判定返回 -1)。 */ - let maxOrigGid = 0; - for (const g of origToNew.keys()) if (g > maxOrigGid) maxOrigGid = g; - const gidLookup: GidLookup = new Int32Array(maxOrigGid + 1).fill(-1); - for (const [g, n] of origToNew) gidLookup[g] = n; + * GPOS coverage 常引用大于子集 maxGid 的 gid,remapGid 内做越界判定返回 -1)。 + * 与 subsetGSUB 内容一致,调用方构造一次共享传入;未传时兜底自建。 */ + const gidLookup: GidLookup = sharedGidLookup ?? buildGidLookup(origToNew); /** ---- GPOS Header ---- */ const major = dv.getUint16(0, false); diff --git a/backend/font_util/gsub-subset.ts b/backend/font_util/gsub-subset.ts index b8f137d..c8f9c64 100644 --- a/backend/font_util/gsub-subset.ts +++ b/backend/font_util/gsub-subset.ts @@ -39,10 +39,14 @@ const COV_RANGE = 2; /** * 重映射单个 gid。子集外的 gid 返回 null。 * GSUB 中 coverage/ClassDef/替换目标引用的 gid 若不在子集内,该条目失效,调用方丢弃之。 + * 优化342:gidLookup(Int32Array 索引 ~1ns)替代 origToNew.get(Map.get ~9ns),全字符集 + * (27677 gid)下 coverage/ClassDef 逐 gid 重映射是 Map 残余热点。语义等价: + * Map.get 缺失返回 undefined→null;gidLookup 越界返回 undefined、子集外为 -1,均映射为 null。 */ -function remapGid(origToNew: Map, gid: number): number | null { - const m = origToNew.get(gid); - return m === undefined ? null : m; +function remapGid(gidLookup: GidLookup, gid: number): number | null { + if (gid >= gidLookup.length) return null; + const m = gidLookup[gid]; + return m < 0 ? null : m; } /** Coverage 表单个 range 展开为 gid 的上限保护:超出视为偏移错位读到垃圾数据。 */ @@ -83,6 +87,20 @@ const EMPTY_GIDS: number[] = []; */ type GidLookup = Int32Array; +/** + * 构造 原gid→新gid 的 Int32Array 查找表(下标=原gid,值=新gid,-1=不在子集)。 + * subsetGPOS 与 subsetGSUB 用同一 origToNew,查表内容完全一致——由 rewriteLayoutTablesForSubset + * 构造一次共享传入,省掉每侧各自的 maxOrigGid 扫描 + fill + Map 遍历(全字符集 27677 gid + * 时两轮 Map 遍历 + 两次 fill 合计 ~1.6ms 量级)。 + */ +export const buildGidLookup = (origToNew: Map): GidLookup => { + let maxOrigGid = 0; + for (const g of origToNew.keys()) if (g > maxOrigGid) maxOrigGid = g; + const gidLookup: GidLookup = new Int32Array(maxOrigGid + 1).fill(-1); + for (const [g, n] of origToNew) gidLookup[g] = n; + return gidLookup; +}; + /** * 当前 subsetGSUB 调用的子集原始 gid 升序数组,供 readClassDefMap format2 二分优化复用。 * @@ -924,7 +942,7 @@ function serializeChainedContextSubst( rules: Array<{ back: number[]; input: number[]; look: number[]; records: Array<{ seq: number; lookup: number }> }>; }> = []; for (let i = 0; i < covGids.length && i < ruleSetCount; i++) { - const firstNew = remapGid(origToNew, covGids[i]); + const firstNew = remapGid(gidLookup, covGids[i]); if (firstNew === null) continue; const setOff = off + dv.getUint16(off + 6 + i * 2, false); const ruleCount = dv.getUint16(setOff, false); @@ -932,7 +950,7 @@ function serializeChainedContextSubst( const validRules: Array<{ back: number[]; input: number[]; look: number[]; records: Array<{ seq: number; lookup: number }> }> = []; for (let j = 0; j < ruleCount; j++) { const ruleOff = setOff + dv.getUint16(setOff + 2 + j * 2, false); - const parsed = parseChainRuleFormat1or2(r, ruleOff, origToNew, true); + const parsed = parseChainRuleFormat1or2(r, ruleOff, gidLookup, true); if (parsed) validRules.push(parsed); } if (validRules.length > 0) entries.push({ firstGid: firstNew, rules: validRules }); @@ -984,7 +1002,7 @@ function serializeChainedContextSubst( const ruleOff = setOff + dv.getUint16(setOff + 2 + j * 2, false); /** format2 的元素是 class index(非 gid),原样保留,传 isGidFormat=false。 * class index 始终有效(ClassDef 重映射 gid 后 class 编号不变),规则恒保留。 */ - const parsed = parseChainRuleFormat1or2(r, ruleOff, origToNew, false); + const parsed = parseChainRuleFormat1or2(r, ruleOff, gidLookup, false); if (!parsed) continue; const list = classToRules.get(i) ?? []; list.push(parsed); @@ -1015,7 +1033,7 @@ function serializeChainedContextSubst( function parseChainRuleFormat1or2( r: Reader, ruleOff: number, - origToNew: Map, + gidLookup: GidLookup, isGidFormat: boolean, ): { back: number[]; input: number[]; look: number[]; records: Array<{ seq: number; lookup: number }> } | null { const dv = r.dv; @@ -1033,7 +1051,7 @@ function parseChainRuleFormat1or2( if (isGidFormat) { const out: number[] = []; for (let k = 0; k < count; k++) { - const m = remapGid(origToNew, dv.getUint16(p + k * 2, false)); + const m = remapGid(gidLookup, dv.getUint16(p + k * 2, false)); if (m === null) return null; out.push(m); } @@ -1466,6 +1484,7 @@ function serializeSubtable( export function subsetGSUB( gsubBytes: Uint8Array, origToNew: Map, + sharedGidLookup?: GidLookup, ): Uint8Array { const dv = new DataView(gsubBytes.buffer, gsubBytes.byteOffset, gsubBytes.byteLength); const r = new Reader(dv); @@ -1475,11 +1494,9 @@ export function subsetGSUB( const covCache: CoverageCache = new Map(); /** 原gid → 新gid 数组查找表(coverage 边解析边过滤的热路径用,数组索引比 Map.get 快数倍)。 - * 下标=原gid,值=新gid,-1 表示不在子集。容量覆盖出现的最大原 gid。 */ - let maxOrigGid = 0; - for (const g of origToNew.keys()) if (g > maxOrigGid) maxOrigGid = g; - const gidLookup: GidLookup = new Int32Array(maxOrigGid + 1).fill(-1); - for (const [g, n] of origToNew) gidLookup[g] = n; + * 下标=原gid,值=新gid,-1 表示不在子集。容量覆盖出现的最大原 gid。 + * 与 subsetGPOS 内容一致,调用方(rewriteLayoutTablesForSubset)构造一次共享传入;未传时兜底自建。 */ + const gidLookup: GidLookup = sharedGidLookup ?? buildGidLookup(origToNew); /** 升序子集原始 gid 数组(惰性):供 readClassDefMap format2 / coverage range 二分优化。 * 优化336:不在入口无条件 sort(全字符集 27677 gid 耗 ~3.2ms),改为设 source + 清缓存, diff --git a/package.json b/package.json index 643a93d..b02724f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "webfont", "private": true, - "version": "1.13.0", + "version": "1.14.0", "type": "module", "scripts": { "dev": "pnpx tsx scripts/dev-all.ts", diff --git a/vendor/fonteditor-core/woff2/woff2-encode.js b/vendor/fonteditor-core/woff2/woff2-encode.js index d39a66e..1515f34 100644 --- a/vendor/fonteditor-core/woff2/woff2-encode.js +++ b/vendor/fonteditor-core/woff2/woff2-encode.js @@ -783,7 +783,9 @@ function encodeTTFToWOFF2(ttfBuffer) { /* glyf + loca 变换 */ let glyfTransformed = null; - if (glyfTable && locaTable) { + /** 实验开关:WF2_NOTRANSFORM=1 时跳过 glyf/loca transform(规范允许,flags 置 0x40) */ + const noTransform = typeof process !== "undefined" && process.env && process.env.WF2_NOTRANSFORM === "1"; + if (glyfTable && locaTable && !noTransform) { const glyfData = data.subarray(glyfTable.offset, glyfTable.offset + glyfTable.length); const locaData = data.subarray(locaTable.offset, locaTable.offset + locaTable.length); const result = transformGlyfAndLoca(glyfData, locaData, indexToLocFormat, numGlyphs); @@ -813,6 +815,19 @@ function encodeTTFToWOFF2(ttfBuffer) { for (var fi2 = 0, fl2 = filtered.length; fi2 < fl2; fi2++) { var t = filtered[fi2]; if (t.tagU32 === TAG_loca) { + if (noTransform) { + /** untransformed:loca 原始字节保留(transform version 3,flags 置 0x40) */ + dirEntries[dirIdx++] = { + tagU32: t.tagU32, tagIndex: t.tagIndex, + flags: t.tagIndex | 0x40, + origLength: t.length, + transformLength: t.length, + data: data.subarray(t.offset, t.offset + t.length), + hasTransform: false, + }; + totalDirSize += entrySize(t.tagIndex, t.length, false, t.length); + continue; + } /** 优化:直接使用 transformGlyfAndLoca 返回的 locaOrigLength,避免重复计算 */ const origLength = glyfTransformed ? glyfTransformed.locaOrigLength : t.length; dirEntries[dirIdx++] = { @@ -840,6 +855,20 @@ function encodeTTFToWOFF2(ttfBuffer) { continue; } + if (t.tagU32 === TAG_glyf && noTransform) { + /** untransformed glyf:原始字节直出 */ + dirEntries[dirIdx++] = { + tagU32: t.tagU32, tagIndex: t.tagIndex, + flags: t.tagIndex | 0x40, + origLength: t.length, + transformLength: t.length, + data: data.subarray(t.offset, t.offset + t.length), + hasTransform: false, + }; + totalDirSize += entrySize(t.tagIndex, t.length, false, t.length); + continue; + } + let tableData = data.subarray(t.offset, t.offset + t.length); dirEntries[dirIdx++] = {