mirror of
https://github.com/2234839/web-font.git
synced 2026-06-06 09:52:55 +08:00
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:
parent
4acb1c1e5d
commit
6f0e3d8e6b
@ -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",
|
||||||
|
|||||||
@ -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
42
scripts/check_ttf.ts
Normal 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));
|
||||||
71
scripts/test_font_valid.ts
Normal file
71
scripts/test_font_valid.ts
Normal 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 字体查看器中打开验证");
|
||||||
2
task.md
2
task.md
@ -18,4 +18,6 @@
|
|||||||
|
|
||||||
## 其他方向
|
## 其他方向
|
||||||
|
|
||||||
|
woff2 格式是不是更优越,可以新增这种格式吗,然后ttf的也还支持,但是默认使用这个
|
||||||
|
|
||||||
就是有一个纯前端的优化,咱们提供的js SDK好像是通过定时器扫描的吧,这当然是一种方式,也是最省心的一种方式,但是咱们是不是还可以考虑另外一种方式,就是通过配置来启用定时扫描还是由用户自己的事件来触发,甚至由用户直接传递文本,这样的话对于首页上的demo来说,可能会有更高的及时性响应
|
就是有一个纯前端的优化,咱们提供的js SDK好像是通过定时器扫描的吧,这当然是一种方式,也是最省心的一种方式,但是咱们是不是还可以考虑另外一种方式,就是通过配置来启用定时扫描还是由用户自己的事件来触发,甚至由用户直接传递文本,这样的话对于首页上的demo来说,可能会有更高的及时性响应
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
3
vendor/fonteditor-core/lib/ttf/table/name.js
vendored
3
vendor/fonteditor-core/lib/ttf/table/name.js
vendored
@ -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
77
基准测试_llrt.ts
Normal 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
115
基准测试_verify.ts
Normal 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();
|
||||||
Loading…
x
Reference in New Issue
Block a user