mirror of
https://github.com/2234839/web-font.git
synced 2026-09-04 14:53:32 +08:00
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>
This commit is contained in:
parent
f4af6f7b68
commit
bebb8503f4
740
backend/font_util/cff-subset.ts
Normal file
740
backend/font_util/cff-subset.ts
Normal file
@ -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<number, number[]>;
|
||||
|
||||
/**
|
||||
* 解析 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<number>(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<number, number>();
|
||||
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<number, PrivInfo>();
|
||||
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<number, number>();
|
||||
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<number, number>();
|
||||
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<number, number>([
|
||||
[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<number, number>([[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<number>(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<number, number>): 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;
|
||||
}
|
||||
@ -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 失效、连字不渲染。
|
||||
|
||||
542
backend/font_util/otf-subset.ts
Normal file
542
backend/font_util/otf-subset.ts
Normal file
@ -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<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 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<number>();
|
||||
/** 先扫 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<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);
|
||||
}
|
||||
3
vendor/fonteditor-core/woff2/woff2-encode.d.ts
vendored
Normal file
3
vendor/fonteditor-core/woff2/woff2-encode.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/** WOFF2 编码器:裸 sfnt 字节(ttf 或 otf)→ WOFF2 字节。
|
||||
* 对 glyf/loca 做 transform,其余表(含 CFF)按普通表 brotli 压缩。 */
|
||||
export function encodeTTFToWOFF2(sfntBuffer: Uint8Array): Uint8Array;
|
||||
33
基准测试.test.ts
33
基准测试.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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user