From bebb8503f452c7cc094b1bb4f3e9f3b9734ee923 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: Fri, 24 Jul 2026 22:48:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(otf):=20=E7=8B=AC=E7=AB=8B=20OTF(CFF)=20?= =?UTF-8?q?=E5=AD=90=E9=9B=86=E5=8C=96=E5=99=A8=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=80=9D=E6=BA=90=E7=AD=89=20CID=20=E5=AD=97=E4=BD=93=20OTS=20?= =?UTF-8?q?=E6=8B=92=E7=BB=9D=E4=B8=8E=20gid=20=E9=94=99=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/font_util/cff-subset.ts | 740 ++++++++++++++++++ backend/font_util/font.ts | 19 + backend/font_util/otf-subset.ts | 542 +++++++++++++ .../fonteditor-core/woff2/woff2-encode.d.ts | 3 + 基准测试.test.ts | 33 +- 5 files changed, 1327 insertions(+), 10 deletions(-) create mode 100644 backend/font_util/cff-subset.ts create mode 100644 backend/font_util/otf-subset.ts create mode 100644 vendor/fonteditor-core/woff2/woff2-encode.d.ts diff --git a/backend/font_util/cff-subset.ts b/backend/font_util/cff-subset.ts new file mode 100644 index 0000000..8e318c0 --- /dev/null +++ b/backend/font_util/cff-subset.ts @@ -0,0 +1,740 @@ +/** + * CFF (Compact Font Format) 表子集化器 —— OTF 字体保留 CFF 轮廓的子集化。 + * + * 背景:fonteditor-core 对 OTF 输入走 otf2ttfobject(CFF 三次贝塞尔 → glyf 二次贝塞尔), + * 子集化后再以 glyf 输出。但浏览器对 CFF(三次)与 glyf(二次)的光栅化路径不同, + * 子集后渲染与原始 OTF 存在像素差异(基准 SSIM 0.93~0.97,ink 差数百像素)。 + * + * 本模块直接对原始 CFF 表做子集化:保留 CID-keyed 结构(charset/FDSelect/CharStrings/ + * FDArray/Private),按 subsetGids 重排顺序并透传 charstring 原始字节。charstring 内的 + * 坐标与 callsubr/callgsubr 调用相对自身,故 Global Subr INDEX 与各 FD 的 Local Subr INDEX + * 原样透传即可保持引用有效。实测白狐/思源 OTF 子集后浏览器渲染 ink 与原始 OTF 像素级一致 + * (SSIM 可达 ≈1.0)。 + * + * 仅支持 CID-keyed CFF(Top DICT 含 ROS / FDArray / FDSelect)。非 CID(Type 2 name-keyed) + * 字体走另一套 charstring 索引结构,当前生产用例不涉及,遇到时返回 null 降级。 + * + * @reference https://learn.microsoft.com/en-us/typography/opentype/spec/cff + */ + +/** CFF INDEX 解析结果。offsets 为 1-based,object i 的字节区间 = [dataStart+offsets[i]-1, dataStart+offsets[i+1]-1) */ +interface CffIndex { + /** INDEX 起始偏移 */ + start: number; + /** INDEX 内对象数量 */ + count: number; + /** 偏移量字节宽度(1~4) */ + offSize: number; + /** (count+1) 个 1-based 偏移量 */ + offsets: number[]; + /** 数据区起始位置(紧接偏移量数组之后) */ + dataStart: number; + /** INDEX 结束位置(= 最后一个 object 的尾) */ + end: number; +} + +/** + * 解析 CFF INDEX 结构(count + offSize + offsets + data)。 + * @param b CFF 字节 + * @param pos INDEX 起始偏移 + */ +function readIndex(b: Uint8Array, pos: number): CffIndex { + const count = (b[pos] << 8) | b[pos + 1]; + /** count=0 的 INDEX 仅 2 字节 */ + if (count === 0) return { start: pos, count: 0, offSize: 0, offsets: [], dataStart: pos + 2, end: pos + 2 }; + const offSize = b[pos + 2]; + let op = pos + 3; + const offsets: number[] = new Array(count + 1); + for (let i = 0; i <= count; i++) { + let v = 0; + for (let j = 0; j < offSize; j++) v = (v << 8) | b[op++]; + offsets[i] = v; + } + /** offsets 是 1-based:object i 的数据从 dataStart + offsets[i] - 1 开始 */ + const dataStart = op; + return { start: pos, count, offSize, offsets, dataStart, end: dataStart + offsets[count] - 1 }; +} + +/** 取一个已解析 INDEX 的完整字节切片(含头部+偏移量+数据,[start, end)) */ +function getIndexBytes(b: Uint8Array, idx: CffIndex): Uint8Array { + return b.subarray(idx.start, idx.end); +} + +/** CFF Top/Private DICT 解析结果:操作码键 → 操作数数组。双字节操作码 12,n 存为 (12<<8)|n */ +type CffDict = Map; + +/** + * 解析 CFF DICT 字节为操作码键→操作数数组的映射。 + * 操作数编码(CFF 规范 §3.1): + * 32~246 → 1 字节小整数 v-139 + * 247~250 → 2 字节 (v-247)*256+b+108 + * 251~254 → 2 字节 -(v-251)*256-b-108 + * 28 → 2 字节 int16 + * 29 → 4 字节 int32 + * 12 → 双字节操作码前缀(下一字节为操作码低位) + * 其他 <31(非 12)→ 单字节操作码 + * @param b DICT 字节 + * @param start DICT 起始偏移 + * @param end DICT 结束偏移(不含) + */ +function parseDict(b: Uint8Array, start: number, end: number): CffDict { + const dict: CffDict = new Map(); + const operands: number[] = []; + let p = start; + while (p < end) { + const b0 = b[p++]; + if (b0 <= 21) { + /** 操作码 */ + let op = b0; + if (b0 === 12) op = (12 << 8) | b[p++]; + dict.set(op, operands.splice(0, operands.length)); + } else if (b0 === 28) { + operands.push(((b[p] << 24) | (b[p + 1] << 16)) >> 16); + p += 2; + } else if (b0 === 29) { + operands.push(((b[p] << 24) | (b[p + 1] << 16) | (b[p + 2] << 8) | b[p + 3]) | 0); + p += 4; + } else if (b0 >= 32 && b0 <= 246) { + operands.push(b0 - 139); + } else if (b0 >= 247 && b0 <= 250) { + operands.push((b0 - 247) * 256 + b[p] + 108); + p += 1; + } else if (b0 >= 251 && b0 <= 254) { + operands.push(-(b0 - 251) * 256 - b[p] - 108); + p += 1; + } + } + return dict; +} + +/** + * 编码一个 DICT 整数操作数为字节数组(CFF 规范 §3.1 整数编码)。 + * @param v 整数值 + */ +function encodeDictInt(v: number): number[] { + if (v >= -107 && v <= 107) return [v + 139]; + if (v >= 108 && v <= 1131) { + const v0 = v - 108; + return [247 + (v0 >> 8), v0 & 0xff]; + } + if (v >= -1131 && v <= -108) { + const v0 = -v - 108; + return [251 + (v0 >> 8), v0 & 0xff]; + } + if (v >= -32768 && v <= 32767) return [28, (v >> 8) & 0xff, v & 0xff]; + return [29, (v >>> 24) & 0xff, (v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff]; +} + +/** + * 序列化 CFF INDEX:count + offSize + (count+1)*offSize 偏移量 + 数据拼接。 + * 选最小能容纳最大偏移量的 offSize。偏移量 1-based(首个 offset=1)。 + * @param objects 每个对象的字节切片(Uint8Array 或等价的 {start,len} 引用) + */ +function writeIndex(objects: { bytes: Uint8Array; start: number; len: number }[]): Uint8Array { + const count = objects.length; + if (count === 0) return new Uint8Array(2); /** count=0 的空 INDEX */ + + /** 累计数据长度,算最大 offset(含末尾哨兵) */ + let totalData = 0; + for (const o of objects) totalData += o.len; + const maxOffset = totalData + 1; + + /** 选 offSize:1~4 字节 */ + let offSize = 1; + if (maxOffset > 0xffff) offSize = 4; + else if (maxOffset > 0xff) offSize = 2; + let tmp = maxOffset; + while (tmp > 0xff && offSize < 4) { + offSize++; + tmp >>>= 8; + } + + const offsetsSize = (count + 1) * offSize; + const totalSize = 2 + 1 + offsetsSize + totalData; + const out = new Uint8Array(totalSize); + out[0] = (count >> 8) & 0xff; + out[1] = count & 0xff; + out[2] = offSize; + + /** 写偏移量(1-based,大端 offSize 字节) */ + let op = 3; + let acc = 1; + const writeOffset = (v: number) => { + for (let s = (offSize - 1) * 8; s >= 0; s -= 8) out[op++] = (v >>> s) & 0xff; + }; + writeOffset(acc); + for (const o of objects) { + acc += o.len; + writeOffset(acc); + } + + /** 写数据 */ + let dp = 3 + offsetsSize; + for (const o of objects) { + out.set(o.bytes.subarray(o.start, o.start + o.len), dp); + dp += o.len; + } + return out; +} + +/** CFF 操作码键(单字节直接用值,双字节用 (12<<8)|n) */ +const OP_charset = 15; +const OP_charStrings = 17; +const OP_Private = 18; +/** Private DICT 内:Local Subr INDEX 相对 Private 起始的偏移 */ +const OP_LocalSubr = 19; +const OP_FDArray = (12 << 8) | 36; +const OP_FDSelect = (12 << 8) | 37; +const OP_ROS = (12 << 8) | 30; + +/** CFF FDSelect 格式 3 的单个 range:首 glyph index + FD index */ +interface FdSelectRange { + first: number; + fd: number; +} + +/** + * 解析原 FDSelect,返回每个原始 gid 的 FD index 数组。 + * 格式 0:format(1) + numGlyphs×uint8(标准无 count 字段) + * 格式 3:format(1) + nRanges(u16) + ranges[3]×nRanges + sentinel(u16) + * @param b CFF 字节 + * @param fdSelectOff FDSelect 表起始偏移 + * @param numGlyphs 字形总数 + */ +function parseFDSelect(b: Uint8Array, fdSelectOff: number, numGlyphs: number): number[] { + const fmt = b[fdSelectOff]; + const fds = new Array(numGlyphs).fill(0); + if (fmt === 0) { + for (let i = 0; i < numGlyphs; i++) fds[i] = b[fdSelectOff + 1 + i]; + return fds; + } + /** format 3 */ + const nRanges = (b[fdSelectOff + 1] << 8) | b[fdSelectOff + 2]; + const ranges: FdSelectRange[] = []; + let p = fdSelectOff + 3; + for (let i = 0; i < nRanges; i++) { + const first = (b[p] << 8) | b[p + 1]; + const fd = b[p + 2]; + ranges.push({ first, fd }); + p += 3; + } + for (let i = 0; i < nRanges; i++) { + const next = i + 1 < nRanges ? ranges[i + 1].first : numGlyphs; + for (let g = ranges[i].first; g < next; g++) fds[g] = ranges[i].fd; + } + return fds; +} + +/** + * 编码 FDSelect:单 FD(所有字形同属一个 FD)用格式 0 最省;多 FD 用格式 3 ranges。 + * 格式 0:format(1) + numGlyphs×uint8 + * 格式 3:format(1) + nRanges(u16) + ranges[first(u16), fd(u8)]×nRanges + sentinel(u16) + * 多 FD 选格式 3:与原始 CID 字体(思源等)结构一致,规避 OTS 对强制 format 0 的严格校验。 + * @param gidToFd 每个新 gid 的新 FD 编号(顺序,含 gid 0) + */ +function encodeFDSelect(gidToFd: number[]): Uint8Array { + /** 单 FD:所有字形同一个 FD,用格式 0 */ + let singleFd = gidToFd.length > 0 ? gidToFd[0] : 0; + let isSingle = gidToFd.length > 0; + for (const fd of gidToFd) { + if (fd !== singleFd) { isSingle = false; break; } + } + if (isSingle) { + const out = new Uint8Array(1 + gidToFd.length); + out[0] = 0; + for (let i = 0; i < gidToFd.length; i++) out[1 + i] = gidToFd[i]; + return out; + } + /** 多 FD:格式 3 ranges。连续相同 FD 的 gid 合并为一个 range。 */ + const ranges: FdSelectRange[] = []; + let curFirst = 0; + let curFd = gidToFd[0]; + for (let i = 1; i < gidToFd.length; i++) { + if (gidToFd[i] !== curFd) { + ranges.push({ first: curFirst, fd: curFd }); + curFirst = i; + curFd = gidToFd[i]; + } + } + ranges.push({ first: curFirst, fd: curFd }); + const nRanges = ranges.length; + const out = new Uint8Array(3 + nRanges * 3 + 2); + out[0] = 3; /** format 3 */ + out[1] = (nRanges >> 8) & 0xff; + out[2] = nRanges & 0xff; + let p = 3; + for (const r of ranges) { + out[p] = (r.first >> 8) & 0xff; + out[p + 1] = r.first & 0xff; + out[p + 2] = r.fd; + p += 3; + } + /** sentinel = numGlyphs(最后一个 range 之后的第一个 gid) */ + const sentinel = gidToFd.length; + out[p] = (sentinel >> 8) & 0xff; + out[p + 1] = sentinel & 0xff; + return out; +} + +/** + * CID CFF 子集化主入口。 + * + * 重建流程: + * 1. 解析 Header / Name INDEX / Top DICT INDEX / String INDEX / Global Subr INDEX(这些段透传或仅改 Top DICT offset) + * 2. 按 subsetGids 重排 charset / FDSelect / CharStrings INDEX + * 3. 收集命中的 FD,重建 FDArray(透传各 FD 的 DICT + Private)+ 重写 FDSelect 用新 FD 编号 + * 4. patch Top DICT 的 charset / charStrings / FDArray / FDSelect offset 指向新位置 + * + * @param cffBytes 原始 CFF 表字节 + * @param subsetGids 子集字形原始 gid 顺序(含 0 = .notdef,新 gid = 数组索引) + * @returns 子集 CFF 字节;非 CID 或不支持的结构返回 null(调用方降级) + */ +export function subsetCFF(cffBytes: Uint8Array, subsetGids: number[]): Uint8Array | null { + const b = cffBytes; + + /** Header: major(1) minor(1) hdrSize(1) offSize(1) */ + const hdrSize = b[2]; + /** Name INDEX 紧接 Header */ + const nameIndex = readIndex(b, hdrSize); + /** Top DICT INDEX 紧接 Name INDEX */ + const topDictIndex = readIndex(b, nameIndex.end); + if (topDictIndex.count < 1) return null; + + /** Top DICT 数据 */ + const topDictDataStart = topDictIndex.dataStart + topDictIndex.offsets[0] - 1; + const topDictDataEnd = topDictIndex.dataStart + topDictIndex.offsets[1] - 1; + const topDict = parseDict(b, topDictDataStart, topDictDataEnd); + + /** 非 CID 字体(无 ROS)走 name-keyed 结构,当前不支持 */ + if (!topDict.has(OP_ROS)) return null; + + /** String INDEX 紧接 Top DICT INDEX;Global Subr INDEX 紧接其后 */ + const stringIndex = readIndex(b, topDictIndex.end); + const globalSubrIndex = readIndex(b, stringIndex.end); + + /** Top DICT 中各结构表的绝对偏移(相对 CFF 起始) */ + const charStringsOff = topDict.get(OP_charStrings)?.[0]; + const charsetOff = topDict.get(OP_charset)?.[0]; + const fdArrayOff = topDict.get(OP_FDArray)?.[0]; + const fdSelectOff = topDict.get(OP_FDSelect)?.[0]; + if (charStringsOff === undefined || charsetOff === undefined || fdArrayOff === undefined || fdSelectOff === undefined) { + return null; + } + + const charStringsIndex = readIndex(b, charStringsOff); + /** 子集字形数 = CharStrings 数(含 .notdef) */ + const numGlyphs = charStringsIndex.count; + if (subsetGids.length === 0) return null; + + /** .notdef(gid 0)必须保留,且 subsetGids[0] 应为 0 */ + const newSubsetGids = subsetGids[0] === 0 ? subsetGids : [0, ...subsetGids]; + + /** 重建 CharStrings INDEX:按 newSubsetGids 顺序透传原 charstring 字节 */ + const newCharStringObjects: { bytes: Uint8Array; start: number; len: number }[] = []; + for (const gid of newSubsetGids) { + const s = charStringsIndex.dataStart + charStringsIndex.offsets[gid] - 1; + const e = charStringsIndex.dataStart + charStringsIndex.offsets[gid + 1] - 1; + newCharStringObjects.push({ bytes: b, start: s, len: e - s }); + } + const newCharStrings = writeIndex(newCharStringObjects); + + /** 重建 charset:CID-keyed 字体的 charset 是 gid→CID 映射。格式 0/1/2,按 newSubsetGids 取 CID。 + * CID 0 固定留给 .notdef(gid 0),其余按原 charset 顺序。新 charset 用格式 0 最简单: + * format(1) + (numGlyphs-1)×CID(u16)(charset 不含 gid 0,它隐式为 CID 0)。 */ + const origCids = readCharsetCIDs(b, charsetOff, numGlyphs); + const newSubsetNumGlyphs = newSubsetGids.length; + const newCharsetBody: number[] = []; + for (let i = 1; i < newSubsetNumGlyphs; i++) { + /** newSubsetGids[i] 是原始 gid,取其原 CID */ + newCharsetBody.push(origCids[newSubsetGids[i]] ?? 0); + } + const newCharset = encodeCharsetFormat0(newCharsetBody); + + /** FDSelect:原始 gid→FD index,重映射为新 gid→新 FD index */ + const origFds = parseFDSelect(b, fdSelectOff, numGlyphs); + + /** 收集命中的 FD(保持首次出现顺序),构建 原FD→新FD 映射 */ + const fdRemap = new Map(); + const usedFds: number[] = []; + for (const gid of newSubsetGids) { + const fd = origFds[gid] ?? 0; + if (!fdRemap.has(fd)) { + fdRemap.set(fd, usedFds.length); + usedFds.push(fd); + } + } + + /** 重建 FDArray:解析各命中 FD 的 DICT,patch 其 Private [len, offset] 指向新 Private 段。 + * Private 数据本身透传(仅重定位 offset)。若 Private 声明了 Local Subr INDEX(op 19), + * 必须把该 INDEX 字节一并透传到新 Private 段之后,并 patch op 19 指向新相对偏移—— + * 否则子集 op 19 指向越界/错位,OTS 解析 Local Subr 失败致 "Failed to parse Top DICT Data"。 + * charstring 的 callsubr 按 subr 编号 + bias 索引,透传 INDEX 内容后调用仍有效。 */ + const fdArrayIndex = readIndex(b, fdArrayOff); + /** 每个 usedFd 对应的 (FD DICT 原字节, Private 信息) */ + interface PrivInfo { + /** Private 段在原 CFF 的绝对偏移(-1 表示无 Private) */ + origOff: number; + /** Private 段长度(仅 DICT 字节,不含 Local Subr INDEX) */ + len: number; + /** Local Subr INDEX 原始字节(无则 null)。思源等 CID 字体字形通过 callsubr 引用本地 subr */ + localSubr: Uint8Array | null; + } + interface FdInfo { dictBytes: Uint8Array; priv: PrivInfo; } + /** 原始 Private 段去重:相同 origOff 的 Private 共享同一份(含其 Local Subr) */ + const privSegCache = new Map(); + const fdInfos: FdInfo[] = []; + for (const fd of usedFds) { + const s = fdArrayIndex.dataStart + fdArrayIndex.offsets[fd] - 1; + const e = fdArrayIndex.dataStart + fdArrayIndex.offsets[fd + 1] - 1; + const dictBytes = b.subarray(s, e); + const fdDict = parseDict(b, s, e); + const priv = fdDict.get(OP_Private); + /** 无 Private 的 FD(极罕见)原样透传 */ + if (!priv || priv.length < 2) { + fdInfos.push({ dictBytes, priv: { origOff: -1, len: 0, localSubr: null } }); + continue; + } + const privLen = priv[0]; + const privOrigOff = priv[1]; + let info = privSegCache.get(privOrigOff); + if (!info) { + /** 解析 Private DICT,查 op 19(Local Subr INDEX 相对 Private 起始的偏移) */ + const privDict = parseDict(b, privOrigOff, privOrigOff + privLen); + const subrRel = privDict.get(OP_LocalSubr)?.[0]; + let localSubr: Uint8Array | null = null; + if (subrRel !== undefined) { + /** Local Subr INDEX 紧接 Private DICT 字节之后(绝对偏移 = privOrigOff + subrRel) */ + const subrIdx = readIndex(b, privOrigOff + subrRel); + localSubr = b.subarray(subrIdx.start, subrIdx.end); + } + info = { origOff: privOrigOff, len: privLen, localSubr }; + privSegCache.set(privOrigOff, info); + } + fdInfos.push({ dictBytes, priv: info }); + } + + /** 新 FDSelect:每个新 gid → 新 FD 编号。单 FD 用格式 0(最省,1+numGlyphs 字节); + * 多 FD 用格式 3 ranges(与原始 CID 字体一致,兼容 OTS 严格校验)。 */ + const newGidToFd: number[] = new Array(newSubsetNumGlyphs); + for (let i = 0; i < newSubsetNumGlyphs; i++) { + const origFd = origFds[newSubsetGids[i]] ?? 0; + newGidToFd[i] = fdRemap.get(origFd) ?? 0; + } + const newFdSelectBody = encodeFDSelect(newGidToFd); + + /** 组装新 CFF:Header + Name INDEX + Top DICT INDEX + String INDEX + Global Subr INDEX + * + charset + charStrings + FDArray + FDSelect + Private 段。 + * Top DICT 的四个 offset 须 patch 为新位置。 */ + const headerNameBytes = combineBytes([b.subarray(0, hdrSize), getIndexBytes(b, nameIndex)]); + const stringSeg = getIndexBytes(b, stringIndex); + const globalSubrSeg = getIndexBytes(b, globalSubrIndex); + + /** Top DICT 原始字节(待 patch offset 后替换) */ + const topDictBytes = b.subarray(topDictDataStart, topDictDataEnd); + + /** 新结构段(FDArray 段依赖 patch,迭代中确定) */ + const charsetSeg = newCharset; + const charStringsSeg = newCharStrings; + const fdSelectSeg = newFdSelectBody; + + /** 联合迭代收敛 Top DICT patch + FD DICT patch + Private op19 patch(三者长度互相影响后续偏移)。 + * 结构:headerName + TopDICT INDEX + String INDEX + GlobalSubr INDEX + charset + charStrings + * + FDArray INDEX + FDSelect + Private 段(每段 = Private DICT + 其 Local Subr INDEX)。 */ + let topDictLen = topDictDataEnd - topDictDataStart; + let fdArrayTotalLen = 0; + /** 去重后的唯一 Private 段(按首次出现顺序),用于计算偏移与最终拼接 */ + const uniquePrivInfos: PrivInfo[] = []; + const privOrigToUniqueIdx = new Map(); + for (const info of fdInfos) { + if (info.priv.origOff >= 0 && !privOrigToUniqueIdx.has(info.priv.origOff)) { + privOrigToUniqueIdx.set(info.priv.origOff, uniquePrivInfos.length); + uniquePrivInfos.push(info.priv); + } + } + /** 各唯一 Private 段 patch 后的 DICT 字节 + op19 新值(迭代收敛,op19 = patchedDICT.length) */ + let patchedPrivSegs: { dict: Uint8Array; subr: Uint8Array | null }[] = []; + let privSegsTotalLen = 0; + let patchedTopDict: Uint8Array = topDictBytes; + let newFdArrayBytes: Uint8Array = new Uint8Array(0); + for (let iter = 0; iter < 8; iter++) { + /** 先 patch 各唯一 Private DICT:op19 指向新 DICT 长度(Local Subr 紧跟其后) */ + const curPatchedPriv: { dict: Uint8Array; subr: Uint8Array | null }[] = []; + for (const pi of uniquePrivInfos) { + const patchedPriv = patchPrivateDict(b, pi.origOff, pi.len, pi.localSubr !== null); + curPatchedPriv.push({ dict: patchedPriv, subr: pi.localSubr }); + } + /** patched 私有段总长(含各自 Local Subr) */ + let curPrivTotal = 0; + for (const pp of curPatchedPriv) { + curPrivTotal += pp.dict.length; + if (pp.subr) curPrivTotal += pp.subr.length; + } + + /** Top DICT INDEX 总长 = count(2)+offSize(1)+(count+1)*offSize + topDictLen,count=1 */ + const tdOffSize = patchedTopDictOffSize(topDictLen + 1); + const tdIdxTotalLen = 2 + 1 + 2 * tdOffSize + topDictLen; + const stringOff = headerNameBytes.length + tdIdxTotalLen; + const gsubrOff = stringOff + stringSeg.length; + const charsetOff = gsubrOff + globalSubrSeg.length; + const charStringsOff = charsetOff + charsetSeg.length; + const fdArrayOff = charStringsOff + charStringsSeg.length; + const fdSelectOff = fdArrayOff + fdArrayTotalLen; + const privateOff = fdSelectOff + fdSelectSeg.length; + + /** 各唯一 Private 段在新 CFF 中的绝对偏移(顺序拼接,起始 privateOff) */ + let pAcc = privateOff; + const origToNewPrivOff = new Map(); + for (const [origOff, uid] of privOrigToUniqueIdx) { + origToNewPrivOff.set(origOff, pAcc); + pAcc += curPatchedPriv[uid].dict.length; + if (curPatchedPriv[uid].subr) pAcc += curPatchedPriv[uid].subr!.length; + } + /** patch FD DICT 的 Private [len, offset]:len = patchedDICT 长度(不含 Local Subr), + * offset = 新 Private 绝对偏移。CFF 规范 Private len 是 DICT 字节长度,Local Subr 在其外。 */ + const patchedFdObjects: { bytes: Uint8Array; start: number; len: number }[] = []; + for (const info of fdInfos) { + if (info.priv.origOff < 0) { + patchedFdObjects.push({ bytes: info.dictBytes, start: 0, len: info.dictBytes.length }); + } else { + const uid = privOrigToUniqueIdx.get(info.priv.origOff)!; + const newPrivOff = origToNewPrivOff.get(info.priv.origOff)!; + const newPrivLen = curPatchedPriv[uid].dict.length; + const patched = patchFdDictPrivate(info.dictBytes, newPrivLen, newPrivOff); + patchedFdObjects.push({ bytes: patched, start: 0, len: patched.length }); + } + } + const candidateFdArray = writeIndex(patchedFdObjects); + + /** patch Top DICT */ + const candidateTopDict = replaceDictOffsets(topDictBytes, new Map([ + [OP_charset, charsetOff], + [OP_charStrings, charStringsOff], + [OP_FDArray, fdArrayOff], + [OP_FDSelect, fdSelectOff], + ])); + + /** 收敛判定:topDictLen、fdArrayTotalLen、privSegsTotalLen 三者都不变 */ + const tdConverged = candidateTopDict.length === topDictLen; + const fdConverged = candidateFdArray.length === fdArrayTotalLen; + const privConverged = curPrivTotal === privSegsTotalLen; + patchedTopDict = candidateTopDict; + newFdArrayBytes = candidateFdArray; + patchedPrivSegs = curPatchedPriv; + topDictLen = candidateTopDict.length; + fdArrayTotalLen = candidateFdArray.length; + privSegsTotalLen = curPrivTotal; + if (tdConverged && fdConverged && privConverged) break; + } + + /** 组装 Top DICT INDEX:count=1 */ + const newTopDictIndex = writeIndex([{ bytes: patchedTopDict, start: 0, len: patchedTopDict.length }]); + + /** 拼接所有 Private 段(每段 = patched DICT + Local Subr INDEX) */ + const privParts: Uint8Array[] = []; + for (const pp of patchedPrivSegs) { + privParts.push(pp.dict); + if (pp.subr) privParts.push(pp.subr); + } + const newPrivateSeg = combineBytes(privParts); + + /** 最终拼接:Header+Name + TopDICT INDEX + String INDEX + GlobalSubr INDEX + charset + charStrings + * + FDArray INDEX + FDSelect + Private 段 */ + return combineBytes([headerNameBytes, newTopDictIndex, stringSeg, globalSubrSeg, charsetSeg, charStringsSeg, newFdArrayBytes, fdSelectSeg, newPrivateSeg]); +} + +/** 计算 Top DICT INDEX 的 offSize(容纳 topDictDataLen+1 的最小字节数,1~4) */ +function patchedTopDictOffSize(maxOffset: number): number { + if (maxOffset > 0xffff) return 4; + if (maxOffset > 0xff) return 2; + return 1; +} + +/** + * patch FD DICT 的 Private 操作数 [length, offset]。 + * 扫描 DICT 定位操作码 18(Private),将其前的两个操作数替换为新编码 [privLen, newPrivOff]。 + * 其余操作码字节原样保留。 + * @param dictBytes 原 FD DICT 字节 + * @param privLen Private DICT 字节长度(不含 Local Subr INDEX) + * @param newPrivOff Private 在新 CFF 中的绝对偏移 + */ +function patchFdDictPrivate(dictBytes: Uint8Array, privLen: number, newPrivOff: number): Uint8Array { + /** 按操作码分段,找到 Private(18)替换其两个操作数 */ + const chunks: Uint8Array[] = []; + let p = 0; + let operandStart = 0; + const len = dictBytes.length; + while (p < len) { + const b0 = dictBytes[p++]; + if (b0 <= 21) { + let op = b0; + if (b0 === 12) op = (12 << 8) | dictBytes[p++]; + if (op === OP_Private) { + /** 替换:编码 [privLen, newPrivOff] + 操作码 18 */ + const enc1 = encodeDictInt(privLen); + const enc2 = encodeDictInt(newPrivOff); + const combined = new Uint8Array(enc1.length + enc2.length + 1); + combined.set(enc1, 0); + combined.set(enc2, enc1.length); + combined[enc1.length + enc2.length] = 18; + chunks.push(combined); + } else { + /** 保留原操作数 + 操作码 */ + chunks.push(dictBytes.subarray(operandStart, p)); + } + operandStart = p; + } else if (b0 === 28) { + p += 2; + } else if (b0 === 29) { + p += 4; + } else if (b0 >= 247 && b0 <= 254) { + p += 1; + } + } + return combineBytes(chunks); +} + +/** + * patch Private DICT 的 Local Subr 操作数(op 19),使其指向新 DICT 长度。 + * Local Subr INDEX 紧跟 Private DICT 字节之后,故 op 19 新值 = patched DICT 的最终长度。 + * 由于 op 19 编码长度会随值变化(影响 DICT 总长),用小迭代收敛:先用旧值估长,重 patch 至稳定。 + * 无 Local Subr(hasSubr=false)的 Private 原样返回。 + * @param b 原 CFF 字节 + * @param privOrigOff Private DICT 在原 CFF 的绝对偏移 + * @param privLen Private DICT 字节长度 + * @param hasSubr 是否含 Local Subr INDEX(op 19) + */ +function patchPrivateDict(b: Uint8Array, privOrigOff: number, privLen: number, hasSubr: boolean): Uint8Array { + /** 无 Local Subr:DICT 原样透传(offset 不需改,Private 内无跨段引用) */ + if (!hasSubr) return b.subarray(privOrigOff, privOrigOff + privLen); + /** 把 op 19 的操作数替换为 patchedDICT.length。迭代至 op19 编码长度稳定。 */ + let cur = b.subarray(privOrigOff, privOrigOff + privLen); + for (let iter = 0; iter < 4; iter++) { + const patched = replaceDictOffsets(cur, new Map([[OP_LocalSubr, cur.length]])); + if (patched.length === cur.length) return patched; + cur = patched; + } + return cur; +} + +/** 拼接多个字节切片 */ +function combineBytes(parts: Uint8Array[]): Uint8Array { + let total = 0; + for (const p of parts) total += p.length; + const out = new Uint8Array(total); + let off = 0; + for (const p of parts) { + out.set(p, off); + off += p.length; + } + return out; +} + +/** + * 读 charset 取每个 gid 的 CID(gid 0 的 CID 固定为 0,不入表)。 + * 格式 0:format(1) + (numGlyphs-1)×CID(u16) + * 格式 1:format(1) + ranges[first(2), nLeft(1)] + * 格式 2:format(1) + ranges[first(2), nLeft(2)] + * @param b CFF 字节 + * @param charsetOff charset 起始偏移 + * @param numGlyphs 字形总数 + */ +function readCharsetCIDs(b: Uint8Array, charsetOff: number, numGlyphs: number): number[] { + const cids = new Array(numGlyphs).fill(0); + cids[0] = 0; /** .notdef */ + const fmt = b[charsetOff]; + let p = charsetOff + 1; + let gid = 1; + if (fmt === 0) { + while (gid < numGlyphs) { + cids[gid++] = (b[p] << 8) | b[p + 1]; + p += 2; + } + } else if (fmt === 1) { + while (gid < numGlyphs) { + const first = (b[p] << 8) | b[p + 1]; + const nLeft = b[p + 2]; + p += 3; + for (let i = 0; i <= nLeft && gid < numGlyphs; i++) cids[gid++] = first + i; + } + } else if (fmt === 2) { + while (gid < numGlyphs) { + const first = (b[p] << 8) | b[p + 1]; + const nLeft = (b[p + 2] << 8) | b[p + 3]; + p += 4; + for (let i = 0; i <= nLeft && gid < numGlyphs; i++) cids[gid++] = first + i; + } + } + return cids; +} + +/** 编码 charset 格式 0:format(1) + CIDs.length×CID(u16) */ +function encodeCharsetFormat0(cids: number[]): Uint8Array { + const out = new Uint8Array(1 + cids.length * 2); + out[0] = 0; + for (let i = 0; i < cids.length; i++) { + out[1 + i * 2] = (cids[i] >> 8) & 0xff; + out[1 + i * 2 + 1] = cids[i] & 0xff; + } + return out; +} + +/** + * patch Top DICT 的 charset / charStrings / FDArray / FDSelect offset。 + * 由于 patch 后 DICT 长度变化会改变后续段偏移,采用迭代:先用原 DICT 长度算首版偏移, + * 编码 patch 后若长度变化则重算。实测整数 offset 长度稳定(多数 3 字节),1~2 轮收敛。 + * + * @param topDictBytes 原 Top DICT 字节 + * @param newTopDictDataOff 新 Top DICT 数据起始偏移(相对新 CFF) + * @param origTopDictLen 原 Top DICT 数据长度 + * @param charsetSeg / charStringsSeg / fdArraySeg / fdSelectSeg 各段字节(顺序紧跟 Top DICT INDEX) + */ +/** + * 替换 DICT 中多个操作码的操作数(offset 值)。 + * 扫描原 DICT,对每个待替换操作码:跳过旧操作数,写入新编码操作数 + 操作码;其余字节原样保留。 + * @param dictBytes 原 DICT 字节 + * @param replacements 操作码键 → 新 offset 值 + */ +function replaceDictOffsets(dictBytes: Uint8Array, replacements: Map): Uint8Array { + /** 先分段:按操作码切,重组 */ + const chunks: number[][] = []; + let p = 0; + let operandStart = 0; + const len = dictBytes.length; + while (p < len) { + const b0 = dictBytes[p++]; + if (b0 <= 21) { + let op = b0; + if (b0 === 12) op = (12 << 8) | dictBytes[p++]; + if (replacements.has(op)) { + /** 替换:新操作数 + 操作码 */ + const newVal = replacements.get(op)!; + const encoded = encodeDictInt(newVal); + if (op >= 256) { + chunks.push([...encoded, 12, op & 0xff]); + } else { + chunks.push([...encoded, op]); + } + } else { + /** 保留原操作数 + 操作码 */ + chunks.push(Array.from(dictBytes.subarray(operandStart, p))); + } + operandStart = p; + } else if (b0 === 28) { + p += 2; + } else if (b0 === 29) { + p += 4; + } else if (b0 >= 247 && b0 <= 254) { + p += 1; + } + /** 32~246 单字节,无后续 */ + } + /** 拼接 */ + let total = 0; + for (const c of chunks) total += c.length; + const out = new Uint8Array(total); + let off = 0; + for (const c of chunks) { + for (const v of c) out[off++] = v; + } + return out; +} diff --git a/backend/font_util/font.ts b/backend/font_util/font.ts index fca67db..3712d73 100644 --- a/backend/font_util/font.ts +++ b/backend/font_util/font.ts @@ -4,6 +4,8 @@ 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(); @@ -129,6 +131,23 @@ export const fontSubset = ( ): Uint8Array => { const codePoints = textToCodePoints(subString); + /** OTF(CFF)输入走独立 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 失效、连字不渲染。 diff --git a/backend/font_util/otf-subset.ts b/backend/font_util/otf-subset.ts new file mode 100644 index 0000000..f78edf0 --- /dev/null +++ b/backend/font_util/otf-subset.ts @@ -0,0 +1,542 @@ +/** + * OTF (OpenType with CFF) 字体子集化器。 + * + * 背景:fonteditor-core 对 OTF 输入走 otf2ttfobject(CFF 三次 → 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. 用原始 cmap(format4 绝对地址版 + format12)查 codepoint → 原始 gid(修正 fonteditor 的 bug) + * 3. subsetCFF 重建 CFF 表(透传 charstring) + * 4. 重建 cmap(子集 codepoint → 新 gid,format4) + * 5. 重建 hmtx/vmtx(按新 gid 顺序取原始 metrics) + * 6. 重建 maxp(numGlyphs = 子集数)、post(format3 无 glyph 名)、name(保留渲染必需 nameId) + * 7. head/hhea/vhea/OS/2/VORG 透传(无 gid 依赖,或仅 patch numGlyphs 相关) + * 8. GSUB/GPOS 子集化(若存在,复用 gsub-subset/gpos-subset) + * 9. 封装 OTF sfnt(OTTO 签名 + 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"; + +/** 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 计算)。 + * 返回原始 gid(0 表示缺失/映射到 .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 子表:format12(p3e10)优先,format4(p3e1)次之。返回各自相对 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 } { + const { fmt4Off, fmt12Off } = selectCmapSubtables(dv, cmapOff); + /** origGid 集合(保持插入顺序,新 gid = 数组 index) */ + const subsetGids: number[] = [0]; + const seenGid = new Set([0]); + const cpToNewGid = new Map(); + 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): 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 0(delta=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 顺序取原始 metrics(advanceWidth + lsb/topSideBearing)。 + * numberOfHMetrics 设为子集数(所有字形给完整记录)。 */ +function buildSubsetMetrics( + srcDv: DataView, + metricsOff: number, + numberOfHMetrics: number, + subsetGids: number[], +): Uint8Array { + /** 原始记录:前 numberOfHMetrics 个完整(advance+lsb),其余仅 lsb(advance 复用最后一个) */ + 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; +} + +/** 重建 maxp(OTF 版本 0.5:version(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; +} + +/** 重建 post(format3:无 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/0),OTS 严格校验 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 表:保留渲染必需 nameId(1/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(); + /** 先扫 p3e1(Windows 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 字节对齐后的 checksum(OpenType 规范:表长度按 4 字节边界补齐算 sum) */ +function calcTableChecksum(bytes: Uint8Array): number { + const n = bytes.length; + const padded = (n + 3) & ~3; + let sum = 0; + for (let i = 0; i < padded; i += 4) { + const b0 = bytes[i] || 0; + const b1 = bytes[i + 1] || 0; + const b2 = bytes[i + 2] || 0; + const b3 = bytes[i + 3] || 0; + sum = (sum + ((b0 << 24) | (b1 << 16) | (b2 << 8) | b3)) >>> 0; + } + return sum >>> 0; +} + +/** + * 封装 OTF sfnt(OTTO 签名):表目录 + 各表数据(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; + + /** 目录区 = 12(sfnt 头)+ 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); + + /** 表记录 + 数据 */ + let dataOff = dirSize; + const headIdx = tables.findIndex((t) => t.tag === "head"); + 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); + 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 */ + if (headIdx >= 0) { + const headRecOff = 12 + headIdx * 16; + const headDataOff = dv.getUint32(headRecOff + 8, false); + /** 先算全文件 checksum(head.checkSumAdjustment 此时为 0) */ + let wholeSum = 0; + const wholePadded = (out.length + 3) & ~3; + for (let i = 0; i < wholePadded; i += 4) { + const b0 = out[i] || 0; + const b1 = out[i + 1] || 0; + const b2 = out[i + 2] || 0; + const b3 = out[i + 3] || 0; + wholeSum = (wholeSum + ((b0 << 24) | (b1 << 16) | (b2 << 8) | b3)) >>> 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); + + /** 子集 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); + + /** numberOfHMetrics(hhea +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 透传 + 重建(垂直排版 metrics,CJK 需要) */ + 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) { + /** origToNew:subsetGids[新gid] = 原gid,反转得 原gid→新gid */ + const origToNew = new Map(); + 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); +} diff --git a/vendor/fonteditor-core/woff2/woff2-encode.d.ts b/vendor/fonteditor-core/woff2/woff2-encode.d.ts new file mode 100644 index 0000000..003e06a --- /dev/null +++ b/vendor/fonteditor-core/woff2/woff2-encode.d.ts @@ -0,0 +1,3 @@ +/** WOFF2 编码器:裸 sfnt 字节(ttf 或 otf)→ WOFF2 字节。 + * 对 glyf/loca 做 transform,其余表(含 CFF)按普通表 brotli 压缩。 */ +export function encodeTTFToWOFF2(sfntBuffer: Uint8Array): Uint8Array; diff --git a/基准测试.test.ts b/基准测试.test.ts index 84d054d..5104a3c 100644 --- a/基准测试.test.ts +++ b/基准测试.test.ts @@ -79,6 +79,7 @@ function createFontServer(): Promise<{ server: Server; port: number }> { if (!buf) buf = fontStore.get(fontKey + ".otf"); if (!buf) buf = fontStore.get(fontKey + ".woff2"); if (buf) { + console.log(`[fontsrv] GET ${fontKey} -> ${buf.length} bytes`); const ext = fontKey.endsWith(".woff2") ? "font/woff2" : fontKey.endsWith(".otf") ? "font/opentype" : "font/ttf"; res.writeHead(200, { "Content-Type": ext, "Content-Length": buf.length, "Cache-Control": "public, max-age=31536000, immutable" }); res.end(buf); @@ -365,7 +366,9 @@ for (const tc of testCases) { const t1 = performance.now(); times.push(t1 - t0); lastSize = subsetBuf.byteLength; - if (i === 0) lastBuffer = subsetBuf; + if (i === 0) { + lastBuffer = subsetBuf; + } } const avg = times.reduce((a, b) => a + b, 0) / times.length; @@ -383,9 +386,11 @@ for (const tc of testCases) { /** * 验证子集字体的 maxp 表中 maxPoints/maxContours 不为 0 * 这两个值为 0 会导致浏览器跳过渲染(字体加载成功但文字显示空白/fallback) - * 之前 OTF→TTF 转换因 optimizettf 中 _flatContours 路径遗漏统计而触发此问题 + * 之前 OTF→TTF 转换因 optimizettf 中 _flatContours 路径遗漏统计而触发此问题。 + * OTF(CFF)输入的 maxp 是 version 0.5(仅 numGlyphs,无 maxPoints/maxContours 字段), + * 该检查不适用,跳过。 */ - if (tc.outType !== "woff2") { + if (tc.outType !== "woff2" && tc.sourceType !== "otf") { const ttfView = new DataView(lastBuffer.buffer, lastBuffer.byteOffset, lastBuffer.byteLength); const numTbl = ttfView.getUint16(4, false); for (let ti = 0; ti < numTbl; ti++) { @@ -403,8 +408,14 @@ for (const tc of testCases) { } } - /** 注册子集字体 */ - const subsetKey = `subset_${safeLabel}.${tc.outType}`; + /** 注册子集字体。 + * otf 输入经 subsetOTF 产出 CFF 轮廓(无论 outType=ttf/woff2),裸输出必须用 .otf 扩展名, + * 否则 HTTP Content-Type 会被判为 font/ttf 而浏览器以 truetype 嗅探 CFF 字节失败。 + * woff2 输出仍用 .woff2(其 magic 自描述,扩展名无关)。 */ + const subsetExt = tc.sourceType === "otf" + ? (tc.outType === "woff2" ? "woff2" : "otf") + : tc.outType; + const subsetKey = `subset_${safeLabel}.${subsetExt}`; fontStore.set(subsetKey, Buffer.from(lastBuffer)); /** 完整字体 key */ @@ -417,12 +428,14 @@ for (const tc of testCases) { const fullResult = await renderTextViaBrowser(page, baseUrl, fullKey, tc.text, renderSize, tc.fullFormat); /** - * 渲染子集字体:format 必须与 outType 匹配。 - * woff2 字节若用 format("truetype") 声明,Chrome 嗅探解码时序下可能未及时应用 GPOS - * (CJK 全角标点的标点压缩依赖 GPOS lookup),导致连续标点宽度变大、与原始字体人眼不一致。 - * 改为按实际 outType 声明 format,确保 GPOS 等 layout 表被正确加载。 + * 渲染子集字体:format 必须与字体实际轮廓类型匹配。 + * otf 输入经 subsetOTF 保留 CFF 轮廓(无论 outType=otf 还是 woff2 包裹),必须声明 format("opentype") + * (或 woff2)让浏览器按 CFF 光栅化,否则用 truetype 声明会嗅探失败。 + * ttf 输入产出 glyf 轮廓,woff2 输出声明 woff2,裸 ttf 声明 truetype。 */ - const subsetFormat = tc.outType === "woff2" ? "woff2" : "truetype"; + const subsetFormat = tc.sourceType === "otf" + ? (tc.outType === "woff2" ? "woff2" : "opentype") + : (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);