web-font/backend/font_util/gsub-probe.ts
崮生(子虚) 2c56d2cbb2 perf(font): 轻量 GSUB probe 跳过 glyf 解析,CJK 子集化提速 17%~56%
fontSubset 原先对 probe 阶段也调用完整 Font.create(subset 模式),
它会解析子集 glyph 的 glyf 轮廓(parseSimpleGlyf)。但 probe 只需要
GSUB(原始字节透传)和 cmap(codepoint→gid),根本不需要轮廓。
实测 CJK 字体(思源黑体/令东齐伋)两次 Font.create 占总耗时 36%~63%。

新增 gsub-probe.ts:直接从字体字节解析表目录,提取 GSUB 字节切片 +
cmap 的 codepoint→gid 查找(format4 BMP + format12 补充平面二分,
与 fonteditor-core readWindowsAllCodes 优先级一致),完全跳过 glyf。

三种路径(与原 Font.create probe 行为严格等价):
- otf / ttf 无 GSUB:直接判无 reachable(otf 的 fonteditor probe 本就
  origGSUB=undefined),连 probe Font.create 也跳过
- ttf 有 GSUB 且 cmap 含 fmt4/12:轻量 probe 算 reachable
- ttf 有 GSUB 但 cmap 无 fmt4/12(极罕见):回退 Font.create probe

验证:
- seedGids / GSUB字节 / reachable 与原 probe 逐项等价(FiraCode 87 target 等)
- 5 个代表字体 woff2 输出与优化前逐字节一致(cmp 通过)
- pnpx tsx 基准测试.test.ts 全部 SSIM 无回归:
  思源黑体-千字文中段 7.8→4.5ms(-42%)、千字文前段 11.9→8.9ms(-25%)、
  otf-思源 2.9→1.9ms(-34%)、霞鹜文楷-汉字标点 5.0→2.2ms(-56%)、
  FiraCode 18.5→17.2ms,SSIM 全部不变

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 17:41:16 +08:00

224 lines
10 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.

