feat(gsub): GSUB/GPOS 布局表子集化 + FiraCode 连字修复

实现 GSUB/GPOS 的 gid 重映射子集化,解决 CJK 标点压缩丢失与连字不渲染问题。

核心改动:
- ot-bytes.ts: 共享 OTWriter/OTReader + ScriptList/FeatureList 紧凑重排
- gpos-subset.ts: SinglePos/PairPos coverage/ClassDef gid 重映射
- gsub-subset.ts: type1/2/3/4/6 coverage/ClassDef/替换目标 gid 重映射
- gsub-reachable.ts: GSUB 替换链不动点迭代,收集无 unicode 的连字 target 字形
- GSUB.js + support/ttfreader/ttfwriter: GSUB 字节透传读写接入
- parse.js: 修复 F2DOT14 复合字形解码(负数 +0.5|0 截断 bug)

关键 bug 修复:
- ChainContextSubst format2 字段错位:漏读 coverageOffset 致 chainSubClassSetCount
  读成 lookaheadClassDefOffset,连字核心规则降级为空。修复后 FiraCode <= 走对
  spacer+liga 路径。
- ScriptList/FeatureList 物理交错 + subtable 散落致 GSUB 膨胀损坏,改遍历重排。

SSIM: FiraCode-代码 0.9636 → 0.9923(与 pyftsubset 黄金像素级一致),
CJK 标点压缩全面恢复(初夏明朝纯标点 0.7950→0.9920 等)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
崮生(子虚) 2026-07-23 13:01:03 +08:00
parent 7d0243e6f5
commit a84a4aa6f2
17 changed files with 2644 additions and 39 deletions

View File

