From 6f0e3d8e6bd4c9830893ee1cf38ab7950aa2fbe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B4=AE=E7=94=9F=EF=BC=88=E5=AD=90=E8=99=9A=EF=BC=89?= <2234839456@qq.com> Date: Thu, 9 Apr 2026 11:21:25 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20name=20=E8=A1=A8?= =?UTF-8?q?=E5=86=99=E5=85=A5=E5=81=8F=E7=A7=BB=E9=94=99=E8=AF=AF=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E6=B5=8F=E8=A7=88=E5=99=A8=E6=97=A0=E6=B3=95=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E5=AD=97=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit name.js write 函数改用 view.setUint16 后未同步 writer.offset, 导致 name 字符串覆盖 header,name 表损坏。浏览器依赖 name 表 识别字体所以无法渲染,而 Node 端渲染不依赖 name 表所以基准 测试未检测到。 同时修复 cmap sizeof 动态计算记录头大小的问题。 Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- scripts/build-backend.ts | 6 +- scripts/check_ttf.ts | 42 +++++++ scripts/test_font_valid.ts | 71 +++++++++++ task.md | 2 + tsup.config.ts | 2 +- .../lib/ttf/table/cmap/sizeof.js | 8 +- .../lib/ttf/table/cmap/write.js | 21 ++-- vendor/fonteditor-core/lib/ttf/table/name.js | 3 +- 基准测试_llrt.ts | 77 ++++++++++++ 基准测试_verify.ts | 115 ++++++++++++++++++ 11 files changed, 336 insertions(+), 13 deletions(-) create mode 100644 scripts/check_ttf.ts create mode 100644 scripts/test_font_valid.ts create mode 100644 基准测试_llrt.ts create mode 100644 基准测试_verify.ts diff --git a/package.json b/package.json index ae5de8b..f0b7bc6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "webfont", "private": true, - "version": "1.3.0", + "version": "1.3.1", "type": "module", "scripts": { "dev": "pnpx tsx scripts/dev-all.ts", diff --git a/scripts/build-backend.ts b/scripts/build-backend.ts index ed6dbc6..98448fe 100644 --- a/scripts/build-backend.ts +++ b/scripts/build-backend.ts @@ -103,7 +103,11 @@ function runTsup() { /** 使用 LLRT compile 生成 .lrt 文件 */ function runLlrtCompile() { console.log("\n--- Running LLRT compile ---"); - execSync(`${LLRT_BIN} compile ./dist_backend/app.cjs ./dist_backend/app.lrt`, { + execSync(`${LLRT_BIN} compile ./dist_backend/backend/app.cjs ./dist_backend/app.lrt`, { + stdio: "inherit", + cwd: ROOT_DIR, + }); + execSync(`${LLRT_BIN} compile "./dist_backend/基准测试_llrt.cjs" ./dist_backend/llrt_bench.lrt`, { stdio: "inherit", cwd: ROOT_DIR, }); diff --git a/scripts/check_ttf.ts b/scripts/check_ttf.ts new file mode 100644 index 0000000..e80adb9 --- /dev/null +++ b/scripts/check_ttf.ts @@ -0,0 +1,42 @@ +import { readFile } from "node:fs/promises"; +import { Font } from "../vendor/fonteditor-core/lib/ttf/font.js"; + +const raw = await readFile("font/temp/YiShanBeiZhuanTi.ttf"); +const buf = new Uint8Array(raw).buffer; +const text = "你好世界"; +const subset = [...text].map(c => c.codePointAt(0)); + +const font = Font.create(buf, { type: "ttf", subset }); +const optimized = font.optimize().sort(); +const result = optimized.write({ type: "ttf" }); +const data = new Uint8Array(result); + +console.log("=== TTF Header ==="); +const view = new DataView(data.buffer, data.byteOffset, data.byteLength); +console.log("sfVersion:", "0x" + view.getUint32(0, false).toString(16)); +console.log("numTables:", view.getUint16(4, false)); + +const tables: Array<{ tag: string; offset: number; length: number }> = []; +for (let i = 0; i < view.getUint16(4, false); i++) { + const offset = 12 + i * 16; + const tag = String.fromCharCode(view.getUint8(offset), view.getUint8(offset + 1), view.getUint8(offset + 2), view.getUint8(offset + 3)); + const toffset = view.getUint32(offset + 8, false); + const tlength = view.getUint32(offset + 12, false); + tables.push({ tag, offset: toffset, length: tlength }); + console.log(" ", tag, "offset=" + toffset, "length=" + tlength); +} + +const headEntry = tables.find(t => t.tag === "head"); +if (headEntry) { + const magic = view.getUint32(headEntry.offset + 12, false); + console.log("\nhead magicNumber:", "0x" + magic.toString(16), magic === 0x5F0F3CF5 ? "OK" : "INVALID!"); +} + +console.log("\nfile size:", data.length); +console.log("last table end:", Math.max(...tables.map(t => t.offset + t.length))); + +/** 和原始字体对比 */ +const origView = new DataView(buf); +console.log("\n=== 原始字体 ==="); +console.log("sfVersion:", "0x" + origView.getUint32(0, false).toString(16)); +console.log("numTables:", origView.getUint16(4, false)); diff --git a/scripts/test_font_valid.ts b/scripts/test_font_valid.ts new file mode 100644 index 0000000..b53b14d --- /dev/null +++ b/scripts/test_font_valid.ts @@ -0,0 +1,71 @@ +/** + * 字体裁剪验证 — 裁剪多种字体并保存为文件 + * 运行: pnpm tsx scripts/test_font_valid.ts + */ +import { Font } from "../vendor/fonteditor-core/lib/ttf/font.js"; +import { readFile, writeFile, mkdir, readdir } from "node:fs/promises"; + +const OUTPUT_DIR = "benchmark_results/font_test"; +await mkdir(OUTPUT_DIR, { recursive: true }); + +/** 在所有字体目录中查找字体 */ +import { stat } from "node:fs/promises"; +const fontDirs = ["font/admin", "font", "font/temp"]; +async function findFonts(): Promise> { + const all: Array<{ name: string; path: string }> = []; + for (const dir of fontDirs) { + try { + const entries = await readdir(dir); + for (const entry of entries) { + const name = typeof entry === "string" ? entry : entry.name; + if (/\.(ttf|otf|woff|woff2)$/i.test(name)) { + const fullPath = `${dir}/${name}`; + try { + const s = await stat(fullPath); + if (s.isFile()) { + all.push({ name, path: fullPath }); + } + } catch { /* skip */ } + } + } + } catch { /* skip */ } + } + return all; +} + +const fonts = await findFonts(); +const testText = "你好世界"; +const codePoints = [...testText].map(c => c.codePointAt(0)!); + +console.log("\n=== 字体裁剪验证 ===\n"); +console.log(`测试文本: "${testText}"\n`); + +for (const f of fonts) { + try { + const raw = await readFile(f.path); + const buf = new Uint8Array(raw).buffer; + + const font = Font.create(buf, { type: "ttf", subset: codePoints }); + const optimized = font.optimize().sort(); + const result = optimized.write({ type: "ttf" }); + + const data = typeof result === "string" + ? new TextEncoder().encode(result) + : new Uint8Array(result); + + const outPath = `${OUTPUT_DIR}/${f.name.replace(/\.[^.]+$/, "")}_subset.ttf`; + await writeFile(outPath, data); + + /** 检查 TTF 文件头 */ + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + const sfVersion = view.getUint32(0, false); + const numTables = view.getUint16(4, false); + + console.log(` ${f.name}: ${data.length.toLocaleString()} bytes, sfVersion=0x${sfVersion.toString(16)}, numTables=${numTables}`); + } catch (e: any) { + console.log(` ${f.name}: ERROR - ${e.message}`); + } +} + +console.log(`\n输出目录: ${OUTPUT_DIR}/`); +console.log("请在 Windows 字体查看器中打开验证"); diff --git a/task.md b/task.md index ee6aa61..df9c3f9 100644 --- a/task.md +++ b/task.md @@ -18,4 +18,6 @@ ## 其他方向 +woff2 格式是不是更优越,可以新增这种格式吗,然后ttf的也还支持,但是默认使用这个 + 就是有一个纯前端的优化,咱们提供的js SDK好像是通过定时器扫描的吧,这当然是一种方式,也是最省心的一种方式,但是咱们是不是还可以考虑另外一种方式,就是通过配置来启用定时扫描还是由用户自己的事件来触发,甚至由用户直接传递文本,这样的话对于首页上的demo来说,可能会有更高的及时性响应 \ No newline at end of file diff --git a/tsup.config.ts b/tsup.config.ts index 97d52a0..ebf25a5 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["backend/app.ts"], + entry: ["backend/app.ts", "基准测试_llrt.ts"], splitting: false, sourcemap: true, clean: true, diff --git a/vendor/fonteditor-core/lib/ttf/table/cmap/sizeof.js b/vendor/fonteditor-core/lib/ttf/table/cmap/sizeof.js index 7fa6048..10a0d66 100644 --- a/vendor/fonteditor-core/lib/ttf/table/cmap/sizeof.js +++ b/vendor/fonteditor-core/lib/ttf/table/cmap/sizeof.js @@ -82,7 +82,8 @@ function sizeof(ttf) { ttf.support.cmap.format4Segments = getSegments(unicodes2Bytes, 0xFFFF); ttf.support.cmap.format4Size = 24 + ttf.support.cmap.format4Segments.length * 8; ttf.support.cmap.format0Segments = getFormat0Segment(glyfUnicodes); - ttf.support.cmap.format0Size = 262; + ttf.support.cmap.hasFormat0 = ttf.support.cmap.format0Segments.length > 0; + ttf.support.cmap.format0Size = ttf.support.cmap.hasFormat0 ? 262 : 0; var hasGLyphsOver2Bytes = false; for (var gi = 0, gil = unicodes2Bytes.length; gi < gil; gi++) { @@ -97,7 +98,10 @@ function sizeof(ttf) { ttf.support.cmap.format12Segments = getSegments(unicodes4Bytes); ttf.support.cmap.format12Size = 16 + ttf.support.cmap.format12Segments.length * 12; } - var size = 4 + (hasGLyphsOver2Bytes ? 32 : 24) + /** 记录头大小必须动态计算,与 write.js 中的 numRecords 保持一致,否则会导致表偏移错位 */ + var numRecords = 2 + (ttf.support.cmap.hasFormat0 ? 1 : 0) + (hasGLyphsOver2Bytes ? 1 : 0); + var recordHeaderSize = 4 + numRecords * 8; + var size = recordHeaderSize + ttf.support.cmap.format0Size + ttf.support.cmap.format4Size + (hasGLyphsOver2Bytes ? ttf.support.cmap.format12Size : 0); diff --git a/vendor/fonteditor-core/lib/ttf/table/cmap/write.js b/vendor/fonteditor-core/lib/ttf/table/cmap/write.js index 94d529a..99faf40 100644 --- a/vendor/fonteditor-core/lib/ttf/table/cmap/write.js +++ b/vendor/fonteditor-core/lib/ttf/table/cmap/write.js @@ -92,26 +92,33 @@ function writeSubTable12(writer, segments) { function write(writer, ttf) { var hasGLyphsOver2Bytes = ttf.support.cmap.hasGLyphsOver2Bytes; + var hasFormat0 = ttf.support.cmap.hasFormat0; var pos = writer.offset; var view = writer.view; + var numRecords = 2 + (hasFormat0 ? 1 : 0) + (hasGLyphsOver2Bytes ? 1 : 0); view.setUint16(pos, 0, false); pos += 2; - view.setUint16(pos, hasGLyphsOver2Bytes ? 4 : 3, false); pos += 2; + view.setUint16(pos, numRecords, false); pos += 2; writer.offset = pos; - var subTableOffset = 4 + (hasGLyphsOver2Bytes ? 32 : 24); + var headerSize = 4 + numRecords * 8; var format4Size = ttf.support.cmap.format4Size; var format0Size = ttf.support.cmap.format0Size; - writer.writeUint16(0); writer.writeUint16(3); writer.writeUint32(subTableOffset); - writer.writeUint16(1); writer.writeUint16(0); writer.writeUint32(subTableOffset + format4Size); - writer.writeUint16(3); writer.writeUint16(1); writer.writeUint32(subTableOffset); + /** platform 0 (Unicode) 和 platform 3 (Windows) 共享同一个 format 4 subtable */ + writer.writeUint16(0); writer.writeUint16(3); writer.writeUint32(headerSize); + if (hasFormat0) { + writer.writeUint16(1); writer.writeUint16(0); writer.writeUint32(headerSize + format4Size); + } + writer.writeUint16(3); writer.writeUint16(1); writer.writeUint32(headerSize); if (hasGLyphsOver2Bytes) { - writer.writeUint16(3); writer.writeUint16(10); writer.writeUint32(subTableOffset + format4Size + format0Size); + writer.writeUint16(3); writer.writeUint16(10); writer.writeUint32(headerSize + format4Size + format0Size); } writeSubTable4(writer, ttf.support.cmap.format4Segments); - writeSubTable0(writer, ttf.support.cmap.format0Segments); + if (hasFormat0) { + writeSubTable0(writer, ttf.support.cmap.format0Segments); + } if (hasGLyphsOver2Bytes) { writeSubTable12(writer, ttf.support.cmap.format12Segments); } diff --git a/vendor/fonteditor-core/lib/ttf/table/name.js b/vendor/fonteditor-core/lib/ttf/table/name.js index 9fd5c4a..393ada8 100644 --- a/vendor/fonteditor-core/lib/ttf/table/name.js +++ b/vendor/fonteditor-core/lib/ttf/table/name.js @@ -87,10 +87,11 @@ var _default = exports.default = _table.default.create('name', [], { offset += r.name.length; } + /** 必须在 writeBytes 前同步 writer.offset,否则 writeBytes 会从旧偏移写入,覆盖 header */ + writer.offset = pos; for (var j = 0, jl = nameRecordTbl.length; j < jl; j++) { writer.writeBytes(nameRecordTbl[j].name); } - writer.offset = pos; return writer; }, size: function size(ttf) { diff --git a/基准测试_llrt.ts b/基准测试_llrt.ts new file mode 100644 index 0000000..f5ddda3 --- /dev/null +++ b/基准测试_llrt.ts @@ -0,0 +1,77 @@ +/** + * LLRT 字体裁剪 — 生成子集字体供 Node 渲染验证 + * 运行: pnpm build_backend && ./llrt dist_backend/llrt_bench.lrt + */ +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { performance } from "node:perf_hooks"; +import { Font } from "./vendor/fonteditor-core/lib/ttf/font.js"; + +const FONT_PATH = "font/令东齐伋复刻体.ttf"; +const FONT_NAME = "令东齐伋复刻体"; +const BENCHMARK_DIR = "benchmark_results"; + +const testCases = [ + { label: "8个汉字", text: "天地玄黄宇宙洪荒" }, + { label: "拉丁+数字", text: "Hello World 123" }, + { label: "千字文前段", text: "天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈剑号巨阙珠称夜光果珍李柰菜重芥姜海咸河淡鳞潜羽翔" }, +]; + +const ROUNDS = 10; + +async function main() { + const raw = await readFile(FONT_PATH); + const fontBuffer = new Uint8Array(raw).buffer; + + await mkdir(BENCHMARK_DIR, { recursive: true }); + + console.log("\n=== LLRT 字体裁剪验证 ===\n"); + console.log("[runtime]", globalThis.process?.release?.name ?? "unknown"); + console.log("[font]", FONT_PATH, "size:", fontBuffer.byteLength, "bytes\n"); + + const summary: Array<{ label: string; text: string; avg: number; min: number; max: number; outputSize: number; fontFile: string }> = []; + + for (const { label, text } of testCases) { + const subset = [...text].map((c) => c.codePointAt(0)!); + const times: number[] = []; + let lastOutput: Uint8Array | null = null; + + for (let i = 0; i < ROUNDS; i++) { + const t0 = performance.now(); + const font = Font.create(fontBuffer, { type: "ttf", subset }); + const optimized = font.optimize().sort(); + const result = optimized.write({ type: "ttf" }); + const t1 = performance.now(); + times.push(t1 - t0); + + if (i === 0) { + lastOutput = typeof result === "string" + ? new TextEncoder().encode(result) + : new Uint8Array(result); + } + } + + const avg = times.reduce((a, b) => a + b, 0) / times.length; + const min = Math.min(...times); + const max = Math.max(...times); + const size = lastOutput?.length ?? 0; + + /** 保存子集字体文件 */ + const safeLabel = label.replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, "_"); + const fontFile = `${BENCHMARK_DIR}/llrt_${safeLabel}.ttf`; + if (lastOutput) { + await writeFile(fontFile, lastOutput); + } + + summary.push({ label, text, avg, min, max, outputSize: size, fontFile }); + console.log(` ${label}: avg=${avg.toFixed(1)}ms min=${min.toFixed(1)}ms max=${max.toFixed(1)}ms 输出=${size.toLocaleString()} bytes`); + } + + await writeFile( + `${BENCHMARK_DIR}/llrt_bench_meta.json`, + JSON.stringify({ runtime: globalThis.process?.release?.name, fontPath: FONT_PATH, fontName: FONT_NAME, rounds: ROUNDS, results: summary }, null, 2), + ); + + console.log("\n子集字体已保存,请运行: pnpm tsx 基准测试_verify.ts"); +} + +main(); diff --git a/基准测试_verify.ts b/基准测试_verify.ts new file mode 100644 index 0000000..247a33d --- /dev/null +++ b/基准测试_verify.ts @@ -0,0 +1,115 @@ +/** + * Node 端渲染验证 — 加载 llrt 生成的子集字体,渲染并与完整字体对比 SSIM + * 运行: pnpm tsx 基准测试_verify.ts + * + * 前置: 先运行 llrt 基准测试生成子集字体文件 + */ +import { readFile } from "node:fs/promises"; +import { Canvas, FontLibrary } from "skia-canvas"; +import type { FontFace } from "skia-canvas"; + +const BENCHMARK_DIR = "benchmark_results"; + +/** 渲染文字到像素数据 */ +function renderText(fontFamily: string, text: string, fontSize: number): Uint8Array { + const charWidth = Math.ceil(fontSize * 1.5); + const width = text.length * charWidth + 20; + const height = Math.ceil(fontSize * 1.5); + const canvas = new Canvas(width, height); + const ctx = canvas.getContext("2d"); + ctx.fillStyle = "white"; + ctx.fillRect(0, 0, width, height); + ctx.font = `${fontSize}px ${fontFamily}`; + ctx.fillStyle = "black"; + ctx.fillText(text, 10, Math.ceil(fontSize * 1.2)); + const imgData = ctx.getImageData(0, 0, width, height); + return new Uint8Array(imgData.data.buffer); +} + +/** 渲染文字并保存为 PNG */ +async function renderTextToPng(fontFamily: string, text: string, fontSize: number, filePath: string) { + const charWidth = Math.ceil(fontSize * 1.5); + const width = text.length * charWidth + 20; + const height = Math.ceil(fontSize * 1.5); + const canvas = new Canvas(width, height); + const ctx = canvas.getContext("2d"); + ctx.fillStyle = "white"; + ctx.fillRect(0, 0, width, height); + ctx.font = `${fontSize}px ${fontFamily}`; + ctx.fillStyle = "black"; + ctx.fillText(text, 10, Math.ceil(fontSize * 1.2)); + const buffer = await canvas.toBuffer("png"); + const { writeFile } = await import("node:fs/promises"); + return writeFile(filePath, buffer); +} + +/** 计算两张图片的结构相似度(简化版 SSIM),返回 0~1 */ +function calculateSSIM(a: Uint8Array, b: Uint8Array): number { + if (a.length !== b.length) return 0; + + const toGray = (data: Uint8Array, offset: number) => + 0.299 * data[offset] + 0.587 * data[offset + 1] + 0.114 * data[offset + 2]; + + const pixelCount = a.length / 4; + let sumA = 0, sumB = 0, sumA2 = 0, sumB2 = 0, sumAB = 0; + + for (let i = 0; i < pixelCount; i++) { + const idx = i * 4; + const ga = toGray(a, idx); + const gb = toGray(b, idx); + sumA += ga; + sumB += gb; + sumA2 += ga * ga; + sumB2 += gb * gb; + sumAB += ga * gb; + } + + const meanA = sumA / pixelCount; + const meanB = sumB / pixelCount; + const varA = sumA2 / pixelCount - meanA * meanA; + const varB = sumB2 / pixelCount - meanB * meanB; + const covAB = sumAB / pixelCount - meanA * meanB; + + const C1 = 6.5025; + const C2 = 58.5225; + + return (2 * meanA * meanB + C1) * (2 * covAB + C2) / + ((meanA * meanA + meanB * meanB + C1) * (varA + varB + C2)); +} + +async function main() { + const metaRaw = await readFile(`${BENCHMARK_DIR}/llrt_bench_meta.json`, "utf-8"); + const meta = JSON.parse(metaRaw); + + console.log("\n=== LLRT 子集字体渲染验证(Node 渲染) ===\n"); + console.log(`[来源] ${meta.runtime} 运行时裁剪`); + console.log(`[字体] ${meta.fontName}`); + console.log(`[轮次] ${meta.rounds}\n`); + + /** 注册完整字体 */ + FontLibrary.use(meta.fontName, meta.fontPath); + + let allPassed = true; + + for (const item of meta.results) { + const familyName = `LLRT_${item.label.replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, "_")}`; + FontLibrary.use(familyName, item.fontFile); + + const fullPixels = renderText(meta.fontName, item.text, 48); + const subsetPixels = renderText(familyName, item.text, 48); + const ssim = calculateSSIM(fullPixels, subsetPixels); + + /** 保存渲染对比图 */ + const safeLabel = item.label.replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, "_"); + await renderTextToPng(meta.fontName, item.text, 48, `${BENCHMARK_DIR}/llrt_verify_${safeLabel}_full.png`); + await renderTextToPng(familyName, item.text, 48, `${BENCHMARK_DIR}/llrt_verify_${safeLabel}_subset.png`); + + const passed = ssim >= 0.999; + if (!passed) allPassed = false; + console.log(` ${item.label}: ssim=${ssim.toFixed(4)} ${passed ? "PASS" : "FAIL"} (${item.outputSize.toLocaleString()} bytes)`); + } + + console.log(`\n${allPassed ? "全部通过!" : "存在渲染差异,请检查对比图片!"}`); +} + +main();