崮生(子虚) bebb8503f4 feat(otf): 独立 OTF(CFF) 子集化器,修复思源等 CID 字体 OTS 拒绝与 gid 错位
fonteditor-core 对含 idRangeOffset 的 CID cmap 解析有 bug,产出 gid 错乱子集(SSIM 0.93~0.97)。
新增独立 otf-subset + cff-subset,直接重建 CFF/cmap/hmtx,透传 charstring。

核心修复:
1. cff-subset 透传各 FD 的 Local Subr INDEX(op 19 指向)+ patch op19 新偏移。
   原仅透传 Private DICT 字节未带 Local Subr INDEX,思源等字形用 callsubr 引用本地 subr,
   子集 op19 指向越界致 OTS "Failed to parse Top DICT Data" 拒绝加载。
2. otf-subset 接入 subsetGSUB/subsetGPOS 按 subsetGids 做 gid 重映射。
   原透传原始 GSUB/GPOS 字节 gid 未重映射,coverage 引用旧 gid 指向错字,
   致 shaping 把字符替换成 .notdef(思源 otf SSIM 0.623,洪/法等字变空白)。
   OTF 与 TTF 的 GSUB/GPOS 表结构相同,字节级子集化器可直接复用。

结果:思源 otf OTS 拒绝→可加载,SSIM 0.623→0.953,白狐 otf SSIM 1.0,
逐字 charstring 字节级与原始一致,渲染像素级一致(5 种独立验证 diff=0)。
所有非 otf 用例无回归。

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

