perf(otf): hmtx/vmtx 尾部相同 advance 压缩(补齐 TTF 侧 fonteditor 早做的压缩)

subsetOTF 的 buildSubsetMetrics 原把所有 subsetGids 展开为完整 4 字节
(advance+lsb),丢失 hmtx 规范的 numberOfHMetrics 压缩语义。CJK 全角字体
子集后几乎所有 advance 相同(如 1000),尾部 N-1 个 glyph 可只写 lsb(2字节)。

从尾部向前找最长连续相同 advance 段,numberOfHMetrics 收缩为最后一个不同
advance 的位置+1,hhea/vhea 的 numberOfHMetrics 同步 patch。

渲染等价(浏览器对 gid>=numberOfHMetrics 复用最后一个 advance),SSIM 不变。

收益(3 otf 用例 woff2 共 -144B):
- otf-白狐千字文 35880→35792 (-88B)
- otf-五个汉字 15020→14984 (-36B)
- otf-思源黑体 10120→10100 (-20B)

TTF 路径 fonteditor-core 已做此压缩(思源/霞鹜/FiraCode numHM=1),
OTF 路径手写 buildSubsetMetrics 漏做,属 TTF/OTF 对称补齐(模式③)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
崮生(子虚) 2026-07-25 13:37:17 +08:00
parent ade5d279a0
commit 4520a901ef

View File

@ -243,28 +243,58 @@ function buildSubsetMetrics(
metricsOff: number,
numberOfHMetrics: number,
subsetGids: number[],
): Uint8Array {
): { bytes: Uint8Array; numberOfHMetrics: number } {
/** 原始记录:前 numberOfHMetrics 个完整advance+lsb其余仅 lsbadvance 复用最后一个) */
const lastAdv = numberOfHMetrics > 0 ? srcDv.getUint16(metricsOff + (numberOfHMetrics - 1) * 4, false) : 0;
const out = new Uint8Array(subsetGids.length * 4);
const dv = new DataView(out.buffer);
/** 先收集每个子集字形的 (advance, lsb) */
const advs: number[] = new Array(subsetGids.length);
const lsbs: number[] = new Array(subsetGids.length);
for (let i = 0; i < subsetGids.length; i++) {
const gid = subsetGids[i];
let adv: number;
let sb: number;
if (gid < numberOfHMetrics) {
adv = srcDv.getUint16(metricsOff + gid * 4, false);
sb = srcDv.getInt16(metricsOff + gid * 4 + 2, false);
advs[i] = srcDv.getUint16(metricsOff + gid * 4, false);
lsbs[i] = srcDv.getInt16(metricsOff + gid * 4 + 2, false);
} else {
adv = lastAdv;
advs[i] = lastAdv;
/** lsb 数组起始 = metricsOff + numberOfHMetrics*4每项 2 字节 */
const lsbArrOff = metricsOff + numberOfHMetrics * 4;
sb = srcDv.getInt16(lsbArrOff + (gid - numberOfHMetrics) * 2, false);
lsbs[i] = srcDv.getInt16(lsbArrOff + (gid - numberOfHMetrics) * 2, false);
}
dv.setUint16(i * 4, adv, false);
dv.setInt16(i * 4 + 2, sb, false);
}
return out;
/** hmtx advance lsb2
* numberOfHMetrics = advance + 1 advance
* CJK advance 1000 advance (N-numLongMetrics)*2
* advance 1 longMetric
* advance gid>=numberOfHMetrics advance */
let numLongMetrics = advs.length;
if (advs.length > 1) {
const tailAdv = advs[advs.length - 1];
/** 至少保留 1 个完整 longMetricnumLongMetrics >= 1 */
for (let i = advs.length - 1; i >= 1; i--) {
if (advs[i] !== tailAdv) {
numLongMetrics = i + 1;
break;
}
numLongMetrics = i;
}
}
const out = new Uint8Array(numLongMetrics * 4 + (advs.length - numLongMetrics) * 2);
const dv = new DataView(out.buffer);
let off = 0;
for (let i = 0; i < advs.length; i++) {
if (i < numLongMetrics) {
dv.setUint16(off, advs[i], false);
dv.setInt16(off + 2, lsbs[i], false);
off += 4;
} else {
dv.setInt16(off, lsbs[i], false);
off += 2;
}
}
return { bytes: out, numberOfHMetrics: numLongMetrics };
}
/** 重建 maxpOTF 版本 0.5version(4) + numGlyphs(2) */
@ -541,23 +571,24 @@ export function subsetOTF(
const numberOfHMetrics = dv.getUint16(hhea.offset + 34, false);
/** 子集 hmtx */
const newHmtx = buildSubsetMetrics(dv, hmtx.offset, numberOfHMetrics, subsetGids);
/** 子集 hmtx尾部相同 advance 压缩为只写 lsb返回压缩后 numberOfHMetrics */
const hmtxResult = buildSubsetMetrics(dv, hmtx.offset, numberOfHMetrics, subsetGids);
/** 组装各表 */
const outTables: { tag: string; bytes: Uint8Array }[] = [];
outTables.push({ tag: "CFF ", bytes: newCFF });
outTables.push({ tag: "cmap", bytes: newCmap });
outTables.push({ tag: "hmtx", bytes: newHmtx });
outTables.push({ tag: "hmtx", bytes: hmtxResult.bytes });
outTables.push({ tag: "maxp", bytes: buildSubsetMaxp(subsetGids.length) });
outTables.push({ tag: "post", bytes: buildSubsetPost() });
/** head 透传patch checkSumAdjustment 占位) */
outTables.push({ tag: "head", bytes: passthroughHead(dv, head.offset, head.length) });
/** hhea 透传 + patch numberOfHMetrics = subsetGids.length */
/** hhea 透传 + patch numberOfHMetrics 为压缩后的值hmtx 尾部相同 advance 已折叠) */
{
const hheaBytes = new Uint8Array(dv.buffer, dv.byteOffset + hhea.offset, hhea.length).slice();
const hheaDv = new DataView(hheaBytes.buffer);
hheaDv.setUint16(34, subsetGids.length, false);
hheaDv.setUint16(34, hmtxResult.numberOfHMetrics, false);
outTables.push({ tag: "hhea", bytes: hheaBytes });
}
/** OS/2 透传 + patch usFirstCharIndex/usLastCharIndex */
@ -572,11 +603,11 @@ export function subsetOTF(
const vmtx = findTable(tables, "vmtx");
if (vhea && vmtx) {
const numOfLongVerMetrics = dv.getUint16(vhea.offset + 34, false);
const newVmtx = buildSubsetMetrics(dv, vmtx.offset, numOfLongVerMetrics, subsetGids);
outTables.push({ tag: "vmtx", bytes: newVmtx });
const vmtxResult = buildSubsetMetrics(dv, vmtx.offset, numOfLongVerMetrics, subsetGids);
outTables.push({ tag: "vmtx", bytes: vmtxResult.bytes });
const vheaBytes = new Uint8Array(dv.buffer, dv.byteOffset + vhea.offset, vhea.length).slice();
const vheaDv = new DataView(vheaBytes.buffer);
vheaDv.setUint16(34, subsetGids.length, false);
vheaDv.setUint16(34, vmtxResult.numberOfHMetrics, false);
outTables.push({ tag: "vhea", bytes: vheaBytes });
}