diff --git a/_p_check.ts b/_p_check.ts new file mode 100644 index 0000000..d4943ff --- /dev/null +++ b/_p_check.ts @@ -0,0 +1,16 @@ +import fs from 'node:fs'; +import { Font } from './vendor/fonteditor-core/lib/ttf/font.js'; +const buf = fs.readFileSync('/mnt/d/字体资源/初夏明朝/初夏明朝-Regular.ttf'); +const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset+buf.byteLength) as ArrayBuffer; +const cps = Array.from(',。!?、;:“”‘’'); +const font = Font.create(ab, { type: 'ttf', subset: cps as any, kerning: true }); +const opt = (font as any).optimize(); +const ttf = opt.get(); +console.log('GPOS present:', !!ttf.GPOS, 'len:', ttf.GPOS && ttf.GPOS.length); +console.log('GSUB present:', !!ttf.GSUB); +console.log('numGlyphs:', ttf.glyf.length); +const ttfBuf = opt.write({ type: 'ttf', hinting: false } as any); +console.log('ttfBuf type:', ttfBuf.constructor.name, 'len:', ttfBuf.length); +// 完整 woff2 via font.write +const woff2 = opt.write({ type: 'woff2', hinting: false } as any); +console.log('woff2 (font.write):', woff2.length, 'type:', woff2.constructor.name); diff --git a/_p_e2e.ts b/_p_e2e.ts new file mode 100644 index 0000000..7988380 --- /dev/null +++ b/_p_e2e.ts @@ -0,0 +1,24 @@ +import fs from 'node:fs'; +import { fontSubset } from './backend/font_util/font.js'; + +function median(a: number[]) { return a.slice().sort((x,y)=>x-y)[Math.floor(a.length/2)]; } +function bench(label: string, fn: () => void, iters = 15) { + for (let i = 0; i < 5; i++) fn(); + const ts: number[] = []; + for (let i = 0; i < iters; i++) { const t0 = performance.now(); fn(); ts.push(performance.now() - t0); } + console.log(`${label}: ${median(ts).toFixed(3)}ms (min ${Math.min(...ts).toFixed(3)})`); +} + +for (const [name, p, text] of [ + ['初夏纯标点', '/mnt/d/字体资源/初夏明朝/初夏明朝-Regular.ttf', ',。!?、;:“”‘’'], + ['初夏汉字标点', '/mnt/d/字体资源/初夏明朝/初夏明朝-Regular.ttf', '你好,世界!今天天气不错。'], + ['思源8字', './font/思源黑体.ttf', '天地玄黄宇宙洪荒'], + ['令东千字', './font/令东齐伋复刻体.ttf', '天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈'], +] as const) { + const buf = fs.readFileSync(p); + const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset+buf.byteLength) as ArrayBuffer; + console.log(`\n=== ${name} ===`); + let outLen = 0; + bench(' fontSubset(woff2)', () => { const o = fontSubset(ab, text, { sourceType: 'ttf' as const, outType: 'woff2' as const }); outLen = o.length; }); + console.log(` woff2 out: ${outLen}B`); +} diff --git a/_p_gsub_cmp.ts b/_p_gsub_cmp.ts new file mode 100644 index 0000000..bc335ab --- /dev/null +++ b/_p_gsub_cmp.ts @@ -0,0 +1,29 @@ +import fs from 'node:fs'; +import { Font } from './vendor/fonteditor-core/lib/ttf/font.js'; +import { subsetGSUB } from './backend/font_util/gsub-subset.js'; +import crypto from 'node:crypto'; + +const cases: [string, string, string][] = [ + ['初夏纯标点', '/mnt/d/字体资源/初夏明朝/初夏明朝-Regular.ttf', ',。!?、;:“”‘’'], + ['初夏汉字', '/mnt/d/字体资源/初夏明朝/初夏明朝-Regular.ttf', '你好,世界!今天天气不错。'], + ['思源8字', './font/思源黑体.ttf', '天地玄黄宇宙洪荒'], + ['令东千字', './font/令东齐伋复刻体.ttf', '天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈'], + ['FiraCode', '/mnt/d/字体资源/FiraCode/FiraCode-Medium.ttf', '=> !== >= <= ==='], +]; +for (const [name, p, text] of cases) { + if (!fs.existsSync(p)) { console.log(`${name}: 跳过(文件不存在)`); continue; } + const buf = fs.readFileSync(p); + const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset+buf.byteLength) as ArrayBuffer; + const cps = Array.from(text).map(c => c.codePointAt(0)!); + const font = Font.create(ab, { type: 'ttf', subset: cps as any, kerning: true }); + const ttf = font.get(); + const subsetGids: number[] = ttf.subsetGids ?? []; + const origToNew = new Map(); + for (let i = 0; i < subsetGids.length; i++) origToNew.set(subsetGids[i], i); + const gsub = ttf.GSUB; + if (!gsub) { console.log(`${name}: 无 GSUB`); continue; } + const b = gsub instanceof Uint8Array ? gsub : new Uint8Array(gsub); + const out = subsetGSUB(b, origToNew); + const hash = crypto.createHash('sha256').update(out).digest('hex').slice(0,16); + console.log(`${name}: gsubOut=${out.length}B hash=${hash}`); +} diff --git a/_p_gsub_struct.ts b/_p_gsub_struct.ts new file mode 100644 index 0000000..503e958 --- /dev/null +++ b/_p_gsub_struct.ts @@ -0,0 +1,23 @@ +import fs from 'node:fs'; +const buf = fs.readFileSync('/mnt/d/字体资源/初夏明朝/初夏明朝-Regular.ttf'); +const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset+buf.byteLength) as ArrayBuffer; +const view = new DataView(ab); +// 找 GSUB +const numTables = view.getUint16(4, false); +let off=0; +for (let i=0;i>>16), '.', (v&0xffff)); +console.log('lookupCount:', lookupCount); +for (let i=0;ix-y)[Math.floor(a.length/2)]; } + +const p = '/mnt/d/字体资源/初夏明朝/初夏明朝-Regular.ttf'; +const text = ',。!?、;:“”‘’'; +const buf = fs.readFileSync(p); +const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset+buf.byteLength) as ArrayBuffer; +const codePoints = Array.from(text).map(c => c.codePointAt(0)!); + +// 一次性拿到 subsetGids + GPOS 字节 + origToNew +const font = Font.create(ab, { type: 'ttf', subset: codePoints as any, kerning: true }); +const preOpt = font.get(); +const subsetGids: number[] = preOpt.subsetGids ?? []; +const origToNew = new Map(); +for (let i = 0; i < subsetGids.length; i++) origToNew.set(subsetGids[i], i); +const gposBytes = preOpt.GPOS instanceof Uint8Array ? preOpt.GPOS : new Uint8Array(preOpt.GPOS); +const gsubBytes = preOpt.GSUB instanceof Uint8Array ? preOpt.GSUB : new Uint8Array(preOpt.GSUB); +console.log('subsetGids:', subsetGids.length, 'GPOS:', gposBytes.length, 'GSUB:', gsubBytes.length); + +function bench(label: string, fn: () => void, iters = 25) { + for (let i = 0; i < 8; i++) fn(); + const ts: number[] = []; + for (let i = 0; i < iters; i++) { const t0 = performance.now(); fn(); ts.push(performance.now() - t0); } + console.log(` ${label}: ${median(ts).toFixed(3)}ms (min ${Math.min(...ts).toFixed(3)})`); +} + +bench('subsetGPOS', () => subsetGPOS(gposBytes, origToNew)); +bench('subsetGSUB', () => subsetGSUB(gsubBytes, origToNew)); diff --git a/_p_stages.ts b/_p_stages.ts new file mode 100644 index 0000000..47da29d --- /dev/null +++ b/_p_stages.ts @@ -0,0 +1,85 @@ +import fs from 'node:fs'; +import { Font } from './vendor/fonteditor-core/lib/ttf/font.js'; +import { probeGsubAndCmap } from './backend/font_util/gsub-probe.js'; +import { collectReachableGsubTargets } from './backend/font_util/gsub-reachable.js'; +import { subsetGPOS } from './backend/font_util/gpos-subset.js'; +import { subsetGSUB } from './backend/font_util/gsub-subset.js'; + +function rewriteLayoutTablesForSubset(opt: any, subsetGids: number[]) { + const ttf = opt.get(); + const origToNew = new Map(); + for (let i = 0; i < subsetGids.length; i++) origToNew.set(subsetGids[i], i); + const og = ttf.GPOS; + if (og) { const b = og instanceof Uint8Array ? og : new Uint8Array(og); if (b.byteLength > 0) { const r = subsetGPOS(b, origToNew); if (r) ttf.GPOS = r; } } + const os = ttf.GSUB; + if (os) { const b = os instanceof Uint8Array ? os : new Uint8Array(os); if (b.byteLength > 0) { ttf.GSUB = subsetGSUB(b, origToNew); } } +} + +function median(a: number[]) { return a.slice().sort((x,y)=>x-y)[Math.floor(a.length/2)]; } + +const p = '/mnt/d/字体资源/初夏明朝/初夏明朝-Regular.ttf'; +const text = ',。!?、;:“”‘’'; +const buf = fs.readFileSync(p); +const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset+buf.byteLength) as ArrayBuffer; +const codePoints = Array.from(text).map(c => c.codePointAt(0)!); + +// 准备共享对象 +function build() { + const probe = probeGsubAndCmap(ab, codePoints, 'ttf'); + let extraSubsetGids: number[] | undefined; + if (probe.ok) { + const seed = new Set([0]); + for (const cp of codePoints) { const g = probe.lookup.get(cp); if (g !== undefined) seed.add(g); } + const r = collectReachableGsubTargets(probe.gsubBytes!, seed); + if (r.size > 0) extraSubsetGids = [...r]; + } + return { extraSubsetGids }; +} + +const { extraSubsetGids } = build(); + +function timePhase(label: string, fn: () => void, iters = 13) { + for (let i = 0; i < 4; i++) fn(); + const ts: number[] = []; + for (let i = 0; i < iters; i++) { const t0 = performance.now(); fn(); ts.push(performance.now() - t0); } + console.log(` ${label}: ${median(ts).toFixed(3)}ms`); +} + +console.log('=== 初夏纯标点 各阶段(独立 best-of)==='); +timePhase('1.probe', () => probeGsubAndCmap(ab, codePoints, 'ttf')); + +let fontRef: any; +timePhase('2.Font.create', () => { fontRef = Font.create(ab, { type: 'ttf', subset: codePoints as any, kerning: true, extraSubsetGids }); }); + +const font = Font.create(ab, { type: 'ttf', subset: codePoints as any, kerning: true, extraSubsetGids }); +const preOpt = font.get(); +const subsetGids: number[] = preOpt.subsetGids ?? []; +console.log(` subsetGids count: ${subsetGids.length}`); + +timePhase('3.optimize', () => { const f = Font.create(ab, { type: 'ttf', subset: codePoints as any, kerning: true, extraSubsetGids }); f.optimize(); }); + +const opt = font.optimize(); +// rewrite 内部拆解 +timePhase('4a.subsetGPOS', () => { + const f = Font.create(ab, { type: 'ttf', subset: codePoints as any, kerning: true, extraSubsetGids }); + const o = f.optimize(); + // 复刻 rewriteLayoutTablesForSubset 的 GPOS 部分 + const ttf = o.get(); + const origToNew = new Map(); + for (let i = 0; i < subsetGids.length; i++) origToNew.set(subsetGids[i], i); + const g = ttf.GPOS; + if (g) { subsetGPOS(g instanceof Uint8Array ? g : new Uint8Array(g), origToNew); } +}); + +timePhase('4.rewriteLayoutTables', () => { + const f = Font.create(ab, { type: 'ttf', subset: codePoints as any, kerning: true, extraSubsetGids }); + const o = f.optimize(); + rewriteLayoutTablesForSubset(o, subsetGids); +}); + +timePhase('5.write(woff2)', () => { + const f = Font.create(ab, { type: 'ttf', subset: codePoints as any, kerning: true, extraSubsetGids }); + const o = f.optimize(); + rewriteLayoutTablesForSubset(o, subsetGids); + o.write({ type: 'woff2', hinting: false } as any); +}); diff --git a/_p_woff2enc.ts b/_p_woff2enc.ts new file mode 100644 index 0000000..07a7a18 --- /dev/null +++ b/_p_woff2enc.ts @@ -0,0 +1,30 @@ +import fs from 'node:fs'; +import { Font } from './vendor/fonteditor-core/lib/ttf/font.js'; +const woff2enc: any = await import('./vendor/fonteditor-core/woff2/woff2-encode.js'); + +function median(a: number[]) { return a.slice().sort((x,y)=>x-y)[Math.floor(a.length/2)]; } +function bench(label: string, fn: () => void, iters = 15) { + for (let i = 0; i < 5; i++) fn(); + const ts: number[] = []; + for (let i = 0; i < iters; i++) { const t0 = performance.now(); fn(); ts.push(performance.now() - t0); } + console.log(`${label}: ${median(ts).toFixed(3)}ms (min ${Math.min(...ts).toFixed(3)})`); +} + +for (const [name, p, text] of [ + ['初夏标点', '/mnt/d/字体资源/初夏明朝/初夏明朝-Regular.ttf', ',。!?、;:“”‘’'], + ['思源8字', './font/思源黑体.ttf', '天地玄黄宇宙洪荒'], + ['令东千字', './font/令东齐伋复刻体.ttf', '天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈'], +] as const) { + const buf = fs.readFileSync(p); + const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset+buf.byteLength) as ArrayBuffer; + const cps = Array.from(text); + const font = Font.create(ab, { type: 'ttf', subset: cps as any, kerning: true }); + const opt = (font as any).optimize(); + const ttfBuf = opt.write({ type: 'ttf', hinting: false } as any); + console.log(`\n=== ${name} (ttf ${(ttfBuf.length/1024).toFixed(1)}KB) ===`); + bench(' TTFWriter.write(ttf)', () => opt.write({ type: 'ttf', hinting: false } as any)); + bench(' encodeTTFToWOFF2(全部)', () => woff2enc.encodeTTFToWOFF2(ttfBuf)); + // 单独 transformGlyfAndLoca?需拆解,先量总 encode + const w2 = woff2enc.encodeTTFToWOFF2(ttfBuf); + console.log(` woff2 out: ${(w2.length)}B`); +} diff --git a/backend/font_util/gsub-subset.ts b/backend/font_util/gsub-subset.ts index ed5f763..74acae3 100644 --- a/backend/font_util/gsub-subset.ts +++ b/backend/font_util/gsub-subset.ts @@ -1209,6 +1209,11 @@ export function subsetGSUB( effectiveType: number; subtableAbsOffs: number[]; origLookupOff: number; + /** 该 lookup 的所有子表 coverage 是否都「原非空且全子集外」(→ 序列化必为空 subtable)。 + * 为 true 时跳过逐子表 serializeSubtable,直接批量写空 subtable(保留 subCount,不删 lookup)。 + * 含 format2 class 驱动子表的 lookup,isSubtableSkipableByCoverage 返回 false,故 allEmpty 必为 false, + * 走原逐子表路径(保守,与 FiraCode 连字安全要求一致 [[gsub-lookup-deletion-failed-fira]])。 */ + allEmpty: boolean; } const lookups: LookupInfo[] = []; for (let i = 0; i < lookupCount; i++) { @@ -1239,7 +1244,31 @@ export function subsetGSUB( effectiveType === LT_ALTERNATE || effectiveType === LT_LIGATURE || effectiveType === LT_CHAIN; - lookups.push({ supported, effectiveType, subtableAbsOffs, origLookupOff: lOff }); + lookups.push({ supported, effectiveType, subtableAbsOffs, origLookupOff: lOff, allEmpty: false }); + } + + /** + * 优化331:lookup 级全空预扫描。 + * 大字体小子集场景(初夏 51 lookup × lookup[5] 268 子表),逐子表 serializeSubtable 即使预检 + * 判空仍要付出「函数调用 + isSubtableSkipableByCoverage 读 coverage + rollback」的 per-subtable 开销。 + * 若整个 lookup 的所有子表都 skipable(全子集外),序列化结果必然是 N 个空 subtable—— + * 直接在 lookup 级批量写空,跳过 N 次 serializeSubtable 调用。 + * + * 安全性:allEmpty 当且仅当「所有子表 isSubtableSkipableByCoverage === true」。而 serializeSubtable + * 内部对 skipable 子表直接 return false(→ 调用方写空 subtable)。故 allEmpty 路径的输出与 + * 逐子表路径**逐字节相同**(都是 N 个 writeEmptySubtable)。format2 子表不预检(返回 false), + * 含 format2 的 lookup allEmpty 必为 false,走原路径。 */ + for (let i = 0; i < lookupCount; i++) { + const lk = lookups[i]; + if (!lk.supported) continue; + let allEmpty = lk.subtableAbsOffs.length > 0; + for (let j = 0; j < lk.subtableAbsOffs.length; j++) { + if (!isSubtableSkipableByCoverage(r, lk.subtableAbsOffs[j], lk.effectiveType, gidLookup, covCache)) { + allEmpty = false; + break; + } + } + lk.allEmpty = allEmpty; } /** ---- 重新序列化 ---- @@ -1328,18 +1357,28 @@ export function subsetGSUB( 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; - /** 单个 subtable 重映射失败(coverage gid 全不在子集 / 解析异常)时, - * 回退已写入字节,改为输出合法的空 subtable(空 coverage,浏览器跳过,不破坏字体)。 - * 不再用 copyBytesBlock 按估算范围拷贝——原始 subtable 数据可能与其他 lookup 物理交错, - * 按 lookup 边界估算会拷贝到错误字节(霞鹜文楷实测 subtable 在表头区之后)。 */ - const before = w.length; - const ok = serializeSubtable(w, r, lk.subtableAbsOffs[j], lk.effectiveType, origToNew, covCache, gidLookup); - if (!ok) { - w.rollback(before); + if (lk.allEmpty) { + /** 优化331:全空 lookup 批量写空 subtable,跳过逐子表 serializeSubtable。 + * 输出与逐子表路径逐字节相同(每个子表都是 writeEmptySubtable),仅省去 N 次 + * 函数调用 + 预检 + rollback 的开销。subCount 不变,lookup 仍存在。 */ + for (let j = 0; j < lk.subtableAbsOffs.length; j++) { + subtableAbsPositions[j] = w.length; writeEmptySubtable(w, lk.effectiveType); } + } else { + for (let j = 0; j < lk.subtableAbsOffs.length; j++) { + subtableAbsPositions[j] = w.length; + /** 单个 subtable 重映射失败(coverage gid 全不在子集 / 解析异常)时, + * 回退已写入字节,改为输出合法的空 subtable(空 coverage,浏览器跳过,不破坏字体)。 + * 不再用 copyBytesBlock 按估算范围拷贝——原始 subtable 数据可能与其他 lookup 物理交错, + * 按 lookup 边界估算会拷贝到错误字节(霞鹜文楷实测 subtable 在表头区之后)。 */ + const before = w.length; + const ok = serializeSubtable(w, r, lk.subtableAbsOffs[j], lk.effectiveType, origToNew, covCache, gidLookup); + if (!ok) { + w.rollback(before); + writeEmptySubtable(w, lk.effectiveType); + } + } } } else { /** 不支持的 lookup(type5 ReverseChain,及尚未验证的类型):