web-font/backend/font_util/ot-bytes.ts
崮生(子虚) 791ea0c89e perf(ot-writer): 主 Writer 按原表大小预分配容量消除 grow 全拷贝
OTWriter 加可选 initialCapacity 构造参数(默认 2048)。subsetGPOS/subsetGSUB
主 Writer 按原表 byteLength 预分配,避免大表多次 2× 扩容的全拷贝。
初夏纯标点 GPOS 单次 fontSubset 原 5 次 grow(cap 2048→4096→8192→16384),
改后 5 字体(初夏/FiraCode/令东千字文/思源/霞鹜)grow 全部 0。

12 字体 woff2 sha 逐字节一致,SSIM 全不变。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 19:54:07 +08:00

368 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* OpenType 表二进制读写器(大端序)
*
* GPOS / GSUB 等 OpenType 表全部使用大端序big-endian—— 这是 OpenType 1.9 规范
* 对 TTF/OTF/woff/woff2 中表内容字节的硬性规定(表内所有整数均为大端)。
* fonteditor-core 的 reader/writervendor/fonteditor-core/lib/ttf/reader.js虽用参数化
* 的 littleEndian但 TTFReader.readBuffer 实例化 Reader 时第 4 参数恒传 false大端
* 佐证 TTF 流一律大端;本模块对表内容直接硬编码大端,与之一致。
*
* 这些表的子集化逻辑(按子集字形重映射 coverage/ClassDef/替换目标 gid需要逐字节
* 重新序列化两份子集化器gpos-subset.ts、gsub-subset.ts原本各自维护了一份几乎
* 相同的 Writer/Reader 实现,本模块将其抽为单一来源。
*
* Writer 支持「向前引用」subtable 主体先写、coverage 等偏移量后填,
* 通过 reserveOffset16 预留槽位、flush 时统一回填。
*/
/**
* 大端序字节写入器
*
* 支持预留 Offset16 槽位并延迟回填,以支持「向前引用」
* subtable 主体先写coverage/PairSet 偏移量后填)。
*/
export class OTWriter {
/** 优化Uint8Array 底层缓冲):原用 number[] + push 累积字节,每个 writeUint8/16 触发
* 数字装箱与数组扩容gsub-subset 逐字节 writeUint8 复制 ScriptList/FeatureList 字节块
* 极慢。改用 Uint8Array 容量缓冲 + size 指针writeUint8/16 索引写入(无装箱),
* writeBytes 用 TypedArray.set 批量复制toUint8Array 零拷贝 subarray。
*
* 优化327: 初始容量 256→2048并让 writeUint16/writeInt16/reserveOffset16 内联容量检查。
* GPOS/GSUB 输出常达数 KB思源 GPOS 978B、令东 GSUB 数十 KB256 起步触发多次 2× 扩容
* (每次扩容 new Uint8Array + set 全拷贝)。更关键的是 writeUint16 是序列化第一热点
* (思源 subsetGPOS 207 次/call 占 ~58%),原实现每次调 private ensure()——V8 对 class
* private method 内联不充分per-call 函数调用开销在百次累计下显著。改为内联容量判断
* (够用直接写,不够才调 grow消除热路径上的函数调用。 */
/** 初始容量:默认 2048subsetGPOS/subsetGSUB 主 Writer 按原表大小预分配避免多次 grow 全拷贝 */
private buf: Uint8Array;
private size: number = 0;
private patches: Array<{ pos: number; base: number; targetGetter: () => number }> = [];
/** 预估输出容量的 Writer省去从默认容量多次 2× 扩容的全拷贝。initialCapacity 为 0/省略时用默认 2048。 */
constructor(initialCapacity: number = 2048) {
this.buf = new Uint8Array(initialCapacity > 0 ? initialCapacity : 2048);
}
get length(): number {
return this.size;
}
/** 容量不足时扩容(仅在 write 路径内联判断发现不够时调用) */
private grow(required: number): void {
let cap = this.buf.byteLength;
while (cap < required) cap *= 2;
const grown = new Uint8Array(cap);
grown.set(this.buf);
this.buf = grown;
}
/** 回退到指定字节位置,丢弃之后写入的字节与对应的偏移量槽(用于 subtable 重映射失败的保守降级)。
* patches 按 pos 单调递增追加,故从尾部 pop 掉 pos >= 阈值的项即可,无需全量 filter
* subsetGSUB 每个失败的 subtable 都 rollbackFiraCode 实测 392 次/callfilter 改 pop 后此热点消失)。 */
rollback(pos: number): void {
this.size = pos;
const patches = this.patches;
while (patches.length > 0 && patches[patches.length - 1].pos >= pos) patches.pop();
}
writeUint8(v: number): void {
const s = this.size;
if (s + 1 > this.buf.byteLength) this.grow(s + 1);
this.buf[s] = v & 0xff;
this.size = s + 1;
}
writeUint16(v: number): void {
const s = this.size;
if (s + 2 > this.buf.byteLength) this.grow(s + 2);
this.buf[s] = (v >>> 8) & 0xff;
this.buf[s + 1] = v & 0xff;
this.size = s + 2;
}
/** 批量写入字节块TypedArray.set远快于逐字节 writeUint8 循环) */
writeBytes(arr: Uint8Array): void {
const n = arr.byteLength;
const s = this.size;
const required = s + n;
if (required > this.buf.byteLength) this.grow(required);
this.buf.set(arr, s);
this.size = required;
}
/** 在当前末尾写入 int16大端支持负数如 SingleSubst format1 的 deltaGlyphID
* 原实现依赖 number[] 索引赋值到 length 位置隐式扩展数组Uint8Array 版需显式 ensure + 推进 size。 */
writeInt16(v: number): void {
const s = this.size;
if (s + 2 > this.buf.byteLength) this.grow(s + 2);
const u16 = v < 0 ? 0x10000 + (v & 0xffff) : v & 0xffff;
this.buf[s] = (u16 >>> 8) & 0xff;
this.buf[s + 1] = u16 & 0xff;
this.size = s + 2;
}
/** 在指定绝对位置写入 int16支持负数同时用于 flush 回填可能为负的偏移量)。
* pos 必须已在已写入范围内(由 reserveOffset16 的 ensure 保证),仅覆盖不扩展。 */
writeInt16At(pos: number, v: number): void {
const u16 = v < 0 ? 0x10000 + (v & 0xffff) : v & 0xffff;
this.buf[pos] = (u16 >>> 8) & 0xff;
this.buf[pos + 1] = u16 & 0xff;
}
/** 预留一个 uint16 偏移量槽位flush 时写入 (targetGetter() - base) */
reserveOffset16(base: number, targetGetter: () => number): void {
const pos = this.size;
if (pos + 2 > this.buf.byteLength) this.grow(pos + 2);
this.size = pos + 2;
this.patches.push({ pos, base, targetGetter });
}
/** flush 所有预留偏移量,必须在所有字节写完后调用 */
flush(): void {
for (const p of this.patches) {
this.writeInt16At(p.pos, p.targetGetter() - p.base);
}
}
toUint8Array(): Uint8Array {
return new Uint8Array(this.buf.subarray(0, this.size));
}
}
/**
* 大端序字节读取器
*
* 越界读取不会抛出异常,而是设置 errorFlag 并返回 0。
* 调用方据此将该 subtable 降级为原样拷贝,避免解析损坏/异常的表时崩溃
* (如 FiraCode 某些 ChainedContext type6 格式 2 的 classSetCount 远大于实际类数)。
*/
export class OTReader {
errorFlag = false;
/** 原始 DataView热路径如 coverage 解析)可直接用 getUint16 绕过 u16 的逐次边界检查 */
readonly dv: DataView;
constructor(dv: DataView) {
this.dv = dv;
}
u16(off: number): number {
if (off < 0 || off + 2 > this.dv.byteLength) {
this.errorFlag = true;
return 0;
}
return this.dv.getUint16(off, false);
}
i16(off: number): number {
if (off < 0 || off + 2 > this.dv.byteLength) {
this.errorFlag = true;
return 0;
}
return this.dv.getInt16(off, false);
}
u32(off: number): number {
if (off < 0 || off + 4 > this.dv.byteLength) {
this.errorFlag = true;
return 0;
}
return this.dv.getUint32(off, false);
}
/** 清除 errorFlag开始解析新 subtable 前调用) */
clearError(): void {
this.errorFlag = false;
}
}
/**
* 计算 ScriptList 的连续字节跨度(相对 listAbs 的字节数),用于判断能否整块原样拷贝。
*
* ScriptList 不含 glyphId子表ScriptTable/LangSys偏移相对 listAbs 起始。
* 绝大多数字体(含思源/初夏/令东的 GPOS 与 GSUB的 ScriptList 子表紧凑排列在
* [listAbs, listAbs+span) 内、与 FeatureList/LookupList 无物理交错——此时该字节块本身
* 就是合法 ScriptList可整块拷贝跳过逐字段重序列化并保留 fontTools 的 LangSys 去重
* serializeScriptList 紧凑重排会丢失去重、输出反而更大,如思源 OTF 538→1066B
*
* 本函数扫描全部 ScriptTable/LangSys返回其最大结束偏移作为 span。返回 -1 表示解析异常
* errorFlag调用方降级 serializeScriptList。注意span 仅保证「子表结束位置」,
* 调用方还需校验 span 不越过下一表起始以排除物理交错。
*
* @param r 原始字节读取器
* @param listAbs ScriptList 在原始字节中的绝对偏移
* @returns ScriptList 字节跨度≥0解析异常返回 -1
*/
export function scriptListSpan(r: OTReader, listAbs: number): number {
r.clearError();
const scriptCount = r.u16(listAbs);
/** span = listAbs 起,覆盖 ScriptRecord 数组 + 所有 ScriptTable 及其 LangSys 的结束位置 */
let span = 2 + scriptCount * 6;
for (let i = 0; i < scriptCount; i++) {
/** ScriptRecord 偏移相对 listAbs */
const scriptRel = r.u16(listAbs + 2 + i * 6 + 4);
const scriptAbs = listAbs + scriptRel;
const defaultLangSysOff = r.u16(scriptAbs);
const langSysCount = r.u16(scriptAbs + 2);
/** ScriptTable 头defaultLangSysOff(2) + langSysCount(2) + LangSysRecord[langSysCount](6) */
span = Math.max(span, scriptRel + 4 + langSysCount * 6);
/** defaultLangSys 表lookupOrder(2)+reqFeatureIdx(2)+featureIdxCount(2)+indices */
if (defaultLangSysOff !== 0) {
const dlAbs = scriptAbs + defaultLangSysOff;
const dlRel = scriptRel + defaultLangSysOff;
const fic = r.u16(dlAbs + 4);
span = Math.max(span, dlRel + 6 + fic * 2);
}
/** 各 LangSys 表 */
for (let li = 0; li < langSysCount; li++) {
const lsRel = r.u16(scriptAbs + 4 + li * 6 + 4);
const lsAbs = scriptAbs + lsRel;
const fic = r.u16(lsAbs + 4);
span = Math.max(span, scriptRel + lsRel + 6 + fic * 2);
}
}
return r.errorFlag ? -1 : span;
}
/**
* 计算 FeatureList 的连续字节跨度(相对 listAbs 的字节数),用于判断能否整块原样拷贝。
*
* FeatureList 不含 glyphIdFeatureTable 偏移相对 listAbs。fontTools 常对内容相同的
* FeatureTable 去重(多个 FeatureRecord 指向同一 FeatureTable如初夏 GPOS 296 个 feature
* 共享 1 个 FeatureTable。serializeFeatureList 逐 feature 重写会丢失去重296 份独立拷贝,
* 1840→3728B。若 FeatureTable 紧凑排列在 [listAbs, listAbs+span) 内无交错,整块拷贝
* 既跳过逐字段序列化、又保留去重。
*
* @returns FeatureList 字节跨度≥0解析异常返回 -1
*/
export function featureListSpan(r: OTReader, listAbs: number): number {
r.clearError();
const featureCount = r.u16(listAbs);
let span = 2 + featureCount * 6;
for (let i = 0; i < featureCount; i++) {
/** FeatureRecord 偏移相对 listAbs */
const ftRel = r.u16(listAbs + 2 + i * 6 + 4);
const ftAbs = listAbs + ftRel;
const lookupIndexCount = r.u16(ftAbs + 2);
/** FeatureTable: featureParamsOff(2) + lookupIndexCount(2) + indices */
span = Math.max(span, ftRel + 4 + lookupIndexCount * 2);
}
return r.errorFlag ? -1 : span;
}
/**
* 重新序列化 ScriptListGPOS/GSUB 通用,结构完全相同)
*
* ScriptList 不含 glyphId但子表ScriptTable/LangSys偏移相对 ScriptList 起始,
* 原始字体中 ScriptList 与 FeatureList/LookupList 的子表可能【物理交错】
* (如霞鹜文楷 GSUBScriptList 跨越 FeatureList 起始位置),不能按连续字节块原样拷贝。
* 本函数遍历所有子表,按遍历顺序紧凑重排并回填相对偏移,保证输出为合法连续块。
*
* @param r 原始字节读取器
* @param listAbs ScriptList 在原始字节中的绝对偏移
* @returns 重序列化后的 ScriptList 字节解析异常errorFlag返回 null
*/
export function serializeScriptList(r: OTReader, listAbs: number): Uint8Array | null {
r.clearError();
const w = new OTWriter();
const scriptCount = r.u16(listAbs);
w.writeUint16(scriptCount);
/** 预留 ScriptRecord 数组槽位tag4 + offset2记录每个 script 的新偏移 */
const scriptNewOffs: number[] = new Array(scriptCount);
for (let i = 0; i < scriptCount; i++) {
const recAbs = listAbs + 2 + i * 6;
/** tag 4 字节原样拷贝 */
w.writeUint8(r.u16(recAbs) >>> 8);
w.writeUint8(r.u16(recAbs) & 0xff);
w.writeUint8(r.u16(recAbs + 2) >>> 8);
w.writeUint8(r.u16(recAbs + 2) & 0xff);
w.reserveOffset16(0, ((idx) => () => scriptNewOffs[idx])(i));
}
/** 逐 ScriptTable 序列化 */
for (let i = 0; i < scriptCount; i++) {
scriptNewOffs[i] = w.length;
const scriptOldOff = listAbs + r.u16(listAbs + 2 + i * 6 + 4);
const defaultLangSysOff = r.u16(scriptOldOff);
const langSysCount = r.u16(scriptOldOff + 2);
/** 收集该 ScriptTable 的 LangSys 表,紧凑排布 */
const langSysNewOffs: number[] = new Array(langSysCount);
/** 先写 ScriptTable 头 */
const stStart = w.length;
/** defaultLangSysOffset 槽(相对 ScriptTable 起始) */
const defaultSlotHolder: number[] = [0];
w.reserveOffset16(stStart, () => defaultSlotHolder[0]);
w.writeUint16(langSysCount);
for (let li = 0; li < langSysCount; li++) {
const lr = scriptOldOff + 4 + li * 6;
w.writeUint8(r.u16(lr) >>> 8);
w.writeUint8(r.u16(lr) & 0xff);
w.writeUint8(r.u16(lr + 2) >>> 8);
w.writeUint8(r.u16(lr + 2) & 0xff);
w.reserveOffset16(stStart, ((idx) => () => langSysNewOffs[idx])(li));
}
/** defaultLangSys 表 */
if (defaultLangSysOff !== 0) {
defaultSlotHolder[0] = w.length;
copyLangSys(w, r, scriptOldOff + defaultLangSysOff);
}
/** 各 LangSys 表 */
for (let li = 0; li < langSysCount; li++) {
const lr = scriptOldOff + 4 + li * 6;
const lsOldOff = scriptOldOff + r.u16(lr + 4);
langSysNewOffs[li] = w.length;
copyLangSys(w, r, lsOldOff);
}
}
if (r.errorFlag) return null;
w.flush();
return w.toUint8Array();
}
/** 拷贝一个 LangSys 表lookupOrderOffset + requiredFeatureIndex + featureIndexCount + featureIndices
* lookupOrderOffset 规范已废弃恒为 0直接写 0避免指向无效重排后位置。 */
function copyLangSys(w: OTWriter, r: OTReader, absOff: number): void {
w.writeUint16(0);
w.writeUint16(r.u16(absOff + 2));
const fic = r.u16(absOff + 4);
w.writeUint16(fic);
for (let fi = 0; fi < fic; fi++) w.writeUint16(r.u16(absOff + 6 + fi * 2));
}
/**
* 重新序列化 FeatureListGPOS/GSUB 通用)
*
* 同 serializeScriptList 的理由FeatureTable 偏移相对 FeatureList 起始,
* 子表可能与其他块物理交错需遍历重排。FeatureTable 的 featureParamsOffset
* 通常为 0非 0 时原样保留相对偏移FeatureParams 不含 gid不重映射
*
* @returns 重序列化后的 FeatureList 字节;解析异常返回 null
*/
export function serializeFeatureList(r: OTReader, listAbs: number): Uint8Array | null {
r.clearError();
const w = new OTWriter();
const featureCount = r.u16(listAbs);
w.writeUint16(featureCount);
const featureNewOffs: number[] = new Array(featureCount);
for (let i = 0; i < featureCount; i++) {
const recAbs = listAbs + 2 + i * 6;
w.writeUint8(r.u16(recAbs) >>> 8);
w.writeUint8(r.u16(recAbs) & 0xff);
w.writeUint8(r.u16(recAbs + 2) >>> 8);
w.writeUint8(r.u16(recAbs + 2) & 0xff);
w.reserveOffset16(0, ((idx) => () => featureNewOffs[idx])(i));
}
for (let i = 0; i < featureCount; i++) {
featureNewOffs[i] = w.length;
const ftOldOff = listAbs + r.u16(listAbs + 2 + i * 6 + 4);
const featureParamsOff = r.u16(ftOldOff);
const lookupIndexCount = r.u16(ftOldOff + 2);
/** featureParamsOffset 原样保留(非 0 时相对 FeatureTable其内容不含 gid */
w.writeUint16(featureParamsOff);
w.writeUint16(lookupIndexCount);
for (let li = 0; li < lookupIndexCount; li++) w.writeUint16(r.u16(ftOldOff + 4 + li * 2));
}
if (r.errorFlag) return null;
w.flush();
return w.toUint8Array();
}