web-font/backend/font_util/otf-subset.ts
崮生(子虚) 07432852cf fix(otf-subset): 保留 GSUB locl 变体 target 字形,思源 otf SSIM 0.9530→1.0000
otf 子集化路径此前完全跳过 probeGsubAndCmap/collectReachableGsubTargets,
subsetGids 仅含 cmap 查到的 codepoint→基础 gid。思源黑体等 CID 字体含 locl
feature,浏览器按中文 locale 渲染时 GSUB 把基础字形替换为地区变体(如
"天" gid A → CN 变体 gid A+1)。subsetGids 不含这些 target,子集 GSUB 重
映射后 target gid 指向不存在/错误字形,渲染出错误变体。

逐字 ink 对比定位(地/黄/洪/波一致,天/玄/宇/法/海多 ink、宙少 ink)正是
locl 变体丢失特征。subsetOTF 内部复用 collectReachableGsubTargets 收集 seed
经 GSUB 可达的 target gid 合并进 subsetGids,subsetCFF/FDSelect/charset 与
GSUB/GPOS 重映射统一基于扩展后的 subsetGids,变体字形正确保留。

实测思源 9 字 otf/woff2 输出均 ink 6724/6724 SSIM 1.0000(修复前 0.9530),
白狐 5 字保持 1.0000,其余用例无回归。

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

577 lines
26 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.

