From a84a4aa6f25db928e13e45cd9dd22dc255fd2148 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 13:01:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(gsub):=20GSUB/GPOS=20=E5=B8=83=E5=B1=80?= =?UTF-8?q?=E8=A1=A8=E5=AD=90=E9=9B=86=E5=8C=96=20+=20FiraCode=20=E8=BF=9E?= =?UTF-8?q?=E5=AD=97=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现 GSUB/GPOS 的 gid 重映射子集化,解决 CJK 标点压缩丢失与连字不渲染问题。 核心改动: - ot-bytes.ts: 共享 OTWriter/OTReader + ScriptList/FeatureList 紧凑重排 - gpos-subset.ts: SinglePos/PairPos coverage/ClassDef gid 重映射 - gsub-subset.ts: type1/2/3/4/6 coverage/ClassDef/替换目标 gid 重映射 - gsub-reachable.ts: GSUB 替换链不动点迭代,收集无 unicode 的连字 target 字形 - GSUB.js + support/ttfreader/ttfwriter: GSUB 字节透传读写接入 - parse.js: 修复 F2DOT14 复合字形解码(负数 +0.5|0 截断 bug) 关键 bug 修复: - ChainContextSubst format2 字段错位:漏读 coverageOffset 致 chainSubClassSetCount 读成 lookaheadClassDefOffset,连字核心规则降级为空。修复后 FiraCode <= 走对 spacer+liga 路径。 - ScriptList/FeatureList 物理交错 + subtable 散落致 GSUB 膨胀损坏,改遍历重排。 SSIM: FiraCode-代码 0.9636 → 0.9923(与 pyftsubset 黄金像素级一致), CJK 标点压缩全面恢复(初夏明朝纯标点 0.7950→0.9920 等)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/font_util/font.ts | 100 +- backend/font_util/gpos-subset.ts | 751 ++++++++++++ backend/font_util/gsub-reachable.ts | 356 ++++++ backend/font_util/gsub-subset.ts | 1010 +++++++++++++++++ backend/font_util/ot-bytes.ts | 234 ++++ task.md | 2 +- vendor/fonteditor-core/lib/ttf/table/GSUB.js | 31 + vendor/fonteditor-core/lib/ttf/table/glyf.js | 12 + .../lib/ttf/table/glyf/parse.js | 16 +- .../fonteditor-core/lib/ttf/table/support.js | 2 + vendor/fonteditor-core/lib/ttf/ttfreader.js | 7 +- vendor/fonteditor-core/lib/ttf/ttfwriter.js | 2 +- .../lib/ttf/util/optimizettf.js | 25 +- .../lib/ttf/util/readWindowsAllCodes.js | 2 +- .../lib/ttf/util/transformGlyfContours.js | 45 + vendor/fonteditor-core/woff2/woff2-encode.js | 20 +- 基准测试.test.ts | 68 +- 17 files changed, 2644 insertions(+), 39 deletions(-) create mode 100644 backend/font_util/gpos-subset.ts create mode 100644 backend/font_util/gsub-reachable.ts create mode 100644 backend/font_util/gsub-subset.ts create mode 100644 backend/font_util/ot-bytes.ts create mode 100644 vendor/fonteditor-core/lib/ttf/table/GSUB.js diff --git a/backend/font_util/font.ts b/backend/font_util/font.ts index 7c0bbb4..b55215c 100644 --- a/backend/font_util/font.ts +++ b/backend/font_util/font.ts @@ -1,5 +1,8 @@ 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 { collectReachableGsubTargets } from "./gsub-reachable.js"; /** 优化291: TextEncoder 模块级单例 */ const textEncoder = new TextEncoder(); @@ -22,6 +25,11 @@ export const textToCodePoints = (text: string) => { /** * 解析字体并执行 subset(最耗时的步骤) + * + * kerning: true —— 读取并保留 GPOS/kern 表。 + * CJK 字体(如思源黑体)的全角标点连续排列时,浏览器依赖 GPOS 的标点压缩规则 + * 调整字间距。子集化若丢弃 GPOS,连续标点渲染会变宽,与原始字体人眼不一致。 + * fonteditor-core 的 GPOS 为原始字节透传,按子集字形重映射后保留即可恢复压缩。 */ export const createSubsetFont = ( fontBuffer: ArrayBuffer, @@ -31,6 +39,7 @@ export const createSubsetFont = ( Font.create(fontBuffer, { type: sourceType, subset: codePoints, + kerning: true, }); /** @@ -43,13 +52,60 @@ export const optimizeFont = (font: ReturnType) => { return optimized; }; +/** + * GPOS/GSUB 表子集化(按子集字形重映射 glyphId) + * + * CJK 字体的全角标点压缩依赖 GPOS 的 SinglePos/PairPos lookup;连字/上下文替换 + * (如 FiraCode 的 => → ⇒)依赖 GSUB 的 ligature/context lookup。子集化后 glyphId 重编号, + * fonteditor-core 的 GPOS/GSUB 是原始字节透传,需按子集字形重映射 coverage/ClassDef 的 gid。 + * + * 原gid→新gid 映射直接由 subsetGids 顺序建立:subsetGids[i] 是子集保留的第 i 个原始 gid, + * optimize 后 glyf 顺序与 subsetGids 一致(fonteditor-core 的 optimize 不重排 glyf), + * 故新 gid = i。这比 unicode 桥接更可靠,且支持无 unicode 的 glyph(如 GSUB 连字 target)。 + * subsetGPOS 遇到完全不支持的版本会返回 null,此时保留原始 GPOS 字节(不劣于子集化前)。 + * + * @param subsetOptimized optimize 后的 subset 字体(含按新 gid 顺序的 glyf 与原始 GPOS/GSUB 字节) + * @param subsetGids 子集字形对应的原始 gid 序列(optimize 前后均保留) + */ +const rewriteLayoutTablesForSubset = ( + subsetOptimized: ReturnType["optimize"]>, + subsetGids: number[], +): void => { + const ttf = (subsetOptimized as any).get(); + + /** subsetGids[i] = 原始 gid,新 gid = i(optimize 不重排 glyf,顺序一一对应) */ + const origToNew = new Map(); + for (let i = 0; i < subsetGids.length; i++) origToNew.set(subsetGids[i], i); + + /** 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); + if (rewritten) ttf.GPOS = rewritten; + /** rewritten === null 表示含不支持的版本,保留原始 GPOS 字节(安全降级) */ + } + } + + /** GSUB 子集化(总是返回有效字节,重映射 coverage/ClassDef/替换目标 gid) */ + const origGSUB = ttf.GSUB; + if (origGSUB) { + const gsubBytes = origGSUB instanceof Uint8Array ? origGSUB : new Uint8Array(origGSUB); + if (gsubBytes.byteLength > 0) { + ttf.GSUB = subsetGSUB(gsubBytes, origToNew); + } + } +}; + /** 序列化为指定格式的二进制数据 */ /** 优化291: 移除 async,消除不必要的微任务调度 */ export const writeFont = ( font: ReturnType["optimize"]>, outType: FontEditor.FontType, ): Uint8Array => { - const result = font.write({ type: outType }); + /** kerning: true —— 写出时保留 GPOS/kern 表,与 createSubsetFont 的读取保持一致 */ + const result = font.write({ type: outType, kerning: true }); if (typeof result === "string") { return textEncoder.encode(result); } @@ -71,7 +127,47 @@ export const fontSubset = ( option: { sourceType: FontEditor.FontType; outType: FontEditor.FontType }, ): Uint8Array => { const codePoints = textToCodePoints(subString); - const font = createSubsetFont(fontBuffer, codePoints, option.sourceType); + + /** GSUB 连字 target glyph 保留:原始字体含 GSUB 时,先做一次 subset 解析,找出子集 codepoint 经 + * GSUB 替换链可达的 target glyph(多为无 unicode 的纯连字字形,如 FiraCode 的 greater_equal.liga), + * 注入 extraSubsetGids 使其被子集保留,否则连字规则 target 失效、连字不渲染。 + * probe 采用 subset 模式(与正式子集相同的稳定路径),cmap 仅展开子集 codepoint 映射, + * GSUB 为原始字节透传。无 GSUB 的字体 reachable 为空,extraSubsetGids 保持 undefined。 */ + 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 */ + for (const cp of codePoints) { + const gid = origCmap[cp]; + if (gid !== undefined) seedGids.add(gid); + } + const reachable = collectReachableGsubTargets(gsubBytes, seedGids); + if (reachable.size > 0) extraSubsetGids = [...reachable]; + } + + const font = Font.create(fontBuffer, { + type: option.sourceType, + subset: codePoints, + kerning: true, + extraSubsetGids, + }); + + /** subsetGids 在 optimize 前后均保留,记录子集字形的原始 gid 顺序(新 gid = 数组索引)。 */ + const preOptTtf = (font as any).get(); + const subsetGids: number[] = preOptTtf.subsetGids ?? []; + const optimized = optimizeFont(font); + /** GPOS/GSUB 表子集化:按子集字形重映射布局表 glyphId,恢复标点压缩与连字/上下文替换 */ + rewriteLayoutTablesForSubset(optimized, subsetGids); return writeFont(optimized, option.outType); }; diff --git a/backend/font_util/gpos-subset.ts b/backend/font_util/gpos-subset.ts new file mode 100644 index 0000000..2954ad8 --- /dev/null +++ b/backend/font_util/gpos-subset.ts @@ -0,0 +1,751 @@ +/** + * GPOS 表子集化器 + * + * CJK 字体(思源黑体、霞鹜文楷等)的全角标点连续排列时,浏览器依赖 GPOS 表的 + * 标点压缩 lookup(SinglePos/PairPos)调整字间距。子集化后 glyphId 被重编号, + * 但 fonteditor-core 的 GPOS 读写器是原始字节透传,不会按子集字形重映射 coverage, + * 导致浏览器用新 glyphId 查 GPOS coverage 查不到,压缩规则失效,连续标点渲染变宽。 + * + * 本模块按 OpenType 1.9.1 规范解析 GPOS,对 LookupType 1 (SinglePos) 和 2 (PairPos) + * 的 coverage/ClassDef glyphId 做 原gid→新gid 重映射后重新序列化。 + * 遇到不支持的 lookup 类型(3-8,以及 9 包裹的非 1/2 类型)时整体降级(返回 null), + * 调用方保留原始 GPOS 字节,不会比子集化前更差。 + * + * @reference https://learn.microsoft.com/en-us/typography/opentype/spec/gpos + */ + +import { OTWriter as Writer, OTReader as Reader, serializeScriptList, serializeFeatureList } from "./ot-bytes.js"; + +/** ValueFormat 位掩码 */ +const VF_X_PLACEMENT = 0x0001; +const VF_Y_PLACEMENT = 0x0002; +const VF_X_ADVANCE = 0x0004; +const VF_Y_ADVANCE = 0x0008; +const VF_X_PLA_DEVICE = 0x0010; +const VF_Y_PLA_DEVICE = 0x0020; +const VF_X_ADV_DEVICE = 0x0040; +const VF_Y_ADV_DEVICE = 0x0080; + +/** 一个 ValueRecord 占用的 uint16 数量(低 8 位的 set bit 数) */ +function valueRecordSize(valueFormat: number): number { + let n = 0; + for (let bits = valueFormat & 0xff; bits; bits >>>= 1) n += bits & 1; + return n; +} + +const LT_SINGLE_POS = 1; +const LT_PAIR_POS = 2; +const LT_MARKBASE_POS = 4; +const LT_MARKLIG_POS = 5; +const LT_MARKMARK_POS = 6; +const LT_EXTENSION = 9; + +/** + * 写出一个合法的「空 Position subtable」(空 coverage / 空 MarkArray,浏览器查不到字形会跳过)。 + * 用于 unsupported lookup 的保守降级,避免单个不重映射的 lookup 拖累整个 GPOS return null。 + * 各类型最小合法结构(format 始终为 1): + * - SinglePos: format + coverageOff + valueFormat=0 + coverage + * - PairPos: format + coverageOff + valueFormat1=0 + valueFormat2=0 + pairSetCount=0 + coverage + * - CursivePos: format + coverageOff + entryExitCount=0 + coverage + * - MarkBase/MarkLig/MarkMark: format + markCoverageOff + (base/lig/mark2)CoverageOff + markClassCount=0 + * + markArrayOff + baseArrayOff + 空 markCoverage + 空 baseCoverage + * + markArray(markCount=0) + baseArray(baseCount=0) + * - ContextPos/ChainContextPos: format + coverageOff + ruleSetCount=0 + coverage + */ +function writeEmptyPosSubtable(w: Writer, effectiveType: number): void { + const subStart = w.length; + w.writeUint16(1); + + if ( + effectiveType === LT_MARKBASE_POS || + effectiveType === LT_MARKLIG_POS || + effectiveType === LT_MARKMARK_POS + ) { + /** MarkBase/MarkLig/MarkMark format1: + * format(2) + markCoverageOff(2) + baseCoverageOff(2) + markClassCount(2)=0 + markArrayOff(2) + baseArrayOff(2) = 12 字节头 */ + const markCovHolder: number[] = [0]; + const baseCovHolder: number[] = [0]; + const markArrayHolder: number[] = [0]; + const baseArrayHolder: number[] = [0]; + w.reserveOffset16(subStart, () => markCovHolder[0]); + w.reserveOffset16(subStart, () => baseCovHolder[0]); + w.writeUint16(0); /** markClassCount = 0 */ + w.reserveOffset16(subStart, () => markArrayHolder[0]); + w.reserveOffset16(subStart, () => baseArrayHolder[0]); + markCovHolder[0] = w.length; + writeEmptyCoverage(w); + baseCovHolder[0] = w.length; + writeEmptyCoverage(w); + markArrayHolder[0] = w.length; + w.writeUint16(0); /** markArray: markCount = 0 */ + baseArrayHolder[0] = w.length; + w.writeUint16(0); /** baseArray: baseCount = 0 */ + return; + } + + /** coverageOffset 槽(相对 subtable 起始),coverage 紧随本类型头部之后 */ + const coverageAfter = (() => { + if (effectiveType === LT_SINGLE_POS) return subStart + 6; + if (effectiveType === LT_PAIR_POS) return subStart + 10; + return subStart + 6; /** CursivePos / ContextPos / ChainContextPos */ + })(); + w.reserveOffset16(subStart, () => coverageAfter); + if (effectiveType === LT_SINGLE_POS) { + w.writeUint16(0); /** valueFormat = 0 */ + } else if (effectiveType === LT_PAIR_POS) { + w.writeUint16(0); /** valueFormat1 */ + w.writeUint16(0); /** valueFormat2 */ + w.writeUint16(0); /** pairSetCount */ + } else { + /** CursivePos: entryExitCount=0;Context/ChainContext: ruleSetCount=0 */ + w.writeUint16(0); + } + writeEmptyCoverage(w); +} + +/** 写入空 coverage 表(format1 + count0) */ +function writeEmptyCoverage(w: Writer): void { + w.writeUint16(1); + w.writeUint16(0); +} + +/** + * GPOS 子集化入口 + * + * @param gposBytes 原始 GPOS 表字节 + * @param origToNew 原gid → 新gid 映射;不在 map 中的原 gid 表示已被子集化剔除 + * @returns 重映射后的 GPOS 字节;若包含不支持的 lookup 类型则返回 null(调用方保留原字节) + */ +export function subsetGPOS( + gposBytes: Uint8Array, + origToNew: Map, +): Uint8Array | null { + const dv = new DataView(gposBytes.buffer, gposBytes.byteOffset, gposBytes.byteLength); + const r = new Reader(dv); + + /** ---- GPOS Header ---- */ + const major = r.u16(0); + const minor = r.u16(2); + /** 仅支持 v1.0 / v1.1;v1.1 的 FeatureVariations 本 MVP 不处理,存在时降级 */ + if (major !== 1 || minor > 1) return null; + const scriptListOff = r.u16(4); + const featureListOff = r.u16(6); + const lookupListOff = r.u16(8); + if (minor === 1 && r.u32(10) !== 0) return null; + + /** ---- 解析 LookupList,记录每个 lookup 的类型与 subtable 绝对偏移 ---- */ + const lookupCount = r.u16(lookupListOff); + const lookupRelOffs: number[] = []; + for (let i = 0; i < lookupCount; i++) { + lookupRelOffs.push(r.u16(lookupListOff + 2 + i * 2)); + } + const lookups: Array<{ + supported: boolean; + effectiveType: number; + subtableAbsOffs: number[]; + origLookupOff: number; + }> = []; + + for (let i = 0; i < lookupCount; i++) { + const lOff = lookupListOff + lookupRelOffs[i]; + const lookupType = r.u16(lOff); + const subTableCount = r.u16(lOff + 4); + const subtableAbsOffs: number[] = []; + let effectiveType = lookupType; + for (let j = 0; j < subTableCount; j++) { + const subOff = lOff + r.u16(lOff + 6 + j * 2); + if (lookupType === LT_EXTENSION) { + if (r.u16(subOff) !== 1) { + /** 无法解包的 extension,标记为不支持 */ + effectiveType = -1; + continue; + } + effectiveType = r.u16(subOff + 2); + subtableAbsOffs.push(subOff + r.u32(subOff + 4)); + } else { + subtableAbsOffs.push(subOff); + } + } + const supported = effectiveType === LT_SINGLE_POS || effectiveType === LT_PAIR_POS; + lookups.push({ supported, effectiveType, subtableAbsOffs, origLookupOff: lOff }); + } + + /** ---- 重新序列化 ---- + * ScriptList / FeatureList 与 glyphId 无关(仅引用 lookup index),但其子表 + * (ScriptTable/LangSys/FeatureTable)偏移相对各自 List 起始,且在原始字体中可能与 + * 其他块【物理交错】,不能按连续字节块原样拷贝。这里遍历所有子表紧凑重排并回填相对偏移 + * (serializeScriptList / serializeFeatureList),保证输出为合法连续块。 + * LookupList 逐 lookup 处理: + * - 支持的(SinglePos/PairPos):gid 重映射后重新序列化 + * - 不支持的(MarkBase/Context 等):原样拷贝原始字节(gid 不重映射, + * 浏览器用新 gid 查 coverage 查不到会跳过,不会破坏,保留可能的非 gid 相关规则) + */ + const scriptListBytes = serializeScriptList(r, scriptListOff); + const featureListBytes = serializeFeatureList(r, featureListOff); + /** ScriptList/FeatureList 解析失败(异常表)则整体降级返回 null(调用方保留原始 GPOS 字节) */ + if (!scriptListBytes || !featureListBytes) return null; + + const w = new Writer(); + + /** Header(偏移量最后回填) */ + w.writeUint16(1); + w.writeUint16(0); + const scriptListAbsHolder: number[] = []; + w.reserveOffset16(0, () => scriptListAbsHolder[0]); + const featureListAbsHolder: number[] = []; + w.reserveOffset16(0, () => featureListAbsHolder[0]); + const lookupListAbsHolder: number[] = []; + w.reserveOffset16(0, () => lookupListAbsHolder[0]); + + /** ScriptList 重序列化字节 */ + scriptListAbsHolder.push(w.length); + for (let i = 0; i < scriptListBytes.length; i++) w.writeUint8(scriptListBytes[i]); + + /** FeatureList 重序列化字节 */ + featureListAbsHolder.push(w.length); + for (let i = 0; i < featureListBytes.length; i++) w.writeUint8(featureListBytes[i]); + + /** LookupList 重写 */ + const lookupListAbs = w.length; + lookupListAbsHolder.push(lookupListAbs); + w.writeUint16(lookupCount); + /** 预留各 lookup 的 Offset16 槽(相对 LookupList 起始) */ + const lookupAbsPositions: number[] = new Array(lookupCount); + for (let i = 0; i < lookupCount; i++) { + const slotIdx = i; + w.reserveOffset16(lookupListAbs, () => lookupAbsPositions[slotIdx]); + } + + /** 逐个 lookup 序列化 */ + for (let i = 0; i < lookupCount; i++) { + lookupAbsPositions[i] = w.length; + const lk = lookups[i]; + + if (lk.supported) { + /** 支持的 lookup:gid 重映射后重新序列化 */ + const lookupFlag = r.u16(lk.origLookupOff + 2); + const useMarkFilteringSet = (lookupFlag & 0x0010) !== 0; + + /** extension 包裹时输出仍用 effectiveType,直接内嵌 subtable(不再用 extension) */ + w.writeUint16(lk.effectiveType); + w.writeUint16(lookupFlag); + w.writeUint16(lk.subtableAbsOffs.length); + + const lookupStart = w.length - 6; + const subtableAbsPositions: number[] = new Array(lk.subtableAbsOffs.length); + for (let j = 0; j < lk.subtableAbsOffs.length; j++) { + const slotIdx = j; + w.reserveOffset16(lookupStart, () => subtableAbsPositions[slotIdx]); + } + if (useMarkFilteringSet) { + w.writeUint16(r.u16(lk.origLookupOff + 6 + lk.subtableAbsOffs.length * 2)); + } + + for (let j = 0; j < lk.subtableAbsOffs.length; j++) { + subtableAbsPositions[j] = w.length; + r.clearError(); + const ok = serializeSubtable(w, r, lk.subtableAbsOffs[j], lk.effectiveType, origToNew); + /** 越界读取(异常表)也降级为保留原始 GPOS 字节(调用方安全降级) */ + if (!ok || r.errorFlag) return null; + } + } else { + /** 不支持的 lookup(Cursive/MarkBase/Context/ChainContext 等): + * 保持 lookup 表头(type/flag/subCount)与 subtable 槽位数,逐 subtable 输出空 subtable。 + * 不原样拷贝原始 subtable 字节——其 coverage/ClassDef/MarkArray 等子结构在原始字体中 + * 可能与其他 lookup 物理交错、散落在任意偏移,按 lookup 边界估算拷贝会破坏字体。 + * 空 subtable 合法且 coverage 为空,浏览器跳过,仅丢失该 lookup 的定位规则。 + * 对无法构造合法空 subtable 的罕见类型(MarkBase/MarkLig/MarkMark),整体降级 return null。 */ + const lookupFlag = r.u16(lk.origLookupOff + 2); + const useMarkFilteringSet = (lookupFlag & 0x0010) !== 0; + w.writeUint16(r.u16(lk.origLookupOff)); + w.writeUint16(lookupFlag); + w.writeUint16(lk.subtableAbsOffs.length); + const lookupStart = w.length - 6; + const subtableAbsPositions: number[] = new Array(lk.subtableAbsOffs.length); + for (let j = 0; j < lk.subtableAbsOffs.length; j++) { + const slotIdx = j; + w.reserveOffset16(lookupStart, () => subtableAbsPositions[slotIdx]); + } + if (useMarkFilteringSet) { + w.writeUint16(r.u16(lk.origLookupOff + 6 + lk.subtableAbsOffs.length * 2)); + } + for (let j = 0; j < lk.subtableAbsOffs.length; j++) { + subtableAbsPositions[j] = w.length; + writeEmptyPosSubtable(w, lk.effectiveType); + } + } + } + + w.flush(); + return w.toUint8Array(); +} + +function serializeSubtable( + w: Writer, + r: Reader, + subAbs: number, + type: number, + origToNew: Map, +): boolean { + if (type === LT_SINGLE_POS) return serializeSinglePos(w, r, subAbs, origToNew); + if (type === LT_PAIR_POS) return serializePairPos(w, r, subAbs, origToNew); + return false; +} + +/** 重映射原 gid;不在子集中返回 -1 */ +function remapGid(origGid: number, origToNew: Map): number { + const ng = origToNew.get(origGid); + return ng === undefined ? -1 : ng; +} + +/** + * 写入一个 ValueRecord(按 valueFormat 决定字段顺序) + * 字段顺序固定:xPlacement, yPlacement, xAdvance, yAdvance, xPlaDevice, yPlaDevice, xAdvDevice, yAdvDevice。 + * 含 Device/VariationIndex offset 时降级(这些 offset 是相对父表,重序列化后位置失真; + * CJK 标点压缩的 ValueFormat 通常只用 xAdvance,不含 Device)。 + */ +function writeValueRecord(w: Writer, r: Reader, absOff: number, valueFormat: number): boolean { + if (valueFormat & (VF_X_PLA_DEVICE | VF_Y_PLA_DEVICE | VF_X_ADV_DEVICE | VF_Y_ADV_DEVICE)) { + return false; + } + let off = absOff; + if (valueFormat & VF_X_PLACEMENT) { + w.writeInt16(r.i16(off)); + off += 2; + } + if (valueFormat & VF_Y_PLACEMENT) { + w.writeInt16(r.i16(off)); + off += 2; + } + if (valueFormat & VF_X_ADVANCE) { + w.writeInt16(r.i16(off)); + off += 2; + } + if (valueFormat & VF_Y_ADVANCE) { + w.writeInt16(r.i16(off)); + off += 2; + } + return true; +} + +/** ValueRecord 的 uint16 数量 */ +function vrCount(valueFormat: number): number { + return valueRecordSize(valueFormat); +} + +/** 读取 coverage 表的原始 gid 列表(按 coverage index 顺序) */ +function readCoverageGids(r: Reader, coverageAbs: number): number[] | null { + const fmt = r.u16(coverageAbs); + const out: number[] = []; + if (fmt === 1) { + const count = r.u16(coverageAbs + 2); + for (let i = 0; i < count; i++) out.push(r.u16(coverageAbs + 4 + i * 2)); + return out; + } + if (fmt === 2) { + const rangeCount = r.u16(coverageAbs + 2); + for (let i = 0; i < rangeCount; i++) { + const recOff = coverageAbs + 4 + i * 6; + const start = r.u16(recOff); + const end = r.u16(recOff + 2); + for (let g = start; g <= end; g++) out.push(g); + } + return out; + } + return null; +} + +/** 由已排序的新 gid 数组输出 coverage 字节,返回是否为空 */ +function emitCoverageFromGids(w: Writer, sorted: number[]): boolean { + if (sorted.length === 0) { + /** 空 coverage:format1 + count0 */ + w.writeUint16(1); + w.writeUint16(0); + return true; + } + /** 构造连续范围段 */ + const ranges: Array<{ start: number; end: number }> = []; + let curStart = sorted[0]; + let prev = sorted[0]; + for (let i = 1; i < sorted.length; i++) { + if (sorted[i] === prev + 1) { + prev = sorted[i]; + } else { + ranges.push({ start: curStart, end: prev }); + curStart = sorted[i]; + prev = sorted[i]; + } + } + ranges.push({ start: curStart, end: prev }); + + const listCost = 4 + sorted.length * 2; + const rangeCost = 4 + ranges.length * 6; + if (rangeCost < listCost) { + w.writeUint16(2); + w.writeUint16(ranges.length); + let startCoverageIndex = 0; + for (const rg of ranges) { + w.writeUint16(rg.start); + w.writeUint16(rg.end); + w.writeUint16(startCoverageIndex); + startCoverageIndex += rg.end - rg.start + 1; + } + } else { + w.writeUint16(1); + w.writeUint16(sorted.length); + for (const g of sorted) w.writeUint16(g); + } + return true; +} + +/** + * 由 新gid->class 映射输出 ClassDef 字节(选更紧凑的 format)。 + * 不在 map 中的 gid 规范默认归 class 0。 + */ +function emitClassDefFromClassMap(w: Writer, classMap: Map): boolean { + const entries = [...classMap.entries()].sort((a, b) => a[0] - b[0]); + if (entries.length === 0) { + /** 空 ClassDef:format1, startGlyph=0, glyphCount=0 */ + w.writeUint16(1); + w.writeUint16(0); + w.writeUint16(0); + return true; + } + /** 构造连续范围段(同 class 且 gid 连续) */ + const ranges: Array<{ start: number; end: number; cls: number }> = []; + let curStart = entries[0][0]; + let curCls = entries[0][1]; + let prev = entries[0][0]; + for (let i = 1; i < entries.length; i++) { + const [gid, cls] = entries[i]; + if (gid === prev + 1 && cls === curCls) { + prev = gid; + } else { + ranges.push({ start: curStart, end: prev, cls: curCls }); + curStart = gid; + curCls = cls; + prev = gid; + } + } + ranges.push({ start: curStart, end: prev, cls: curCls }); + + const listStart = entries[0][0]; + const listCount = entries[entries.length - 1][0] - listStart + 1; + const listCost = 6 + listCount * 2; + const rangeCost = 4 + ranges.length * 6; + if (rangeCost < listCost) { + w.writeUint16(2); + w.writeUint16(ranges.length); + for (const rg of ranges) { + w.writeUint16(rg.start); + w.writeUint16(rg.end); + w.writeUint16(rg.cls); + } + } else { + w.writeUint16(1); + w.writeUint16(listStart); + w.writeUint16(listCount); + for (let gid = listStart; gid <= listStart + listCount - 1; gid++) { + w.writeUint16(classMap.get(gid) ?? 0); + } + } + return true; +} + +/** + * 读取 ClassDef 表 → 新gid -> 原class 映射(仅记录子集保留的 gid) + */ +function readClassDefMap( + r: Reader, + classDefAbs: number, + origToNew: Map, +): Map | null { + const out = new Map(); + const fmt = r.u16(classDefAbs); + if (fmt === 1) { + const startGlyph = r.u16(classDefAbs + 2); + const glyphCount = r.u16(classDefAbs + 4); + for (let i = 0; i < glyphCount; i++) { + const ng = remapGid(startGlyph + i, origToNew); + if (ng < 0) continue; + out.set(ng, r.u16(classDefAbs + 6 + i * 2)); + } + } else if (fmt === 2) { + const rangeCount = r.u16(classDefAbs + 2); + for (let i = 0; i < rangeCount; i++) { + const recOff = classDefAbs + 4 + i * 6; + const start = r.u16(recOff); + const end = r.u16(recOff + 2); + const cls = r.u16(recOff + 4); + for (let g = start; g <= end; g++) { + const ng = remapGid(g, origToNew); + if (ng >= 0) out.set(ng, cls); + } + } + } else { + return null; + } + return out; +} + +/** + * SinglePos subtable + * Format 1: 一个 ValueRecord 应用于 coverage 内所有 glyph + * Format 2: 每个 coverage glyph 一个 ValueRecord(按 coverage index 顺序) + */ +function serializeSinglePos( + w: Writer, + r: Reader, + subAbs: number, + origToNew: Map, +): boolean { + const fmt = r.u16(subAbs); + const coverageOff = r.u16(subAbs + 2); + const valueFormat = r.u16(subAbs + 4); + + /** 解析原始 coverage → 原gid 列表 */ + const origGids = readCoverageGids(r, subAbs + coverageOff); + if (!origGids) return false; + + /** 按 glyph 重建(origGid -> newGid),保留在子集中的 */ + const sz = vrCount(valueFormat); + const kept: Array<{ newGid: number; valueAbs: number }> = []; + for (let idx = 0; idx < origGids.length; idx++) { + const ng = remapGid(origGids[idx], origToNew); + if (ng < 0) continue; + if (fmt === 1) { + /** format1 所有 glyph 共用 subAbs + 6 处的 ValueRecord */ + kept.push({ newGid: ng, valueAbs: subAbs + 6 }); + } else if (fmt === 2) { + /** format2 ValueRecord 数组在 subAbs + 8,按 coverage index 顺序 */ + kept.push({ newGid: ng, valueAbs: subAbs + 8 + idx * sz * 2 }); + } else { + return false; + } + } + kept.sort((a, b) => a.newGid - b.newGid); + + const subStart = w.length; + if (fmt === 1) { + w.writeUint16(1); + } else { + w.writeUint16(2); + } + const covSlot = w.length; + w.writeUint16(0); + w.writeUint16(valueFormat); + if (fmt === 2) { + w.writeUint16(kept.length); + } + /** ValueRecord */ + for (const e of kept) { + if (!writeValueRecord(w, r, e.valueAbs, valueFormat)) return false; + } + /** coverage */ + const covPos = w.length; + emitCoverageFromGids(w, kept.map((e) => e.newGid)); + w.writeInt16At(covSlot, covPos - subStart); + return true; +} + +/** + * PairPos subtable + */ +function serializePairPos( + w: Writer, + r: Reader, + subAbs: number, + origToNew: Map, +): boolean { + const fmt = r.u16(subAbs); + const coverageOff = r.u16(subAbs + 2); + const valueFormat1 = r.u16(subAbs + 4); + const valueFormat2 = r.u16(subAbs + 6); + if (fmt === 1) { + return serializePairPosFormat1(w, r, subAbs, coverageOff, valueFormat1, valueFormat2, origToNew); + } + if (fmt === 2) { + return serializePairPosFormat2(w, r, subAbs, coverageOff, valueFormat1, valueFormat2, origToNew); + } + return false; +} + +/** + * PairPosFormat1:glyph 对(PairSet 表) + * firstGlyph 与 secondGlyph 都必须在子集中才保留该对。 + */ +function serializePairPosFormat1( + w: Writer, + r: Reader, + subAbs: number, + coverageOff: number, + valueFormat1: number, + valueFormat2: number, + origToNew: Map, +): boolean { + const firstGids = readCoverageGids(r, subAbs + coverageOff); + if (!firstGids) return false; + const pairSetCount = r.u16(subAbs + 8); + const vr1 = vrCount(valueFormat1); + const vr2 = vrCount(valueFormat2); + + /** 重建每个保留 firstGlyph 的 PairSet */ + const rebuilt: Array<{ newFirstGid: number; pairSetBytes: Uint8Array }> = []; + for (let idx = 0; idx < pairSetCount; idx++) { + const newFirstGid = remapGid(firstGids[idx], origToNew); + if (newFirstGid < 0) continue; + const pairSetOff = subAbs + r.u16(subAbs + 10 + idx * 2); + const pairValueCount = r.u16(pairSetOff); + /** 收集 secondGlyph 也在子集中的对 */ + const keptSeconds: Array<{ newSecondGid: number; recAbs: number }> = []; + for (let p = 0; p < pairValueCount; p++) { + const recAbs = pairSetOff + 2 + p * (2 + vr1 * 2 + vr2 * 2); + const newSecondGid = remapGid(r.u16(recAbs), origToNew); + if (newSecondGid < 0) continue; + keptSeconds.push({ newSecondGid, recAbs }); + } + keptSeconds.sort((a, b) => a.newSecondGid - b.newSecondGid); + + const pw = new Writer(); + pw.writeUint16(keptSeconds.length); + for (const ks of keptSeconds) { + pw.writeUint16(ks.newSecondGid); + if (vr1 > 0 && !writeValueRecord(pw, r, ks.recAbs + 2, valueFormat1)) return false; + if (vr2 > 0 && !writeValueRecord(pw, r, ks.recAbs + 2 + vr1 * 2, valueFormat2)) return false; + } + pw.flush(); + rebuilt.push({ newFirstGid, pairSetBytes: pw.toUint8Array() }); + } + rebuilt.sort((a, b) => a.newFirstGid - b.newFirstGid); + + const subStart = w.length; + w.writeUint16(1); + const covSlot = w.length; + w.writeUint16(0); + w.writeUint16(valueFormat1); + w.writeUint16(valueFormat2); + w.writeUint16(rebuilt.length); + + const pairSetAbsPositions: number[] = new Array(rebuilt.length); + for (let i = 0; i < rebuilt.length; i++) { + const slotIdx = i; + w.reserveOffset16(subStart, () => pairSetAbsPositions[slotIdx]); + } + for (let i = 0; i < rebuilt.length; i++) { + pairSetAbsPositions[i] = w.length; + const bs = rebuilt[i].pairSetBytes; + for (let k = 0; k < bs.length; k++) w.writeUint8(bs[k]); + } + + const covPos = w.length; + emitCoverageFromGids(w, rebuilt.map((e) => e.newFirstGid)); + w.writeInt16At(covSlot, covPos - subStart); + return true; +} + +/** + * PairPosFormat2:class 对(ClassDef + Class1/Class2 二维 ValueRecord 数组) + * + * 子集化必须对 class 做【紧致重编号】:原始 class1Count×class2Count 可能很大 + * (如 124×107=13268 个 ValueRecord),直接沿用会让 subtable 超过 Offset16 (32767) + * 寻址范围,coverage/classDef 偏移溢出导致字体损坏。 + * 只保留实际被子集 gid 命中的 class,重新编号为 0..N-1(保留 class 0), + * ValueRecord 数组按紧致 class 索引从原始数组对应位置取值。 + */ +function serializePairPosFormat2( + w: Writer, + r: Reader, + subAbs: number, + coverageOff: number, + valueFormat1: number, + valueFormat2: number, + origToNew: Map, +): boolean { + const classDef1Off = r.u16(subAbs + 8); + const classDef2Off = r.u16(subAbs + 10); + const class2Count = r.u16(subAbs + 14); + + /** 解析原始 classDef → 新gid -> 原class(classDef 未出现的 gid 默认 class 0) */ + const gidToClass1 = readClassDefMap(r, subAbs + classDef1Off, origToNew); + const gidToClass2 = readClassDefMap(r, subAbs + classDef2Off, origToNew); + if (!gidToClass1 || !gidToClass2) return false; + + /** 收集实际命中的 class(class 0 永远保留) */ + const usedClass1 = new Set([0]); + for (const cls of gidToClass1.values()) usedClass1.add(cls); + const usedClass2 = new Set([0]); + for (const cls of gidToClass2.values()) usedClass2.add(cls); + + /** 原class -> 紧致新class(升序重编号,class 0 → 0) */ + const class1Remap = new Map(); + [...usedClass1].sort((a, b) => a - b).forEach((c, i) => class1Remap.set(c, i)); + const class2Remap = new Map(); + [...usedClass2].sort((a, b) => a - b).forEach((c, i) => class2Remap.set(c, i)); + const newClass1Count = class1Remap.size; + const newClass2Count = class2Remap.size; + + /** 反查:紧致新class -> 原class */ + const newC1ToOld = new Map(); + for (const [oldC, newC] of class1Remap) newC1ToOld.set(newC, oldC); + const newC2ToOld = new Map(); + for (const [oldC, newC] of class2Remap) newC2ToOld.set(newC, oldC); + + /** 紧致后的 classDef(新gid -> 紧致class) */ + const compactCD1 = new Map(); + for (const [gid, cls] of gidToClass1) compactCD1.set(gid, class1Remap.get(cls)!); + const compactCD2 = new Map(); + for (const [gid, cls] of gidToClass2) compactCD2.set(gid, class2Remap.get(cls)!); + + /** 重映射 coverage */ + const covGids = readCoverageGids(r, subAbs + coverageOff); + if (!covGids) return false; + const newCovGids: number[] = []; + for (const g of covGids) { + const ng = remapGid(g, origToNew); + if (ng >= 0) newCovGids.push(ng); + } + newCovGids.sort((a, b) => a - b); + + const vr1 = vrCount(valueFormat1); + const vr2 = vrCount(valueFormat2); + const class2RecSize = vr1 * 2 + vr2 * 2; + + const subStart = w.length; + w.writeUint16(2); + const covSlot = w.length; + w.writeUint16(0); + w.writeUint16(valueFormat1); + w.writeUint16(valueFormat2); + const cd1Slot = w.length; + w.writeUint16(0); + const cd2Slot = w.length; + w.writeUint16(0); + w.writeUint16(newClass1Count); + w.writeUint16(newClass2Count); + + /** class1Records:按紧致 class 遍历,从原始数组 (原class1 * class2Count + 原class2) 取值 */ + for (let nc1 = 0; nc1 < newClass1Count; nc1++) { + const oc1 = newC1ToOld.get(nc1)!; + for (let nc2 = 0; nc2 < newClass2Count; nc2++) { + const oc2 = newC2ToOld.get(nc2)!; + const recAbs = subAbs + 16 + (oc1 * class2Count + oc2) * class2RecSize; + if (vr1 > 0 && !writeValueRecord(w, r, recAbs, valueFormat1)) return false; + if (vr2 > 0 && !writeValueRecord(w, r, recAbs + vr1 * 2, valueFormat2)) return false; + } + } + + /** classDef1 */ + const cd1Pos = w.length; + if (!emitClassDefFromClassMap(w, compactCD1)) return false; + /** classDef2 */ + const cd2Pos = w.length; + if (!emitClassDefFromClassMap(w, compactCD2)) return false; + /** coverage */ + const covPos = w.length; + emitCoverageFromGids(w, newCovGids); + + w.writeInt16At(cd1Slot, cd1Pos - subStart); + w.writeInt16At(cd2Slot, cd2Pos - subStart); + w.writeInt16At(covSlot, covPos - subStart); + return true; +} diff --git a/backend/font_util/gsub-reachable.ts b/backend/font_util/gsub-reachable.ts new file mode 100644 index 0000000..5b5f129 --- /dev/null +++ b/backend/font_util/gsub-reachable.ts @@ -0,0 +1,356 @@ +/** + * 计算子集 glyph 通过 GSUB 替换链可达的全部 target glyph id + * + * GSUB(连字/上下文替换)的替换目标常为无 unicode 的纯字形 glyph(如 FiraCode 的 + * `greater_equal.liga`,unicode=None)。子集化基于 codepoint 无法保留这些 glyph, + * 导致替换规则 target 失效、连字不渲染。本模块在子集化前解析原始 GSUB,找出从子集起始 + * gid 出发、经替换链可达的全部 target gid,供调用方注入子集(extraSubsetGids)。 + * + * 处理的 lookup 类型: + * - type1 SingleSubst: coverage gid(在子集)→ target gid + * - type2 MultipleSubst: coverage gid(在子集)→ 多个 target gid + * - type3 AlternateSubst: coverage gid(在子集)→ 多个候选 target gid(全部保留) + * - type4 LigatureSubst: 全部 component 在子集 → target gid(多对一,target 为新字形) + * - type6 ChainedContext: 匹配上下文后调用其他 lookup(递归处理被引用 lookup) + * - type7 Extension: 解包后递归 + * + * @reference https://learn.microsoft.com/en-us/typography/opentype/spec/gsub + */ + +import { OTReader } from "./ot-bytes.js"; + +/** GSUB lookup 类型常量 */ +const LT_SINGLE = 1; +const LT_MULTIPLE = 2; +const LT_ALTERNATE = 3; +const LT_LIGATURE = 4; +const LT_REVERSE_CHAIN = 5; +const LT_CHAIN = 6; +const LT_EXTENSION = 7; + +/** 读取 Coverage 表的 gid 列表 */ +function readCoverageGids(r: OTReader, off: number): number[] { + const format = r.u16(off); + const gids: number[] = []; + if (format === 1) { + const count = r.u16(off + 2); + for (let i = 0; i < count; i++) gids.push(r.u16(off + 4 + i * 2)); + } else if (format === 2) { + const rangeCount = r.u16(off + 2); + let p = off + 4; + for (let i = 0; i < rangeCount; i++) { + const start = r.u16(p); + const end = r.u16(p + 2); + for (let g = start; g <= end; g++) gids.push(g); + p += 6; + } + } + return gids; +} + +/** + * 收集从 seedGids 出发、经 GSUB 替换链可达的全部 target gid(不含 seed 本身)。 + * + * @param gsubBytes 原始 GSUB 表字节 + * @param seedGids 子集起始 gid 集合(子集 codepoint 对应的 gid) + * @returns 需额外保留的 target gid 集合 + */ +export function collectReachableGsubTargets( + gsubBytes: Uint8Array, + seedGids: Set, +): Set { + const dv = new DataView(gsubBytes.buffer, gsubBytes.byteOffset, gsubBytes.byteLength); + const r = new OTReader(dv); + + const major = r.u16(0); + const minor = r.u16(2); + if (major !== 1 || minor > 1) return new Set(); + const lookupListOff = r.u16(8); + + const lookupCount = r.u16(lookupListOff); + const lookupRelOffs: number[] = []; + for (let i = 0; i < lookupCount; i++) { + lookupRelOffs.push(r.u16(lookupListOff + 2 + i * 2)); + } + + /** 解析每个 lookup 的 (effectiveType, subtableAbsOffs) */ + interface LookupParsed { + effectiveType: number; + subtableAbsOffs: number[]; + } + const lookups: LookupParsed[] = []; + for (let i = 0; i < lookupCount; i++) { + const lOff = lookupListOff + lookupRelOffs[i]; + const lookupType = r.u16(lOff); + const subTableCount = r.u16(lOff + 4); + const subtableAbsOffs: number[] = []; + let effectiveType = lookupType; + for (let j = 0; j < subTableCount; j++) { + const subOff = lOff + r.u16(lOff + 6 + j * 2); + if (lookupType === LT_EXTENSION) { + if (r.u16(subOff) !== 1) { + effectiveType = -1; + continue; + } + effectiveType = r.u16(subOff + 2); + subtableAbsOffs.push(subOff + r.u32(subOff + 4)); + } else { + subtableAbsOffs.push(subOff); + } + } + lookups.push({ effectiveType, subtableAbsOffs }); + } + + /** 固定点迭代:不断扩展 reachable 集合。 + * type6 ChainedContext 规则:若其全部 input/backtrack/lookahead gid 都在子集内, + * 则规则可触发——此时被引用 lookup 的 target 需保留(通过 chainRefs 在迭代中处理 type1/2/3/4 target)。 + * type1/2/3/4 规则:coverage gid 在子集 → target 保留。 + * 这模拟了 pyftsubset 的「实际触发路径」分析,避免保留无关 calt 规则的海量 context glyph。 */ + const reachable = new Set(); + let changed = true; + /** 当前「在子集内」的 gid 集合 = seed ∪ reachable */ + const inSubset = (gid: number) => seedGids.has(gid) || reachable.has(gid); + + while (changed) { + changed = false; + for (let i = 0; i < lookupCount; i++) { + const lk = lookups[i]; + for (const subAbs of lk.subtableAbsOffs) { + if (lk.effectiveType === LT_CHAIN) { + /** type6:收集可触发规则引用的 lookup index 与所需 context gid */ + const refs = new Set(); + const ctxGids = new Set(); + collectChainRefs(r, subAbs, refs, ctxGids, inSubset); + for (const g of ctxGids) { + if (!reachable.has(g)) { reachable.add(g); changed = true; } + } + for (const li of refs) { + const refLk = lookups[li]; + if (!refLk) continue; + for (const refSub of refLk.subtableAbsOffs) { + const refTargets = collectSubtableTargets(r, refSub, refLk.effectiveType, inSubset); + for (const g of refTargets) { + if (!reachable.has(g)) { reachable.add(g); changed = true; } + } + } + } + } else { + const newTargets = collectSubtableTargets(r, subAbs, lk.effectiveType, inSubset); + for (const g of newTargets) { + if (!reachable.has(g)) { + reachable.add(g); + changed = true; + } + } + } + } + } + if (r.errorFlag) break; + } + + return reachable; +} + +/** 从单个 subtable 收集 target gid(type1/2/3/4),type6 不直接产生 target(通过 chainRefs 间接) */ +function collectSubtableTargets( + r: OTReader, + off: number, + type: number, + inSubset: (gid: number) => boolean, +): number[] { + const targets: number[] = []; + if (type === LT_SINGLE) { + /** SingleSubst: format1 coverage+delta / format2 coverage+gidArray */ + const format = r.u16(off); + const covOff = off + r.u16(off + 2); + const covGids = readCoverageGids(r, covOff); + if (format === 1) { + const delta = r.i16(off + 4); + for (const g of covGids) { + if (inSubset(g)) targets.push((g + delta) & 0xffff); + } + } else if (format === 2) { + const count = r.u16(off + 4); + for (let i = 0; i < covGids.length && i < count; i++) { + if (inSubset(covGids[i])) targets.push(r.u16(off + 6 + i * 2)); + } + } + } else if (type === LT_MULTIPLE) { + const covOff = off + r.u16(off + 2); + const seqCount = r.u16(off + 4); + const covGids = readCoverageGids(r, covOff); + for (let i = 0; i < covGids.length && i < seqCount; i++) { + if (!inSubset(covGids[i])) continue; + const seqOff = off + r.u16(off + 6 + i * 2); + const glyphCount = r.u16(seqOff); + for (let k = 0; k < glyphCount; k++) targets.push(r.u16(seqOff + 2 + k * 2)); + } + } else if (type === LT_ALTERNATE) { + const covOff = off + r.u16(off + 2); + const altCount = r.u16(off + 4); + const covGids = readCoverageGids(r, covOff); + for (let i = 0; i < covGids.length && i < altCount; i++) { + if (!inSubset(covGids[i])) continue; + const altOff = off + r.u16(off + 6 + i * 2); + const cnt = r.u16(altOff); + for (let k = 0; k < cnt; k++) targets.push(r.u16(altOff + 2 + k * 2)); + } + } else if (type === LT_LIGATURE) { + /** LigatureSubst: 全部 component 在子集 → target gid */ + const covOff = off + r.u16(off + 2); + const setCount = r.u16(off + 4); + const covGids = readCoverageGids(r, covOff); + for (let i = 0; i < covGids.length && i < setCount; i++) { + const firstInSubset = inSubset(covGids[i]); + const setOff = off + r.u16(off + 6 + i * 2); + const ligCount = r.u16(setOff); + for (let j = 0; j < ligCount; j++) { + const ligOff = setOff + r.u16(setOff + 2 + j * 2); + const compCount = r.u16(ligOff); + const target = r.u16(ligOff + 2); + /** 第一分量需在子集(若 first 不在子集,整条 ligature 不会触发) */ + if (!firstInSubset) continue; + let allIn = true; + for (let k = 0; k < compCount - 1; k++) { + if (!inSubset(r.u16(ligOff + 4 + k * 2))) { + allIn = false; + break; + } + } + if (allIn) targets.push(target); + } + } + } + /** type5 ReverseChain / type6 ChainedContext 不直接产生 target(type6 通过 chainRefs 间接, + * 被引用 lookup 的 target 在其自身 subtable 收集;ReverseChain 罕见且 target 提取复杂,暂忽略) */ + return targets; +} + +/** + * 收集 type6 ChainedContext subtable 中「可触发规则」引用的 lookup index 与所需 context gid。 + * + * 一条 ChainSubstRule 可触发,当且仅当其全部 backtrack/input/lookahead gid 都在当前子集内。 + * format1:元素是直接 gid,用 inSubset 逐项判断;context gid 收集到 contextGids(保留它们使规则可触发)。 + * format2:元素是 class index(非 gid),class 匹配的 gid 由 ClassDef 决定;class index 始终有效, + * 但规则是否「可能触发」取决于该 class 是否含子集内 gid(保守起见视为可触发,收集 ClassDef gid)。 + * format3:元素是 coverage,gid 全在子集则可触发。 + * + * 只对可触发规则收集其 SubstLookupRecord 引用的 lookup index(refs),供调用方进一步追踪 target。 + * 这模拟 pyftsubset 的触发路径分析,避免保留无关规则的海量 context glyph。 + */ +function collectChainRefs( + r: OTReader, + off: number, + refs: Set, + contextGids: Set, + inSubset: (gid: number) => boolean, +): void { + const format = r.u16(off); + if (format === 1) { + /** format1: coverage(gid) + SubRuleSet 数组,按 coverage gid 索引 */ + const covOff = off + r.u16(off + 2); + const covGids = readCoverageGids(r, covOff); + const setCount = r.u16(off + 4); + for (let i = 0; i < covGids.length && i < setCount; i++) { + /** 第一分量(coverage gid)须在子集,否则该 SubRuleSet 不触发 */ + if (!inSubset(covGids[i])) continue; + contextGids.add(covGids[i]); + const setOffRel = r.u16(off + 6 + i * 2); + if (setOffRel === 0) continue; + const setOff = off + setOffRel; + const ruleCount = r.u16(setOff); + for (let j = 0; j < ruleCount; j++) { + const ruleOff = setOff + r.u16(setOff + 2 + j * 2); + collectChainRuleRefs(r, ruleOff, refs, contextGids, inSubset, true); + } + } + } else if (format === 2) { + /** format2: 三个 ClassDef + 按 input 第一分量 class 索引的 SubClassSet。 + * class index 不重映射,规则的 backtrack/input/lookahead 是 class index(非 gid), + * 无法直接用 inSubset 判断(class 可含任意 gid)。保守收集被引用 lookup 与 ClassDef gid。 */ + const classSetCount = r.u16(off + 10); + for (let i = 0; i < classSetCount; i++) { + const setOffRel = r.u16(off + 12 + i * 2); + if (setOffRel === 0) continue; + const setOff = off + setOffRel; + const ruleCount = r.u16(setOff); + for (let j = 0; j < ruleCount; j++) { + const ruleOff = setOff + r.u16(setOff + 2 + j * 2); + collectChainRuleRefs(r, ruleOff, refs, contextGids, inSubset, false); + } + } + } else if (format === 3) { + /** format3: 显式 coverage 数组 + SubstLookupRecords。 + * 三个 coverage 数组(backtrack/input/lookahead)的 gid 须全在子集才触发。 */ + let p = off + 2; + const allGids: number[] = []; + let triggerable = true; + const readCovGids = (cnt: number): boolean => { + for (let k = 0; k < cnt; k++) { + const covGids = readCoverageGids(r, off + r.u16(p + k * 2)); + for (const g of covGids) { + allGids.push(g); + if (!inSubset(g)) triggerable = false; + } + } + p += cnt * 2; + return true; + }; + const backtrackCount = r.u16(p); p += 2; + readCovGids(backtrackCount); + const inputCount = r.u16(p); p += 2; + readCovGids(inputCount); + const lookaheadCount = r.u16(p); p += 2; + readCovGids(lookaheadCount); + if (triggerable) { + for (const g of allGids) contextGids.add(g); + const substCount = r.u16(p); + for (let k = 0; k < substCount; k++) refs.add(r.u16(p + 2 + k * 4 + 2)); + } + } +} + +/** + * 从一条 ChainSubstRule 收集引用的 lookup index 与 context gid。 + * @param isGidFormat true=format1(元素为 gid,需 inSubset 判断且收集 gid); + * false=format2(元素为 class index,原样保留,不判断不收集)。 + * 规则全部 context gid 在子集时才收集 refs(format1);format2 class index 始终收集。 + */ +function collectChainRuleRefs( + r: OTReader, + ruleOff: number, + refs: Set, + contextGids: Set, + inSubset: (gid: number) => boolean, + isGidFormat: boolean, +): void { + /** format1/2 rule: backtrackCount + backtrack[] + inputCount + input[] + lookaheadCount + lookahead[] + substCount + substRecords[] */ + let p = ruleOff; + const backtrackCount = r.u16(p); p += 2; + const backRaw: number[] = []; + for (let k = 0; k < backtrackCount; k++) backRaw.push(r.u16(p + k * 2)); + p += backtrackCount * 2; + const inputCount = r.u16(p); p += 2; + const inputRaw: number[] = []; + for (let k = 0; k < inputCount - 1; k++) inputRaw.push(r.u16(p + k * 2)); + p += (inputCount - 1) * 2; + const lookaheadCount = r.u16(p); p += 2; + const lookRaw: number[] = []; + for (let k = 0; k < lookaheadCount; k++) lookRaw.push(r.u16(p + k * 2)); + p += lookaheadCount * 2; + const substCount = r.u16(p); p += 2; + + /** format1:全部 context gid 在子集才触发;format2:class index 始终「可触发」(保守) */ + if (isGidFormat) { + for (const arr of [backRaw, inputRaw, lookRaw]) { + for (const g of arr) { + if (!inSubset(g)) return; /** 含子集外 gid,规则不触发 */ + } + for (const g of arr) contextGids.add(g); + } + } + for (let k = 0; k < substCount; k++) { + /** SubstLookupRecord: sequenceIndex(2) + lookupListIndex(2) */ + refs.add(r.u16(p + k * 4 + 2)); + } +} diff --git a/backend/font_util/gsub-subset.ts b/backend/font_util/gsub-subset.ts new file mode 100644 index 0000000..519721b --- /dev/null +++ b/backend/font_util/gsub-subset.ts @@ -0,0 +1,1010 @@ +/** + * GSUB 表子集化器 + * + * GSUB(Glyph Substitution Table)控制字形替换:连字(ligature,如 FiraCode 的 != → ≠)、 + * 上下文替换(calt)、字形组合分解(ccmp)等。子集化后 glyphId 被重编号,fonteditor-core 的 + * GSUB 读写器是原始字节透传,不会按子集字形重映射 coverage/ClassDef,导致浏览器用新 glyphId + * 查 GSUB coverage 查不到,连字/替换规则失效,子集字体与原字体人眼不一致(墨量差异、形状错位)。 + * + * 本模块按 OpenType 1.9.1 规范解析 GSUB,对主要 lookup 类型的 coverage/ClassDef/替换目标 gid + * 做 原gid→新gid 重映射后重新序列化: + * - LookupType 1 SingleSubst(单字形替换,format1 delta / format2 数组) + * - LookupType 2 MultipleSubst(一换多) + * - LookupType 3 AlternateSubst(一换多选一) + * - LookupType 4 LigatureSubst(多换一,连字核心) + * - LookupType 6 ChainedContextSubst(链式上下文,重映射 coverage/ClassDef,保留 lookup index 引用) + * - LookupType 7 Extension(解包后递归处理内嵌 lookup) + * + * 遇到不支持的 lookup 类型(如 type5 ReverseChain)时该 lookup 原样拷贝字节(gid 不重映射, + * 浏览器查不到 coverage 会跳过,不会破坏字体;缺失的功能仅影响该 lookup 覆盖的字形)。 + * + * @reference https://learn.microsoft.com/en-us/typography/opentype/spec/gsub + */ + +import { OTWriter as Writer, OTReader as Reader, serializeScriptList, serializeFeatureList } from "./ot-bytes.js"; + +/** GSUB lookup 类型常量 */ +const LT_SINGLE = 1; +const LT_MULTIPLE = 2; +const LT_ALTERNATE = 3; +const LT_LIGATURE = 4; +const LT_CHAIN = 6; +const LT_EXTENSION = 7; + +/** Coverage format 常量 */ +const COV_LIST = 1; +const COV_RANGE = 2; + +/** + * 重映射单个 gid。子集外的 gid 返回 null。 + * GSUB 中 coverage/ClassDef/替换目标引用的 gid 若不在子集内,该条目失效,调用方丢弃之。 + */ +function remapGid(origToNew: Map, gid: number): number | null { + const m = origToNew.get(gid); + return m === undefined ? null : m; +} + +/** Coverage 表单个 range 展开为 gid 的上限保护:超出视为偏移错位读到垃圾数据。 */ +const COVERAGE_MAX_EXPAND = 0x10000; + +/** 读取 Coverage 表,返回覆盖的原 gid 列表(保持顺序)。 */ +function readCoverageGids(r: Reader, off: number): number[] { + const format = r.u16(off); + const gids: number[] = []; + if (format === COV_LIST) { + const count = r.u16(off + 2); + for (let i = 0; i < count; i++) gids.push(r.u16(off + 4 + i * 2)); + } else if (format === COV_RANGE) { + const rangeCount = r.u16(off + 2); + let p = off + 4; + for (let i = 0; i < rangeCount; i++) { + const start = r.u16(p); + const end = r.u16(p + 2); + /** 偏移错位会读到 end < start 或区间异常大的垃圾 range。 + * Coverage 的 gid 总数不可能超过字体 glyph 总数(< 0x10000), + * 累计展开超出上限视为损坏数据,停止展开(返回已收集的部分,调用方按子集过滤,多余 gid 自然被丢弃)。 */ + if (end >= start && end - start < COVERAGE_MAX_EXPAND && gids.length + (end - start + 1) <= COVERAGE_MAX_EXPAND) { + for (let g = start; g <= end; g++) gids.push(g); + } + /** startCoverageIndex(uint16)未使用,跳过 */ + p += 6; + } + } + return gids; +} + +/** + * 从一组(子集内的)新 gid 序列写出 Coverage 表,返回其在 Writer 中的起始偏移。 + * 自动选择 format1(列表)或 format2(区间)中更紧凑的。 + */ +function emitCoverage(w: Writer, newGids: number[]): number { + /** 去重并升序(Coverage 要求升序且唯一) */ + const sorted = Array.from(new Set(newGids)).sort((a, b) => a - b); + const off = w.length; + let ranges: Array<{ start: number; end: number }> = []; + for (let i = 0; i < sorted.length; ) { + let j = i; + while (j + 1 < sorted.length && sorted[j + 1] === sorted[j] + 1) j++; + ranges.push({ start: sorted[i], end: sorted[j] }); + i = j + 1; + } + if (ranges.length > 0 && ranges.length < sorted.length) { + /** format2 区间更紧凑 */ + w.writeUint16(COV_RANGE); + w.writeUint16(ranges.length); + let covIndex = 0; + for (const rg of ranges) { + w.writeUint16(rg.start); + w.writeUint16(rg.end); + w.writeUint16(covIndex); + covIndex += rg.end - rg.start + 1; + } + } else { + /** format1 列表 */ + w.writeUint16(COV_LIST); + w.writeUint16(sorted.length); + for (const g of sorted) w.writeUint16(g); + } + return off; +} + +/** + * 写出 SingleSubst(type1)subtable,成功返回 true。 + * format1: coverage + deltaGlyphID;format2: coverage + glyphId 数组。 + * 子集化后统一 delta 规则可能被破坏(部分 gid 被 delta 移出子集),此时升级为 format2 逐项映射。 + */ +function serializeSingleSubst( + w: Writer, + r: Reader, + off: number, + origToNew: Map, +): boolean { + const format = r.u16(off); + const covOff = off + r.u16(off + 2); + const covGids = readCoverageGids(r, covOff); + + /** 收集 (from新gid → to新gid) 有效项,子集外的剔除 */ + const entries: Array<{ from: number; to: number }> = []; + if (format === 1) { + const delta = r.i16(off + 4); + for (const g of covGids) { + const fromNew = remapGid(origToNew, g); + const toNew = remapGid(origToNew, (g + delta) & 0xffff); + if (fromNew !== null && toNew !== null) entries.push({ from: fromNew, to: toNew }); + } + } else if (format === 2) { + const count = r.u16(off + 4); + for (let i = 0; i < covGids.length && i < count; i++) { + const fromNew = remapGid(origToNew, covGids[i]); + const toNew = remapGid(origToNew, r.u16(off + 6 + i * 2)); + if (fromNew !== null && toNew !== null) entries.push({ from: fromNew, to: toNew }); + } + } else { + return false; + } + if (entries.length === 0) return false; + + /** 关键:按 from gid 升序排序,使 coverage(emitCoverage 会排序)与 target 数组保持配对一致。 + * SingleSubst format2 的 source gid 数组与 target 数组按下标一一对应, + * 而 emitCoverage 输出 Coverage 时强制升序去重——若 entries 不先排序, + * coverage 顺序会与 target 顺序错位(source↔target 配对断裂,连字替换到错误字形)。 + * 排序后 coverage 与 target 共用同一升序,配对关系得以保持。 */ + entries.sort((a, b) => a.from - b.from); + + /** 检查是否所有项仍为统一 delta(to - from 恒定),若是用 format1 更紧凑,否则 format2 */ + let uniform = true; + const firstDelta = (entries[0].to - entries[0].from) & 0xffff; + for (const e of entries) { + if (((e.to - e.from) & 0xffff) !== firstDelta) { + uniform = false; + break; + } + } + + const subStart = w.length; + const coveragePosHolder: number[] = [0]; + if (uniform) { + w.writeUint16(1); + w.reserveOffset16(subStart, () => coveragePosHolder[0]); + w.writeUint16(firstDelta); + } else { + w.writeUint16(2); + w.reserveOffset16(subStart, () => coveragePosHolder[0]); + w.writeUint16(entries.length); + for (const e of entries) w.writeUint16(e.to); + } + coveragePosHolder[0] = emitCoverage(w, entries.map((e) => e.from)); + return true; +} + +/** + * 写出一个合法的「空 subtable」用于降级(重映射失败 / 不支持的类型)。 + * 输出合法结构 + 空 coverage,浏览器查不到任何字形会直接跳过,不会破坏字体。 + * + * 各 lookup 类型的 subtable 首字段是 format,合法 format 集合不同: + * - SingleSubst: {1, 2};其余(Multiple/Alternate/Ligature/Chain 等)首 format 通常只接受 1。 + * 故按 effectiveType 选择 format:SingleSubst 用 format2(count=0),其余用 format1(count=0)。 + * 两者结构同形:format(2) + coverageOffset(2) + count(2)=0 + 空 coverage,coverage 紧随其后。 + * + * 不原样拷贝原始 subtable 字节——其 coverage/ClassDef 等子结构在原始字体中可能与其他 lookup + * 物理交错、散落在任意偏移(霞鹜文楷 type4 的 coverage 在 subtable 后 2594 字节处), + * 按间距/边界估算拷贝会破坏字体。 + */ +function writeEmptySubtable(w: Writer, effectiveType: number): void { + const subStart = w.length; + /** SingleSubst 用 format2,其余类型用 format1(避免 LigatureSubst 等报 unknown format:2) */ + const format = effectiveType === LT_SINGLE ? 2 : 1; + w.writeUint16(format); + w.reserveOffset16(subStart, () => subStart + 6); + w.writeUint16(0); + /** 空 coverage:format1 + count0 */ + w.writeUint16(1); + w.writeUint16(0); +} + +/** + * 写出 MultipleSubst(type2)subtable:coverage + sequence 数组(每项 = gid 数组)。 + * 覆盖字形在子集外的剔除;sequence 内子集外的目标 gid 剔除(序列变短,仍合法)。 + */ +function serializeMultipleSubst( + w: Writer, + r: Reader, + off: number, + origToNew: Map, +): boolean { + const covOff = off + r.u16(off + 2); + const seqCount = r.u16(off + 4); + const covGids = readCoverageGids(r, covOff); + + /** 逐 coverage 字形读取其 sequence,重映射后保留有效项 */ + const entries: Array<{ from: number; seq: number[] }> = []; + for (let i = 0; i < covGids.length && i < seqCount; i++) { + const fromNew = remapGid(origToNew, covGids[i]); + if (fromNew === null) continue; + const seqOff = off + r.u16(off + 6 + i * 2); + const glyphCount = r.u16(seqOff); + const newSeq: number[] = []; + for (let k = 0; k < glyphCount; k++) { + const g = remapGid(origToNew, r.u16(seqOff + 2 + k * 2)); + if (g !== null) newSeq.push(g); + } + /** 序列至少 1 个目标 gid 才有意义 */ + if (newSeq.length > 0) entries.push({ from: fromNew, seq: newSeq }); + } + if (entries.length === 0) return false; + /** 按 from gid 升序排序,使 coverage(emitCoverage 强制升序)与 sequence 数组保持下标配对 */ + entries.sort((a, b) => a.from - b.from); + + const subStart = w.length; + const coveragePosHolder: number[] = [0]; + const seqOffHolders: number[][] = entries.map(() => [0]); + w.writeUint16(1); + w.reserveOffset16(subStart, () => coveragePosHolder[0]); + w.writeUint16(entries.length); + for (const h of seqOffHolders) w.reserveOffset16(subStart, () => h[0]); + + coveragePosHolder[0] = emitCoverage(w, entries.map((e) => e.from)); + for (let i = 0; i < entries.length; i++) { + seqOffHolders[i][0] = w.length; + w.writeUint16(entries[i].seq.length); + for (const g of entries[i].seq) w.writeUint16(g); + } + return true; +} + +/** + * 写出 AlternateSubst(type3)subtable:coverage + alternate 数组(每项 = 可选 gid 数组)。 + * 与 MultipleSubst 结构同形,仅语义不同(选一而非全用),重映射逻辑相同。 + */ +function serializeAlternateSubst( + w: Writer, + r: Reader, + off: number, + origToNew: Map, +): boolean { + const covOff = off + r.u16(off + 2); + const altCount = r.u16(off + 4); + const covGids = readCoverageGids(r, covOff); + + const entries: Array<{ from: number; alts: number[] }> = []; + for (let i = 0; i < covGids.length && i < altCount; i++) { + const fromNew = remapGid(origToNew, covGids[i]); + if (fromNew === null) continue; + const altOff = off + r.u16(off + 6 + i * 2); + const cnt = r.u16(altOff); + const newAlts: number[] = []; + for (let k = 0; k < cnt; k++) { + const g = remapGid(origToNew, r.u16(altOff + 2 + k * 2)); + if (g !== null) newAlts.push(g); + } + if (newAlts.length > 0) entries.push({ from: fromNew, alts: newAlts }); + } + if (entries.length === 0) return false; + /** 按 from gid 升序排序,使 coverage(emitCoverage 强制升序)与 alternate 数组保持下标配对 */ + entries.sort((a, b) => a.from - b.from); + + const subStart = w.length; + const coveragePosHolder: number[] = [0]; + const altOffHolders: number[][] = entries.map(() => [0]); + w.writeUint16(1); + w.reserveOffset16(subStart, () => coveragePosHolder[0]); + w.writeUint16(entries.length); + for (const h of altOffHolders) w.reserveOffset16(subStart, () => h[0]); + + coveragePosHolder[0] = emitCoverage(w, entries.map((e) => e.from)); + for (let i = 0; i < entries.length; i++) { + altOffHolders[i][0] = w.length; + w.writeUint16(entries[i].alts.length); + for (const g of entries[i].alts) w.writeUint16(g); + } + return true; +} + +/** + * 写出 LigatureSubst(type4)subtable:coverage + ligature set 数组。 + * 每个 ligature set 含多条 ligature(components 序列 + ligature 目标 gid)。 + * 第一分量(coverage 字形)在子集外的剔除;components 子集外或目标子集外的剔除该条 ligature。 + */ +function serializeLigatureSubst( + w: Writer, + r: Reader, + off: number, + origToNew: Map, +): boolean { + const covOff = off + r.u16(off + 2); + const setCount = r.u16(off + 4); + const covGids = readCoverageGids(r, covOff); + + /** 每个 coverage 字形收集有效 ligature 列表 */ + const entries: Array<{ from: number; ligs: Array<{ comp: number[]; lig: number }> }> = []; + for (let i = 0; i < covGids.length && i < setCount; i++) { + const fromNew = remapGid(origToNew, covGids[i]); + if (fromNew === null) continue; + const setOff = off + r.u16(off + 6 + i * 2); + const ligCount = r.u16(setOff); + const newLigs: Array<{ comp: number[]; lig: number }> = []; + for (let j = 0; j < ligCount; j++) { + const ligOff = setOff + r.u16(setOff + 2 + j * 2); + const compCount = r.u16(ligOff); + const ligNew = remapGid(origToNew, r.u16(ligOff + 2)); + if (ligNew === null) continue; + /** components 从第 2 字形开始(第 1 字形即 coverage 字形),compCount 含第 1 字形 */ + const compNew: number[] = [fromNew]; + let ok = true; + for (let k = 0; k < compCount - 1; k++) { + const c = remapGid(origToNew, r.u16(ligOff + 4 + k * 2)); + if (c === null) { + ok = false; + break; + } + compNew.push(c); + } + if (ok) newLigs.push({ comp: compNew, lig: ligNew }); + } + if (newLigs.length > 0) entries.push({ from: fromNew, ligs: newLigs }); + } + if (entries.length === 0) return false; + /** 按 from gid 升序排序,使 coverage(emitCoverage 强制升序)与 ligature set 数组保持下标配对 */ + entries.sort((a, b) => a.from - b.from); + + const subStart = w.length; + const coveragePosHolder: number[] = [0]; + const setOffHolders: number[][] = entries.map(() => [0]); + w.writeUint16(1); + w.reserveOffset16(subStart, () => coveragePosHolder[0]); + w.writeUint16(entries.length); + for (const h of setOffHolders) w.reserveOffset16(subStart, () => h[0]); + + coveragePosHolder[0] = emitCoverage(w, entries.map((e) => e.from)); + for (let i = 0; i < entries.length; i++) { + setOffHolders[i][0] = w.length; + const ligs = entries[i].ligs; + w.writeUint16(ligs.length); + const ligOffHolders: number[][] = ligs.map(() => [0]); + for (const h of ligOffHolders) w.reserveOffset16(setOffHolders[i][0], () => h[0]); + for (let j = 0; j < ligs.length; j++) { + ligOffHolders[j][0] = w.length; + w.writeUint16(ligs[j].comp.length); + w.writeUint16(ligs[j].lig); + /** 第 2 个分量起 */ + for (let k = 1; k < ligs[j].comp.length; k++) w.writeUint16(ligs[j].comp[k]); + } + } + return true; +} + +/** + * 读取 ClassDef 表为 (origGid → classIndex) map,仅含子集内 gid。 + * format1: 逐 gid 赋 class;format2: 区间赋 class。 + */ +/** 读取 ClassDef 表为 (新gid → classIndex) map,class index 原样保留(不紧致重编号) */ +function readClassDefMap(r: Reader, off: number, origToNew: Map): Map { + const result = new Map(); + if (off === 0) return result; + const format = r.u16(off); + if (format === 1) { + const startGid = r.u16(off + 2); + const count = r.u16(off + 4); + for (let i = 0; i < count; i++) { + const origGid = startGid + i; + const newGid = origToNew.get(origGid); + if (newGid !== undefined) result.set(newGid, r.u16(off + 6 + i * 2)); + } + } else if (format === 2) { + const rangeCount = r.u16(off + 2); + let p = off + 4; + for (let i = 0; i < rangeCount; i++) { + const start = r.u16(p); + const end = r.u16(p + 2); + const cls = r.u16(p + 4); + for (let g = start; g <= end; g++) { + const newGid = origToNew.get(g); + if (newGid !== undefined) result.set(newGid, cls); + } + p += 6; + } + } + return result; +} + +/** 从 (新gid → 新class) map 写出 ClassDef(format2 区间),返回起始偏移 */ +function writeClassDefFromMap(w: Writer, newGidToClass: Map): number { + const off = w.length; + if (newGidToClass.size === 0) { + w.writeUint16(2); + w.writeUint16(0); + return off; + } + const entries = Array.from(newGidToClass.entries()).sort((a, b) => a[0] - b[0]); + const ranges: Array<{ start: number; end: number; cls: number }> = []; + for (let i = 0; i < entries.length; ) { + const cls = entries[i][1]; + let j = i; + while (j + 1 < entries.length && entries[j + 1][0] === entries[j][0] + 1 && entries[j + 1][1] === cls) j++; + ranges.push({ start: entries[i][0], end: entries[j][0], cls }); + i = j + 1; + } + w.writeUint16(2); + w.writeUint16(ranges.length); + for (const rg of ranges) { + w.writeUint16(rg.start); + w.writeUint16(rg.end); + w.writeUint16(rg.cls); + } + return off; +} + +/** + * 写出 ChainedContextSubst(type6)subtable,3 种 format 均支持。 + * 重映射 backtrack/input/lookahead 的 coverage(format1/3)或 ClassDef(format2)gid, + * 保留 SubstLookupRecord(lookup index 引用不变,被引用 lookup 自行重映射)。 + * + * format1: coverage(gid) 匹配;format2: ClassDef 匹配;format3: 显式 coverage 数组匹配。 + * 规则中若任一匹配 gid 组含子集外 gid,该规则整体失效(剔除)。 + */ +function serializeChainedContextSubst( + w: Writer, + r: Reader, + off: number, + origToNew: Map, +): boolean { + const format = r.u16(off); + if (format === 1) { + /** coverage(gid) + 子规则数组,每规则含 backtrack/input/lookahead gid 序列 + SubstLookupRecord */ + const covOff = off + r.u16(off + 2); + const covGids = readCoverageGids(r, covOff); + const ruleSetCount = r.u16(off + 4); + if (ruleSetCount > 0x7fff) return false; + /** 按 coverage 字形收集有效规则 */ + const entries: Array<{ + firstGid: number; + 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]); + if (firstNew === null) continue; + const setOff = off + r.u16(off + 6 + i * 2); + const ruleCount = r.u16(setOff); + if (ruleCount > 0x7fff) return false; + 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 + r.u16(setOff + 2 + j * 2); + const parsed = parseChainRuleFormat1or2(r, ruleOff, origToNew, true); + if (parsed) validRules.push(parsed); + } + if (validRules.length > 0) entries.push({ firstGid: firstNew, rules: validRules }); + } + if (entries.length === 0) return false; + /** 按 firstGid 升序排序,使 coverage(emitCoverage 强制升序)与 SubRuleSet 数组保持下标配对 */ + entries.sort((a, b) => a.firstGid - b.firstGid); + writeChainFormat1(w, entries); + return true; + } + + if (format === 2) { + /** format2(ClassDef 匹配):Coverage + 三个 ClassDef + 按 input 第一分量 class 索引的 rule sets。 + * 关键:class index 不重编号(class 0 是「未分类」语义,紧致化会破坏),仅重映射 ClassDef 内的 gid, + * rule 内的 class index 原样保留;rule 中匹配的字形若全部在子集内则保留该规则,否则剔除。 + * + * OpenType ChainContextSubstFormat2 字段顺序(每个 Offset16 相对 subtable 起始): + * off+0 format(=2) + * off+2 coverageOffset + * off+4 backtrackClassDefOffset + * off+6 inputClassDefOffset + * off+8 lookaheadClassDefOffset + * off+10 chainSubClassSetCount + * off+12 chainSubClassSetOffset[count] + * 旧实现漏读 coverageOffset、把后续字段整体前移 2 字节,导致 classSetCount 读成 + * lookaheadClassDefOffset(如 FiraCode L194 的 66),遍历大量垃圾 slot 触发降级, + * 连字核心规则(如 <= 的 equal→less_equal.liga)丢失,渲染走错误 shaping 路径。 */ + const coverageOff = off + r.u16(off + 2); + const backtrackCDOff = off + r.u16(off + 4); + const inputCDOff = off + r.u16(off + 6); + const lookaheadCDOff = off + r.u16(off + 8); + const classSetCount = r.u16(off + 10); + /** classSetCount 异常大(>256)通常意味着字体数据含扩展 padding 或异常结构 + * (如 FiraCode 某些 format2 有 4138 个 classSet slot),严格解析会读到大量重叠垃圾数据。 + * 此时原样拷贝该 subtable(gid 不重映射,浏览器渲染该 lookup 跳过,不破坏字体)。 */ + if (classSetCount > 256) return false; + + /** Coverage 的重映射(仅保留子集内 gid)在 writeChainFormat2 中处理 */ + + /** 收集每个 input class 的有效规则(class index 不变) */ + const classToRules = new Map }>>(); + for (let i = 0; i < classSetCount; i++) { + const setOffRel = r.u16(off + 12 + i * 2); + if (setOffRel === 0) continue; + const setOff = off + setOffRel; + const ruleCount = r.u16(setOff); + if (ruleCount > 0x7fff) return false; + for (let j = 0; j < ruleCount; j++) { + const ruleOff = setOff + r.u16(setOff + 2 + j * 2); + /** format2 的元素是 class index(非 gid),原样保留,传 isGidFormat=false。 + * class index 始终有效(ClassDef 重映射 gid 后 class 编号不变),规则恒保留。 */ + const parsed = parseChainRuleFormat1or2(r, ruleOff, origToNew, false); + if (!parsed) continue; + const list = classToRules.get(i) ?? []; + list.push(parsed); + classToRules.set(i, list); + } + } + if (classToRules.size === 0) return false; + writeChainFormat2(w, r, coverageOff, backtrackCDOff, inputCDOff, lookaheadCDOff, origToNew, classToRules); + return true; + } + + if (format === 3) { + /** 显式 coverage 数组 + SubstLookupRecord */ + const parsed = parseChainFormat3(r, off, origToNew); + if (!parsed) return false; + writeChainFormat3(w, parsed); + return true; + } + + return false; +} + +/** + * 解析 format1/format2 的单条 ChainSubRule,返回重映射后的规则或 null(含子集外 gid)。 + * @param isGidFormat true=format1(元素为 gid,需 原gid→新gid 重映射,子集外 gid 则规则失效); + * false=format2(元素为 class index,原样保留,不重映射)。 + */ +function parseChainRuleFormat1or2( + r: Reader, + ruleOff: number, + origToNew: Map, + isGidFormat: boolean, +): { back: number[]; input: number[]; look: number[]; records: Array<{ seq: number; lookup: number }> } | null { + const backCount = r.u16(ruleOff); + /** count 异常大(偏移错位读到垃圾值)则放弃该规则,返回 null。实际规则序列长度很小(<256) */ + if (backCount > 255) return null; + let p = ruleOff + 2; + const backRaw: number[] = []; + for (let k = 0; k < backCount; k++) backRaw.push(r.u16(p + k * 2)); + p += backCount * 2; + const inputCount = r.u16(p); + /** inputCount 为 0 是异常(ChainSubRule 至少含第一分量,inputCount>=1),返回 null 跳过该规则 */ + if (inputCount === 0 || inputCount > 255) return null; + p += 2; + /** input 序列不含第一分量(第一分量由 coverage/class 决定) */ + const inputRaw: number[] = []; + for (let k = 0; k < inputCount - 1; k++) inputRaw.push(r.u16(p + k * 2)); + p += (inputCount - 1) * 2; + const lookCount = r.u16(p); + if (lookCount > 255) return null; + p += 2; + const lookRaw: number[] = []; + for (let k = 0; k < lookCount; k++) lookRaw.push(r.u16(p + k * 2)); + p += lookCount * 2; + const seqCount = r.u16(p); + if (seqCount > 255) return null; + p += 2; + const records: Array<{ seq: number; lookup: number }> = []; + for (let k = 0; k < seqCount; k++) { + records.push({ seq: r.u16(p + k * 4), lookup: r.u16(p + k * 4 + 2) }); + } + + /** format1:gid 重映射,子集外 gid 则规则失效;format2:class index 原样保留 */ + const remapSeq = (arr: number[]): number[] | null => { + if (!isGidFormat) return arr.slice(); + const out: number[] = []; + for (const v of arr) { + const m = remapGid(origToNew, v); + if (m === null) return null; + out.push(m); + } + return out; + }; + + const back = remapSeq(backRaw); + const input = remapSeq(inputRaw); + const look = remapSeq(lookRaw); + if (back === null || input === null || look === null) return null; + return { back, input, look, records }; +} + +/** 写出 ChainedContext format1(coverage + rule sets) */ +function writeChainFormat1( + w: Writer, + entries: Array<{ firstGid: number; rules: Array<{ back: number[]; input: number[]; look: number[]; records: Array<{ seq: number; lookup: number }> }> }>, +): void { + const subStart = w.length; + const coverageHolder: number[] = [0]; + const setOffHolders: number[][] = entries.map(() => [0]); + w.writeUint16(1); + w.reserveOffset16(subStart, () => coverageHolder[0]); + w.writeUint16(entries.length); + for (const h of setOffHolders) w.reserveOffset16(subStart, () => h[0]); + + coverageHolder[0] = emitCoverage(w, entries.map((e) => e.firstGid)); + for (let i = 0; i < entries.length; i++) { + setOffHolders[i][0] = w.length; + const rules = entries[i].rules; + w.writeUint16(rules.length); + const ruleOffHolders: number[][] = rules.map(() => [0]); + for (const h of ruleOffHolders) w.reserveOffset16(setOffHolders[i][0], () => h[0]); + for (let j = 0; j < rules.length; j++) { + ruleOffHolders[j][0] = w.length; + writeChainRuleBody(w, rules[j]); + } + } +} + +/** 写出单条 ChainSubRule 主体(不含偏移量槽) */ +function writeChainRuleBody(w: Writer, rule: { back: number[]; input: number[]; look: number[]; records: Array<{ seq: number; lookup: number }> }): void { + w.writeUint16(rule.back.length); + for (const g of rule.back) w.writeUint16(g); + /** inputCount 含第一分量 */ + w.writeUint16(rule.input.length + 1); + for (const g of rule.input) w.writeUint16(g); + w.writeUint16(rule.look.length); + for (const g of rule.look) w.writeUint16(g); + w.writeUint16(rule.records.length); + for (const rc of rule.records) { + w.writeUint16(rc.seq); + w.writeUint16(rc.lookup); + } +} + +/** + * 写出 ChainedContext format2(三个 ClassDef + 按 class 的 rule sets)。 + * class index 不重编号(保留原始 class 0 的「未分类」语义),仅重映射 ClassDef 内的 gid。 + * rule 内的 class index 直接原样写入(parseChainRuleFormat1or2 已验证有效性)。 + */ +function writeChainFormat2( + w: Writer, + r: Reader, + coverageOff: number, + backtrackCDOff: number, + inputCDOff: number, + lookaheadCDOff: number, + origToNew: Map, + classToRules: Map }>>, +): void { + const subStart = w.length; + const coverageHolder: number[] = [0]; + const backHolder: number[] = [0]; + const inputHolder: number[] = [0]; + const lookHolder: number[] = [0]; + w.writeUint16(2); + w.reserveOffset16(subStart, () => coverageHolder[0]); + w.reserveOffset16(subStart, () => backHolder[0]); + w.reserveOffset16(subStart, () => inputHolder[0]); + w.reserveOffset16(subStart, () => lookHolder[0]); + + /** classSetCount = 出现在 classToRules 中的最大 class index + 1(保持原始 class index) */ + let maxClass = -1; + for (const cls of classToRules.keys()) if (cls > maxClass) maxClass = cls; + const classSetCount = maxClass + 1; + w.writeUint16(classSetCount); + /** 每个 class 一个偏移量槽:有规则用 reserveOffset16,无规则立即写 0 */ + const setOffHolders: Array = []; + for (let i = 0; i < classSetCount; i++) { + if (classToRules.has(i)) { + const h: number[] = [0]; + setOffHolders.push(h); + w.reserveOffset16(subStart, () => h[0]); + } else { + setOffHolders.push(null); + w.writeUint16(0); + } + } + + /** Coverage 重映射:仅保留子集内 gid(input 第一分量必须在子集内才会被 shaping 命中) */ + const origCovGids = readCoverageGids(r, coverageOff); + const newCovGids = origCovGids.map((g) => remapGid(origToNew, g)).filter((g): g is number => g !== null); + coverageHolder[0] = emitCoverage(w, newCovGids); + + /** 重映射三个 ClassDef 的 gid(class index 不变) */ + const backMap = readClassDefMap(r, backtrackCDOff, origToNew); + const inputMap = readClassDefMap(r, inputCDOff, origToNew); + const lookMap = readClassDefMap(r, lookaheadCDOff, origToNew); + backHolder[0] = writeClassDefFromMap(w, backMap); + inputHolder[0] = writeClassDefFromMap(w, inputMap); + lookHolder[0] = writeClassDefFromMap(w, lookMap); + + for (let i = 0; i < classSetCount; i++) { + const rules = classToRules.get(i); + if (!rules) continue; + setOffHolders[i]![0] = w.length; + w.writeUint16(rules.length); + const ruleOffHolders: number[][] = rules.map(() => [0]); + for (const h of ruleOffHolders) w.reserveOffset16(setOffHolders[i]![0], () => h[0]); + for (let j = 0; j < rules.length; j++) { + ruleOffHolders[j][0] = w.length; + writeChainRuleBody(w, rules[j]); + } + } +} + +/** 解析 format3:显式 coverage 数组 + records,返回重映射后的结构或 null */ +function parseChainFormat3( + r: Reader, + off: number, + origToNew: Map, +): { backCovs: number[][]; inputCovs: number[][]; lookCovs: number[][]; records: Array<{ seq: number; lookup: number }> } | null { + let p = off + 2; + const readCovArr = (): number[][] | null => { + const count = r.u16(p); + p += 2; + const arr: number[][] = []; + for (let k = 0; k < count; k++) { + const covOff = off + r.u16(p + k * 2); + const gids = readCoverageGids(r, covOff); + const newGids = gids.map((g) => remapGid(origToNew, g)).filter((g): g is number => g !== null); + /** coverage 全部 gid 落在子集外,该 coverage 空 → 规则失效 */ + if (newGids.length === 0 && gids.length > 0) return null; + arr.push(newGids); + } + p += count * 2; + return arr; + }; + const backCovs = readCovArr(); + const inputCovs = readCovArr(); + const lookCovs = readCovArr(); + if (backCovs === null || inputCovs === null || lookCovs === null) return null; + + const seqCount = r.u16(p); + p += 2; + const records: Array<{ seq: number; lookup: number }> = []; + for (let k = 0; k < seqCount; k++) { + records.push({ seq: r.u16(p + k * 4), lookup: r.u16(p + k * 4 + 2) }); + } + return { backCovs, inputCovs, lookCovs, records }; +} + +/** 写出 format3:重映射后的 coverage 数组 + records */ +function writeChainFormat3( + w: Writer, + parsed: { backCovs: number[][]; inputCovs: number[][]; lookCovs: number[][]; records: Array<{ seq: number; lookup: number }> }, +): void { + const subStart = w.length; + const allHolders: number[][] = []; + /** 预留一个 Offset16 槽,flush 时回填 allHolders[slotIdx] 的值。 + * 必须用 IIFE 捕获当前 slotIdx——闭包直接引用 allHolders.length-1 会在 flush 时(循环已结束) + * 统一取到最后一个槽,导致所有 coverage 偏移指向同一个 coverage(FiraCode === 连字断裂的根因)。 */ + const reserveCovSlot = () => { + const slotIdx = allHolders.length; + allHolders.push([0]); + w.reserveOffset16(subStart, () => allHolders[slotIdx][0]); + }; + w.writeUint16(3); + w.writeUint16(parsed.backCovs.length); + for (let k = 0; k < parsed.backCovs.length; k++) reserveCovSlot(); + w.writeUint16(parsed.inputCovs.length); + for (let k = 0; k < parsed.inputCovs.length; k++) reserveCovSlot(); + w.writeUint16(parsed.lookCovs.length); + for (let k = 0; k < parsed.lookCovs.length; k++) reserveCovSlot(); + w.writeUint16(parsed.records.length); + for (const rc of parsed.records) { + w.writeUint16(rc.seq); + w.writeUint16(rc.lookup); + } + + let holderIdx = 0; + for (const cov of parsed.backCovs) { + allHolders[holderIdx++][0] = emitCoverage(w, cov); + } + for (const cov of parsed.inputCovs) { + allHolders[holderIdx++][0] = emitCoverage(w, cov); + } + for (const cov of parsed.lookCovs) { + allHolders[holderIdx++][0] = emitCoverage(w, cov); + } +} + +/** 单个 subtable 序列化分发。返回 false 表示该 subtable 无法重映射(调用方决定降级) */ +function serializeSubtable( + w: Writer, + r: Reader, + off: number, + type: number, + origToNew: Map, +): boolean { + r.clearError(); + let ok: boolean; + switch (type) { + case LT_SINGLE: + ok = serializeSingleSubst(w, r, off, origToNew); + break; + case LT_MULTIPLE: + ok = serializeMultipleSubst(w, r, off, origToNew); + break; + case LT_ALTERNATE: + ok = serializeAlternateSubst(w, r, off, origToNew); + break; + case LT_LIGATURE: + ok = serializeLigatureSubst(w, r, off, origToNew); + break; + case LT_CHAIN: + ok = serializeChainedContextSubst(w, r, off, origToNew); + break; + default: + return false; + } + /** 读取越界(偏移计算异常)则降级,避免输出损坏数据 */ + if (r.errorFlag) return false; + return ok; +} + +/** + * GSUB 子集化入口 + * + * @param gsubBytes 原始 GSUB 表字节 + * @param origToNew 原gid → 新gid 映射;不在 map 中的原 gid 表示已被子集化剔除 + * @returns 重映射后的 GSUB 字节 + */ +export function subsetGSUB( + gsubBytes: Uint8Array, + origToNew: Map, +): Uint8Array { + const dv = new DataView(gsubBytes.buffer, gsubBytes.byteOffset, gsubBytes.byteLength); + const r = new Reader(dv); + + /** ---- GSUB Header ---- */ + const major = r.u16(0); + const minor = r.u16(2); + if (major !== 1 || minor > 1) { + /** 不支持的版本,原样返回 */ + return gsubBytes; + } + const scriptListOff = r.u16(4); + const featureListOff = r.u16(6); + const lookupListOff = r.u16(8); + + /** ---- 解析 LookupList ---- */ + const lookupCount = r.u16(lookupListOff); + const lookupRelOffs: number[] = []; + for (let i = 0; i < lookupCount; i++) { + lookupRelOffs.push(r.u16(lookupListOff + 2 + i * 2)); + } + + /** 第一遍:解析每个 lookup 的 effectiveType 与 subtable 偏移,判断是否可重映射 */ + interface LookupInfo { + supported: boolean; + effectiveType: number; + subtableAbsOffs: number[]; + origLookupOff: number; + } + const lookups: LookupInfo[] = []; + for (let i = 0; i < lookupCount; i++) { + const lOff = lookupListOff + lookupRelOffs[i]; + const lookupType = r.u16(lOff); + const subTableCount = r.u16(lOff + 4); + const subtableAbsOffs: number[] = []; + let effectiveType = lookupType; + for (let j = 0; j < subTableCount; j++) { + const subOff = lOff + r.u16(lOff + 6 + j * 2); + if (lookupType === LT_EXTENSION) { + /** ExtensionSubst format1:ExtensionFormat(=1) + ExtensionLookupType + ExtensionOffset(u32) */ + if (r.u16(subOff) !== 1) { + effectiveType = -1; + continue; + } + effectiveType = r.u16(subOff + 2); + subtableAbsOffs.push(subOff + r.u32(subOff + 4)); + } else { + subtableAbsOffs.push(subOff); + } + } + /** 支持重映射的类型:1 Single / 2 Multiple / 3 Alternate / 4 Ligature / 6 ChainedContext。 + * type5 ReverseChain 罕见且结构特殊,标记为不支持(走空 subtable 降级)。 */ + const supported = + effectiveType === LT_SINGLE || + effectiveType === LT_MULTIPLE || + effectiveType === LT_ALTERNATE || + effectiveType === LT_LIGATURE || + effectiveType === LT_CHAIN; + lookups.push({ supported, effectiveType, subtableAbsOffs, origLookupOff: lOff }); + } + + /** ---- 重新序列化 ---- + * ScriptList / FeatureList 与 glyphId 无关(仅引用 lookup index),但其子表 + * (ScriptTable/LangSys/FeatureTable)偏移相对各自 List 起始,且在原始字体中可能与 + * 其他块【物理交错】(如霞鹜文楷 GSUB:ScriptList 跨越 FeatureList 起始位置), + * 不能按连续字节块原样拷贝。这里遍历所有子表紧凑重排并回填相对偏移(serializeScriptList / + * serializeFeatureList),保证输出为合法连续块。 + * LookupList 逐 lookup 处理: + * - 支持的:gid 重映射后重新序列化 + * - 不支持的(type5 等):原样拷贝原始字节(gid 不重映射,浏览器查 coverage 查不到会跳过) + */ + const scriptListBytes = serializeScriptList(r, scriptListOff); + const featureListBytes = serializeFeatureList(r, featureListOff); + /** ScriptList/FeatureList 解析失败(异常表)则整体保留原始 GSUB 字节(安全降级) */ + if (!scriptListBytes || !featureListBytes) return gsubBytes; + + const w = new Writer(); + + /** Header */ + w.writeUint16(1); + w.writeUint16(minor); + const scriptListAbsHolder: number[] = [0]; + const featureListAbsHolder: number[] = [0]; + const lookupListAbsHolder: number[] = [0]; + w.reserveOffset16(0, () => scriptListAbsHolder[0]); + w.reserveOffset16(0, () => featureListAbsHolder[0]); + w.reserveOffset16(0, () => lookupListAbsHolder[0]); + + /** ScriptList 重序列化字节 */ + scriptListAbsHolder[0] = w.length; + for (let i = 0; i < scriptListBytes.length; i++) w.writeUint8(scriptListBytes[i]); + + /** FeatureList 重序列化字节 */ + featureListAbsHolder[0] = w.length; + for (let i = 0; i < featureListBytes.length; i++) w.writeUint8(featureListBytes[i]); + + /** LookupList 重写 */ + lookupListAbsHolder[0] = w.length; + w.writeUint16(lookupCount); + const lookupAbsPositions: number[] = new Array(lookupCount); + for (let i = 0; i < lookupCount; i++) { + const slotIdx = i; + w.reserveOffset16(lookupListAbsHolder[0], () => lookupAbsPositions[slotIdx]); + } + + /** 逐 lookup 序列化 */ + for (let i = 0; i < lookupCount; i++) { + lookupAbsPositions[i] = w.length; + const lk = lookups[i]; + + if (lk.supported) { + const lookupFlag = r.u16(lk.origLookupOff + 2); + const useMarkFilteringSet = (lookupFlag & 0x0010) !== 0; + + /** 输出用 effectiveType(extension 解包后内嵌,不再用 extension 包裹) */ + w.writeUint16(lk.effectiveType); + w.writeUint16(lookupFlag); + w.writeUint16(lk.subtableAbsOffs.length); + const lookupStart = w.length - 6; + const subtableAbsPositions: number[] = new Array(lk.subtableAbsOffs.length); + for (let j = 0; j < lk.subtableAbsOffs.length; j++) { + const slotIdx = j; + w.reserveOffset16(lookupStart, () => subtableAbsPositions[slotIdx]); + } + if (useMarkFilteringSet) { + w.writeUint16(r.u16(lk.origLookupOff + 6 + lk.subtableAbsOffs.length * 2)); + } + for (let j = 0; j < lk.subtableAbsOffs.length; j++) { + subtableAbsPositions[j] = w.length; + /** 单个 subtable 重映射失败(coverage gid 全不在子集 / 解析异常)时, + * 回退已写入字节,改为输出合法的空 subtable(空 coverage,浏览器跳过,不破坏字体)。 + * 不再用 copyBytesBlock 按估算范围拷贝——原始 subtable 数据可能与其他 lookup 物理交错, + * 按 lookup 边界估算会拷贝到错误字节(霞鹜文楷实测 subtable 在表头区之后)。 */ + const before = w.length; + const ok = serializeSubtable(w, r, lk.subtableAbsOffs[j], lk.effectiveType, origToNew); + if (!ok) { + w.rollback(before); + writeEmptySubtable(w, lk.effectiveType); + } + } + } else { + /** 不支持的 lookup(type5 ReverseChain,及尚未验证的类型): + * 保持 lookup 表头(type/flag/subCount)与 subtable 槽位数,逐 subtable 输出空 subtable。 + * 不原样拷贝原始 subtable 字节——其 coverage/ClassDef 等子结构在原始字体中可能与其他 + * lookup 物理交错、散落在任意偏移,按间距/边界估算拷贝会破坏字体(实测霞鹜文楷 type4 + * 的 coverage 在 subtable 后 2594 字节处)。空 subtable 合法且 coverage 为空,浏览器跳过, + * 仅丢失该 lookup 覆盖字形的替换规则,不影响其他 lookup 与整体结构。 */ + const lookupFlag = r.u16(lk.origLookupOff + 2); + const useMarkFilteringSet = (lookupFlag & 0x0010) !== 0; + w.writeUint16(r.u16(lk.origLookupOff)); + w.writeUint16(lookupFlag); + w.writeUint16(lk.subtableAbsOffs.length); + const lookupStart = w.length - 6; + const subtableAbsPositions: number[] = new Array(lk.subtableAbsOffs.length); + for (let j = 0; j < lk.subtableAbsOffs.length; j++) { + const slotIdx = j; + w.reserveOffset16(lookupStart, () => subtableAbsPositions[slotIdx]); + } + if (useMarkFilteringSet) { + w.writeUint16(r.u16(lk.origLookupOff + 6 + lk.subtableAbsOffs.length * 2)); + } + for (let j = 0; j < lk.subtableAbsOffs.length; j++) { + subtableAbsPositions[j] = w.length; + writeEmptySubtable(w, lk.effectiveType); + } + } + } + + w.flush(); + return w.toUint8Array(); +} diff --git a/backend/font_util/ot-bytes.ts b/backend/font_util/ot-bytes.ts new file mode 100644 index 0000000..55dc430 --- /dev/null +++ b/backend/font_util/ot-bytes.ts @@ -0,0 +1,234 @@ +/** + * OpenType 表二进制读写器(大端序) + * + * GPOS / GSUB 等 OpenType 表全部使用大端序(big-endian)—— 这是 OpenType 1.9 规范 + * 对 TTF/OTF/woff/woff2 中表内容字节的硬性规定(表内所有整数均为大端)。 + * fonteditor-core 的 reader/writer(vendor/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 { + private bytes: number[] = []; + private patches: Array<{ pos: number; base: number; targetGetter: () => number }> = []; + + get length(): number { + return this.bytes.length; + } + + /** 回退到指定字节位置,丢弃之后写入的字节与对应的偏移量槽(用于 subtable 重映射失败的保守降级) */ + rollback(pos: number): void { + this.bytes.length = pos; + this.patches = this.patches.filter((p) => p.pos < pos); + } + + writeUint8(v: number): void { + this.bytes.push(v & 0xff); + } + + writeUint16(v: number): void { + this.bytes.push((v >>> 8) & 0xff, v & 0xff); + } + + /** 在当前末尾写入 int16(大端,支持负数;如 SingleSubst format1 的 deltaGlyphID) */ + writeInt16(v: number): void { + this.writeInt16At(this.bytes.length, v); + } + + /** 在指定绝对位置写入 int16(支持负数;同时用于 flush 回填可能为负的偏移量) */ + writeInt16At(pos: number, v: number): void { + const u16 = v < 0 ? 0x10000 + (v & 0xffff) : v & 0xffff; + this.bytes[pos] = (u16 >>> 8) & 0xff; + this.bytes[pos + 1] = u16 & 0xff; + } + + /** 预留一个 uint16 偏移量槽位,flush 时写入 (targetGetter() - base) */ + reserveOffset16(base: number, targetGetter: () => number): void { + const pos = this.bytes.length; + this.bytes.push(0, 0); + 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.bytes); + } +} + +/** + * 大端序字节读取器 + * + * 越界读取不会抛出异常,而是设置 errorFlag 并返回 0。 + * 调用方据此将该 subtable 降级为原样拷贝,避免解析损坏/异常的表时崩溃 + * (如 FiraCode 某些 ChainedContext type6 格式 2 的 classSetCount 远大于实际类数)。 + */ +export class OTReader { + errorFlag = false; + constructor(private dv: DataView) {} + + 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(GPOS/GSUB 通用,结构完全相同) + * + * ScriptList 不含 glyphId,但子表(ScriptTable/LangSys)偏移相对 ScriptList 起始, + * 原始字体中 ScriptList 与 FeatureList/LookupList 的子表可能【物理交错】 + * (如霞鹜文楷 GSUB:ScriptList 跨越 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)); +} + +/** + * 重新序列化 FeatureList(GPOS/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(); +} diff --git a/task.md b/task.md index 9aa2b0d..bc17344 100644 --- a/task.md +++ b/task.md @@ -1,4 +1,4 @@ -/loop 持续优化字体子集化性能和提高ssim评分,可以大胆放开手脚的去做,但是优化完一定要通过`pnpx tsx ./基准测试.test.ts`。中途不要切换到其他模式,比如计划模式也不要询问我,你直接做就行了,请你持续的去优化,不要去询问我,不要去中断,好吧 +/loop 持续优化字体子集化性能和提高ssim评分,需要注意不要产生bug,所以要利用好基准测试,可以大胆放开手脚的去做(不管用什么办法,必须保证 SSIM 通过,意味着和原始字体人眼一致),但是优化完一定要通过`pnpx tsx ./基准测试.test.ts`。中途不要切换到其他模式,比如计划模式也不要询问我,你直接做就行了,请你持续的去优化,不要去询问我,不要去中断,好吧 把基准测试结果文档保存在本地 benchmark_results/ ,这样我方便查看。你的文档中应该在每个重大节点更新基准测试结果(benchmark_results/OPTIMIZATION_LOG.md),这样我能方便看到你使用了哪些优化方法,得到了什么样的优化效果。 不要修改基准测试中的full生成方案,如果基准测试结果不对一定是你的修改有问题,而非浏览器渲染等其他问题 diff --git a/vendor/fonteditor-core/lib/ttf/table/GSUB.js b/vendor/fonteditor-core/lib/ttf/table/GSUB.js new file mode 100644 index 0000000..cfc06b4 --- /dev/null +++ b/vendor/fonteditor-core/lib/ttf/table/GSUB.js @@ -0,0 +1,31 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var _table = _interopRequireDefault(require("./table")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +/** + * @file GSUB + * + * GSUB(Glyph Substitution Table)原始字节透传读写。 + * 子集化时由 backend/font_util/gsub-subset.ts 按 glyphId 重映射 coverage/ClassDef, + * 保留连字(ligature)、上下文替换等规则,使子集字体的连字/替换与原字体人眼一致。 + * + * @reference: https://learn.microsoft.com/en-us/typography/opentype/spec/gsub + */ +var _default = exports.default = _table.default.create('GSUB', [], { + read: function read(reader, ttf) { + var length = ttf.tables.GSUB.length; + return reader.readBytes(this.offset, length); + }, + write: function write(writer, ttf) { + if (ttf.GSUB) { + writer.writeBytes(ttf.GSUB, ttf.GSUB.length); + } + }, + size: function size(ttf) { + return ttf.GSUB ? ttf.GSUB.length : 0; + } +}); diff --git a/vendor/fonteditor-core/lib/ttf/table/glyf.js b/vendor/fonteditor-core/lib/ttf/table/glyf.js index 2b3e2de..d791405 100644 --- a/vendor/fonteditor-core/lib/ttf/table/glyf.js +++ b/vendor/fonteditor-core/lib/ttf/table/glyf.js @@ -48,6 +48,18 @@ var _default = exports.default = _table.default.create('glyf', [], { ttf.subsetMap = subsetMap; ttf.subsetGids = subsetGids; ttf._subsetUnicodeMap = subsetUnicodeMap; + /* 注入额外保留的 gid(如 GSUB 连字 target glyph,多数无 unicode,无法通过 codepoint 子集保留)。 + * 这些 glyph 仍参与 compound 引用解析与 glyf 构建,origToNew 通过 subsetGids 顺序直接映射。 */ + var extraSubsetGids = ttf.readOptions.extraSubsetGids; + if (extraSubsetGids && extraSubsetGids.length > 0) { + for (var ei = 0, el = extraSubsetGids.length; ei < el; ei++) { + var eg = extraSubsetGids[ei]; + if (!subsetMap[eg]) { + subsetMap[eg] = true; + subsetGids.push(eg); + } + } + } var parsedGlyfMap = {}; /* 优化:迭代式广度优先遍历替代递归,消除 isEmptyObject 调用 */ diff --git a/vendor/fonteditor-core/lib/ttf/table/glyf/parse.js b/vendor/fonteditor-core/lib/ttf/table/glyf/parse.js index 29cadec..8f64b66 100644 --- a/vendor/fonteditor-core/lib/ttf/table/glyf/parse.js +++ b/vendor/fonteditor-core/lib/ttf/table/glyf/parse.js @@ -128,8 +128,8 @@ function parseCompoundGlyf(reader, glyf) { var view = reader.view; var vOffset = view.byteOffset + reader.offset; - /** 优化293: F2DOT14_SCALE 提升到循环外 */ - var F2DOT14_SCALE = 0.0001; + /** 优化293: F2DOT14 逆比例常量提升到循环外(raw * (1/16384) 浮点解码) */ + var F2DOT14_INV = 0.00006103515625; do { flags = view.getUint16(vOffset, false); vOffset += 2; var glyphIndex = view.getUint16(vOffset, false); vOffset += 2; @@ -158,21 +158,27 @@ function parseCompoundGlyf(reader, glyf) { scale10 = view.getInt16(vOffset, false); vOffset += 2; scaleY = view.getInt16(vOffset, false); vOffset += 2; } - /** 优化293: F2DOT14_SCALE 提升到循环外,避免每次迭代重新赋值 */ + /** + * F2DOT14 解码:raw / 16384。 + * 原实现 (raw * 0.6103515625 + 0.5 | 0) * 0.0001 对负数有 bug—— + * `+0.5 | 0` 对负数是向零截断而非四舍五入,导致 -16384(=-1.0) 被解码成 -0.9999, + * 复合字形水平镜像(如 FiraCode less_equal.liga 引用 greater_equal.liga)产生坐标漂移。 + * 直接 raw * (1/16384) 浮点解码,无量化、无符号偏差。 + */ if (ARGS_ARE_XY_VALUES & flags) { glyf.glyfs.push({ flags: flags, glyphIndex: glyphIndex, useMyMetrics: !!(flags & USE_MY_METRICS), overlapCompound: !!(flags & OVERLAP_COMPOUND), - transform: { a: (scaleX * 0.6103515625 + 0.5 | 0) * F2DOT14_SCALE, b: (scale01 * 0.6103515625 + 0.5 | 0) * F2DOT14_SCALE, c: (scale10 * 0.6103515625 + 0.5 | 0) * F2DOT14_SCALE, d: (scaleY * 0.6103515625 + 0.5 | 0) * F2DOT14_SCALE, e: arg1, f: arg2 } + transform: { a: scaleX * F2DOT14_INV, b: scale01 * F2DOT14_INV, c: scale10 * F2DOT14_INV, d: scaleY * F2DOT14_INV, e: arg1, f: arg2 } }); } else { glyf.glyfs.push({ flags: flags, glyphIndex: glyphIndex, points: [arg1, arg2], - transform: { a: (scaleX * 0.6103515625 + 0.5 | 0) * F2DOT14_SCALE, b: (scale01 * 0.6103515625 + 0.5 | 0) * F2DOT14_SCALE, c: (scale10 * 0.6103515625 + 0.5 | 0) * F2DOT14_SCALE, d: (scaleY * 0.6103515625 + 0.5 | 0) * F2DOT14_SCALE, e: 0, f: 0 } + transform: { a: scaleX * F2DOT14_INV, b: scale01 * F2DOT14_INV, c: scale10 * F2DOT14_INV, d: scaleY * F2DOT14_INV, e: 0, f: 0 } }); } } while (MORE_COMPONENTS & flags); diff --git a/vendor/fonteditor-core/lib/ttf/table/support.js b/vendor/fonteditor-core/lib/ttf/table/support.js index 8f8a8e7..3ea597a 100644 --- a/vendor/fonteditor-core/lib/ttf/table/support.js +++ b/vendor/fonteditor-core/lib/ttf/table/support.js @@ -19,6 +19,7 @@ var _cvt = _interopRequireDefault(require("./cvt")); var _prep = _interopRequireDefault(require("./prep")); var _gasp = _interopRequireDefault(require("./gasp")); var _GPOS = _interopRequireDefault(require("./GPOS")); +var _GSUB = _interopRequireDefault(require("./GSUB")); var _kern = _interopRequireDefault(require("./kern")); var _kerx = _interopRequireDefault(require("./kerx")); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } @@ -42,6 +43,7 @@ var _default = exports.default = { prep: _prep.default, gasp: _gasp.default, GPOS: _GPOS.default, + GSUB: _GSUB.default, kern: _kern.default, kerx: _kerx.default }; \ No newline at end of file diff --git a/vendor/fonteditor-core/lib/ttf/ttfreader.js b/vendor/fonteditor-core/lib/ttf/ttfreader.js index c9e5c27..f1818cd 100644 --- a/vendor/fonteditor-core/lib/ttf/ttfreader.js +++ b/vendor/fonteditor-core/lib/ttf/ttfreader.js @@ -14,7 +14,7 @@ var _post = require("./table/post"); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } /** 优化291: 表名列表提升为模块级常量,避免每次 readBuffer 创建新数组 */ -var TTF_TABLE_NAMES = ['head', 'maxp', 'loca', 'cmap', 'glyf', 'name', 'hhea', 'hmtx', 'post', 'OS/2', 'fpgm', 'cvt', 'prep', 'gasp', 'GPOS', 'kern', 'kerx']; +var TTF_TABLE_NAMES = ['head', 'maxp', 'loca', 'cmap', 'glyf', 'name', 'hhea', 'hmtx', 'post', 'OS/2', 'fpgm', 'cvt', 'prep', 'gasp', 'GPOS', 'GSUB', 'kern', 'kerx']; function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; } @@ -73,8 +73,8 @@ var TTFReader = exports.default = /*#__PURE__*/function () { if (!hinting && (tableName === 'fpgm' || tableName === 'cvt' || tableName === 'prep' || tableName === 'gasp')) { continue; } - /* 优化8: hinting=false && kerning=false 时跳过 GPOS/kern/kerx */ - if (!hinting && !kerning && (tableName === 'GPOS' || tableName === 'kern' || tableName === 'kerx')) { + /* 优化8: hinting=false && kerning=false 时跳过 GPOS/GSUB/kern/kerx */ + if (!hinting && !kerning && (tableName === 'GPOS' || tableName === 'GSUB' || tableName === 'kern' || tableName === 'kerx')) { continue; } var offset = ttf.tables[tableName].offset; @@ -234,6 +234,7 @@ var TTFReader = exports.default = /*#__PURE__*/function () { } if (!this.options.hinting && !this.options.kerning) { ttf.GPOS = null; + ttf.GSUB = null; ttf.kern = null; ttf.kerx = null; } diff --git a/vendor/fonteditor-core/lib/ttf/ttfwriter.js b/vendor/fonteditor-core/lib/ttf/ttfwriter.js index e456cbc..76c1104 100644 --- a/vendor/fonteditor-core/lib/ttf/ttfwriter.js +++ b/vendor/fonteditor-core/lib/ttf/ttfwriter.js @@ -202,7 +202,7 @@ var TTFWriter = exports.default = /*#__PURE__*/function () { } } if (this.options.kerning) { - var kernTables = ['GPOS', 'kern', 'kerx']; + var kernTables = ['GPOS', 'GSUB', 'kern', 'kerx']; for (var j = 0; j < kernTables.length; j++) { var kn = kernTables[j]; if (ttf[kn] && !added[kn]) { diff --git a/vendor/fonteditor-core/lib/ttf/util/optimizettf.js b/vendor/fonteditor-core/lib/ttf/util/optimizettf.js index a506a44..2208e7e 100644 --- a/vendor/fonteditor-core/lib/ttf/util/optimizettf.js +++ b/vendor/fonteditor-core/lib/ttf/util/optimizettf.js @@ -394,25 +394,16 @@ function optimizettf(ttf) { glyfNotEmpty: m_glyfNotEmpty }; - /* 优化99+103: hasCompound 已在主循环中追踪,过滤使用 _numContours 或 contours.length */ + /* 原逻辑会删除「无轮廓字形」(_numContours === 0),但这会误删两类必须保留的字形: + * (1) space(U+0020)等空白字符——无轮廓但 cmap 引用,删除后该码点渲染为 .notdef; + * (2) GSUB 连字的 spacer/seq target(如 FiraCode 的 equal.spacer)——无轮廓的占位字形, + * 是 GSUB ChainedContext 替换的目标,删除后连字规则 target gid 失效,连字不渲染。 + * 无轮廓字形在 OpenType 中合法(.notdef 即常见无轮廓字形),保留它们仅增加极小体积, + * 因此这里不再按轮廓数过滤,保留全部字形,glyf 顺序与 subsetGids 保持一一对应, + * 使调用方(rewriteLayoutTablesForSubset)的 原gid→新gid 映射稳定可靠。 */ if (!hasCompound) { - /* 优化284: 预分配 filtered + 索引赋值替代 push,indexMap 保持稀疏数组 */ - var filtered = new Array(gl); - var indexMap = []; - var fLen = 0; - filtered[fLen++] = glyfs[0]; - indexMap[0] = 0; - for (var gi = 1; gi < gl; gi++) { - var g = glyfs[gi]; - if (g._numContours != null ? g._numContours > 0 : (g.contours && g.contours.length)) { - indexMap[gi] = fLen; - filtered[fLen++] = g; - } - } - filtered.length = fLen; - ttf.glyf = filtered; if (ttf.support && ttf.support.maxp) { - ttf.support.maxp.numGlyphs = filtered.length; + ttf.support.maxp.numGlyphs = glyfs.length; } } if (!repeatList.length) { diff --git a/vendor/fonteditor-core/lib/ttf/util/readWindowsAllCodes.js b/vendor/fonteditor-core/lib/ttf/util/readWindowsAllCodes.js index d3cbee8..178ec9f 100644 --- a/vendor/fonteditor-core/lib/ttf/util/readWindowsAllCodes.js +++ b/vendor/fonteditor-core/lib/ttf/util/readWindowsAllCodes.js @@ -161,7 +161,7 @@ function readWindowsAllCodes(tables, ttf) { } /* 非subset模式 - 全量展开(原始逻辑) */ - if (format0) { + if (format0 && format0.glyphIdArray) { for (var i2 = 0, l2 = format0.glyphIdArray.length; i2 < l2; i2++) { if (format0.glyphIdArray[i2]) { codes[i2] = format0.glyphIdArray[i2]; diff --git a/vendor/fonteditor-core/lib/ttf/util/transformGlyfContours.js b/vendor/fonteditor-core/lib/ttf/util/transformGlyfContours.js index acf1a19..73ae53c 100644 --- a/vendor/fonteditor-core/lib/ttf/util/transformGlyfContours.js +++ b/vendor/fonteditor-core/lib/ttf/util/transformGlyfContours.js @@ -10,6 +10,46 @@ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { de * @author mengke01(kekee000@gmail.com) */ +/** 简单字形的 ON_CURVE 位(glyFlag.ONCURVE = 0x01) */ +var _ON_CURVE = 0x01; + +/** + * 从紧凑格式(_xArr/_yArr/_flags/endPtsOfContours)即时构建扁平 contours。 + * + * 复合字形的 component 在 subset 阶段(resolveGlyf → compound2simpleglyf → 本函数) + * 被访问时尚未经过 optimize,simple component 仅解析出紧凑坐标数据而无 contours 字段。 + * 正常 codepoint 子集不会保留无 unicode 的连字 target(compound),故不触发此路径; + * 当 extraSubsetGids 注入连字 target(如 FiraCode 的 greater_equal.liga)时, + * 其 component 为紧凑 simple 字形,需在此即时展开为扁平 contours 供仿射变换使用。 + * + * 扁平格式:contour = [x0,y0,onCurve0, x1,y1,onCurve1, ...](与 transformAndCeilFlat 一致)。 + */ +function buildFlatContoursFromCompact(glyph) { + var xArr = glyph._xArr; + var yArr = glyph._yArr; + var flags = glyph._flags; + var endPts = glyph.endPtsOfContours; + if (!xArr || !yArr || !flags || !endPts) { + return []; + } + var contours = new Array(endPts.length); + var ptStart = 0; + for (var ci = 0, cl = endPts.length; ci < cl; ci++) { + var ptEnd = endPts[ci]; + var ptCount = ptEnd - ptStart + 1; + var contour = new Array(ptCount * 3); + for (var pi = 0; pi < ptCount; pi++) { + var pIdx = ptStart + pi; + contour[pi * 3] = xArr[pIdx]; + contour[pi * 3 + 1] = yArr[pIdx]; + contour[pi * 3 + 2] = (flags[pIdx] & _ON_CURVE) ? 1 : 0; + } + contours[ci] = contour; + ptStart = ptEnd + 1; + } + return contours; +} + /** * 优化15+66+197: 扁平格式单次遍历 pathTransform + pathCeil * 优化197: 使用 +0.5|0 替代 Math.round,避免函数调用开销 @@ -55,6 +95,11 @@ function transformGlyfContours(glyf, ttf) { } var sourceContours = glyph.compound ? contoursList[g.glyphIndex] || [] : glyph.contours; + /* 紧凑 simple component(无 contours,仅有 _xArr/_yArr/_flags/endPtsOfContours): + * subset 阶段尚未 optimize,连字 target 的 component 需即时展开为扁平 contours。 */ + if (!sourceContours && glyph._xArr) { + sourceContours = buildFlatContoursFromCompact(glyph); + } /* 优化259: 提升 transform 属性到局部变量,消除每次迭代的属性链查找 */ var t = g.transform; var ta = t.a, tb = t.b, tc = t.c, td = t.d, te = t.e, tf = t.f; diff --git a/vendor/fonteditor-core/woff2/woff2-encode.js b/vendor/fonteditor-core/woff2/woff2-encode.js index aec1f75..bdb1215 100644 --- a/vendor/fonteditor-core/woff2/woff2-encode.js +++ b/vendor/fonteditor-core/woff2/woff2-encode.js @@ -801,6 +801,20 @@ function encodeTTFToWOFF2(ttfBuffer) { let dirIdx = 0; let totalDirSize = 0; + /** + * 计算单个 Table Directory entry 的序列化字节数 + * WOFF2 规范: flags(1) + (tagIndex===63 时追加 4 字节原始 tag) + origLength(Base128) + (hasTransform 时 transformLength(Base128)) + * + * 修复: tagIndex===63(表名不在已知 63 个表内,如 GPOS/gasp/GDEF)时必须额外写 4 字节 tag。 + * 原实现漏算这 4 字节,导致含此类表的字体(如开启 kerning 保留 GPOS 后)woff2 buffer 预分配不足, + * woff2.set(compressedData, dirPos) 越界。 + */ + const entrySize = (tagIndex, origLength, hasTransform, transformLength) => + 1 + + (tagIndex === 63 ? 4 : 0) + + sizeUIntBase128(origLength) + + (hasTransform ? sizeUIntBase128(transformLength) : 0); + for (var fi2 = 0, fl2 = filtered.length; fi2 < fl2; fi2++) { var t = filtered[fi2]; if (t.tagU32 === TAG_loca) { @@ -814,7 +828,7 @@ function encodeTTFToWOFF2(ttfBuffer) { data: EMPTY_UINT8, hasTransform: true, }; - totalDirSize += 1 + sizeUIntBase128(origLength) + sizeUIntBase128(0); + totalDirSize += entrySize(t.tagIndex, origLength, true, 0); continue; } @@ -827,7 +841,7 @@ function encodeTTFToWOFF2(ttfBuffer) { data: glyfTransformed.transformedGlyf, hasTransform: true, }; - totalDirSize += 1 + sizeUIntBase128(t.length) + sizeUIntBase128(glyfTransformed.transformedGlyf.length); + totalDirSize += entrySize(t.tagIndex, t.length, true, glyfTransformed.transformedGlyf.length); continue; } @@ -841,7 +855,7 @@ function encodeTTFToWOFF2(ttfBuffer) { data: tableData, isHead: t.tagU32 === TAG_head, }; - totalDirSize += 1 + sizeUIntBase128(t.length); + totalDirSize += entrySize(t.tagIndex, t.length, false, t.length); } dirEntries.length = dirIdx; diff --git a/基准测试.test.ts b/基准测试.test.ts index f9d1df8..84d054d 100644 --- a/基准测试.test.ts +++ b/基准测试.test.ts @@ -234,17 +234,66 @@ function calculateSSIM(a: Uint8Array, b: Uint8Array, width: number, height: numb // ======== 测试配置 ======== +/** + * 千字文中段(接续前段,用于更长文本压力测试) + */ +const QIANZIWEN_MID = "墨悲丝染诗赞羔羊景行维贤克念作圣德建名立形端表正空谷传声虚堂习听祸因恶积福缘善庆尺璧非宝寸阴是竞资父事君曰严与敬孝当竭力忠则尽命临深履薄夙兴温凊似兰斯馨如松之盛川流不息渊澄取映"; + const testCases = [ - /** TTF 字体 */ + /** ===== 令东齐伋复刻体(TTF,楷书复古字体,主基准) ===== */ { label: "8个汉字", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天地玄黄宇宙洪荒", sourceType: "ttf" as const, outType: "ttf" as const, fullFormat: "truetype" }, { label: "8个汉字", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天地玄黄宇宙洪荒", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" }, { label: "拉丁+数字", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "Hello World 123", sourceType: "ttf" as const, outType: "ttf" as const, fullFormat: "truetype" }, { label: "拉丁+数字", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "Hello World 123", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" }, { label: "千字文前段", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈剑号巨阙珠称夜光果珍李柰菜重芥姜海咸河淡鳞潜羽翔", sourceType: "ttf" as const, outType: "ttf" as const, fullFormat: "truetype" }, { label: "千字文前段", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈剑号巨阙珠称夜光果珍李柰菜重芥姜海咸河淡鳞潜羽翔", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" }, - /** OTF 字体(含三点水等复杂笔画字,守护 OTF→TTF 转换正确性) */ + /** 重复字符:守护 codePoints 去重逻辑,相同字形不应重复输出 */ + { label: "重复字符", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天天天天地地地地", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" }, + + /** ===== 思源黑体(TTF,无衬线黑体,字形简洁) ===== */ + { label: "思源黑体-8字", fontPath: "font/思源黑体.ttf", fontName: "思源黑体", text: "天地玄黄宇宙洪荒", sourceType: "ttf" as const, outType: "ttf" as const, fullFormat: "truetype" }, + { label: "思源黑体-8字", fontPath: "font/思源黑体.ttf", fontName: "思源黑体", text: "天地玄黄宇宙洪荒", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" }, + /** 纯标点:非汉字字形 + 组合标记的渲染守护 */ + { label: "思源黑体-标点", fontPath: "font/思源黑体.ttf", fontName: "思源黑体", text: ",。!?、;:“”‘’", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" }, + /** CJK 扩展 B 罕见字(代理对):守护 textToCodePoints 的代理对跳过逻辑 */ + { label: "思源黑体-扩展B", fontPath: "font/思源黑体.ttf", fontName: "思源黑体", text: "𠮷𡧑𢀖𤍤𥝹", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" }, + /** 千字文中段:超长文本压力测试(>100 字) */ + { label: "思源黑体-千字文中段", fontPath: "font/思源黑体.ttf", fontName: "思源黑体", text: QIANZIWEN_MID, sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" }, + + /** ===== Yi山碑篆体(TTF,笔画极其复杂的篆书,字形数据量大) ===== */ + { label: "篆体-8字", fontPath: "font/temp/YiShanBeiZhuanTi.ttf", fontName: "Yi山碑篆体", text: "天地玄黄宇宙洪荒", sourceType: "ttf" as const, outType: "ttf" as const, fullFormat: "truetype" }, + { label: "篆体-8字", fontPath: "font/temp/YiShanBeiZhuanTi.ttf", fontName: "Yi山碑篆体", text: "天地玄黄宇宙洪荒", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" }, + + /** ===== OTF 字体(含三点水等复杂笔画字,守护 OTF→TTF 转换正确性) ===== */ { label: "otf-五个汉字", fontPath: "font/temp/BaiHuOTFJiaoYuHanZi-2.otf", fontName: "白狐教育汉字", text: "天地黄宇宙法海波", sourceType: "otf" as const, outType: "ttf" as const, fullFormat: "opentype" }, + /** OTF→woff2 输出路径守护(之前仅测 ttf 输出) */ + { label: "otf-五个汉字", fontPath: "font/temp/BaiHuOTFJiaoYuHanZi-2.otf", fontName: "白狐教育汉字", text: "天地黄宇宙法海波", sourceType: "otf" as const, outType: "woff2" as const, fullFormat: "opentype" }, { label: "otf-思源黑体", fontPath: "font/temp/SourceHanSans-Regular.otf", fontName: "思源黑体", text: "天地玄黄宇宙洪法海波", sourceType: "otf" as const, outType: "ttf" as const, fullFormat: "opentype" }, + { label: "otf-思源黑体", fontPath: "font/temp/SourceHanSans-Regular.otf", fontName: "思源黑体", text: "天地玄黄宇宙洪法海波", sourceType: "otf" as const, outType: "woff2" as const, fullFormat: "opentype" }, + + /** ===== 小字号渲染(size=24,守护 SSIM 在小字号下不退化) ===== */ + { label: "小字号-8字", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天地玄黄宇宙洪荒", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype", fontSize: 24 }, + { label: "小字号-篆体", fontPath: "font/temp/YiShanBeiZhuanTi.ttf", fontName: "Yi山碑篆体", text: "天地玄黄宇宙洪荒", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype", fontSize: 24 }, + + /** + * ===== D:\字体资源 多字体扩展覆盖 ===== + * 用「汉字+标点」混合文本,最能暴露 GPOS 标点压缩丢失问题。 + * 覆盖不同 GPOS lookup 类型:得意黑仅 PairPos(完全支持),霞鹜文楷含 ChainedContextPos(type8,降级), + * 思源宋体(OTF)含 MarkBasePos(type4,降级)。降级时保留原始 GPOS 字节,验证不劣于子集化前。 + */ + { label: "得意黑-汉字标点", fontPath: "/mnt/d/字体资源/得意黑/SmileySans-Oblique.ttf", fontName: "得意黑", text: "你好,世界!今天天气不错。", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" }, + { label: "霞鹜文楷-汉字标点", fontPath: "/mnt/d/字体资源/霞鹜文楷/LXGWWenKai-Regular.ttf", fontName: "霞鹜文楷", text: "你好,世界!今天天气不错。", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" }, + /** 初夏明朝(TTF,宋体/明朝风格衬线字,含标点压缩) */ + { label: "初夏明朝-汉字标点", fontPath: "/mnt/d/字体资源/初夏明朝/初夏明朝-Regular.ttf", fontName: "初夏明朝", text: "你好,世界!今天天气不错。", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" }, + { label: "霞鹜文楷-纯标点", fontPath: "/mnt/d/字体资源/霞鹜文楷/LXGWWenKai-Regular.ttf", fontName: "霞鹜文楷", text: ",。!?、;:“”‘’", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" }, + { label: "初夏明朝-纯标点", fontPath: "/mnt/d/字体资源/初夏明朝/初夏明朝-Regular.ttf", fontName: "初夏明朝", text: ",。!?、;:“”‘’", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" }, + { label: "得意黑-纯标点", fontPath: "/mnt/d/字体资源/得意黑/SmileySans-Oblique.ttf", fontName: "得意黑", text: ",。!?、;:“”‘’", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" }, + /** 鸿蒙黑体(TTF,现代无衬线,GPOS 仅 SinglePos/PairPos) */ + { label: "鸿蒙黑体-汉字标点", fontPath: "/mnt/d/字体资源/鸿蒙字体/HarmonyOS_Sans_SC_Black.ttf", fontName: "鸿蒙黑体", text: "你好,世界!今天天气不错。", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" }, + /** 优设标题黑(TTF,艺术黑体) */ + { label: "优设标题黑-汉字标点", fontPath: "/mnt/d/字体资源/优设标题黑/优设标题黑.ttf", fontName: "优设标题黑", text: "你好,世界!今天天气不错。", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" }, + /** FiraCode(拉丁等宽,GPOS 做 ligature,非 CJK 标点,验证降级路径不破坏拉丁字体) */ + { label: "FiraCode-代码", fontPath: "/mnt/d/字体资源/FiraCode/FiraCode-Medium.ttf", fontName: "FiraCode", text: "=> !== >= <= ===", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" }, ]; // ======== 主测试 ======== @@ -363,11 +412,18 @@ for (const tc of testCases) { ? `full_otf_${tc.label}.otf` : `full_ttf_${tc.label}.ttf`; - /** 渲染完整字体 */ - const fullResult = await renderTextViaBrowser(page, baseUrl, fullKey, tc.text, 48, tc.fullFormat); + /** 渲染完整字体(fontSize 可选,默认 48;小字号用例用于守护 SSIM 在低分辨率下不退化) */ + const renderSize = tc.fontSize ?? 48; + const fullResult = await renderTextViaBrowser(page, baseUrl, fullKey, tc.text, renderSize, tc.fullFormat); - /** 渲染子集字体 */ - const subsetResult = await renderTextViaBrowser(page, baseUrl, subsetKey, tc.text, 48, "truetype"); + /** + * 渲染子集字体:format 必须与 outType 匹配。 + * woff2 字节若用 format("truetype") 声明,Chrome 嗅探解码时序下可能未及时应用 GPOS + * (CJK 全角标点的标点压缩依赖 GPOS lookup),导致连续标点宽度变大、与原始字体人眼不一致。 + * 改为按实际 outType 声明 format,确保 GPOS 等 layout 表被正确加载。 + */ + const subsetFormat = tc.outType === "woff2" ? "woff2" : "truetype"; + const subsetResult = await renderTextViaBrowser(page, baseUrl, subsetKey, tc.text, renderSize, subsetFormat); await writeFile(`${BENCHMARK_DIR}/screenshots/${safeLabel}_full.png`, fullResult.screenshot); await writeFile(`${BENCHMARK_DIR}/screenshots/${safeLabel}_subset.png`, subsetResult.screenshot);