fix: 修复 name 表写入偏移错误导致浏览器无法渲染字体

name.js write 函数改用 view.setUint16 后未同步 writer.offset,
导致 name 字符串覆盖 header,name 表损坏。浏览器依赖 name 表
识别字体所以无法渲染,而 Node 端渲染不依赖 name 表所以基准
测试未检测到。

同时修复 cmap sizeof 动态计算记录头大小的问题。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
崮生(子虚) 2026-04-09 11:21:25 +08:00
parent 4acb1c1e5d
commit 6f0e3d8e6b
11 changed files with 336 additions and 13 deletions

View File

@ -1,7 +1,7 @@
{ {
"name": "webfont", "name": "webfont",
"private": true, "private": true,
"version": "1.3.0", "version": "1.3.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "pnpx tsx scripts/dev-all.ts", "dev": "pnpx tsx scripts/dev-all.ts",

View File

@ -103,7 +103,11 @@ function runTsup() {
/** 使用 LLRT compile 生成 .lrt 文件 */ /** 使用 LLRT compile 生成 .lrt 文件 */
function runLlrtCompile() { function runLlrtCompile() {
console.log("\n--- Running LLRT compile ---"); 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", stdio: "inherit",
cwd: ROOT_DIR, cwd: ROOT_DIR,
}); });

42
scripts/check_ttf.ts Normal file
View File

@ -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));

View File

@ -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<Array<{ name: string; path: string }>> {
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 字体查看器中打开验证");

View File

@ -18,4 +18,6 @@
## 其他方向 ## 其他方向
woff2 格式是不是更优越可以新增这种格式吗然后ttf的也还支持但是默认使用这个
就是有一个纯前端的优化咱们提供的js SDK好像是通过定时器扫描的吧这当然是一种方式也是最省心的一种方式但是咱们是不是还可以考虑另外一种方式就是通过配置来启用定时扫描还是由用户自己的事件来触发甚至由用户直接传递文本这样的话对于首页上的demo来说可能会有更高的及时性响应 就是有一个纯前端的优化咱们提供的js SDK好像是通过定时器扫描的吧这当然是一种方式也是最省心的一种方式但是咱们是不是还可以考虑另外一种方式就是通过配置来启用定时扫描还是由用户自己的事件来触发甚至由用户直接传递文本这样的话对于首页上的demo来说可能会有更高的及时性响应

View File

@ -1,7 +1,7 @@
import { defineConfig } from "tsup"; import { defineConfig } from "tsup";
export default defineConfig({ export default defineConfig({
entry: ["backend/app.ts"], entry: ["backend/app.ts", "基准测试_llrt.ts"],
splitting: false, splitting: false,
sourcemap: true, sourcemap: true,
clean: true, clean: true,

View File

@ -82,7 +82,8 @@ function sizeof(ttf) {
ttf.support.cmap.format4Segments = getSegments(unicodes2Bytes, 0xFFFF); ttf.support.cmap.format4Segments = getSegments(unicodes2Bytes, 0xFFFF);
ttf.support.cmap.format4Size = 24 + ttf.support.cmap.format4Segments.length * 8; ttf.support.cmap.format4Size = 24 + ttf.support.cmap.format4Segments.length * 8;
ttf.support.cmap.format0Segments = getFormat0Segment(glyfUnicodes); 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; var hasGLyphsOver2Bytes = false;
for (var gi = 0, gil = unicodes2Bytes.length; gi < gil; gi++) { 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.format12Segments = getSegments(unicodes4Bytes);
ttf.support.cmap.format12Size = 16 + ttf.support.cmap.format12Segments.length * 12; 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.format0Size
+ ttf.support.cmap.format4Size + ttf.support.cmap.format4Size
+ (hasGLyphsOver2Bytes ? ttf.support.cmap.format12Size : 0); + (hasGLyphsOver2Bytes ? ttf.support.cmap.format12Size : 0);

View File

@ -92,26 +92,33 @@ function writeSubTable12(writer, segments) {
function write(writer, ttf) { function write(writer, ttf) {
var hasGLyphsOver2Bytes = ttf.support.cmap.hasGLyphsOver2Bytes; var hasGLyphsOver2Bytes = ttf.support.cmap.hasGLyphsOver2Bytes;
var hasFormat0 = ttf.support.cmap.hasFormat0;
var pos = writer.offset; var pos = writer.offset;
var view = writer.view; var view = writer.view;
var numRecords = 2 + (hasFormat0 ? 1 : 0) + (hasGLyphsOver2Bytes ? 1 : 0);
view.setUint16(pos, 0, false); pos += 2; 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; writer.offset = pos;
var subTableOffset = 4 + (hasGLyphsOver2Bytes ? 32 : 24); var headerSize = 4 + numRecords * 8;
var format4Size = ttf.support.cmap.format4Size; var format4Size = ttf.support.cmap.format4Size;
var format0Size = ttf.support.cmap.format0Size; var format0Size = ttf.support.cmap.format0Size;
writer.writeUint16(0); writer.writeUint16(3); writer.writeUint32(subTableOffset); /** platform 0 (Unicode) 和 platform 3 (Windows) 共享同一个 format 4 subtable */
writer.writeUint16(1); writer.writeUint16(0); writer.writeUint32(subTableOffset + format4Size); writer.writeUint16(0); writer.writeUint16(3); writer.writeUint32(headerSize);
writer.writeUint16(3); writer.writeUint16(1); writer.writeUint32(subTableOffset); if (hasFormat0) {
writer.writeUint16(1); writer.writeUint16(0); writer.writeUint32(headerSize + format4Size);
}
writer.writeUint16(3); writer.writeUint16(1); writer.writeUint32(headerSize);
if (hasGLyphsOver2Bytes) { 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); writeSubTable4(writer, ttf.support.cmap.format4Segments);
if (hasFormat0) {
writeSubTable0(writer, ttf.support.cmap.format0Segments); writeSubTable0(writer, ttf.support.cmap.format0Segments);
}
if (hasGLyphsOver2Bytes) { if (hasGLyphsOver2Bytes) {
writeSubTable12(writer, ttf.support.cmap.format12Segments); writeSubTable12(writer, ttf.support.cmap.format12Segments);
} }

View File

@ -87,10 +87,11 @@ var _default = exports.default = _table.default.create('name', [], {
offset += r.name.length; offset += r.name.length;
} }
/** 必须在 writeBytes 前同步 writer.offset否则 writeBytes 会从旧偏移写入,覆盖 header */
writer.offset = pos;
for (var j = 0, jl = nameRecordTbl.length; j < jl; j++) { for (var j = 0, jl = nameRecordTbl.length; j < jl; j++) {
writer.writeBytes(nameRecordTbl[j].name); writer.writeBytes(nameRecordTbl[j].name);
} }
writer.offset = pos;
return writer; return writer;
}, },
size: function size(ttf) { size: function size(ttf) {

77
基准测试_llrt.ts Normal file
View File

@ -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();

115
基准测试_verify.ts Normal file
View File

@ -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();