mirror of
https://github.com/2234839/web-font.git
synced 2026-09-04 23:02:27 +08:00
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:
parent
7d0243e6f5
commit
a84a4aa6f2
@ -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。
|
||||
*
|
||||
* 原gid→新gid 映射直接由 subsetGids 顺序建立:subsetGids[i] 是子集保留的第 i 个原始 gid,
|
||||
* optimize 后 glyf 顺序与 subsetGids 一致(fonteditor-core 的 optimize 不重排 glyf),
|
||||
* 故新 gid = i。这比 unicode 桥接更可靠,且支持无 unicode 的 glyph(如 GSUB 连字 target)。
|
||||
* subsetGPOS 遇到完全不支持的版本会返回 null,此时保留原始 GPOS 字节(不劣于子集化前)。
|
||||
*
|
||||
* @param subsetOptimized optimize 后的 subset 字体(含按新 gid 顺序的 glyf 与原始 GPOS/GSUB 字节)
|
||||
* @param subsetGids 子集字形对应的原始 gid 序列(optimize 前后均保留)
|
||||
*/
|
||||
const rewriteLayoutTablesForSubset = (
|
||||
subsetOptimized: ReturnType<ReturnType<typeof Font.create>["optimize"]>,
|
||||
subsetGids: number[],
|
||||
): void => {
|
||||
const ttf = (subsetOptimized as any).get();
|
||||
|
||||
/** subsetGids[i] = 原始 gid,新 gid = i(optimize 不重排 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);
|
||||
};
|
||||
|
||||
751
backend/font_util/gpos-subset.ts
Normal file
751
backend/font_util/gpos-subset.ts
Normal file
@ -0,0 +1,751 @@
|
||||
/**
|
||||
* GPOS 表子集化器
|
||||
*
|
||||
* CJK 字体(思源黑体、霞鹜文楷等)的全角标点连续排列时,浏览器依赖 GPOS 表的
|
||||
* 标点压缩 lookup(SinglePos/PairPos)调整字间距。子集化后 glyphId 被重编号,
|
||||
* 但 fonteditor-core 的 GPOS 读写器是原始字节透传,不会按子集字形重映射 coverage,
|
||||
* 导致浏览器用新 glyphId 查 GPOS coverage 查不到,压缩规则失效,连续标点渲染变宽。
|
||||
*
|
||||
* 本模块按 OpenType 1.9.1 规范解析 GPOS,对 LookupType 1 (SinglePos) 和 2 (PairPos)
|
||||
* 的 coverage/ClassDef glyphId 做 原gid→新gid 重映射后重新序列化。
|
||||
* 遇到不支持的 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=0;Context/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.1;v1.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/PairPos):gid 重映射后重新序列化
|
||||
* - 不支持的(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) {
|
||||
/** 支持的 lookup:gid 重映射后重新序列化 */
|
||||
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 {
|
||||
/** 不支持的 lookup(Cursive/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) {
|
||||
/** 空 coverage:format1 + 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) {
|
||||
/** 空 ClassDef:format1, 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* PairPosFormat1:glyph 对(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;
|
||||
}
|
||||
|
||||
/**
|
||||
* PairPosFormat2:class 对(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 -> 原class(classDef 未出现的 gid 默认 class 0) */
|
||||
const gidToClass1 = readClassDefMap(r, subAbs + classDef1Off, origToNew);
|
||||
const gidToClass2 = readClassDefMap(r, subAbs + classDef2Off, origToNew);
|
||||
if (!gidToClass1 || !gidToClass2) return false;
|
||||
|
||||
/** 收集实际命中的 class(class 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;
|
||||
}
|
||||
356
backend/font_util/gsub-reachable.ts
Normal file
356
backend/font_util/gsub-reachable.ts
Normal 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 gid,供调用方注入子集(extraSubsetGids)。
|
||||
*
|
||||
* 处理的 lookup 类型:
|
||||
* - type1 SingleSubst: coverage gid(在子集)→ target gid
|
||||
* - type2 MultipleSubst: coverage gid(在子集)→ 多个 target gid
|
||||
* - type3 AlternateSubst: coverage gid(在子集)→ 多个候选 target gid(全部保留)
|
||||
* - type4 LigatureSubst: 全部 component 在子集 → target gid(多对一,target 为新字形)
|
||||
* - 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 gid(type1/2/3/4),type6 不直接产生 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 不直接产生 target(type6 通过 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(非 gid),class 匹配的 gid 由 ClassDef 决定;class index 始终有效,
|
||||
* 但规则是否「可能触发」取决于该 class 是否含子集内 gid(保守起见视为可触发,收集 ClassDef gid)。
|
||||
* format3:元素是 coverage,gid 全在子集则可触发。
|
||||
*
|
||||
* 只对可触发规则收集其 SubstLookupRecord 引用的 lookup index(refs),供调用方进一步追踪 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 在子集时才收集 refs(format1);format2 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 在子集才触发;format2:class 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));
|
||||
}
|
||||
}
|
||||
1010
backend/font_util/gsub-subset.ts
Normal file
1010
backend/font_util/gsub-subset.ts
Normal file
File diff suppressed because it is too large
Load Diff
234
backend/font_util/ot-bytes.ts
Normal file
234
backend/font_util/ot-bytes.ts
Normal file
@ -0,0 +1,234 @@
|
||||
/**
|
||||
* OpenType 表二进制读写器(大端序)
|
||||
*
|
||||
* GPOS / GSUB 等 OpenType 表全部使用大端序(big-endian)—— 这是 OpenType 1.9 规范
|
||||
* 对 TTF/OTF/woff/woff2 中表内容字节的硬性规定(表内所有整数均为大端)。
|
||||
* fonteditor-core 的 reader/writer(vendor/fonteditor-core/lib/ttf/reader.js)虽用参数化
|
||||
* 的 littleEndian,但 TTFReader.readBuffer 实例化 Reader 时第 4 参数恒传 false(大端),
|
||||
* 佐证 TTF 流一律大端;本模块对表内容直接硬编码大端,与之一致。
|
||||
*
|
||||
* 这些表的子集化逻辑(按子集字形重映射 coverage/ClassDef/替换目标 gid)需要逐字节
|
||||
* 重新序列化,两份子集化器(gpos-subset.ts、gsub-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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新序列化 ScriptList(GPOS/GSUB 通用,结构完全相同)
|
||||
*
|
||||
* ScriptList 不含 glyphId,但子表(ScriptTable/LangSys)偏移相对 ScriptList 起始,
|
||||
* 原始字体中 ScriptList 与 FeatureList/LookupList 的子表可能【物理交错】
|
||||
* (如霞鹜文楷 GSUB:ScriptList 跨越 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新序列化 FeatureList(GPOS/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();
|
||||
}
|
||||
2
task.md
2
task.md
@ -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生成方案,如果基准测试结果不对一定是你的修改有问题,而非浏览器渲染等其他问题
|
||||
|
||||
31
vendor/fonteditor-core/lib/ttf/table/GSUB.js
vendored
Normal file
31
vendor/fonteditor-core/lib/ttf/table/GSUB.js
vendored
Normal 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
|
||||
*
|
||||
* GSUB(Glyph 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;
|
||||
}
|
||||
});
|
||||
12
vendor/fonteditor-core/lib/ttf/table/glyf.js
vendored
12
vendor/fonteditor-core/lib/ttf/table/glyf.js
vendored
@ -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 调用 */
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
};
|
||||
7
vendor/fonteditor-core/lib/ttf/ttfreader.js
vendored
7
vendor/fonteditor-core/lib/ttf/ttfreader.js
vendored
@ -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;
|
||||
}
|
||||
|
||||
2
vendor/fonteditor-core/lib/ttf/ttfwriter.js
vendored
2
vendor/fonteditor-core/lib/ttf/ttfwriter.js
vendored
@ -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]) {
|
||||
|
||||
@ -394,25 +394,16 @@ function optimizettf(ttf) {
|
||||
glyfNotEmpty: m_glyfNotEmpty
|
||||
};
|
||||
|
||||
/* 优化99+103: hasCompound 已在主循环中追踪,过滤使用 _numContours 或 contours.length */
|
||||
/* 原逻辑会删除「无轮廓字形」(_numContours === 0),但这会误删两类必须保留的字形:
|
||||
* (1) space(U+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 + 索引赋值替代 push,indexMap 保持稀疏数组 */
|
||||
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) {
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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 → 本函数)
|
||||
* 被访问时尚未经过 optimize,simple component 仅解析出紧凑坐标数据而无 contours 字段。
|
||||
* 正常 codepoint 子集不会保留无 unicode 的连字 target(compound),故不触发此路径;
|
||||
* 当 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;
|
||||
|
||||
20
vendor/fonteditor-core/woff2/woff2-encode.js
vendored
20
vendor/fonteditor-core/woff2/woff2-encode.js
vendored
@ -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;
|
||||
|
||||
|
||||
68
基准测试.test.ts
68
基准测试.test.ts
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user