@ -1,5 +1,8 @@
import { Font } from "../../vendor/fonteditor-core/lib/ttf/font.js";
import type { FontEditor } from "../../vendor/fonteditor-core/lib/ttf/font.js";
import { subsetGPOS } from "./gpos-subset.js";
import { subsetGSUB } from "./gsub-subset.js";
import { collectReachableGsubTargets } from "./gsub-reachable.js";
/** 优化291: TextEncoder 模块级单例 */
const textEncoder = new TextEncoder();
@ -22,6 +25,11 @@ export const textToCodePoints = (text: string) => {
/**
* subset
*
* kerning: true GPOS/kern
* CJK GPOS
* GPOS
* fonteditor-core GPOS
*/
export const createSubsetFont = (
fontBuffer: ArrayBuffer,
@ -31,6 +39,7 @@ export const createSubsetFont = (
Font.create(fontBuffer, {
type: sourceType,
subset: codePoints,
kerning: true,
});
/**
@ -43,13 +52,60 @@ export const optimizeFont = (font: ReturnType<typeof Font.create>) => {
return optimized;
};
/**
* GPOS/GSUB glyphId
*
* CJK GPOS SinglePos/PairPos lookup/
* FiraCode => GSUB ligature/context lookup glyphId
* fonteditor-core GPOS/GSUB coverage/ClassDef gid
*
* gidgid subsetGids subsetGids[i] i gid
* optimize glyf subsetGids fonteditor-core optimize glyf
* gid = i unicode unicode glyph GSUB target
* subsetGPOS null GPOS
*
* @param subsetOptimized optimize subset gid glyf GPOS/GSUB
* @param subsetGids gid optimize
*/
const rewriteLayoutTablesForSubset = (
subsetOptimized: ReturnType<ReturnType<typeof Font.create>["optimize"]>,
subsetGids: number[],
): void => {
const ttf = (subsetOptimized as any).get();
/** subsetGids[i] = 原始 gid新 gid = ioptimize 不重排 glyf顺序一一对应 */
const origToNew = new Map<number, number>();
for (let i = 0; i < subsetGids.length; i++) origToNew.set(subsetGids[i], i);
/** GPOS 子集化 */
const origGPOS = ttf.GPOS;
if (origGPOS) {
const gposBytes = origGPOS instanceof Uint8Array ? origGPOS : new Uint8Array(origGPOS);
if (gposBytes.byteLength > 0) {
const rewritten = subsetGPOS(gposBytes, origToNew);
if (rewritten) ttf.GPOS = rewritten;
/** rewritten === null 表示含不支持的版本,保留原始 GPOS 字节(安全降级) */
}
}
/** GSUB 子集化(总是返回有效字节,重映射 coverage/ClassDef/替换目标 gid */
const origGSUB = ttf.GSUB;
if (origGSUB) {
const gsubBytes = origGSUB instanceof Uint8Array ? origGSUB : new Uint8Array(origGSUB);
if (gsubBytes.byteLength > 0) {
ttf.GSUB = subsetGSUB(gsubBytes, origToNew);
}
}
};
/** 序列化为指定格式的二进制数据 */
/** 优化291: 移除 async消除不必要的微任务调度 */
export const writeFont = (
font: ReturnType<ReturnType<typeof Font.create>["optimize"]>,
outType: FontEditor.FontType,
): Uint8Array => {
const result = font.write({ type: outType });
/** kerning: true —— 写出时保留 GPOS/kern 表,与 createSubsetFont 的读取保持一致 */
const result = font.write({ type: outType, kerning: true });
if (typeof result === "string") {
return textEncoder.encode(result);
}
@ -71,7 +127,47 @@ export const fontSubset = (
option: { sourceType: FontEditor.FontType; outType: FontEditor.FontType },
): Uint8Array => {
const codePoints = textToCodePoints(subString);
const font = createSubsetFont(fontBuffer, codePoints, option.sourceType);
/** GSUB target glyph GSUB subset codepoint
* GSUB target glyph unicode FiraCode greater_equal.liga
* extraSubsetGids 使 target
* probe subset cmap codepoint
* GSUB GSUB reachable extraSubsetGids undefined */
let extraSubsetGids: number[] | undefined;
const probeFont = Font.create(fontBuffer, {
type: option.sourceType,
subset: codePoints,
kerning: true,
});
const probeTtf = (probeFont as any).get();
const origGSUB = probeTtf.GSUB;
const origCmap = probeTtf.cmap;
if (origGSUB && origCmap) {
const gsubBytes = origGSUB instanceof Uint8Array ? origGSUB : new Uint8Array(origGSUB);
/** seed = 子集 codepoint 经 cmap 映射的原始 gid */
const seedGids = new Set<number>();
seedGids.add(0); /** .notdef */
for (const cp of codePoints) {
const gid = origCmap[cp];
if (gid !== undefined) seedGids.add(gid);
}
const reachable = collectReachableGsubTargets(gsubBytes, seedGids);
if (reachable.size > 0) extraSubsetGids = [...reachable];
}
const font = Font.create(fontBuffer, {
type: option.sourceType,
subset: codePoints,
kerning: true,
extraSubsetGids,
});
/** subsetGids 在 optimize 前后均保留,记录子集字形的原始 gid 顺序(新 gid = 数组索引)。 */
const preOptTtf = (font as any).get();
const subsetGids: number[] = preOptTtf.subsetGids ?? [];
const optimized = optimizeFont(font);
/** GPOS/GSUB 表子集化:按子集字形重映射布局表 glyphId恢复标点压缩与连字/上下文替换 */
rewriteLayoutTablesForSubset(optimized, subsetGids);
return writeFont(optimized, option.outType);
};

View File

@ -0,0 +1,751 @@
/**
* GPOS
*
* CJK GPOS
* lookupSinglePos/PairPos glyphId
* fonteditor-core GPOS coverage
* glyphId GPOS coverage
*
* OpenType 1.9.1 GPOS LookupType 1 (SinglePos) 2 (PairPos)
* coverage/ClassDef glyphId gidgid
* lookup 3-8 9 1/2 null
* GPOS
*
* @reference https://learn.microsoft.com/en-us/typography/opentype/spec/gpos
*/
import { OTWriter as Writer, OTReader as Reader, serializeScriptList, serializeFeatureList } from "./ot-bytes.js";
/** ValueFormat 位掩码 */
const VF_X_PLACEMENT = 0x0001;
const VF_Y_PLACEMENT = 0x0002;
const VF_X_ADVANCE = 0x0004;
const VF_Y_ADVANCE = 0x0008;
const VF_X_PLA_DEVICE = 0x0010;
const VF_Y_PLA_DEVICE = 0x0020;
const VF_X_ADV_DEVICE = 0x0040;
const VF_Y_ADV_DEVICE = 0x0080;
/** 一个 ValueRecord 占用的 uint16 数量(低 8 位的 set bit 数) */
function valueRecordSize(valueFormat: number): number {
let n = 0;
for (let bits = valueFormat & 0xff; bits; bits >>>= 1) n += bits & 1;
return n;
}
const LT_SINGLE_POS = 1;
const LT_PAIR_POS = 2;
const LT_MARKBASE_POS = 4;
const LT_MARKLIG_POS = 5;
const LT_MARKMARK_POS = 6;
const LT_EXTENSION = 9;
/**
* Position subtable coverage / MarkArray
* unsupported lookup lookup GPOS return null
* format 1
* - SinglePos: format + coverageOff + valueFormat=0 + coverage
* - PairPos: format + coverageOff + valueFormat1=0 + valueFormat2=0 + pairSetCount=0 + coverage
* - CursivePos: format + coverageOff + entryExitCount=0 + coverage
* - MarkBase/MarkLig/MarkMark: format + markCoverageOff + (base/lig/mark2)CoverageOff + markClassCount=0
* + markArrayOff + baseArrayOff + markCoverage + baseCoverage
* + markArray(markCount=0) + baseArray(baseCount=0)
* - ContextPos/ChainContextPos: format + coverageOff + ruleSetCount=0 + coverage
*/
function writeEmptyPosSubtable(w: Writer, effectiveType: number): void {
const subStart = w.length;
w.writeUint16(1);
if (
effectiveType === LT_MARKBASE_POS ||
effectiveType === LT_MARKLIG_POS ||
effectiveType === LT_MARKMARK_POS
) {
/** MarkBase/MarkLig/MarkMark format1
* format(2) + markCoverageOff(2) + baseCoverageOff(2) + markClassCount(2)=0 + markArrayOff(2) + baseArrayOff(2) = 12 */
const markCovHolder: number[] = [0];
const baseCovHolder: number[] = [0];
const markArrayHolder: number[] = [0];
const baseArrayHolder: number[] = [0];
w.reserveOffset16(subStart, () => markCovHolder[0]);
w.reserveOffset16(subStart, () => baseCovHolder[0]);
w.writeUint16(0); /** markClassCount = 0 */
w.reserveOffset16(subStart, () => markArrayHolder[0]);
w.reserveOffset16(subStart, () => baseArrayHolder[0]);
markCovHolder[0] = w.length;
writeEmptyCoverage(w);
baseCovHolder[0] = w.length;
writeEmptyCoverage(w);
markArrayHolder[0] = w.length;
w.writeUint16(0); /** markArray: markCount = 0 */
baseArrayHolder[0] = w.length;
w.writeUint16(0); /** baseArray: baseCount = 0 */
return;
}
/** coverageOffset 槽(相对 subtable 起始coverage 紧随本类型头部之后 */
const coverageAfter = (() => {
if (effectiveType === LT_SINGLE_POS) return subStart + 6;
if (effectiveType === LT_PAIR_POS) return subStart + 10;
return subStart + 6; /** CursivePos / ContextPos / ChainContextPos */
})();
w.reserveOffset16(subStart, () => coverageAfter);
if (effectiveType === LT_SINGLE_POS) {
w.writeUint16(0); /** valueFormat = 0 */
} else if (effectiveType === LT_PAIR_POS) {
w.writeUint16(0); /** valueFormat1 */
w.writeUint16(0); /** valueFormat2 */
w.writeUint16(0); /** pairSetCount */
} else {
/** CursivePos: entryExitCount=0Context/ChainContext: ruleSetCount=0 */
w.writeUint16(0);
}
writeEmptyCoverage(w);
}
/** 写入空 coverage 表format1 + count0 */
function writeEmptyCoverage(w: Writer): void {
w.writeUint16(1);
w.writeUint16(0);
}
/**
* GPOS
*
* @param gposBytes GPOS
* @param origToNew gid gid map gid
* @returns GPOS lookup null
*/
export function subsetGPOS(
gposBytes: Uint8Array,
origToNew: Map<number, number>,
): Uint8Array | null {
const dv = new DataView(gposBytes.buffer, gposBytes.byteOffset, gposBytes.byteLength);
const r = new Reader(dv);
/** ---- GPOS Header ---- */
const major = r.u16(0);
const minor = r.u16(2);
/** 仅支持 v1.0 / v1.1v1.1 的 FeatureVariations 本 MVP 不处理,存在时降级 */
if (major !== 1 || minor > 1) return null;
const scriptListOff = r.u16(4);
const featureListOff = r.u16(6);
const lookupListOff = r.u16(8);
if (minor === 1 && r.u32(10) !== 0) return null;
/** ---- 解析 LookupList记录每个 lookup 的类型与 subtable 绝对偏移 ---- */
const lookupCount = r.u16(lookupListOff);
const lookupRelOffs: number[] = [];
for (let i = 0; i < lookupCount; i++) {
lookupRelOffs.push(r.u16(lookupListOff + 2 + i * 2));
}
const lookups: Array<{
supported: boolean;
effectiveType: number;
subtableAbsOffs: number[];
origLookupOff: number;
}> = [];
for (let i = 0; i < lookupCount; i++) {
const lOff = lookupListOff + lookupRelOffs[i];
const lookupType = r.u16(lOff);
const subTableCount = r.u16(lOff + 4);
const subtableAbsOffs: number[] = [];
let effectiveType = lookupType;
for (let j = 0; j < subTableCount; j++) {
const subOff = lOff + r.u16(lOff + 6 + j * 2);
if (lookupType === LT_EXTENSION) {
if (r.u16(subOff) !== 1) {
/** 无法解包的 extension标记为不支持 */
effectiveType = -1;
continue;
}
effectiveType = r.u16(subOff + 2);
subtableAbsOffs.push(subOff + r.u32(subOff + 4));
} else {
subtableAbsOffs.push(subOff);
}
}
const supported = effectiveType === LT_SINGLE_POS || effectiveType === LT_PAIR_POS;
lookups.push({ supported, effectiveType, subtableAbsOffs, origLookupOff: lOff });
}
/** ---- ----
* ScriptList / FeatureList glyphId lookup index
* ScriptTable/LangSys/FeatureTable List
*
* serializeScriptList / serializeFeatureList
* LookupList lookup
* - SinglePos/PairPosgid
* - MarkBase/Context gid
* gid coverage gid
*/
const scriptListBytes = serializeScriptList(r, scriptListOff);
const featureListBytes = serializeFeatureList(r, featureListOff);
/** ScriptList/FeatureList 解析失败(异常表)则整体降级返回 null调用方保留原始 GPOS 字节) */
if (!scriptListBytes || !featureListBytes) return null;
const w = new Writer();
/** Header偏移量最后回填 */
w.writeUint16(1);
w.writeUint16(0);
const scriptListAbsHolder: number[] = [];
w.reserveOffset16(0, () => scriptListAbsHolder[0]);
const featureListAbsHolder: number[] = [];
w.reserveOffset16(0, () => featureListAbsHolder[0]);
const lookupListAbsHolder: number[] = [];
w.reserveOffset16(0, () => lookupListAbsHolder[0]);
/** ScriptList 重序列化字节 */
scriptListAbsHolder.push(w.length);
for (let i = 0; i < scriptListBytes.length; i++) w.writeUint8(scriptListBytes[i]);
/** FeatureList 重序列化字节 */
featureListAbsHolder.push(w.length);
for (let i = 0; i < featureListBytes.length; i++) w.writeUint8(featureListBytes[i]);
/** LookupList 重写 */
const lookupListAbs = w.length;
lookupListAbsHolder.push(lookupListAbs);
w.writeUint16(lookupCount);
/** 预留各 lookup 的 Offset16 槽(相对 LookupList 起始) */
const lookupAbsPositions: number[] = new Array(lookupCount);
for (let i = 0; i < lookupCount; i++) {
const slotIdx = i;
w.reserveOffset16(lookupListAbs, () => lookupAbsPositions[slotIdx]);
}
/** 逐个 lookup 序列化 */
for (let i = 0; i < lookupCount; i++) {
lookupAbsPositions[i] = w.length;
const lk = lookups[i];
if (lk.supported) {
/** 支持的 lookupgid 重映射后重新序列化 */
const lookupFlag = r.u16(lk.origLookupOff + 2);
const useMarkFilteringSet = (lookupFlag & 0x0010) !== 0;
/** extension 包裹时输出仍用 effectiveType直接内嵌 subtable不再用 extension */
w.writeUint16(lk.effectiveType);
w.writeUint16(lookupFlag);
w.writeUint16(lk.subtableAbsOffs.length);
const lookupStart = w.length - 6;
const subtableAbsPositions: number[] = new Array(lk.subtableAbsOffs.length);
for (let j = 0; j < lk.subtableAbsOffs.length; j++) {
const slotIdx = j;
w.reserveOffset16(lookupStart, () => subtableAbsPositions[slotIdx]);
}
if (useMarkFilteringSet) {
w.writeUint16(r.u16(lk.origLookupOff + 6 + lk.subtableAbsOffs.length * 2));
}
for (let j = 0; j < lk.subtableAbsOffs.length; j++) {
subtableAbsPositions[j] = w.length;
r.clearError();
const ok = serializeSubtable(w, r, lk.subtableAbsOffs[j], lk.effectiveType, origToNew);
/** 越界读取(异常表)也降级为保留原始 GPOS 字节(调用方安全降级) */
if (!ok || r.errorFlag) return null;
}
} else {
/** lookupCursive/MarkBase/Context/ChainContext
* lookup type/flag/subCount subtable subtable subtable
* subtable coverage/ClassDef/MarkArray
* lookup lookup
* subtable coverage lookup
* subtable MarkBase/MarkLig/MarkMark return null */
const lookupFlag = r.u16(lk.origLookupOff + 2);
const useMarkFilteringSet = (lookupFlag & 0x0010) !== 0;
w.writeUint16(r.u16(lk.origLookupOff));
w.writeUint16(lookupFlag);
w.writeUint16(lk.subtableAbsOffs.length);
const lookupStart = w.length - 6;
const subtableAbsPositions: number[] = new Array(lk.subtableAbsOffs.length);
for (let j = 0; j < lk.subtableAbsOffs.length; j++) {
const slotIdx = j;
w.reserveOffset16(lookupStart, () => subtableAbsPositions[slotIdx]);
}
if (useMarkFilteringSet) {
w.writeUint16(r.u16(lk.origLookupOff + 6 + lk.subtableAbsOffs.length * 2));
}
for (let j = 0; j < lk.subtableAbsOffs.length; j++) {
subtableAbsPositions[j] = w.length;
writeEmptyPosSubtable(w, lk.effectiveType);
}
}
}
w.flush();
return w.toUint8Array();
}
function serializeSubtable(
w: Writer,
r: Reader,
subAbs: number,
type: number,
origToNew: Map<number, number>,
): boolean {
if (type === LT_SINGLE_POS) return serializeSinglePos(w, r, subAbs, origToNew);
if (type === LT_PAIR_POS) return serializePairPos(w, r, subAbs, origToNew);
return false;
}
/** 重映射原 gid不在子集中返回 -1 */
function remapGid(origGid: number, origToNew: Map<number, number>): number {
const ng = origToNew.get(origGid);
return ng === undefined ? -1 : ng;
}
/**
* ValueRecord valueFormat
* xPlacement, yPlacement, xAdvance, yAdvance, xPlaDevice, yPlaDevice, xAdvDevice, yAdvDevice
* Device/VariationIndex offset offset
* CJK ValueFormat xAdvance Device
*/
function writeValueRecord(w: Writer, r: Reader, absOff: number, valueFormat: number): boolean {
if (valueFormat & (VF_X_PLA_DEVICE | VF_Y_PLA_DEVICE | VF_X_ADV_DEVICE | VF_Y_ADV_DEVICE)) {
return false;
}
let off = absOff;
if (valueFormat & VF_X_PLACEMENT) {
w.writeInt16(r.i16(off));
off += 2;
}
if (valueFormat & VF_Y_PLACEMENT) {
w.writeInt16(r.i16(off));
off += 2;
}
if (valueFormat & VF_X_ADVANCE) {
w.writeInt16(r.i16(off));
off += 2;
}
if (valueFormat & VF_Y_ADVANCE) {
w.writeInt16(r.i16(off));
off += 2;
}
return true;
}
/** ValueRecord 的 uint16 数量 */
function vrCount(valueFormat: number): number {
return valueRecordSize(valueFormat);
}
/** 读取 coverage 表的原始 gid 列表(按 coverage index 顺序) */
function readCoverageGids(r: Reader, coverageAbs: number): number[] | null {
const fmt = r.u16(coverageAbs);
const out: number[] = [];
if (fmt === 1) {
const count = r.u16(coverageAbs + 2);
for (let i = 0; i < count; i++) out.push(r.u16(coverageAbs + 4 + i * 2));
return out;
}
if (fmt === 2) {
const rangeCount = r.u16(coverageAbs + 2);
for (let i = 0; i < rangeCount; i++) {
const recOff = coverageAbs + 4 + i * 6;
const start = r.u16(recOff);
const end = r.u16(recOff + 2);
for (let g = start; g <= end; g++) out.push(g);
}
return out;
}
return null;
}
/** 由已排序的新 gid 数组输出 coverage 字节,返回是否为空 */
function emitCoverageFromGids(w: Writer, sorted: number[]): boolean {
if (sorted.length === 0) {
/** 空 coverageformat1 + count0 */
w.writeUint16(1);
w.writeUint16(0);
return true;
}
/** 构造连续范围段 */
const ranges: Array<{ start: number; end: number }> = [];
let curStart = sorted[0];
let prev = sorted[0];
for (let i = 1; i < sorted.length; i++) {
if (sorted[i] === prev + 1) {
prev = sorted[i];
} else {
ranges.push({ start: curStart, end: prev });
curStart = sorted[i];
prev = sorted[i];
}
}
ranges.push({ start: curStart, end: prev });
const listCost = 4 + sorted.length * 2;
const rangeCost = 4 + ranges.length * 6;
if (rangeCost < listCost) {
w.writeUint16(2);
w.writeUint16(ranges.length);
let startCoverageIndex = 0;
for (const rg of ranges) {
w.writeUint16(rg.start);
w.writeUint16(rg.end);
w.writeUint16(startCoverageIndex);
startCoverageIndex += rg.end - rg.start + 1;
}
} else {
w.writeUint16(1);
w.writeUint16(sorted.length);
for (const g of sorted) w.writeUint16(g);
}
return true;
}
/**
* gid->class ClassDef format
* map gid class 0
*/
function emitClassDefFromClassMap(w: Writer, classMap: Map<number, number>): boolean {
const entries = [...classMap.entries()].sort((a, b) => a[0] - b[0]);
if (entries.length === 0) {
/** 空 ClassDefformat1, startGlyph=0, glyphCount=0 */
w.writeUint16(1);
w.writeUint16(0);
w.writeUint16(0);
return true;
}
/** 构造连续范围段(同 class 且 gid 连续) */
const ranges: Array<{ start: number; end: number; cls: number }> = [];
let curStart = entries[0][0];
let curCls = entries[0][1];
let prev = entries[0][0];
for (let i = 1; i < entries.length; i++) {
const [gid, cls] = entries[i];
if (gid === prev + 1 && cls === curCls) {
prev = gid;
} else {
ranges.push({ start: curStart, end: prev, cls: curCls });
curStart = gid;
curCls = cls;
prev = gid;
}
}
ranges.push({ start: curStart, end: prev, cls: curCls });
const listStart = entries[0][0];
const listCount = entries[entries.length - 1][0] - listStart + 1;
const listCost = 6 + listCount * 2;
const rangeCost = 4 + ranges.length * 6;
if (rangeCost < listCost) {
w.writeUint16(2);
w.writeUint16(ranges.length);
for (const rg of ranges) {
w.writeUint16(rg.start);
w.writeUint16(rg.end);
w.writeUint16(rg.cls);
}
} else {
w.writeUint16(1);
w.writeUint16(listStart);
w.writeUint16(listCount);
for (let gid = listStart; gid <= listStart + listCount - 1; gid++) {
w.writeUint16(classMap.get(gid) ?? 0);
}
}
return true;
}
/**
* ClassDef gid -> class gid
*/
function readClassDefMap(
r: Reader,
classDefAbs: number,
origToNew: Map<number, number>,
): Map<number, number> | null {
const out = new Map<number, number>();
const fmt = r.u16(classDefAbs);
if (fmt === 1) {
const startGlyph = r.u16(classDefAbs + 2);
const glyphCount = r.u16(classDefAbs + 4);
for (let i = 0; i < glyphCount; i++) {
const ng = remapGid(startGlyph + i, origToNew);
if (ng < 0) continue;
out.set(ng, r.u16(classDefAbs + 6 + i * 2));
}
} else if (fmt === 2) {
const rangeCount = r.u16(classDefAbs + 2);
for (let i = 0; i < rangeCount; i++) {
const recOff = classDefAbs + 4 + i * 6;
const start = r.u16(recOff);
const end = r.u16(recOff + 2);
const cls = r.u16(recOff + 4);
for (let g = start; g <= end; g++) {
const ng = remapGid(g, origToNew);
if (ng >= 0) out.set(ng, cls);
}
}
} else {
return null;
}
return out;
}
/**
* SinglePos subtable
* Format 1: 一个 ValueRecord coverage glyph
* Format 2: 每个 coverage glyph ValueRecord coverage index
*/
function serializeSinglePos(
w: Writer,
r: Reader,
subAbs: number,
origToNew: Map<number, number>,
): boolean {
const fmt = r.u16(subAbs);
const coverageOff = r.u16(subAbs + 2);
const valueFormat = r.u16(subAbs + 4);
/** 解析原始 coverage → 原gid 列表 */
const origGids = readCoverageGids(r, subAbs + coverageOff);
if (!origGids) return false;
/** 按 glyph 重建origGid -> newGid保留在子集中的 */
const sz = vrCount(valueFormat);
const kept: Array<{ newGid: number; valueAbs: number }> = [];
for (let idx = 0; idx < origGids.length; idx++) {
const ng = remapGid(origGids[idx], origToNew);
if (ng < 0) continue;
if (fmt === 1) {
/** format1 所有 glyph 共用 subAbs + 6 处的 ValueRecord */
kept.push({ newGid: ng, valueAbs: subAbs + 6 });
} else if (fmt === 2) {
/** format2 ValueRecord 数组在 subAbs + 8按 coverage index 顺序 */
kept.push({ newGid: ng, valueAbs: subAbs + 8 + idx * sz * 2 });
} else {
return false;
}
}
kept.sort((a, b) => a.newGid - b.newGid);
const subStart = w.length;
if (fmt === 1) {
w.writeUint16(1);
} else {
w.writeUint16(2);
}
const covSlot = w.length;
w.writeUint16(0);
w.writeUint16(valueFormat);
if (fmt === 2) {
w.writeUint16(kept.length);
}
/** ValueRecord */
for (const e of kept) {
if (!writeValueRecord(w, r, e.valueAbs, valueFormat)) return false;
}
/** coverage */
const covPos = w.length;
emitCoverageFromGids(w, kept.map((e) => e.newGid));
w.writeInt16At(covSlot, covPos - subStart);
return true;
}
/**
* PairPos subtable
*/
function serializePairPos(
w: Writer,
r: Reader,
subAbs: number,
origToNew: Map<number, number>,
): boolean {
const fmt = r.u16(subAbs);
const coverageOff = r.u16(subAbs + 2);
const valueFormat1 = r.u16(subAbs + 4);
const valueFormat2 = r.u16(subAbs + 6);
if (fmt === 1) {
return serializePairPosFormat1(w, r, subAbs, coverageOff, valueFormat1, valueFormat2, origToNew);
}
if (fmt === 2) {
return serializePairPosFormat2(w, r, subAbs, coverageOff, valueFormat1, valueFormat2, origToNew);
}
return false;
}
/**
* PairPosFormat1glyph PairSet
* firstGlyph secondGlyph
*/
function serializePairPosFormat1(
w: Writer,
r: Reader,
subAbs: number,
coverageOff: number,
valueFormat1: number,
valueFormat2: number,
origToNew: Map<number, number>,
): boolean {
const firstGids = readCoverageGids(r, subAbs + coverageOff);
if (!firstGids) return false;
const pairSetCount = r.u16(subAbs + 8);
const vr1 = vrCount(valueFormat1);
const vr2 = vrCount(valueFormat2);
/** 重建每个保留 firstGlyph 的 PairSet */
const rebuilt: Array<{ newFirstGid: number; pairSetBytes: Uint8Array }> = [];
for (let idx = 0; idx < pairSetCount; idx++) {
const newFirstGid = remapGid(firstGids[idx], origToNew);
if (newFirstGid < 0) continue;
const pairSetOff = subAbs + r.u16(subAbs + 10 + idx * 2);
const pairValueCount = r.u16(pairSetOff);
/** 收集 secondGlyph 也在子集中的对 */
const keptSeconds: Array<{ newSecondGid: number; recAbs: number }> = [];
for (let p = 0; p < pairValueCount; p++) {
const recAbs = pairSetOff + 2 + p * (2 + vr1 * 2 + vr2 * 2);
const newSecondGid = remapGid(r.u16(recAbs), origToNew);
if (newSecondGid < 0) continue;
keptSeconds.push({ newSecondGid, recAbs });
}
keptSeconds.sort((a, b) => a.newSecondGid - b.newSecondGid);
const pw = new Writer();
pw.writeUint16(keptSeconds.length);
for (const ks of keptSeconds) {
pw.writeUint16(ks.newSecondGid);
if (vr1 > 0 && !writeValueRecord(pw, r, ks.recAbs + 2, valueFormat1)) return false;
if (vr2 > 0 && !writeValueRecord(pw, r, ks.recAbs + 2 + vr1 * 2, valueFormat2)) return false;
}
pw.flush();
rebuilt.push({ newFirstGid, pairSetBytes: pw.toUint8Array() });
}
rebuilt.sort((a, b) => a.newFirstGid - b.newFirstGid);
const subStart = w.length;
w.writeUint16(1);
const covSlot = w.length;
w.writeUint16(0);
w.writeUint16(valueFormat1);
w.writeUint16(valueFormat2);
w.writeUint16(rebuilt.length);
const pairSetAbsPositions: number[] = new Array(rebuilt.length);
for (let i = 0; i < rebuilt.length; i++) {
const slotIdx = i;
w.reserveOffset16(subStart, () => pairSetAbsPositions[slotIdx]);
}
for (let i = 0; i < rebuilt.length; i++) {
pairSetAbsPositions[i] = w.length;
const bs = rebuilt[i].pairSetBytes;
for (let k = 0; k < bs.length; k++) w.writeUint8(bs[k]);
}
const covPos = w.length;
emitCoverageFromGids(w, rebuilt.map((e) => e.newFirstGid));
w.writeInt16At(covSlot, covPos - subStart);
return true;
}
/**
* PairPosFormat2class ClassDef + Class1/Class2 ValueRecord
*
* class class1Count×class2Count
* 124×107=13268 ValueRecord沿 subtable Offset16 (32767)
* coverage/classDef
* gid class 0..N-1 class 0
* ValueRecord class
*/
function serializePairPosFormat2(
w: Writer,
r: Reader,
subAbs: number,
coverageOff: number,
valueFormat1: number,
valueFormat2: number,
origToNew: Map<number, number>,
): boolean {
const classDef1Off = r.u16(subAbs + 8);
const classDef2Off = r.u16(subAbs + 10);
const class2Count = r.u16(subAbs + 14);
/** 解析原始 classDef → 新gid -> 原classclassDef 未出现的 gid 默认 class 0 */
const gidToClass1 = readClassDefMap(r, subAbs + classDef1Off, origToNew);
const gidToClass2 = readClassDefMap(r, subAbs + classDef2Off, origToNew);
if (!gidToClass1 || !gidToClass2) return false;
/** 收集实际命中的 classclass 0 永远保留) */
const usedClass1 = new Set<number>([0]);
for (const cls of gidToClass1.values()) usedClass1.add(cls);
const usedClass2 = new Set<number>([0]);
for (const cls of gidToClass2.values()) usedClass2.add(cls);
/** 原class -> 紧致新class升序重编号class 0 → 0 */
const class1Remap = new Map<number, number>();
[...usedClass1].sort((a, b) => a - b).forEach((c, i) => class1Remap.set(c, i));
const class2Remap = new Map<number, number>();
[...usedClass2].sort((a, b) => a - b).forEach((c, i) => class2Remap.set(c, i));
const newClass1Count = class1Remap.size;
const newClass2Count = class2Remap.size;
/** 反查紧致新class -> 原class */
const newC1ToOld = new Map<number, number>();
for (const [oldC, newC] of class1Remap) newC1ToOld.set(newC, oldC);
const newC2ToOld = new Map<number, number>();
for (const [oldC, newC] of class2Remap) newC2ToOld.set(newC, oldC);
/** 紧致后的 classDef新gid -> 紧致class */
const compactCD1 = new Map<number, number>();
for (const [gid, cls] of gidToClass1) compactCD1.set(gid, class1Remap.get(cls)!);
const compactCD2 = new Map<number, number>();
for (const [gid, cls] of gidToClass2) compactCD2.set(gid, class2Remap.get(cls)!);
/** 重映射 coverage */
const covGids = readCoverageGids(r, subAbs + coverageOff);
if (!covGids) return false;
const newCovGids: number[] = [];
for (const g of covGids) {
const ng = remapGid(g, origToNew);
if (ng >= 0) newCovGids.push(ng);
}
newCovGids.sort((a, b) => a - b);
const vr1 = vrCount(valueFormat1);
const vr2 = vrCount(valueFormat2);
const class2RecSize = vr1 * 2 + vr2 * 2;
const subStart = w.length;
w.writeUint16(2);
const covSlot = w.length;
w.writeUint16(0);
w.writeUint16(valueFormat1);
w.writeUint16(valueFormat2);
const cd1Slot = w.length;
w.writeUint16(0);
const cd2Slot = w.length;
w.writeUint16(0);
w.writeUint16(newClass1Count);
w.writeUint16(newClass2Count);
/** class1Records按紧致 class 遍历,从原始数组 (原class1 * class2Count + 原class2) 取值 */
for (let nc1 = 0; nc1 < newClass1Count; nc1++) {
const oc1 = newC1ToOld.get(nc1)!;
for (let nc2 = 0; nc2 < newClass2Count; nc2++) {
const oc2 = newC2ToOld.get(nc2)!;
const recAbs = subAbs + 16 + (oc1 * class2Count + oc2) * class2RecSize;
if (vr1 > 0 && !writeValueRecord(w, r, recAbs, valueFormat1)) return false;
if (vr2 > 0 && !writeValueRecord(w, r, recAbs + vr1 * 2, valueFormat2)) return false;
}
}
/** classDef1 */
const cd1Pos = w.length;
if (!emitClassDefFromClassMap(w, compactCD1)) return false;
/** classDef2 */
const cd2Pos = w.length;
if (!emitClassDefFromClassMap(w, compactCD2)) return false;
/** coverage */
const covPos = w.length;
emitCoverageFromGids(w, newCovGids);
w.writeInt16At(cd1Slot, cd1Pos - subStart);
w.writeInt16At(cd2Slot, cd2Pos - subStart);
w.writeInt16At(covSlot, covPos - subStart);
return true;
}

View File

@ -0,0 +1,356 @@
/**
* glyph GSUB target glyph id
*
* GSUB/ unicode glyph FiraCode
* `greater_equal.liga`unicode=None codepoint glyph
* target GSUB
* gid target gidextraSubsetGids
*
* lookup
* - type1 SingleSubst: coverage gid target gid
* - type2 MultipleSubst: coverage gid target gid
* - type3 AlternateSubst: coverage gid target gid
* - type4 LigatureSubst: 全部 component target gidtarget
* - type6 ChainedContext: 匹配上下文后调用其他 lookup lookup
* - type7 Extension: 解包后递归
*
* @reference https://learn.microsoft.com/en-us/typography/opentype/spec/gsub
*/
import { OTReader } from "./ot-bytes.js";
/** GSUB lookup 类型常量 */
const LT_SINGLE = 1;
const LT_MULTIPLE = 2;
const LT_ALTERNATE = 3;
const LT_LIGATURE = 4;
const LT_REVERSE_CHAIN = 5;
const LT_CHAIN = 6;
const LT_EXTENSION = 7;
/** 读取 Coverage 表的 gid 列表 */
function readCoverageGids(r: OTReader, off: number): number[] {
const format = r.u16(off);
const gids: number[] = [];
if (format === 1) {
const count = r.u16(off + 2);
for (let i = 0; i < count; i++) gids.push(r.u16(off + 4 + i * 2));
} else if (format === 2) {
const rangeCount = r.u16(off + 2);
let p = off + 4;
for (let i = 0; i < rangeCount; i++) {
const start = r.u16(p);
const end = r.u16(p + 2);
for (let g = start; g <= end; g++) gids.push(g);
p += 6;
}
}
return gids;
}
/**
* seedGids GSUB target gid seed
*
* @param gsubBytes GSUB
* @param seedGids gid codepoint gid
* @returns target gid
*/
export function collectReachableGsubTargets(
gsubBytes: Uint8Array,
seedGids: Set<number>,
): Set<number> {
const dv = new DataView(gsubBytes.buffer, gsubBytes.byteOffset, gsubBytes.byteLength);
const r = new OTReader(dv);
const major = r.u16(0);
const minor = r.u16(2);
if (major !== 1 || minor > 1) return new Set();
const lookupListOff = r.u16(8);
const lookupCount = r.u16(lookupListOff);
const lookupRelOffs: number[] = [];
for (let i = 0; i < lookupCount; i++) {
lookupRelOffs.push(r.u16(lookupListOff + 2 + i * 2));
}
/** 解析每个 lookup 的 (effectiveType, subtableAbsOffs) */
interface LookupParsed {
effectiveType: number;
subtableAbsOffs: number[];
}
const lookups: LookupParsed[] = [];
for (let i = 0; i < lookupCount; i++) {
const lOff = lookupListOff + lookupRelOffs[i];
const lookupType = r.u16(lOff);
const subTableCount = r.u16(lOff + 4);
const subtableAbsOffs: number[] = [];
let effectiveType = lookupType;
for (let j = 0; j < subTableCount; j++) {
const subOff = lOff + r.u16(lOff + 6 + j * 2);
if (lookupType === LT_EXTENSION) {
if (r.u16(subOff) !== 1) {
effectiveType = -1;
continue;
}
effectiveType = r.u16(subOff + 2);
subtableAbsOffs.push(subOff + r.u32(subOff + 4));
} else {
subtableAbsOffs.push(subOff);
}
}
lookups.push({ effectiveType, subtableAbsOffs });
}
/** reachable
* type6 ChainedContext input/backtrack/lookahead gid
* lookup target chainRefs type1/2/3/4 target
* type1/2/3/4 coverage gid target
* pyftsubset calt context glyph */
const reachable = new Set<number>();
let changed = true;
/** 当前「在子集内」的 gid 集合 = seed reachable */
const inSubset = (gid: number) => seedGids.has(gid) || reachable.has(gid);
while (changed) {
changed = false;
for (let i = 0; i < lookupCount; i++) {
const lk = lookups[i];
for (const subAbs of lk.subtableAbsOffs) {
if (lk.effectiveType === LT_CHAIN) {
/** type6收集可触发规则引用的 lookup index 与所需 context gid */
const refs = new Set<number>();
const ctxGids = new Set<number>();
collectChainRefs(r, subAbs, refs, ctxGids, inSubset);
for (const g of ctxGids) {
if (!reachable.has(g)) { reachable.add(g); changed = true; }
}
for (const li of refs) {
const refLk = lookups[li];
if (!refLk) continue;
for (const refSub of refLk.subtableAbsOffs) {
const refTargets = collectSubtableTargets(r, refSub, refLk.effectiveType, inSubset);
for (const g of refTargets) {
if (!reachable.has(g)) { reachable.add(g); changed = true; }
}
}
}
} else {
const newTargets = collectSubtableTargets(r, subAbs, lk.effectiveType, inSubset);
for (const g of newTargets) {
if (!reachable.has(g)) {
reachable.add(g);
changed = true;
}
}
}
}
}
if (r.errorFlag) break;
}
return reachable;
}
/** 从单个 subtable 收集 target gidtype1/2/3/4type6 不直接产生 target通过 chainRefs 间接) */
function collectSubtableTargets(
r: OTReader,
off: number,
type: number,
inSubset: (gid: number) => boolean,
): number[] {
const targets: number[] = [];
if (type === LT_SINGLE) {
/** SingleSubst: format1 coverage+delta / format2 coverage+gidArray */
const format = r.u16(off);
const covOff = off + r.u16(off + 2);
const covGids = readCoverageGids(r, covOff);
if (format === 1) {
const delta = r.i16(off + 4);
for (const g of covGids) {
if (inSubset(g)) targets.push((g + delta) & 0xffff);
}
} else if (format === 2) {
const count = r.u16(off + 4);
for (let i = 0; i < covGids.length && i < count; i++) {
if (inSubset(covGids[i])) targets.push(r.u16(off + 6 + i * 2));
}
}
} else if (type === LT_MULTIPLE) {
const covOff = off + r.u16(off + 2);
const seqCount = r.u16(off + 4);
const covGids = readCoverageGids(r, covOff);
for (let i = 0; i < covGids.length && i < seqCount; i++) {
if (!inSubset(covGids[i])) continue;
const seqOff = off + r.u16(off + 6 + i * 2);
const glyphCount = r.u16(seqOff);
for (let k = 0; k < glyphCount; k++) targets.push(r.u16(seqOff + 2 + k * 2));
}
} else if (type === LT_ALTERNATE) {
const covOff = off + r.u16(off + 2);
const altCount = r.u16(off + 4);
const covGids = readCoverageGids(r, covOff);
for (let i = 0; i < covGids.length && i < altCount; i++) {
if (!inSubset(covGids[i])) continue;
const altOff = off + r.u16(off + 6 + i * 2);
const cnt = r.u16(altOff);
for (let k = 0; k < cnt; k++) targets.push(r.u16(altOff + 2 + k * 2));
}
} else if (type === LT_LIGATURE) {
/** LigatureSubst: 全部 component 在子集 → target gid */
const covOff = off + r.u16(off + 2);
const setCount = r.u16(off + 4);
const covGids = readCoverageGids(r, covOff);
for (let i = 0; i < covGids.length && i < setCount; i++) {
const firstInSubset = inSubset(covGids[i]);
const setOff = off + r.u16(off + 6 + i * 2);
const ligCount = r.u16(setOff);
for (let j = 0; j < ligCount; j++) {
const ligOff = setOff + r.u16(setOff + 2 + j * 2);
const compCount = r.u16(ligOff);
const target = r.u16(ligOff + 2);
/** 第一分量需在子集(若 first 不在子集,整条 ligature 不会触发) */
if (!firstInSubset) continue;
let allIn = true;
for (let k = 0; k < compCount - 1; k++) {
if (!inSubset(r.u16(ligOff + 4 + k * 2))) {
allIn = false;
break;
}
}
if (allIn) targets.push(target);
}
}
}
/** type5 ReverseChain / type6 ChainedContext targettype6 chainRefs
* lookup target subtable ReverseChain target */
return targets;
}
/**
* type6 ChainedContext subtable lookup index context gid
*
* ChainSubstRule backtrack/input/lookahead gid
* format1 gid inSubset context gid contextGids使
* format2 class index gidclass gid ClassDef class index
* class gid ClassDef gid
* format3 coveragegid
*
* SubstLookupRecord lookup indexrefs target
* pyftsubset context glyph
*/
function collectChainRefs(
r: OTReader,
off: number,
refs: Set<number>,
contextGids: Set<number>,
inSubset: (gid: number) => boolean,
): void {
const format = r.u16(off);
if (format === 1) {
/** format1: coverage(gid) + SubRuleSet 数组,按 coverage gid 索引 */
const covOff = off + r.u16(off + 2);
const covGids = readCoverageGids(r, covOff);
const setCount = r.u16(off + 4);
for (let i = 0; i < covGids.length && i < setCount; i++) {
/** 第一分量coverage gid须在子集否则该 SubRuleSet 不触发 */
if (!inSubset(covGids[i])) continue;
contextGids.add(covGids[i]);
const setOffRel = r.u16(off + 6 + i * 2);
if (setOffRel === 0) continue;
const setOff = off + setOffRel;
const ruleCount = r.u16(setOff);
for (let j = 0; j < ruleCount; j++) {
const ruleOff = setOff + r.u16(setOff + 2 + j * 2);
collectChainRuleRefs(r, ruleOff, refs, contextGids, inSubset, true);
}
}
} else if (format === 2) {
/** format2: ClassDef + input class SubClassSet
* class index backtrack/input/lookahead class index gid
* inSubset class gid lookup ClassDef gid */
const classSetCount = r.u16(off + 10);
for (let i = 0; i < classSetCount; i++) {
const setOffRel = r.u16(off + 12 + i * 2);
if (setOffRel === 0) continue;
const setOff = off + setOffRel;
const ruleCount = r.u16(setOff);
for (let j = 0; j < ruleCount; j++) {
const ruleOff = setOff + r.u16(setOff + 2 + j * 2);
collectChainRuleRefs(r, ruleOff, refs, contextGids, inSubset, false);
}
}
} else if (format === 3) {
/** format3: coverage + SubstLookupRecords
* coverage backtrack/input/lookahead gid */
let p = off + 2;
const allGids: number[] = [];
let triggerable = true;
const readCovGids = (cnt: number): boolean => {
for (let k = 0; k < cnt; k++) {
const covGids = readCoverageGids(r, off + r.u16(p + k * 2));
for (const g of covGids) {
allGids.push(g);
if (!inSubset(g)) triggerable = false;
}
}
p += cnt * 2;
return true;
};
const backtrackCount = r.u16(p); p += 2;
readCovGids(backtrackCount);
const inputCount = r.u16(p); p += 2;
readCovGids(inputCount);
const lookaheadCount = r.u16(p); p += 2;
readCovGids(lookaheadCount);
if (triggerable) {
for (const g of allGids) contextGids.add(g);
const substCount = r.u16(p);
for (let k = 0; k < substCount; k++) refs.add(r.u16(p + 2 + k * 4 + 2));
}
}
}
/**
* ChainSubstRule lookup index context gid
* @param isGidFormat true=format1 gid inSubset gid
* false=format2 class index
* context gid refsformat1format2 class index
*/
function collectChainRuleRefs(
r: OTReader,
ruleOff: number,
refs: Set<number>,
contextGids: Set<number>,
inSubset: (gid: number) => boolean,
isGidFormat: boolean,
): void {
/** format1/2 rule: backtrackCount + backtrack[] + inputCount + input[] + lookaheadCount + lookahead[] + substCount + substRecords[] */
let p = ruleOff;
const backtrackCount = r.u16(p); p += 2;
const backRaw: number[] = [];
for (let k = 0; k < backtrackCount; k++) backRaw.push(r.u16(p + k * 2));
p += backtrackCount * 2;
const inputCount = r.u16(p); p += 2;
const inputRaw: number[] = [];
for (let k = 0; k < inputCount - 1; k++) inputRaw.push(r.u16(p + k * 2));
p += (inputCount - 1) * 2;
const lookaheadCount = r.u16(p); p += 2;
const lookRaw: number[] = [];
for (let k = 0; k < lookaheadCount; k++) lookRaw.push(r.u16(p + k * 2));
p += lookaheadCount * 2;
const substCount = r.u16(p); p += 2;
/** format1全部 context gid 在子集才触发format2class index 始终「可触发」(保守) */
if (isGidFormat) {
for (const arr of [backRaw, inputRaw, lookRaw]) {
for (const g of arr) {
if (!inSubset(g)) return; /** 含子集外 gid规则不触发 */
}
for (const g of arr) contextGids.add(g);
}
}
for (let k = 0; k < substCount; k++) {
/** SubstLookupRecord: sequenceIndex(2) + lookupListIndex(2) */
refs.add(r.u16(p + k * 4 + 2));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,234 @@
/**
* OpenType
*
* GPOS / GSUB OpenType 使big-endian OpenType 1.9
* TTF/OTF/woff/woff2
* fonteditor-core reader/writervendor/fonteditor-core/lib/ttf/reader.js
* littleEndian TTFReader.readBuffer Reader 4 false
* TTF
*
* coverage/ClassDef/ gid
* gpos-subset.tsgsub-subset.ts
* Writer/Reader
*
* Writer subtable coverage
* reserveOffset16 flush
*/
/**
*
*
* Offset16
* subtable coverage/PairSet
*/
export class OTWriter {
private bytes: number[] = [];
private patches: Array<{ pos: number; base: number; targetGetter: () => number }> = [];
get length(): number {
return this.bytes.length;
}
/** 回退到指定字节位置,丢弃之后写入的字节与对应的偏移量槽(用于 subtable 重映射失败的保守降级) */
rollback(pos: number): void {
this.bytes.length = pos;
this.patches = this.patches.filter((p) => p.pos < pos);
}
writeUint8(v: number): void {
this.bytes.push(v & 0xff);
}
writeUint16(v: number): void {
this.bytes.push((v >>> 8) & 0xff, v & 0xff);
}
/** 在当前末尾写入 int16大端支持负数如 SingleSubst format1 的 deltaGlyphID */
writeInt16(v: number): void {
this.writeInt16At(this.bytes.length, v);
}
/** 在指定绝对位置写入 int16支持负数同时用于 flush 回填可能为负的偏移量) */
writeInt16At(pos: number, v: number): void {
const u16 = v < 0 ? 0x10000 + (v & 0xffff) : v & 0xffff;
this.bytes[pos] = (u16 >>> 8) & 0xff;
this.bytes[pos + 1] = u16 & 0xff;
}
/** 预留一个 uint16 偏移量槽位flush 时写入 (targetGetter() - base) */
reserveOffset16(base: number, targetGetter: () => number): void {
const pos = this.bytes.length;
this.bytes.push(0, 0);
this.patches.push({ pos, base, targetGetter });
}
/** flush 所有预留偏移量,必须在所有字节写完后调用 */
flush(): void {
for (const p of this.patches) {
this.writeInt16At(p.pos, p.targetGetter() - p.base);
}
}
toUint8Array(): Uint8Array {
return new Uint8Array(this.bytes);
}
}
/**
*
*
* errorFlag 0
* subtable /
* FiraCode ChainedContext type6 2 classSetCount
*/
export class OTReader {
errorFlag = false;
constructor(private dv: DataView) {}
u16(off: number): number {
if (off < 0 || off + 2 > this.dv.byteLength) {
this.errorFlag = true;
return 0;
}
return this.dv.getUint16(off, false);
}
i16(off: number): number {
if (off < 0 || off + 2 > this.dv.byteLength) {
this.errorFlag = true;
return 0;
}
return this.dv.getInt16(off, false);
}
u32(off: number): number {
if (off < 0 || off + 4 > this.dv.byteLength) {
this.errorFlag = true;
return 0;
}
return this.dv.getUint32(off, false);
}
/** 清除 errorFlag开始解析新 subtable 前调用) */
clearError(): void {
this.errorFlag = false;
}
}
/**
* ScriptListGPOS/GSUB
*
* ScriptList glyphIdScriptTable/LangSys ScriptList
* ScriptList FeatureList/LookupList
* GSUBScriptList FeatureList
*
*
* @param r
* @param listAbs ScriptList
* @returns ScriptList errorFlag null
*/
export function serializeScriptList(r: OTReader, listAbs: number): Uint8Array | null {
r.clearError();
const w = new OTWriter();
const scriptCount = r.u16(listAbs);
w.writeUint16(scriptCount);
/** 预留 ScriptRecord 数组槽位tag4 + offset2记录每个 script 的新偏移 */
const scriptNewOffs: number[] = new Array(scriptCount);
for (let i = 0; i < scriptCount; i++) {
const recAbs = listAbs + 2 + i * 6;
/** tag 4 字节原样拷贝 */
w.writeUint8(r.u16(recAbs) >>> 8);
w.writeUint8(r.u16(recAbs) & 0xff);
w.writeUint8(r.u16(recAbs + 2) >>> 8);
w.writeUint8(r.u16(recAbs + 2) & 0xff);
w.reserveOffset16(0, ((idx) => () => scriptNewOffs[idx])(i));
}
/** 逐 ScriptTable 序列化 */
for (let i = 0; i < scriptCount; i++) {
scriptNewOffs[i] = w.length;
const scriptOldOff = listAbs + r.u16(listAbs + 2 + i * 6 + 4);
const defaultLangSysOff = r.u16(scriptOldOff);
const langSysCount = r.u16(scriptOldOff + 2);
/** 收集该 ScriptTable 的 LangSys 表,紧凑排布 */
const langSysNewOffs: number[] = new Array(langSysCount);
/** 先写 ScriptTable 头 */
const stStart = w.length;
/** defaultLangSysOffset 槽(相对 ScriptTable 起始) */
const defaultSlotHolder: number[] = [0];
w.reserveOffset16(stStart, () => defaultSlotHolder[0]);
w.writeUint16(langSysCount);
for (let li = 0; li < langSysCount; li++) {
const lr = scriptOldOff + 4 + li * 6;
w.writeUint8(r.u16(lr) >>> 8);
w.writeUint8(r.u16(lr) & 0xff);
w.writeUint8(r.u16(lr + 2) >>> 8);
w.writeUint8(r.u16(lr + 2) & 0xff);
w.reserveOffset16(stStart, ((idx) => () => langSysNewOffs[idx])(li));
}
/** defaultLangSys 表 */
if (defaultLangSysOff !== 0) {
defaultSlotHolder[0] = w.length;
copyLangSys(w, r, scriptOldOff + defaultLangSysOff);
}
/** 各 LangSys 表 */
for (let li = 0; li < langSysCount; li++) {
const lr = scriptOldOff + 4 + li * 6;
const lsOldOff = scriptOldOff + r.u16(lr + 4);
langSysNewOffs[li] = w.length;
copyLangSys(w, r, lsOldOff);
}
}
if (r.errorFlag) return null;
w.flush();
return w.toUint8Array();
}
/** LangSys lookupOrderOffset + requiredFeatureIndex + featureIndexCount + featureIndices
* lookupOrderOffset 0 0 */
function copyLangSys(w: OTWriter, r: OTReader, absOff: number): void {
w.writeUint16(0);
w.writeUint16(r.u16(absOff + 2));
const fic = r.u16(absOff + 4);
w.writeUint16(fic);
for (let fi = 0; fi < fic; fi++) w.writeUint16(r.u16(absOff + 6 + fi * 2));
}
/**
* FeatureListGPOS/GSUB
*
* serializeScriptList FeatureTable FeatureList
* FeatureTable featureParamsOffset
* 0 0 FeatureParams gid
*
* @returns FeatureList null
*/
export function serializeFeatureList(r: OTReader, listAbs: number): Uint8Array | null {
r.clearError();
const w = new OTWriter();
const featureCount = r.u16(listAbs);
w.writeUint16(featureCount);
const featureNewOffs: number[] = new Array(featureCount);
for (let i = 0; i < featureCount; i++) {
const recAbs = listAbs + 2 + i * 6;
w.writeUint8(r.u16(recAbs) >>> 8);
w.writeUint8(r.u16(recAbs) & 0xff);
w.writeUint8(r.u16(recAbs + 2) >>> 8);
w.writeUint8(r.u16(recAbs + 2) & 0xff);
w.reserveOffset16(0, ((idx) => () => featureNewOffs[idx])(i));
}
for (let i = 0; i < featureCount; i++) {
featureNewOffs[i] = w.length;
const ftOldOff = listAbs + r.u16(listAbs + 2 + i * 6 + 4);
const featureParamsOff = r.u16(ftOldOff);
const lookupIndexCount = r.u16(ftOldOff + 2);
/** featureParamsOffset 原样保留(非 0 时相对 FeatureTable其内容不含 gid */
w.writeUint16(featureParamsOff);
w.writeUint16(lookupIndexCount);
for (let li = 0; li < lookupIndexCount; li++) w.writeUint16(r.u16(ftOldOff + 4 + li * 2));
}
if (r.errorFlag) return null;
w.flush();
return w.toUint8Array();
}

View File

@ -1,4 +1,4 @@
/loop 持续优化字体子集化性能和提高ssim评分可以大胆放开手脚的去做但是优化完一定要通过`pnpx tsx ./基准测试.test.ts`。中途不要切换到其他模式,比如计划模式也不要询问我,你直接做就行了,请你持续的去优化,不要去询问我,不要去中断,好吧
/loop 持续优化字体子集化性能和提高ssim评分需要注意不要产生bug所以要利用好基准测试可以大胆放开手脚的去做(不管用什么办法,必须保证 SSIM 通过,意味着和原始字体人眼一致),但是优化完一定要通过`pnpx tsx ./基准测试.test.ts`。中途不要切换到其他模式,比如计划模式也不要询问我,你直接做就行了,请你持续的去优化,不要去询问我,不要去中断,好吧
把基准测试结果文档保存在本地 benchmark_results/ 这样我方便查看。你的文档中应该在每个重大节点更新基准测试结果benchmark_results/OPTIMIZATION_LOG.md这样我能方便看到你使用了哪些优化方法得到了什么样的优化效果。
不要修改基准测试中的full生成方案如果基准测试结果不对一定是你的修改有问题而非浏览器渲染等其他问题

View File

@ -0,0 +1,31 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
var _table = _interopRequireDefault(require("./table"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
/**
* @file GSUB
*
* GSUBGlyph Substitution Table原始字节透传读写
* 子集化时由 backend/font_util/gsub-subset.ts glyphId 重映射 coverage/ClassDef
* 保留连字ligature上下文替换等规则使子集字体的连字/替换与原字体人眼一致
*
* @reference: https://learn.microsoft.com/en-us/typography/opentype/spec/gsub
*/
var _default = exports.default = _table.default.create('GSUB', [], {
read: function read(reader, ttf) {
var length = ttf.tables.GSUB.length;
return reader.readBytes(this.offset, length);
},
write: function write(writer, ttf) {
if (ttf.GSUB) {
writer.writeBytes(ttf.GSUB, ttf.GSUB.length);
}
},
size: function size(ttf) {
return ttf.GSUB ? ttf.GSUB.length : 0;
}
});

View File

@ -48,6 +48,18 @@ var _default = exports.default = _table.default.create('glyf', [], {
ttf.subsetMap = subsetMap;
ttf.subsetGids = subsetGids;
ttf._subsetUnicodeMap = subsetUnicodeMap;
/* gid GSUB target glyph unicode codepoint
* 这些 glyph 仍参与 compound 引用解析与 glyf 构建origToNew 通过 subsetGids 顺序直接映射 */
var extraSubsetGids = ttf.readOptions.extraSubsetGids;
if (extraSubsetGids && extraSubsetGids.length > 0) {
for (var ei = 0, el = extraSubsetGids.length; ei < el; ei++) {
var eg = extraSubsetGids[ei];
if (!subsetMap[eg]) {
subsetMap[eg] = true;
subsetGids.push(eg);
}
}
}
var parsedGlyfMap = {};
/* 优化:迭代式广度优先遍历替代递归,消除 isEmptyObject 调用 */

View File

@ -128,8 +128,8 @@ function parseCompoundGlyf(reader, glyf) {
var view = reader.view;
var vOffset = view.byteOffset + reader.offset;
/** 优化293: F2DOT14_SCALE 提升到循环外 */
var F2DOT14_SCALE = 0.0001;
/** 优化293: F2DOT14 逆比例常量提升到循环外raw * (1/16384) 浮点解码) */
var F2DOT14_INV = 0.00006103515625;
do {
flags = view.getUint16(vOffset, false); vOffset += 2;
var glyphIndex = view.getUint16(vOffset, false); vOffset += 2;
@ -158,21 +158,27 @@ function parseCompoundGlyf(reader, glyf) {
scale10 = view.getInt16(vOffset, false); vOffset += 2;
scaleY = view.getInt16(vOffset, false); vOffset += 2;
}
/** 优化293: F2DOT14_SCALE 提升到循环外,避免每次迭代重新赋值 */
/**
* F2DOT14 解码raw / 16384
* 原实现 (raw * 0.6103515625 + 0.5 | 0) * 0.0001 对负数有 bug
* `+0.5 | 0` 对负数是向零截断而非四舍五入导致 -16384(=-1.0) 被解码成 -0.9999
* 复合字形水平镜像 FiraCode less_equal.liga 引用 greater_equal.liga产生坐标漂移
* 直接 raw * (1/16384) 浮点解码无量化无符号偏差
*/
if (ARGS_ARE_XY_VALUES & flags) {
glyf.glyfs.push({
flags: flags,
glyphIndex: glyphIndex,
useMyMetrics: !!(flags & USE_MY_METRICS),
overlapCompound: !!(flags & OVERLAP_COMPOUND),
transform: { a: (scaleX * 0.6103515625 + 0.5 | 0) * F2DOT14_SCALE, b: (scale01 * 0.6103515625 + 0.5 | 0) * F2DOT14_SCALE, c: (scale10 * 0.6103515625 + 0.5 | 0) * F2DOT14_SCALE, d: (scaleY * 0.6103515625 + 0.5 | 0) * F2DOT14_SCALE, e: arg1, f: arg2 }
transform: { a: scaleX * F2DOT14_INV, b: scale01 * F2DOT14_INV, c: scale10 * F2DOT14_INV, d: scaleY * F2DOT14_INV, e: arg1, f: arg2 }
});
} else {
glyf.glyfs.push({
flags: flags,
glyphIndex: glyphIndex,
points: [arg1, arg2],
transform: { a: (scaleX * 0.6103515625 + 0.5 | 0) * F2DOT14_SCALE, b: (scale01 * 0.6103515625 + 0.5 | 0) * F2DOT14_SCALE, c: (scale10 * 0.6103515625 + 0.5 | 0) * F2DOT14_SCALE, d: (scaleY * 0.6103515625 + 0.5 | 0) * F2DOT14_SCALE, e: 0, f: 0 }
transform: { a: scaleX * F2DOT14_INV, b: scale01 * F2DOT14_INV, c: scale10 * F2DOT14_INV, d: scaleY * F2DOT14_INV, e: 0, f: 0 }
});
}
} while (MORE_COMPONENTS & flags);

View File

@ -19,6 +19,7 @@ var _cvt = _interopRequireDefault(require("./cvt"));
var _prep = _interopRequireDefault(require("./prep"));
var _gasp = _interopRequireDefault(require("./gasp"));
var _GPOS = _interopRequireDefault(require("./GPOS"));
var _GSUB = _interopRequireDefault(require("./GSUB"));
var _kern = _interopRequireDefault(require("./kern"));
var _kerx = _interopRequireDefault(require("./kerx"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
@ -42,6 +43,7 @@ var _default = exports.default = {
prep: _prep.default,
gasp: _gasp.default,
GPOS: _GPOS.default,
GSUB: _GSUB.default,
kern: _kern.default,
kerx: _kerx.default
};

View File

@ -14,7 +14,7 @@ var _post = require("./table/post");
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
/** 优化291: 表名列表提升为模块级常量,避免每次 readBuffer 创建新数组 */
var TTF_TABLE_NAMES = ['head', 'maxp', 'loca', 'cmap', 'glyf', 'name', 'hhea', 'hmtx', 'post', 'OS/2', 'fpgm', 'cvt', 'prep', 'gasp', 'GPOS', 'kern', 'kerx'];
var TTF_TABLE_NAMES = ['head', 'maxp', 'loca', 'cmap', 'glyf', 'name', 'hhea', 'hmtx', 'post', 'OS/2', 'fpgm', 'cvt', 'prep', 'gasp', 'GPOS', 'GSUB', 'kern', 'kerx'];
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
@ -73,8 +73,8 @@ var TTFReader = exports.default = /*#__PURE__*/function () {
if (!hinting && (tableName === 'fpgm' || tableName === 'cvt' || tableName === 'prep' || tableName === 'gasp')) {
continue;
}
/* 优化8: hinting=false && kerning=false 时跳过 GPOS/kern/kerx */
if (!hinting && !kerning && (tableName === 'GPOS' || tableName === 'kern' || tableName === 'kerx')) {
/* 优化8: hinting=false && kerning=false 时跳过 GPOS/GSUB/kern/kerx */
if (!hinting && !kerning && (tableName === 'GPOS' || tableName === 'GSUB' || tableName === 'kern' || tableName === 'kerx')) {
continue;
}
var offset = ttf.tables[tableName].offset;
@ -234,6 +234,7 @@ var TTFReader = exports.default = /*#__PURE__*/function () {
}
if (!this.options.hinting && !this.options.kerning) {
ttf.GPOS = null;
ttf.GSUB = null;
ttf.kern = null;
ttf.kerx = null;
}

View File

@ -202,7 +202,7 @@ var TTFWriter = exports.default = /*#__PURE__*/function () {
}
}
if (this.options.kerning) {
var kernTables = ['GPOS', 'kern', 'kerx'];
var kernTables = ['GPOS', 'GSUB', 'kern', 'kerx'];
for (var j = 0; j < kernTables.length; j++) {
var kn = kernTables[j];
if (ttf[kn] && !added[kn]) {

View File

@ -394,25 +394,16 @@ function optimizettf(ttf) {
glyfNotEmpty: m_glyfNotEmpty
};
/* 优化99+103: hasCompound 已在主循环中追踪,过滤使用 _numContours 或 contours.length */
/* _numContours === 0
* (1) spaceU+0020等空白字符无轮廓但 cmap 引用删除后该码点渲染为 .notdef
* (2) GSUB 连字的 spacer/seq target FiraCode equal.spacer无轮廓的占位字形
* GSUB ChainedContext 替换的目标删除后连字规则 target gid 失效连字不渲染
* 无轮廓字形在 OpenType 中合法.notdef 即常见无轮廓字形保留它们仅增加极小体积
* 因此这里不再按轮廓数过滤保留全部字形glyf 顺序与 subsetGids 保持一一对应
* 使调用方rewriteLayoutTablesForSubset 原gid新gid 映射稳定可靠 */
if (!hasCompound) {
/* 优化284: 预分配 filtered + 索引赋值替代 pushindexMap 保持稀疏数组 */
var filtered = new Array(gl);
var indexMap = [];
var fLen = 0;
filtered[fLen++] = glyfs[0];
indexMap[0] = 0;
for (var gi = 1; gi < gl; gi++) {
var g = glyfs[gi];
if (g._numContours != null ? g._numContours > 0 : (g.contours && g.contours.length)) {
indexMap[gi] = fLen;
filtered[fLen++] = g;
}
}
filtered.length = fLen;
ttf.glyf = filtered;
if (ttf.support && ttf.support.maxp) {
ttf.support.maxp.numGlyphs = filtered.length;
ttf.support.maxp.numGlyphs = glyfs.length;
}
}
if (!repeatList.length) {

View File

@ -161,7 +161,7 @@ function readWindowsAllCodes(tables, ttf) {
}
/* 非subset模式 - 全量展开(原始逻辑) */
if (format0) {
if (format0 && format0.glyphIdArray) {
for (var i2 = 0, l2 = format0.glyphIdArray.length; i2 < l2; i2++) {
if (format0.glyphIdArray[i2]) {
codes[i2] = format0.glyphIdArray[i2];

View File

@ -10,6 +10,46 @@ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { de
* @author mengke01(kekee000@gmail.com)
*/
/** 简单字形的 ON_CURVE 位glyFlag.ONCURVE = 0x01 */
var _ON_CURVE = 0x01;
/**
* 从紧凑格式_xArr/_yArr/_flags/endPtsOfContours即时构建扁平 contours
*
* 复合字形的 component subset 阶段resolveGlyf compound2simpleglyf 本函数
* 被访问时尚未经过 optimizesimple component 仅解析出紧凑坐标数据而无 contours 字段
* 正常 codepoint 子集不会保留无 unicode 的连字 targetcompound故不触发此路径
* extraSubsetGids 注入连字 target FiraCode greater_equal.liga
* component 为紧凑 simple 字形需在此即时展开为扁平 contours 供仿射变换使用
*
* 扁平格式contour = [x0,y0,onCurve0, x1,y1,onCurve1, ...] transformAndCeilFlat 一致
*/
function buildFlatContoursFromCompact(glyph) {
var xArr = glyph._xArr;
var yArr = glyph._yArr;
var flags = glyph._flags;
var endPts = glyph.endPtsOfContours;
if (!xArr || !yArr || !flags || !endPts) {
return [];
}
var contours = new Array(endPts.length);
var ptStart = 0;
for (var ci = 0, cl = endPts.length; ci < cl; ci++) {
var ptEnd = endPts[ci];
var ptCount = ptEnd - ptStart + 1;
var contour = new Array(ptCount * 3);
for (var pi = 0; pi < ptCount; pi++) {
var pIdx = ptStart + pi;
contour[pi * 3] = xArr[pIdx];
contour[pi * 3 + 1] = yArr[pIdx];
contour[pi * 3 + 2] = (flags[pIdx] & _ON_CURVE) ? 1 : 0;
}
contours[ci] = contour;
ptStart = ptEnd + 1;
}
return contours;
}
/**
* 优化15+66+197: 扁平格式单次遍历 pathTransform + pathCeil
* 优化197: 使用 +0.5|0 替代 Math.round避免函数调用开销
@ -55,6 +95,11 @@ function transformGlyfContours(glyf, ttf) {
}
var sourceContours = glyph.compound ? contoursList[g.glyphIndex] || [] : glyph.contours;
/* simple component contours _xArr/_yArr/_flags/endPtsOfContours
* subset 阶段尚未 optimize连字 target component 需即时展开为扁平 contours */
if (!sourceContours && glyph._xArr) {
sourceContours = buildFlatContoursFromCompact(glyph);
}
/* 优化259: 提升 transform 属性到局部变量,消除每次迭代的属性链查找 */
var t = g.transform;
var ta = t.a, tb = t.b, tc = t.c, td = t.d, te = t.e, tf = t.f;

View File

@ -801,6 +801,20 @@ function encodeTTFToWOFF2(ttfBuffer) {
let dirIdx = 0;
let totalDirSize = 0;
/**
* 计算单个 Table Directory entry 的序列化字节数
* WOFF2 规范: flags(1) + (tagIndex===63 时追加 4 字节原始 tag) + origLength(Base128) + (hasTransform transformLength(Base128))
*
* 修复: tagIndex===63表名不在已知 63 个表内 GPOS/gasp/GDEF时必须额外写 4 字节 tag
* 原实现漏算这 4 字节导致含此类表的字体如开启 kerning 保留 GPOS woff2 buffer 预分配不足
* woff2.set(compressedData, dirPos) 越界
*/
const entrySize = (tagIndex, origLength, hasTransform, transformLength) =>
1
+ (tagIndex === 63 ? 4 : 0)
+ sizeUIntBase128(origLength)
+ (hasTransform ? sizeUIntBase128(transformLength) : 0);
for (var fi2 = 0, fl2 = filtered.length; fi2 < fl2; fi2++) {
var t = filtered[fi2];
if (t.tagU32 === TAG_loca) {
@ -814,7 +828,7 @@ function encodeTTFToWOFF2(ttfBuffer) {
data: EMPTY_UINT8,
hasTransform: true,
};
totalDirSize += 1 + sizeUIntBase128(origLength) + sizeUIntBase128(0);
totalDirSize += entrySize(t.tagIndex, origLength, true, 0);
continue;
}
@ -827,7 +841,7 @@ function encodeTTFToWOFF2(ttfBuffer) {
data: glyfTransformed.transformedGlyf,
hasTransform: true,
};
totalDirSize += 1 + sizeUIntBase128(t.length) + sizeUIntBase128(glyfTransformed.transformedGlyf.length);
totalDirSize += entrySize(t.tagIndex, t.length, true, glyfTransformed.transformedGlyf.length);
continue;
}
@ -841,7 +855,7 @@ function encodeTTFToWOFF2(ttfBuffer) {
data: tableData,
isHead: t.tagU32 === TAG_head,
};
totalDirSize += 1 + sizeUIntBase128(t.length);
totalDirSize += entrySize(t.tagIndex, t.length, false, t.length);
}
dirEntries.length = dirIdx;

View File

@ -234,17 +234,66 @@ function calculateSSIM(a: Uint8Array, b: Uint8Array, width: number, height: numb
// ======== 测试配置 ========
/**
*
*/
const QIANZIWEN_MID = "墨悲丝染诗赞羔羊景行维贤克念作圣德建名立形端表正空谷传声虚堂习听祸因恶积福缘善庆尺璧非宝寸阴是竞资父事君曰严与敬孝当竭力忠则尽命临深履薄夙兴温凊似兰斯馨如松之盛川流不息渊澄取映";
const testCases = [
/** TTF 字体 */
/** ===== 令东齐伋复刻体TTF楷书复古字体主基准 ===== */
{ label: "8个汉字", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天地玄黄宇宙洪荒", sourceType: "ttf" as const, outType: "ttf" as const, fullFormat: "truetype" },
{ label: "8个汉字", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天地玄黄宇宙洪荒", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
{ label: "拉丁+数字", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "Hello World 123", sourceType: "ttf" as const, outType: "ttf" as const, fullFormat: "truetype" },
{ label: "拉丁+数字", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "Hello World 123", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
{ label: "千字文前段", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈剑号巨阙珠称夜光果珍李柰菜重芥姜海咸河淡鳞潜羽翔", sourceType: "ttf" as const, outType: "ttf" as const, fullFormat: "truetype" },
{ label: "千字文前段", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈剑号巨阙珠称夜光果珍李柰菜重芥姜海咸河淡鳞潜羽翔", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
/** OTF 字体(含三点水等复杂笔画字,守护 OTF→TTF 转换正确性) */
/** 重复字符:守护 codePoints 去重逻辑,相同字形不应重复输出 */
{ label: "重复字符", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天天天天地地地地", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
/** ===== 思源黑体TTF无衬线黑体字形简洁 ===== */
{ label: "思源黑体-8字", fontPath: "font/思源黑体.ttf", fontName: "思源黑体", text: "天地玄黄宇宙洪荒", sourceType: "ttf" as const, outType: "ttf" as const, fullFormat: "truetype" },
{ label: "思源黑体-8字", fontPath: "font/思源黑体.ttf", fontName: "思源黑体", text: "天地玄黄宇宙洪荒", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
/** 纯标点:非汉字字形 + 组合标记的渲染守护 */
{ label: "思源黑体-标点", fontPath: "font/思源黑体.ttf", fontName: "思源黑体", text: ",。!?、;:“”‘’", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
/** CJK 扩展 B 罕见字(代理对):守护 textToCodePoints 的代理对跳过逻辑 */
{ label: "思源黑体-扩展B", fontPath: "font/思源黑体.ttf", fontName: "思源黑体", text: "𠮷𡧑𢀖𤍤𥝹", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
/** 千字文中段:超长文本压力测试(>100 字) */
{ label: "思源黑体-千字文中段", fontPath: "font/思源黑体.ttf", fontName: "思源黑体", text: QIANZIWEN_MID, sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
/** ===== Yi山碑篆体TTF笔画极其复杂的篆书字形数据量大 ===== */
{ label: "篆体-8字", fontPath: "font/temp/YiShanBeiZhuanTi.ttf", fontName: "Yi山碑篆体", text: "天地玄黄宇宙洪荒", sourceType: "ttf" as const, outType: "ttf" as const, fullFormat: "truetype" },
{ label: "篆体-8字", fontPath: "font/temp/YiShanBeiZhuanTi.ttf", fontName: "Yi山碑篆体", text: "天地玄黄宇宙洪荒", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
/** ===== OTF 字体(含三点水等复杂笔画字,守护 OTF→TTF 转换正确性) ===== */
{ label: "otf-五个汉字", fontPath: "font/temp/BaiHuOTFJiaoYuHanZi-2.otf", fontName: "白狐教育汉字", text: "天地黄宇宙法海波", sourceType: "otf" as const, outType: "ttf" as const, fullFormat: "opentype" },
/** OTF→woff2 输出路径守护(之前仅测 ttf 输出) */
{ label: "otf-五个汉字", fontPath: "font/temp/BaiHuOTFJiaoYuHanZi-2.otf", fontName: "白狐教育汉字", text: "天地黄宇宙法海波", sourceType: "otf" as const, outType: "woff2" as const, fullFormat: "opentype" },
{ label: "otf-思源黑体", fontPath: "font/temp/SourceHanSans-Regular.otf", fontName: "思源黑体", text: "天地玄黄宇宙洪法海波", sourceType: "otf" as const, outType: "ttf" as const, fullFormat: "opentype" },
{ label: "otf-思源黑体", fontPath: "font/temp/SourceHanSans-Regular.otf", fontName: "思源黑体", text: "天地玄黄宇宙洪法海波", sourceType: "otf" as const, outType: "woff2" as const, fullFormat: "opentype" },
/** ===== 小字号渲染size=24守护 SSIM 在小字号下不退化) ===== */
{ label: "小字号-8字", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天地玄黄宇宙洪荒", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype", fontSize: 24 },
{ label: "小字号-篆体", fontPath: "font/temp/YiShanBeiZhuanTi.ttf", fontName: "Yi山碑篆体", text: "天地玄黄宇宙洪荒", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype", fontSize: 24 },
/**
* ===== D:\ =====
* + GPOS
* GPOS lookup PairPos() ChainedContextPos(type8)
* (OTF) MarkBasePos(type4) GPOS
*/
{ label: "得意黑-汉字标点", fontPath: "/mnt/d/字体资源/得意黑/SmileySans-Oblique.ttf", fontName: "得意黑", text: "你好,世界!今天天气不错。", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
{ label: "霞鹜文楷-汉字标点", fontPath: "/mnt/d/字体资源/霞鹜文楷/LXGWWenKai-Regular.ttf", fontName: "霞鹜文楷", text: "你好,世界!今天天气不错。", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
/** 初夏明朝TTF宋体/明朝风格衬线字,含标点压缩) */
{ label: "初夏明朝-汉字标点", fontPath: "/mnt/d/字体资源/初夏明朝/初夏明朝-Regular.ttf", fontName: "初夏明朝", text: "你好,世界!今天天气不错。", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
{ label: "霞鹜文楷-纯标点", fontPath: "/mnt/d/字体资源/霞鹜文楷/LXGWWenKai-Regular.ttf", fontName: "霞鹜文楷", text: ",。!?、;:“”‘’", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
{ label: "初夏明朝-纯标点", fontPath: "/mnt/d/字体资源/初夏明朝/初夏明朝-Regular.ttf", fontName: "初夏明朝", text: ",。!?、;:“”‘’", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
{ label: "得意黑-纯标点", fontPath: "/mnt/d/字体资源/得意黑/SmileySans-Oblique.ttf", fontName: "得意黑", text: ",。!?、;:“”‘’", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
/** 鸿蒙黑体TTF现代无衬线GPOS 仅 SinglePos/PairPos */
{ label: "鸿蒙黑体-汉字标点", fontPath: "/mnt/d/字体资源/鸿蒙字体/HarmonyOS_Sans_SC_Black.ttf", fontName: "鸿蒙黑体", text: "你好,世界!今天天气不错。", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
/** 优设标题黑TTF艺术黑体 */
{ label: "优设标题黑-汉字标点", fontPath: "/mnt/d/字体资源/优设标题黑/优设标题黑.ttf", fontName: "优设标题黑", text: "你好,世界!今天天气不错。", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
/** FiraCode拉丁等宽GPOS 做 ligature非 CJK 标点,验证降级路径不破坏拉丁字体) */
{ label: "FiraCode-代码", fontPath: "/mnt/d/字体资源/FiraCode/FiraCode-Medium.ttf", fontName: "FiraCode", text: "=> !== >= <= ===", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
];
// ======== 主测试 ========
@ -363,11 +412,18 @@ for (const tc of testCases) {
? `full_otf_${tc.label}.otf`
: `full_ttf_${tc.label}.ttf`;
/** 渲染完整字体 */
const fullResult = await renderTextViaBrowser(page, baseUrl, fullKey, tc.text, 48, tc.fullFormat);
/** 渲染完整字体fontSize 可选,默认 48小字号用例用于守护 SSIM 在低分辨率下不退化) */
const renderSize = tc.fontSize ?? 48;
const fullResult = await renderTextViaBrowser(page, baseUrl, fullKey, tc.text, renderSize, tc.fullFormat);
/** 渲染子集字体 */
const subsetResult = await renderTextViaBrowser(page, baseUrl, subsetKey, tc.text, 48, "truetype");
/**
* format outType
* woff2 format("truetype") Chrome GPOS
* CJK GPOS lookup
* outType format GPOS layout
*/
const subsetFormat = tc.outType === "woff2" ? "woff2" : "truetype";
const subsetResult = await renderTextViaBrowser(page, baseUrl, subsetKey, tc.text, renderSize, subsetFormat);
await writeFile(`${BENCHMARK_DIR}/screenshots/${safeLabel}_full.png`, fullResult.screenshot);
await writeFile(`${BENCHMARK_DIR}/screenshots/${safeLabel}_subset.png`, subsetResult.screenshot);