/**
* 轻量 GSUB probe直接从字体字节解析表目录提取 GSUB 字节 + cmap 的 codepoint→gid 映射,
* 完全跳过 glyf 轮廓解析parseSimpleGlyf为 collectReachableGsubTargets 提供 seed。
*
* 背景fontSubset 原先对 probe 阶段也调用完整 Font.createsubset 模式),它会解析子集 glyph 的
* glyf 轮廓——但 probe 只需要 GSUB原始字节透传和 cmapcodepoint→gid根本不需要轮廓。
* CJK 字体(思源黑体、令东齐伋)两次 Font.create 占总耗时 36%~63%,省掉 probe 那次显著加速。
*
* 本模块只解析表目录ttf/otf 通用)+ cmap 的 format 4BMP和 format 12含补充平面
* 覆盖所有测试用例FiraCode/CJK/篆体 均含 p3e1:fmt4 和/或 p3e10:fmt12
* 查找算法与 fonteditor-core 的 readWindowsAllCodes/lookupFormat4/lookupFormat12 一致,
* 保证 seedGids 与原 probe 路径完全相同reachable 结果不变)。
*
* 字体若无 format 4/12 subtable极罕见如纯 format0/6 字体),返回 null调用方回退到 Font.create。
*
* @reference https://learn.microsoft.com/en-us/typography/opentype/spec/cmap
*/
/** cmap 查找结果:每个子集 codepoint 的原始 gid与 fonteditor-core cmap[cp] 语义一致) */
export interface CmapLookup {
/** codepoint → 原始 gid仅含能在 format4/12 查到的子集 codepoint */
get(cp: number): number | undefined;
}
/** ttf/otf 表目录条目 */
interface TableEntry {
offset: number;
length: number;
}
/**
* 解析表目录,返回指定 tag 的 (offset, length)。ttf 与 otf 表目录结构相同
* otf 的 CFF 在表目录里也是普通条目。sfnt 版本 0x00010000(ttf) / 'OTTO'(otf) 均支持。
*/
function readTableEntry(dv: DataView, tag: string): TableEntry | null {
/** sfnt 头version(4) + numTables(2) + searchRange(2) + entrySelector(2) + rangeShift(2) = 12 字节 */
if (dv.byteLength < 12) return null;
const numTables = dv.getUint16(4, false);
if (numTables <= 0 || numTables > 100) return null;
let off = 12;
for (let i = 0; i < numTables; i++) {
const recOff = off + i * 16;
if (recOff + 16 > dv.byteLength) return null;
/** tag 是 4 字节 ASCII大端逐字节比较避免 String.fromCharCode 分配 */
const t0 = dv.getUint8(recOff);
const t1 = dv.getUint8(recOff + 1);
const t2 = dv.getUint8(recOff + 2);
const t3 = dv.getUint8(recOff + 3);
if (t0 === tag.charCodeAt(0) && t1 === tag.charCodeAt(1) && t2 === tag.charCodeAt(2) && t3 === tag.charCodeAt(3)) {
return { offset: dv.getUint32(recOff + 8, false), length: dv.getUint32(recOff + 12, false) };
}
}
return null;
}
/**
* format 4 二分查找 segment 后计算 gid与 lookupFormat4 一致)。
* format4 表布局(相对 subtable 起始):
* format(2) length(2) language(2) segCountX2(2) searchRange(2) entrySelector(2) rangeShift(2)
* endCode[segCount] reservedPad(2) startCode[segCount] idDelta[segCount] idRangeOffset[segCount] glyphIdArray[]
*/
function lookupFormat4(dv: DataView, subOff: number, unicode: number): number {
if (unicode > 0xFFFF) return -1;
const segCountX2 = dv.getUint16(subOff + 6, false);
const segCount = segCountX2 >>> 1;
/** endCode 起始 = subOff + 147 个 uint16 头字段) */
const endCodeBase = subOff + 14;
/** startCode 起始 = endCodeBase + segCount*2 + 2(reservedPad) */
const startCodeBase = endCodeBase + segCount * 2 + 2;
const idDeltaBase = startCodeBase + segCount * 2;
const idRangeOffsetBase = idDeltaBase + segCount * 2;
/** idRangeOffset 值是「相对 idRangeOffset[i] 字节位置」的偏移(指向 glyphIdArray 某项) */
const lo = 0;
let hi = segCount - 1;
/** 二分 endCode 找包含 unicode 的 segmentendCode 升序) */
let l = lo, h = hi;
while (l <= h) {
const mid = (l + h) >> 1;
const start = dv.getUint16(startCodeBase + mid * 2, false);
const end = dv.getUint16(endCodeBase + mid * 2, false);
if (unicode < start) {
h = mid - 1;
} else if (unicode > end) {
l = mid + 1;
} else {
const idDelta = dv.getInt16(idDeltaBase + mid * 2, false);
const idRangeOffset = dv.getUint16(idRangeOffsetBase + mid * 2, false);
if (idRangeOffset === 0) {
return ((unicode + idDelta) & 0xFFFF);
}
/** idRangeOffset[i] 是从「idRangeOffset[i] 字节位置」到 glyphIdArray 目标项的字节偏移。
* 目标字节位置 = idRangeOffsetBase + mid*2 + idRangeOffset + (unicode - start)*2 */
const glyphOff = idRangeOffsetBase + mid * 2 + idRangeOffset + (unicode - start) * 2;
if (glyphOff + 2 > dv.byteLength) return 0;
const glyphId = dv.getUint16(glyphOff, false);
if (glyphId === 0) return 0;
return ((glyphId + idDelta) & 0xFFFF);
}
}
return -1;
}
/**
* format 12 二分查找 group与 lookupFormat12 一致)。
* format12 表布局format(2) reserved(2) length(4) language(4) nGroups(4) groups[nGroups]
* 每个 group 12 字节startCharCode(4) endCharCode(4) startGlyphID(4)groups 升序。
*/
function lookupFormat12(dv: DataView, subOff: number, unicode: number): number {
const nGroups = dv.getUint32(subOff + 12, false);
const groupsBase = subOff + 16;
let l = 0, h = nGroups - 1;
while (l <= h) {
const mid = (l + h) >> 1;
const gOff = groupsBase + mid * 12;
const gStart = dv.getUint32(gOff, false);
const gEnd = dv.getUint32(gOff + 4, false);
if (unicode < gStart) {
h = mid - 1;
} else if (unicode > gEnd) {
l = mid + 1;
} else {
return dv.getUint32(gOff + 8, false) + (unicode - gStart);
}
}
return -1;
}
/**
* 解析 cmap 表目录,选 format 12p3e10和 format 4p3e1的 subtable 偏移。
* 选用优先级与 fonteditor-core readWindowsAllCodes 一致format12 优先含补充平面format4 兜底BMP
* @returns { fmt4Off, fmt12Off } 各为 subtable 绝对偏移或 -1
*/
function selectCmapSubtables(dv: DataView, cmapOff: number): { fmt4Off: number; fmt12Off: number } {
const numberSubtables = dv.getUint16(cmapOff + 2, false);
let fmt4Off = -1;
let fmt12Off = -1;
/** 子表目录项 8 字节platformID(2) encodingID(2) offset(4)offset 相对 cmap 表起始 */
let dirOff = cmapOff + 4;
for (let i = 0; i < numberSubtables; i++) {
const platformID = dv.getUint16(dirOff, false);
const encodingID = dv.getUint16(dirOff + 2, false);
const subRelOff = dv.getUint32(dirOff + 4, false);
const subOff = cmapOff + subRelOff;
if (subOff + 2 <= dv.byteLength) {
const format = dv.getUint16(subOff, false);
/** 优先 p3e1:fmt4 与 p3e10:fmt12与 readWindowsAllCodes 选择一致) */
if (format === 12 && platformID === 3 && encodingID === 10 && fmt12Off < 0) {
fmt12Off = subOff;
} else if (format === 4 && platformID === 3 && encodingID === 1 && fmt4Off < 0) {
fmt4Off = subOff;
}
}
dirOff += 8;
}
return { fmt4Off, fmt12Off };
}
/** 轻量 probe 结果。result 区分三种情况:
* - { ok: true, gsubBytes, lookup }:成功拿到 GSUB 字节与 cmap 查找器,调用方据此算 reachable跳过 Font.create probe
* - { ok: false, needsFallback: false }:无 reachableotf、或 ttf 无 GSUB调用方跳过 Font.create probeextraSubsetGids=undefined
* - { ok: false, needsFallback: true }:有 GSUB 但无 format4/12 cmap极罕见调用方须回退 Font.create probe */
export type ProbeResult =
| { ok: true; gsubBytes: Uint8Array; lookup: CmapLookup }
| { ok: false; needsFallback: boolean };
/**
* 轻量 probe从字体字节提取 GSUB 字节切片与 codepoint→gid 查找器,跳过 glyf 解析。
*
* @param fontBuffer 原始字体字节
* @param codePoints 子集 codepoint仅这些会被 cmap 查找)
* @param sourceType 字体类型('ttf'/'otf' 等。otf 字体 fonteditor-core 的 probe 本就 origGSUB=undefined
* otf→ttf 转换不携带 GSUBreachable 跳过——此处对 otf 直接判无 reachable 保持该语义,
* 省掉 otf 字体一次完整 otf→ttf 转换的 probe。
*/
export function probeGsubAndCmap(
fontBuffer: ArrayBuffer,
codePoints: number[],
sourceType: string,
): ProbeResult {
/** otf 字体fonteditor-core probe 的 origGSUB 本就是 undefinedotf→ttf 转换不携带 GSUB
* reachable 跳过、extraSubsetGids=undefined。保持该语义避免读到真实 GSUB 改变子集字形集。 */
if (sourceType === "otf") return { ok: false, needsFallback: false };
const dv = new DataView(fontBuffer);
const gsubEntry = readTableEntry(dv, "GSUB");
/** 无 GSUB 表 → 无 reachable与原 probe origGSUB=undefined 一致) */
if (gsubEntry === null) return { ok: false, needsFallback: false };
/** GSUB 字节切片offset/length 来自表目录,与 fonteditor-core 透传的字节一致) */
const gsubBytes = new Uint8Array(fontBuffer, gsubEntry.offset, gsubEntry.length);
const cmapEntry = readTableEntry(dv, "cmap");
if (cmapEntry === null) return { ok: false, needsFallback: true };
const { fmt4Off, fmt12Off } = selectCmapSubtables(dv, cmapEntry.offset);
/** 有 GSUB 但无 format4/12 cmap极罕见字体→ 无法轻量查找,回退 Font.create */
if (fmt4Off < 0 && fmt12Off < 0) return { ok: false, needsFallback: true };
/** 预查所有子集 codepoint 的 gid与 readWindowsAllCodes subset 模式逻辑一致:
* format12 优先BMP 范围先试 format4补充平面用 format12 */
const gidMap = new Map<number, number>();
for (const cp of codePoints) {
if (gidMap.has(cp)) continue;
let gid = -1;
/** BMP<0x10000且有 format4先查 format4与 readWindowsAllCodes 一致format12 存在时 BMP 仍先试 format4 */
if (cp < 0x10000 && fmt4Off >= 0) {
gid = lookupFormat4(dv, fmt4Off, cp);
}
/** format4 未命中(含补充平面)→ 查 format12 */
if (gid < 0 && fmt12Off >= 0) {
gid = lookupFormat12(dv, fmt12Off, cp);
}
if (gid >= 0) gidMap.set(cp, gid);
}
return {
ok: true,
gsubBytes,
lookup: {
get(cp: number): number | undefined {
const g = gidMap.get(cp);
return g === undefined ? undefined : g;
},
},
};
}