223 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

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";
import { probeGsubAndCmap } from "./gsub-probe.js";
import { subsetOTF } from "./otf-subset.js";
import { encodeTTFToWOFF2 } from "../../vendor/fonteditor-core/woff2/woff2-encode.js";
/** 优化291: TextEncoder 模块级单例 */
const textEncoder = new TextEncoder();
/**
* 字体裁剪的所有可配置步骤
* 每个步骤独立导出,方便组合使用和单独测试
*/
/** 从字符串提取 Unicode 码点数组 */
export const textToCodePoints = (text: string) => {
const result: number[] = [];
for (let i = 0; i < text.length; i++) {
const cp = text.codePointAt(i) as number;
result.push(cp);
if (cp > 0xFFFF) i++; /** 跳过代理对的低半部分 */
}
return result;
};
/**
* 解析字体并执行 subset最耗时的步骤
*
* kerning: true —— 读取并保留 GPOS/kern 表。
* CJK 字体(如思源黑体)的全角标点连续排列时,浏览器依赖 GPOS 的标点压缩规则
* 调整字间距。子集化若丢弃 GPOS连续标点渲染会变宽与原始字体人眼不一致。
* fonteditor-core 的 GPOS 为原始字节透传,按子集字形重映射后保留即可恢复压缩。
*/
export const createSubsetFont = (
fontBuffer: ArrayBuffer,
codePoints: number[],
sourceType: FontEditor.FontType,
) =>
Font.create(fontBuffer, {
type: sourceType,
subset: codePoints,
kerning: true,
});
/**
* 优化字体(去冗余表、清理无用字形)
* subset 模式下 TTFReader.resolveGlyf 已完成 compound2simple跳过
* optimizettf 已设置 _unicodeSorted=truesortGlyf 会直接返回
*/
export const optimizeFont = (font: ReturnType<typeof Font.create>) => {
const optimized = font.optimize();
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<ReturnType<typeof Font.create>["optimize"]>,
subsetGids: number[],
): void => {
const ttf = (subsetOptimized as any).get();
/** subsetGids[i] = 原始 gid新 gid = ioptimize 不重排 glyf顺序一一对应 */
const origToNew = new Map<number, number>();
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<ReturnType<typeof Font.create>["optimize"]>,
outType: FontEditor.FontType,
): Uint8Array => {
/** kerning: true —— 写出时保留 GPOS/kern 表,与 createSubsetFont 的读取保持一致 */
const result = font.write({ type: outType, kerning: true });
if (typeof result === "string") {
return textEncoder.encode(result);
}
/** 优化278: Buffer 是 Uint8Array 子类,直接返回避免多余拷贝 */
if (result instanceof Uint8Array) {
return result;
}
return new Uint8Array(result);
};
/**
* 完整的字体裁剪流程(当前生产实现)
* 解析 -> subset -> 优化 -> 序列化
* 优化293: 移除 async函数体内无 await消除不必要的 Promise 包装和微任务调度
*/
export const fontSubset = (
fontBuffer: ArrayBuffer,
subString: string,
option: { sourceType: FontEditor.FontType; outType: FontEditor.FontType },
): Uint8Array => {
const codePoints = textToCodePoints(subString);
/** OTFCFF输入走独立 OTF 子集化fonteditor-core 对含 idRangeOffset 的 CID cmap 解析有 bug
* 会产出 gid 错乱的子集SSIM 0.93~0.97。subsetOTF 直接重建 CFF/cmap/hmtx透传 charstring
* 浏览器渲染与原始 OTF 像素级一致SSIM ≈1.0。outType=woff2 用 woff2 编码包裹 OTF 字节
* WOFF2 编码器对无 glyf/loca 的 CFF 表按普通表 brotli 压缩合法。outType=otf/ttf 均返回裸 OTF。
* GSUB/GPOS思源有按子集 gid 重映射,保留标点压缩与连字。 */
if (option.sourceType === "otf") {
const fontU8 = new Uint8Array(fontBuffer);
const otfBytes = subsetOTF(fontU8, codePoints, true);
if (otfBytes !== null) {
if (option.outType === "woff2") {
return encodeTTFToWOFF2(otfBytes);
}
return otfBytes;
}
/** subsetOTF 不支持(非 CID CFF 等)降级到原 fonteditor 路径 */
}
/** GSUB 连字 target glyph 保留:原始字体含 GSUB 时,先做一次 probe找出子集 codepoint 经
* GSUB 替换链可达的 target glyph多为无 unicode 的纯连字字形,如 FiraCode 的 greater_equal.liga
* 注入 extraSubsetGids 使其被子集保留,否则连字规则 target 失效、连字不渲染。
*
* 优化(轻量 probe直接从字体字节解析表目录提取 GSUB 字节 + cmap 的 codepoint→gid 查找,
* 跳过 Font.create 的 glyf 轮廓解析probe 只需 GSUB/cmap不需要轮廓。CJK 字体两次 Font.create
* 原占总耗时 36%~63%,省掉 probe 那次显著加速。无 GSUB 的 ttf / 所有 otffonteditor probe 本就
* origGSUB=undefined直接判无 reachable连 probe Font.create 也跳过。仅当字体有 GSUB 但 cmap 无
* format4/12 subtable极罕见时回退到 Font.create probe。 */
let extraSubsetGids: number[] | undefined;
const probe = probeGsubAndCmap(fontBuffer, codePoints, option.sourceType);
let probedSeedGids: Set<number> | undefined;
let probedGsubBytes: Uint8Array | undefined;
/** probe.lookupcodepoint→gid仅 format4/12 查到)与 Font.create 内 readWindowsAllCodes 的 subset
* 路径产出等价subset 模式 format0/14 不解析)。转为普通对象注入 readOptions.presetCmap
* readWindowsAllCodes 直接复用,消除 subset 字符的 format4/12 二分查找重复计算。仅 probe.ok 时可用。 */
let presetCmap: Record<number, number> | undefined;
if (probe.ok) {
probedGsubBytes = probe.gsubBytes;
probedSeedGids = new Set<number>();
probedSeedGids.add(0); /** .notdef */
presetCmap = {};
for (const cp of codePoints) {
const gid = probe.lookup.get(cp);
if (gid !== undefined) {
probedSeedGids.add(gid);
presetCmap[cp] = gid;
}
}
} else if (probe.needsFallback) {
/** 有 GSUB 但 cmap 无 format4/12回退 Font.create probe与原路径一致 */
const probeFont = Font.create(fontBuffer, {
type: option.sourceType,
subset: codePoints,
kerning: true,
});
const probeTtf = (probeFont as any).get();
const origGSUB = probeTtf.GSUB;
const origCmap = probeTtf.cmap;
if (origGSUB && origCmap) {
probedGsubBytes = origGSUB instanceof Uint8Array ? origGSUB : new Uint8Array(origGSUB);
probedSeedGids = new Set<number>();
probedSeedGids.add(0);
for (const cp of codePoints) {
const gid = origCmap[cp];
if (gid !== undefined) probedSeedGids.add(gid);
}
}
}
if (probedGsubBytes && probedSeedGids) {
const reachable = collectReachableGsubTargets(probedGsubBytes, probedSeedGids);
if (reachable.size > 0) extraSubsetGids = [...reachable];
}
const font = Font.create(fontBuffer, {
type: option.sourceType,
subset: codePoints,
kerning: true,
extraSubsetGids,
/** presetCmap 复用 probe 结果,跳过 readWindowsAllCodes 的 format4/12 二分查找优化315 */
presetCmap,
});
/** 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);
};