From 2c56d2cbb2a0f5e60c9e2df337cbf477dbdac884 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, 23 Jul 2026 17:41:16 +0800 Subject: [PATCH] =?UTF-8?q?perf(font):=20=E8=BD=BB=E9=87=8F=20GSUB=20probe?= =?UTF-8?q?=20=E8=B7=B3=E8=BF=87=20glyf=20=E8=A7=A3=E6=9E=90=EF=BC=8CCJK?= =?UTF-8?q?=20=E5=AD=90=E9=9B=86=E5=8C=96=E6=8F=90=E9=80=9F=2017%~56%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/font_util/font.ts | 58 ++++++--- backend/font_util/gsub-probe.ts | 223 ++++++++++++++++++++++++++++++++ 2 files changed, 262 insertions(+), 19 deletions(-) create mode 100644 backend/font_util/gsub-probe.ts diff --git a/backend/font_util/font.ts b/backend/font_util/font.ts index b55215c..9b7b747 100644 --- a/backend/font_util/font.ts +++ b/backend/font_util/font.ts @@ -3,6 +3,7 @@ import type { FontEditor } from "../../vendor/fonteditor-core/lib/ttf/font.js"; import { subsetGPOS } from "./gpos-subset.js"; import { subsetGSUB } from "./gsub-subset.js"; import { collectReachableGsubTargets } from "./gsub-reachable.js"; +import { probeGsubAndCmap } from "./gsub-probe.js"; /** 优化291: TextEncoder 模块级单例 */ const textEncoder = new TextEncoder(); @@ -128,30 +129,49 @@ export const fontSubset = ( ): Uint8Array => { const codePoints = textToCodePoints(subString); - /** GSUB 连字 target glyph 保留:原始字体含 GSUB 时,先做一次 subset 解析,找出子集 codepoint 经 + /** GSUB 连字 target glyph 保留:原始字体含 GSUB 时,先做一次 probe,找出子集 codepoint 经 * GSUB 替换链可达的 target glyph(多为无 unicode 的纯连字字形,如 FiraCode 的 greater_equal.liga), * 注入 extraSubsetGids 使其被子集保留,否则连字规则 target 失效、连字不渲染。 - * probe 采用 subset 模式(与正式子集相同的稳定路径),cmap 仅展开子集 codepoint 映射, - * GSUB 为原始字节透传。无 GSUB 的字体 reachable 为空,extraSubsetGids 保持 undefined。 */ + * + * 优化(轻量 probe):直接从字体字节解析表目录提取 GSUB 字节 + cmap 的 codepoint→gid 查找, + * 跳过 Font.create 的 glyf 轮廓解析(probe 只需 GSUB/cmap,不需要轮廓)。CJK 字体两次 Font.create + * 原占总耗时 36%~63%,省掉 probe 那次显著加速。无 GSUB 的 ttf / 所有 otf(fonteditor probe 本就 + * origGSUB=undefined)直接判无 reachable,连 probe Font.create 也跳过。仅当字体有 GSUB 但 cmap 无 + * format4/12 subtable(极罕见)时回退到 Font.create probe。 */ let extraSubsetGids: number[] | undefined; - const probeFont = Font.create(fontBuffer, { - type: option.sourceType, - subset: codePoints, - kerning: true, - }); - const probeTtf = (probeFont as any).get(); - const origGSUB = probeTtf.GSUB; - const origCmap = probeTtf.cmap; - if (origGSUB && origCmap) { - const gsubBytes = origGSUB instanceof Uint8Array ? origGSUB : new Uint8Array(origGSUB); - /** seed = 子集 codepoint 经 cmap 映射的原始 gid */ - const seedGids = new Set(); - seedGids.add(0); /** .notdef */ + const probe = probeGsubAndCmap(fontBuffer, codePoints, option.sourceType); + let probedSeedGids: Set | undefined; + let probedGsubBytes: Uint8Array | undefined; + if (probe.ok) { + probedGsubBytes = probe.gsubBytes; + probedSeedGids = new Set(); + probedSeedGids.add(0); /** .notdef */ for (const cp of codePoints) { - const gid = origCmap[cp]; - if (gid !== undefined) seedGids.add(gid); + const gid = probe.lookup.get(cp); + if (gid !== undefined) probedSeedGids.add(gid); } - const reachable = collectReachableGsubTargets(gsubBytes, seedGids); + } else if (probe.needsFallback) { + /** 有 GSUB 但 cmap 无 format4/12,回退 Font.create probe(与原路径一致) */ + const probeFont = Font.create(fontBuffer, { + type: option.sourceType, + subset: codePoints, + kerning: true, + }); + const probeTtf = (probeFont as any).get(); + const origGSUB = probeTtf.GSUB; + const origCmap = probeTtf.cmap; + if (origGSUB && origCmap) { + probedGsubBytes = origGSUB instanceof Uint8Array ? origGSUB : new Uint8Array(origGSUB); + probedSeedGids = new Set(); + probedSeedGids.add(0); + for (const cp of codePoints) { + const gid = origCmap[cp]; + if (gid !== undefined) probedSeedGids.add(gid); + } + } + } + if (probedGsubBytes && probedSeedGids) { + const reachable = collectReachableGsubTargets(probedGsubBytes, probedSeedGids); if (reachable.size > 0) extraSubsetGids = [...reachable]; } diff --git a/backend/font_util/gsub-probe.ts b/backend/font_util/gsub-probe.ts new file mode 100644 index 0000000..781881a --- /dev/null +++ b/backend/font_util/gsub-probe.ts @@ -0,0 +1,223 @@ +/** + * 轻量 GSUB probe:直接从字体字节解析表目录,提取 GSUB 字节 + cmap 的 codepoint→gid 映射, + * 完全跳过 glyf 轮廓解析(parseSimpleGlyf),为 collectReachableGsubTargets 提供 seed。 + * + * 背景:fontSubset 原先对 probe 阶段也调用完整 Font.create(subset 模式),它会解析子集 glyph 的 + * glyf 轮廓——但 probe 只需要 GSUB(原始字节透传)和 cmap(codepoint→gid),根本不需要轮廓。 + * CJK 字体(思源黑体、令东齐伋)两次 Font.create 占总耗时 36%~63%,省掉 probe 那次显著加速。 + * + * 本模块只解析表目录(ttf/otf 通用)+ cmap 的 format 4(BMP)和 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 + 14(7 个 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 的 segment(endCode 升序) */ + 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 12(p3e10)和 format 4(p3e1)的 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 }:无 reachable(otf、或 ttf 无 GSUB),调用方跳过 Font.create probe,extraSubsetGids=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 转换不携带 GSUB),reachable 跳过——此处对 otf 直接判无 reachable 保持该语义, + * 省掉 otf 字体一次完整 otf→ttf 转换的 probe。 + */ +export function probeGsubAndCmap( + fontBuffer: ArrayBuffer, + codePoints: number[], + sourceType: string, +): ProbeResult { + /** otf 字体:fonteditor-core probe 的 origGSUB 本就是 undefined(otf→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(); + 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; + }, + }, + }; +}