/**
* OTF (OpenType with CFF) 字体子集化器。
*
* 背景fonteditor-core 对 OTF 输入走 otf2ttfobjectCFF 三次 → glyf 二次),其 cmap 解析对
* 含 idRangeOffset 的 format4 子表有 bug_lazySegs 模式 graphIdArrayIndexOffset 计算错误),
* 导致 CID-keyed OTF白狐教育汉字、思源黑体 OTF子集化后 cmap 查不到目标字符、gid 错乱,
* 浏览器渲染与原始字体存在显著差异SSIM 0.93~0.97)。
*
* 本模块完全独立实现 OTF 子集化,不依赖 fonteditor 对 otf 的 Font.create
* 1. 解析原始 sfnt 表目录
* 2. 用原始 cmapformat4 绝对地址版 + format12查 codepoint → 原始 gid修正 fonteditor 的 bug
* 3. subsetCFF 重建 CFF 表(透传 charstring
* 4. 重建 cmap子集 codepoint → 新 gidformat4
* 5. 重建 hmtx/vmtx按新 gid 顺序取原始 metrics
* 6. 重建 maxpnumGlyphs = 子集数、postformat3 无 glyph 名、name保留渲染必需 nameId
* 7. head/hhea/vhea/OS/2/VORG 透传(无 gid 依赖,或仅 patch numGlyphs 相关)
* 8. GSUB/GPOS 子集化(若存在,复用 gsub-subset/gpos-subset
* 9. 封装 OTF sfntOTTO 签名 + checkSumAdjustment
*
* @reference https://learn.microsoft.com/en-us/typography/opentype/spec/
*/
import { subsetCFF } from "./cff-subset.js";
import { subsetGSUB } from "./gsub-subset.js";
import { subsetGPOS } from "./gpos-subset.js";
import { collectReachableGsubTargets } from "./gsub-reachable.js";
/** sfnt 表目录条目 */
interface SfntTable {
/** 4 字节 tag 字符串 */
tag: string;
/** 原始字节偏移 */
offset: number;
/** 原始字节长度 */
length: number;
}
/** 解析 sfnt 表目录 */
function readSfntTables(dv: DataView): SfntTable[] {
const numTables = dv.getUint16(4, false);
const tables: SfntTable[] = [];
for (let i = 0; i < numTables; i++) {
const off = 12 + i * 16;
const tag = String.fromCharCode(dv.getUint8(off), dv.getUint8(off + 1), dv.getUint8(off + 2), dv.getUint8(off + 3));
tables.push({ tag, offset: dv.getUint32(off + 8, false), length: dv.getUint32(off + 12, false) });
}
return tables;
}
/** 按 tag 查表,返回字节切片偏移/长度 */
function findTable(tables: SfntTable[], tag: string): SfntTable | undefined {
for (const t of tables) if (t.tag === tag) return t;
return undefined;
}
/** format4 cmap 二分查找(绝对地址版,修正 idRangeOffset 计算)。
* 返回原始 gid0 表示缺失/映射到 .notdef */
function lookupFormat4(dv: DataView, fmt4Off: number, unicode: number): number {
const segCountX2 = dv.getUint16(fmt4Off + 6, false);
const segCount = segCountX2 >>> 1;
const endCodeBase = fmt4Off + 14;
const startCodeBase = endCodeBase + segCount * 2 + 2;
const idDeltaBase = startCodeBase + segCount * 2;
const idRangeOffsetBase = idDeltaBase + segCount * 2;
let lo = 0;
let hi = segCount - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const start = dv.getUint16(startCodeBase + mid * 2, false);
const end = dv.getUint16(endCodeBase + mid * 2, false);
if (unicode < start) {
hi = mid - 1;
} else if (unicode > end) {
lo = mid + 1;
} else {
const idDelta = dv.getInt16(idDeltaBase + mid * 2, false);
const idRangeOffset = dv.getUint16(idRangeOffsetBase + mid * 2, false);
if (idRangeOffset === 0) return ((unicode + idDelta) & 0xffff) & 0xffff;
/** idRangeOffset 是相对「idRangeOffset 字段自身地址」的字节偏移OpenType 规范) */
const idRangeOffsetAddr = idRangeOffsetBase + mid * 2;
const gidAddr = idRangeOffsetAddr + idRangeOffset + (unicode - start) * 2;
const gid = dv.getUint16(gidAddr, false);
if (gid === 0) return 0;
return (gid + idDelta) & 0xffff;
}
}
return 0;
}
/** format12 cmap 查找(补充平面 + BMP */
function lookupFormat12(dv: DataView, fmt12Off: number, unicode: number): number {
const numGroups = dv.getUint32(fmt12Off + 12, false);
/** groups 起始 = fmt12Off + 16每组 12 字节startCharCode(4) endCharCode(4) startGlyphID(4) */
let lo = 0;
let hi = numGroups - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const gOff = fmt12Off + 16 + mid * 12;
const start = dv.getUint32(gOff, false);
const end = dv.getUint32(gOff + 4, false);
if (unicode < start) {
hi = mid - 1;
} else if (unicode > end) {
lo = mid + 1;
} else {
const startGlyphID = dv.getUint32(gOff + 8, false);
return startGlyphID + (unicode - start);
}
}
return 0;
}
/** 选 cmap 子表format12p3e10优先format4p3e1次之。返回各自相对 cmap 起始的偏移(-1 无) */
function selectCmapSubtables(dv: DataView, cmapOff: number): { fmt4Off: number; fmt12Off: number } {
const numSub = dv.getUint16(cmapOff + 2, false);
let fmt4Off = -1;
let fmt12Off = -1;
for (let i = 0; i < numSub; i++) {
const r = cmapOff + 4 + i * 8;
const pid = dv.getUint16(r, false);
const eid = dv.getUint16(r + 2, false);
const suboff = cmapOff + dv.getUint32(r + 4, false);
const fmt = dv.getUint16(suboff, false);
if (pid === 3 && eid === 10 && fmt === 12 && fmt12Off < 0) fmt12Off = suboff;
else if (pid === 3 && eid === 1 && fmt === 4 && fmt4Off < 0) fmt4Off = suboff;
else if (pid === 0 && fmt === 12 && fmt12Off < 0) fmt12Off = suboff;
else if (pid === 0 && fmt === 4 && fmt4Off < 0) fmt4Off = suboff;
}
return { fmt4Off, fmt12Off };
}
/**
* 从原始 OTF 查子集 codepoint 的原始 gid构建子集字形集含 .notdef
* 查找顺序与 readWindowsAllCodes 一致BMP 先 format4未命中查 format12补充平面查 format12。
* 返回 newSubsetGids[0, ...去重保留的原始 gid])与 codepoint→新gid 映射。
*/
function buildSubsetGids(
dv: DataView,
cmapOff: number,
codePoints: number[],
): { subsetGids: number[]; cpToNewGid: Map<number, number> } {
const { fmt4Off, fmt12Off } = selectCmapSubtables(dv, cmapOff);
/** origGid 集合(保持插入顺序,新 gid = 数组 index */
const subsetGids: number[] = [0];
const seenGid = new Set<number>([0]);
const cpToNewGid = new Map<number, number>();
for (const cp of codePoints) {
if (cpToNewGid.has(cp)) continue;
let gid = 0;
if (cp < 0x10000 && fmt4Off >= 0) gid = lookupFormat4(dv, fmt4Off, cp);
if (gid === 0 && fmt12Off >= 0) gid = lookupFormat12(dv, fmt12Off, cp);
if (gid === 0) continue; /** 字体无此字 */
if (!seenGid.has(gid)) {
seenGid.add(gid);
subsetGids.push(gid);
}
cpToNewGid.set(cp, subsetGids.indexOf(gid));
}
return { subsetGids, cpToNewGid };
}
/** 重建 cmap 表format4子集 codepoint → 新 gid。
* 仅含 BMP 字符format4 上限),补充平面字符需 format12。当前生产用例 otf 字符均为 BMP
* 补充平面若出现降级为不含该字cpToNewGid 不含)。 */
function buildSubsetCmap(cpToNewGid: Map<number, number>): Uint8Array {
/** 收集 BMP 映射,按 codepoint 排序 */
const entries: { cp: number; gid: number }[] = [];
for (const [cp, gid] of cpToNewGid) {
if (cp < 0x10000 && cp > 0) entries.push({ cp, gid });
}
entries.sort((a, b) => a.cp - b.cp);
/** 构建 format4 segment连续 codepoint 且 gid 同步递增delta 模式)合并为一段。
* delta = (gid - cp),同一 delta 的连续 cp 可合并。 */
interface Seg { start: number; end: number; delta: number; }
const segs: Seg[] = [];
for (const e of entries) {
const last = segs[segs.length - 1];
/** 与上一段连续cp 递增 1 且保持同一 delta则扩展 */
if (last && e.cp === last.end + 1 && (e.gid - e.cp) === ((last.delta << 16) >> 16)) {
last.end = e.cp;
} else {
segs.push({ start: e.cp, end: e.cp, delta: (e.gid - e.cp) & 0xffff });
}
}
/** 末尾哨兵段 0xFFFF→gid 0delta=1 使 0xFFFF+1=0 mod 0x10000 */
segs.push({ start: 0xffff, end: 0xffff, delta: 1 });
const segCount = segs.length;
const segCountX2 = segCount * 2;
const searchRange = (1 << (31 - Math.clz32(segCount))) * 2;
const entrySelector = 31 - Math.clz32(segCount);
const rangeShift = segCountX2 - searchRange;
/** format4 表布局format(2) length(2) language(2) segCountX2(2) searchRange(2) entrySelector(2) rangeShift(2)
* + endCode[segCount] + reservedPad(2) + startCode[segCount] + idDelta[segCount] + idRangeOffset[segCount] */
const bodySize = 14 + segCount * 8 + 2;
/** cmap header: version(2) + numSubtables(2) + 子表记录(8) + format4 body */
const headerSize = 4 + 8;
const fmt4Length = bodySize;
const out = new Uint8Array(headerSize + fmt4Length);
const dv = new DataView(out.buffer);
/** cmap header */
dv.setUint16(0, 0, false); /** version */
dv.setUint16(2, 1, false); /** numSubtables */
/** 子表记录pid=3 eid=1 offset=12相对 cmap 起始) */
dv.setUint16(4, 3, false);
dv.setUint16(6, 1, false);
dv.setUint32(8, 12, false);
/** format4 body at offset 12 */
const b = 12;
dv.setUint16(b, 4, false); /** format */
dv.setUint16(b + 2, fmt4Length, false); /** length */
dv.setUint16(b + 4, 0, false); /** language */
dv.setUint16(b + 6, segCountX2, false);
dv.setUint16(b + 8, searchRange, false);
dv.setUint16(b + 10, entrySelector, false);
dv.setUint16(b + 12, rangeShift, false);
const endCodeBase = b + 14;
for (let i = 0; i < segCount; i++) dv.setUint16(endCodeBase + i * 2, segs[i].end, false);
dv.setUint16(endCodeBase + segCount * 2, 0, false); /** reservedPad */
const startCodeBase = endCodeBase + segCount * 2 + 2;
for (let i = 0; i < segCount; i++) dv.setUint16(startCodeBase + i * 2, segs[i].start, false);
const idDeltaBase = startCodeBase + segCount * 2;
for (let i = 0; i < segCount; i++) dv.setInt16(idDeltaBase + i * 2, (segs[i].delta << 16) >> 16, false);
const idRangeOffsetBase = idDeltaBase + segCount * 2;
for (let i = 0; i < segCount; i++) dv.setUint16(idRangeOffsetBase + i * 2, 0, false);
return out;
}
/** 重建 hmtx/vmtx按新 gid 顺序取原始 metricsadvanceWidth + lsb/topSideBearing
* numberOfHMetrics 设为子集数(所有字形给完整记录)。 */
function buildSubsetMetrics(
srcDv: DataView,
metricsOff: number,
numberOfHMetrics: number,
subsetGids: number[],
): Uint8Array {
/** 原始记录:前 numberOfHMetrics 个完整advance+lsb其余仅 lsbadvance 复用最后一个) */
const lastAdv = numberOfHMetrics > 0 ? srcDv.getUint16(metricsOff + (numberOfHMetrics - 1) * 4, false) : 0;
const out = new Uint8Array(subsetGids.length * 4);
const dv = new DataView(out.buffer);
for (let i = 0; i < subsetGids.length; i++) {
const gid = subsetGids[i];
let adv: number;
let sb: number;
if (gid < numberOfHMetrics) {
adv = srcDv.getUint16(metricsOff + gid * 4, false);
sb = srcDv.getInt16(metricsOff + gid * 4 + 2, false);
} else {
adv = lastAdv;
/** lsb 数组起始 = metricsOff + numberOfHMetrics*4每项 2 字节 */
const lsbArrOff = metricsOff + numberOfHMetrics * 4;
sb = srcDv.getInt16(lsbArrOff + (gid - numberOfHMetrics) * 2, false);
}
dv.setUint16(i * 4, adv, false);
dv.setInt16(i * 4 + 2, sb, false);
}
return out;
}
/** 重建 maxpOTF 版本 0.5version(4) + numGlyphs(2) */
function buildSubsetMaxp(subsetCount: number): Uint8Array {
const out = new Uint8Array(6);
const dv = new DataView(out.buffer);
dv.setUint32(0, 0x00005000, false); /** version 0.5 */
dv.setUint16(4, subsetCount, false);
return out;
}
/** 重建 postformat3无 glyph 名最省。version=3.0 */
function buildSubsetPost(): Uint8Array {
/** post format3 = version(4)=0x00030000 + italicAngle(4) + underlinePosition(2) + underlineThickness(2)
* + isFixedPitch(4) + minMemType42(4) + maxMemType42(4) + minMemType1(4) + maxMemType1(4) = 32 字节 */
const out = new Uint8Array(32);
const dv = new DataView(out.buffer);
dv.setUint32(0, 0x00030000, false);
return out;
}
/** OS/2 表透传原始字节patch usFirstCharIndex/usLastCharIndex 为子集 codepoint 的 min/max。
* CID 字体原始 OS/2 这两字段常为占位值65535/0OTS 严格校验 usFirst<=usLast 会拒绝,
* 必须按子集 cmap 的实际 codepoint 范围回填。仅 BMP codepoint 计入(字段是 uint16。 */
function buildSubsetOS2(srcDv: DataView, off: number, len: number, codePoints: number[]): Uint8Array {
const out = new Uint8Array(srcDv.buffer, srcDv.byteOffset + off, len).slice();
const dv = new DataView(out.buffer);
let minCp = 0xffff;
let maxCp = 0;
for (const cp of codePoints) {
if (cp > 0 && cp < 0x10000) {
if (cp < minCp) minCp = cp;
if (cp > maxCp) maxCp = cp;
}
}
if (minCp > maxCp) { minCp = 0; maxCp = 0xffff; }
dv.setUint16(64, minCp, false);
dv.setUint16(66, maxCp, false);
return out;
}
/** name 表:保留渲染必需 nameId1/2/4/6/16/17从原表拷贝对应记录。
* 若原表无这些 nameId写一个最小合法 name 表。 */
function buildSubsetName(srcDv: DataView, nameOff: number): Uint8Array {
const count = srcDv.getUint16(nameOff + 2, false);
const stringOff = nameOff + srcDv.getUint16(nameOff + 4, false);
/** 保留的 nameId 白名单(与 ttf 子集化 [[name-subset-whitelist]] 一致) */
const KEEP = new Set([1, 2, 4, 6, 16, 17]);
/** 收集保留记录(优先 p3e1回退任意 */
const records: { platformID: number; encodingID: number; languageID: number; nameID: number; bytes: Uint8Array }[] = [];
const seenNameId = new Set<number>();
/** 先扫 p3e1Windows UTF-16再补其他 platform */
for (let pass = 0; pass < 2; pass++) {
for (let i = 0; i < count; i++) {
const r = nameOff + 6 + i * 12;
const platformID = srcDv.getUint16(r, false);
const encodingID = srcDv.getUint16(r + 2, false);
const languageID = srcDv.getUint16(r + 4, false);
const nameID = srcDv.getUint16(r + 6, false);
if (!KEEP.has(nameID)) continue;
const isWin = platformID === 3 && encodingID === 1;
if (pass === 0 && !isWin) continue;
if (pass === 1 && seenNameId.has(nameID)) continue;
if (pass === 0 && seenNameId.has(nameID)) continue;
const length = srcDv.getUint16(r + 8, false);
const offset = srcDv.getUint16(r + 10, false);
records.push({ platformID, encodingID, languageID, nameID, bytes: new Uint8Array(srcDv.buffer, srcDv.byteOffset + stringOff + offset, length) });
seenNameId.add(nameID);
}
}
/** 重建 name 表 */
const headerSize = 6 + records.length * 12;
let stringSize = 0;
for (const rec of records) stringSize += rec.bytes.length;
const out = new Uint8Array(headerSize + stringSize);
const dv = new DataView(out.buffer);
dv.setUint16(0, 0, false); /** format */
dv.setUint16(2, records.length, false);
dv.setUint16(4, headerSize, false); /** stringOffset */
let strAcc = 0;
for (let i = 0; i < records.length; i++) {
const rec = records[i];
const r = 6 + i * 12;
dv.setUint16(r, rec.platformID, false);
dv.setUint16(r + 2, rec.encodingID, false);
dv.setUint16(r + 4, rec.languageID, false);
dv.setUint16(r + 6, rec.nameID, false);
dv.setUint16(r + 8, rec.bytes.length, false);
dv.setUint16(r + 10, strAcc, false);
out.set(rec.bytes, headerSize + strAcc);
strAcc += rec.bytes.length;
}
return out;
}
/** head 表patch numGlyphs 相关字段不需要head 无 numGlyphs但需清 checkSumAdjustment封装时统一算
* 透传原始 head 字节即可checkSumAdjustment 在 sfnt 封装时回填。 */
function passthroughHead(srcDv: DataView, off: number, len: number): Uint8Array {
const out = new Uint8Array(srcDv.buffer, srcDv.byteOffset + off, len).slice();
const dv = new DataView(out.buffer);
dv.setUint32(8, 0, false); /** checkSumAdjustment = 0封装时回填 */
return out;
}
/** 计算表 4 字节对齐后的 checksumOpenType 规范:表长度按 4 字节边界补齐算 sum
* DataView.getUint32 批量读大端 uint32V8 对 DataView 有专门内联,[[dataview-getuint32-fastest]]
* 验证其快于 Uint32Array+swap 与逐字节位拼装),尾部 1~3 字节补 0 单独处理。 */
function calcTableChecksum(bytes: Uint8Array): number {
const n = bytes.length;
const dv = new DataView(bytes.buffer, bytes.byteOffset, n);
const fullQuads = n >>> 2;
let sum = 0;
for (let i = 0; i < fullQuads; i++) sum = (sum + dv.getUint32(i * 4, false)) >>> 0;
/** 尾部 1~3 字节n 非 4 倍数时),按规范右侧补 0 到 4 字节参与累加 */
const tail = n & 3;
if (tail) {
const base = fullQuads * 4;
let last = 0;
for (let j = 0; j < tail; j++) last = (last << 8) | bytes[base + j];
last = (last << ((4 - tail) * 8)) >>> 0;
sum = (sum + last) >>> 0;
}
return sum >>> 0;
}
/**
* 封装 OTF sfntOTTO 签名):表目录 + 各表数据4 字节对齐)+ head.checkSumAdjustment 回填。
* @param tables 有序 (tag → bytes) 列表(按 tag 升序排,便于浏览器二分)
*/
function assembleSfnt(tables: { tag: string; bytes: Uint8Array }[]): Uint8Array {
/** 按 tag 升序sfnt 规范要求,浏览器按 tag 二分查找表) */
tables.sort((a, b) => (a.tag < b.tag ? -1 : a.tag > b.tag ? 1 : 0));
const numTables = tables.length;
const entrySelector = numTables > 0 ? 31 - Math.clz32(numTables) : 0;
const searchRange = (1 << entrySelector) * 16;
const rangeShift = numTables * 16 - searchRange;
/** 目录区 = 12sfnt 头)+ numTables*16表记录 */
const dirSize = 12 + numTables * 16;
/** 各表 4 字节对齐后的总数据长度 */
const paddedLens = tables.map((t) => (t.bytes.length + 3) & ~3);
let dataTotal = 0;
for (const pl of paddedLens) dataTotal += pl;
const out = new Uint8Array(dirSize + dataTotal);
const dv = new DataView(out.buffer);
/** sfnt 头OTTO 签名 */
out[0] = 0x4f; out[1] = 0x54; out[2] = 0x54; out[3] = 0x4f;
dv.setUint16(4, numTables, false);
dv.setUint16(6, searchRange, false);
dv.setUint16(8, entrySelector, false);
dv.setUint16(10, rangeShift, false);
/** 表记录 + 数据;各表 checksum 同时累加进 wholeSum数据段 sum 等于表 checksum */
let dataOff = dirSize;
const headIdx = tables.findIndex((t) => t.tag === "head");
/** wholeSum = Σ 各表数据段 checksum。head.checkSumAdjustment 此刻为 0passthroughHead 已清零),目录区稍后补算 */
let tablesDataSum = 0;
for (let i = 0; i < numTables; i++) {
const r = 12 + i * 16;
out[r] = tables[i].tag.charCodeAt(0);
out[r + 1] = tables[i].tag.charCodeAt(1);
out[r + 2] = tables[i].tag.charCodeAt(2);
out[r + 3] = tables[i].tag.charCodeAt(3);
const checksum = calcTableChecksum(tables[i].bytes);
tablesDataSum = (tablesDataSum + checksum) >>> 0;
dv.setUint32(r + 4, checksum, false);
dv.setUint32(r + 8, dataOff, false);
dv.setUint32(r + 12, tables[i].bytes.length, false);
out.set(tables[i].bytes, dataOff);
dataOff += paddedLens[i];
}
/** head.checkSumAdjustment = 0xB1B0AFBA - 全文件 sum。
* 全文件 sum = 目录区 sum + Σ 各表数据段 checksum。
* 数据段 sum 已在写表时累加(=各表 checksum目录区仅 dirSize 字节sfnt 头+表记录),单独遍历即可,
* 避免对数百 KB 的 CFF 等大表做第二次整段遍历。 */
if (headIdx >= 0) {
const headRecOff = 12 + headIdx * 16;
const headDataOff = dv.getUint32(headRecOff + 8, false);
let dirSum = 0;
for (let i = 0; i < dirSize; i += 4) {
dirSum = (dirSum + ((out[i] << 24) | (out[i + 1] << 16) | (out[i + 2] << 8) | out[i + 3])) >>> 0;
}
const wholeSum = (dirSum + tablesDataSum) >>> 0;
const adjustment = (0xb1b0afba - wholeSum) >>> 0;
dv.setUint32(headDataOff + 8, adjustment, false);
}
return out;
}
/**
* OTF 子集化主入口。
* @param fontBuffer 原始 OTF 字节
* @param codePoints 子集字符码点
* @param keepGSUB 是否子集化 GSUB/GPOS连字/标点压缩需要)
* @returns 子集 OTF 字节;非 CFF 字体或不支持返回 null
*/
export function subsetOTF(fontBuffer: Uint8Array, codePoints: number[], keepGSUB: boolean): Uint8Array | null {
const dv = new DataView(fontBuffer.buffer, fontBuffer.byteOffset, fontBuffer.byteLength);
const tables = readSfntTables(dv);
const cff = findTable(tables, "CFF ");
const cmap = findTable(tables, "cmap");
const hmtx = findTable(tables, "hmtx");
const hhea = findTable(tables, "hhea");
const head = findTable(tables, "head");
const maxp = findTable(tables, "maxp");
if (!cff || !cmap || !hmtx || !hhea || !head || !maxp) return null;
/** 子集字形集 + codepoint→新gid */
const { subsetGids, cpToNewGid } = buildSubsetGids(dv, cmap.offset, codePoints);
/** GSUB 替换链 target 字形保留locl/calt/liga 等)。
* 思源黑体等 CID 字体含 locl feature浏览器按系统 locale中文渲染时GSUB 把基础字形
* 替换为地区变体字形(如"天"gid A → CN 变体 gid A+1。若子集 subsetGids 不含这些 target
* 子集 GSUB 重映射后 target gid 指向不存在/错误字形,渲染出错误变体(实测思源 9 字 ink 差 75
* SSIM 0.9530,逐字定位"天玄宇法海"多 ink、"宙"少 ink正是 locl 变体丢失)。
* 复用 ttf 路径同款 collectReachableGsubTargets 收集 seed 经 GSUB 可达的 target gid合并进 subsetGids。
* 仅字体含 GSUB 时收集;无 GSUB纯汉字无连字/变体)跳过,零开销。 */
if (keepGSUB) {
const gsub = findTable(tables, "GSUB");
if (gsub) {
const gsubBytes = new Uint8Array(dv.buffer, dv.byteOffset + gsub.offset, gsub.length);
/** seed = 当前 subsetGids已含 .notdef + 各 codepoint 的 gid */
const seed = new Set<number>(subsetGids);
const reachable = collectReachableGsubTargets(gsubBytes, seed);
if (reachable.size > 0) {
/** 合并去重:新 gid 顺序追加在现有 subsetGids 之后(不破坏 cpToNewGid 已建立的映射) */
for (const gid of reachable) {
if (gid > 0 && !seed.has(gid)) {
seed.add(gid);
subsetGids.push(gid);
}
}
}
}
}
/** 子集 CFF */
const cffBytes = new Uint8Array(fontBuffer.buffer, fontBuffer.byteOffset + cff.offset, cff.length);
const newCFF = subsetCFF(cffBytes, subsetGids);
if (!newCFF) return null;
/** 子集 cmap */
const newCmap = buildSubsetCmap(cpToNewGid);
/** numberOfHMetricshhea +34*/
const numberOfHMetrics = dv.getUint16(hhea.offset + 34, false);
/** 子集 hmtx */
const newHmtx = buildSubsetMetrics(dv, hmtx.offset, numberOfHMetrics, subsetGids);
/** 组装各表 */
const outTables: { tag: string; bytes: Uint8Array }[] = [];
outTables.push({ tag: "CFF ", bytes: newCFF });
outTables.push({ tag: "cmap", bytes: newCmap });
outTables.push({ tag: "hmtx", bytes: newHmtx });
outTables.push({ tag: "maxp", bytes: buildSubsetMaxp(subsetGids.length) });
outTables.push({ tag: "post", bytes: buildSubsetPost() });
/** head 透传patch checkSumAdjustment 占位) */
outTables.push({ tag: "head", bytes: passthroughHead(dv, head.offset, head.length) });
/** hhea 透传 + patch numberOfHMetrics = subsetGids.length */
{
const hheaBytes = new Uint8Array(dv.buffer, dv.byteOffset + hhea.offset, hhea.length).slice();
const hheaDv = new DataView(hheaBytes.buffer);
hheaDv.setUint16(34, subsetGids.length, false);
outTables.push({ tag: "hhea", bytes: hheaBytes });
}
/** OS/2 透传 + patch usFirstCharIndex/usLastCharIndex */
const os2 = findTable(tables, "OS/2");
if (os2) outTables.push({ tag: "OS/2", bytes: buildSubsetOS2(dv, os2.offset, os2.length, codePoints) });
/** name 子集 */
const name = findTable(tables, "name");
if (name) outTables.push({ tag: "name", bytes: buildSubsetName(dv, name.offset) });
/** vhea/vmtx 透传 + 重建(垂直排版 metricsCJK 需要) */
const vhea = findTable(tables, "vhea");
const vmtx = findTable(tables, "vmtx");
if (vhea && vmtx) {
const numOfLongVerMetrics = dv.getUint16(vhea.offset + 34, false);
const newVmtx = buildSubsetMetrics(dv, vmtx.offset, numOfLongVerMetrics, subsetGids);
outTables.push({ tag: "vmtx", bytes: newVmtx });
const vheaBytes = new Uint8Array(dv.buffer, dv.byteOffset + vhea.offset, vhea.length).slice();
const vheaDv = new DataView(vheaBytes.buffer);
vheaDv.setUint16(34, subsetGids.length, false);
outTables.push({ tag: "vhea", bytes: vheaBytes });
}
/** GSUB/GPOS 子集化(若 keepGSUB 且存在):复用 ttf 路径同款 gsub-subset/gpos-subset
* 按 subsetGids 建立原gid→新gid 映射重写布局表 glyphId。OTF 与 TTF 的 GSUB/GPOS 表结构完全相同
* (都是 OpenType 布局表,差异仅在 glyf vs CFF故字节级子集化器可直接复用。
* 不能直接透传原始字节:子集化后 gid 已重排,原始 coverage 引用的旧 gid 会指向错误字形,
* 致 shaping 引擎把字符替换成 .notdef/错字(实测思源 otf 透传 GSUB 致 SSIM 0.623,洪/法等字变空白)。
* keepGSUB=false 时丢弃,纯汉字无标点/连字的场景安全。 */
if (keepGSUB) {
/** origToNewsubsetGids[新gid] = 原gid反转得 原gid→新gid */
const origToNew = new Map<number, number>();
for (let newGid = 0; newGid < subsetGids.length; newGid++) origToNew.set(subsetGids[newGid], newGid);
const gsub = findTable(tables, "GSUB");
if (gsub) {
const gsubBytes = new Uint8Array(dv.buffer, dv.byteOffset + gsub.offset, gsub.length);
outTables.push({ tag: "GSUB", bytes: subsetGSUB(gsubBytes, origToNew) });
}
const gpos = findTable(tables, "GPOS");
if (gpos) {
const gposBytes = new Uint8Array(dv.buffer, dv.byteOffset + gpos.offset, gpos.length);
/** subsetGPOS 可能对不支持的版本返回 null此时丢弃该表无 GPOS 仅丢失 kerning */
const newGpos = subsetGPOS(gposBytes, origToNew);
if (newGpos) outTables.push({ tag: "GPOS", bytes: newGpos });
}
}
return assembleSfnt(outTables);
}