v1.14.0: GSUB/GPOS 共享 gidLookup 查找表 + remapGid 数组化 + WF2_NOTRANSFORM 实验开关

- 优化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 边界输入扫描通过。
This commit is contained in:
崮生(子虚) 2026-08-27 07:57:32 +08:00
parent cc792d27b0
commit 5c118121ff
5 changed files with 72 additions and 23 deletions

View File

@ -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<number, number>();
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);
}
}
};

View File

@ -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<number, number>,
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/hasMap ~9ns vs ~1ns
* coverage/ClassDef/PairPos CJK
* subsetGids gid 65536 fill
* GPOS coverage maxGid gidremapGid -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 gidremapGid -1
* subsetGSUB */
const gidLookup: GidLookup = sharedGidLookup ?? buildGidLookup(origToNew);
/** ---- GPOS Header ---- */
const major = dv.getUint16(0, false);

View File

@ -39,10 +39,14 @@ const COV_RANGE = 2;
/**
* gid gid null
* GSUB coverage/ClassDef/ gid
* 342gidLookupInt32Array ~1ns origToNew.getMap.get ~9ns
* 27677 gid coverage/ClassDef gid Map
* Map.get undefinednullgidLookup undefined -1 null
*/
function remapGid(origToNew: Map<number, number>, 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;
/**
* gidgid Int32Array =gid=gid-1=
* subsetGPOS subsetGSUB origToNew rewriteLayoutTablesForSubset
* maxOrigGid + fill + Map 27677 gid
* Map + fill ~1.6ms
*/
export const buildGidLookup = (origToNew: Map<number, number>): 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<number, number>,
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<number, number>,
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 +

View File

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

View File

@ -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) {
/** untransformedloca 原始字节保留transform version 3flags 置 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++] = {