chore: 移除 skia-canvas 依赖,清理废弃验证脚本

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
崮生(子虚) 2026-04-16 17:30:09 +08:00
parent 03107af2dd
commit 258d9f9746
5 changed files with 2 additions and 399 deletions

View File

@ -1,7 +1,7 @@
{
"name": "webfont",
"private": true,
"version": "1.5.1",
"version": "1.5.2",
"type": "module",
"scripts": {
"dev": "pnpx tsx scripts/dev-all.ts",
@ -24,7 +24,6 @@
"jsdom": "^29.0.2",
"pngjs": "^7.0.0",
"puppeteer": "^24.40.0",
"skia-canvas": "^3.0.8",
"tsup": "^8.5.1",
"typescript": "^6.0.2",
"undici": "^8.0.2",

39
pnpm-lock.yaml generated
View File

@ -30,9 +30,6 @@ importers:
puppeteer:
specifier: ^24.40.0
version: 24.40.0(typescript@6.0.2)
skia-canvas:
specifier: ^3.0.8
version: 3.0.8
tsup:
specifier: ^8.5.1
version: 8.5.1(postcss@8.5.9)(typescript@6.0.2)
@ -997,15 +994,6 @@ packages:
fix-dts-default-cjs-exports@1.0.1:
resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==}
follow-redirects@1.15.11:
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -1249,9 +1237,6 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
parenthesis@3.1.8:
resolution: {integrity: sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==}
parse-json@5.2.0:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
@ -1391,9 +1376,6 @@ packages:
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
skia-canvas@3.0.8:
resolution: {integrity: sha512-FSYKxp8Ng2vOeeOBiyPhnn6ui6FirPJXMyjk4PKl8N/OWzVrkMawUgY9zubIWHMdYtyWFn0gfX3QlRwg6HBmdg==}
smart-buffer@4.2.0:
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
@ -1435,9 +1417,6 @@ packages:
streamx@2.25.0:
resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==}
string-split-by@1.0.0:
resolution: {integrity: sha512-KaJKY+hfpzNyet/emP81PJA9hTVSfxNLS9SFTWxdCnnW1/zOOwiV248+EfoX7IQFcBaOp4G5YE6xTJMF+pLg6A==}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@ -2540,8 +2519,6 @@ snapshots:
mlly: 1.8.2
rollup: 4.60.1
follow-redirects@1.15.11: {}
fsevents@2.3.3:
optional: true
@ -2764,8 +2741,6 @@ snapshots:
dependencies:
callsites: 3.1.0
parenthesis@3.1.8: {}
parse-json@5.2.0:
dependencies:
'@babel/code-frame': 7.29.0
@ -2947,16 +2922,6 @@ snapshots:
siginfo@2.0.0: {}
skia-canvas@3.0.8:
dependencies:
detect-libc: 2.1.2
follow-redirects: 1.15.11
https-proxy-agent: 7.0.6
string-split-by: 1.0.0
transitivePeerDependencies:
- debug
- supports-color
smart-buffer@4.2.0: {}
socks-proxy-agent@8.0.5:
@ -3007,10 +2972,6 @@ snapshots:
- bare-abort-controller
- react-native-b4a
string-split-by@1.0.0:
dependencies:
parenthesis: 3.1.8
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0

View File

@ -1,242 +0,0 @@
/**
*
*
* :
* pnpm tsx verify_font.ts baseline +
* pnpm tsx verify_font.ts
*
* :
* 1. glyf contours/pts/unicode/advanceWidth
* 2. skia-canvas SSIM
*/
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { Font } from "./vendor/fonteditor-core/lib/ttf/font.js";
import { Canvas, FontLibrary } from "skia-canvas";
const isBaseline = process.argv[2] === "baseline";
const FONT_PATH = "font/令东齐伋复刻体.ttf";
const FONT_NAME = "令东齐伋复刻体";
const SIMILARITY_THRESHOLD = 0.98;
const BASELINE_DIR = "benchmark_results";
const raw = await readFile(FONT_PATH);
const fontBuffer = new Uint8Array(raw).buffer;
FontLibrary.use(FONT_NAME, FONT_PATH);
const testCases = [
{ text: "天地玄黄宇宙洪荒", label: "8个汉字" },
{ text: "Hello World 123", label: "拉丁+数字" },
{ text: "天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈剑号巨阙珠称夜光果珍李柰菜重芥姜海咸河淡鳞潜羽翔", label: "千字文前段" },
];
/** 子集字体注册计数器 */
let subsetFontCounter = 0;
/** 渲染文字到纯图片数据(不保存文件),返回 Uint8Array */
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);
}
/**
* SSIM
* 0~1 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;
const ssim =
(2 * meanA * meanB + C1) * (2 * covAB + C2) /
((meanA * meanA + meanB * meanB + C1) * (varA + varB + C2));
return ssim;
}
/** 从 Font 对象中提取可对比的结构数据 */
function extractFontData(font: any) {
const d = font.data;
const glyf = d.glyf.map((g: any, i: number) => {
/** 兼容扁平格式 [x,y,onCurve,...] 和对象格式 [{x,y,onCurve},...] */
const toPoints = (c: any[]) => {
if (!c || !c.length) return [];
if (typeof c[0] === "number") {
const pts: [number, number, boolean][] = [];
for (let k = 0; k < c.length; k += 3) pts.push([c[k], c[k + 1], !!c[k + 2]]);
return pts;
}
return c.slice(0, 3).map((p: any) => [p.x, p.y, !!p.onCurve] as [number, number, boolean]);
};
const contourHeads = g.contours ? g.contours.map(toPoints) : [];
const pointCount = (c: any[]) => {
if (!c || !c.length) return 0;
return typeof c[0] === "number" ? c.length / 3 : c.length;
};
return {
index: i,
contours: g.contours?.length || 0,
pts: g.contours ? g.contours.reduce((s: number, c: any[]) => s + pointCount(c), 0) : 0,
compound: !!g.compound,
unicode: g.unicode ? [...g.unicode].sort((a: number, b: number) => a - b) : [],
advanceWidth: g.advanceWidth,
leftSideBearing: g.leftSideBearing,
contourHeads,
};
});
const out = font.write({ type: "ttf" });
const buf = out instanceof ArrayBuffer ? out : new Uint8Array(out as any).buffer;
const view = new DataView(buf);
const numTables = view.getUint16(4, false);
const tables: Record<string, { offset: number; length: number; checksum: number }> = {};
for (let i = 0; i < numTables; i++) {
const base = 12 + i * 16;
const tag = String.fromCharCode(
view.getUint8(base), view.getUint8(base + 1),
view.getUint8(base + 2), view.getUint8(base + 3)
);
tables[tag] = {
offset: view.getUint32(base + 8, false),
length: view.getUint32(base + 12, false),
checksum: view.getUint32(base + 4, false),
};
}
return { glyfCount: d.glyf.length, glyf, outputSize: buf.byteLength, tables, buffer: buf };
}
/** 注册子集字体并返回 fontFamily 名 */
async function registerSubsetFont(ttfBuffer: ArrayBuffer, counter: number): Promise<string> {
const fontPath = `${BASELINE_DIR}/_verify_${counter}.ttf`;
await writeFile(fontPath, Buffer.from(ttfBuffer));
const familyName = `SubsetFont_${counter}`;
FontLibrary.use(familyName, [fontPath]);
return familyName;
}
await mkdir(BASELINE_DIR, { recursive: true });
if (isBaseline) {
const baseline: Record<string, any> = {};
for (const { text, label } of testCases) {
const subset = [...text].map((c) => c.codePointAt(0)!);
const font = Font.create(fontBuffer, { type: "ttf", subset });
const data = extractFontData(font);
await writeFile(`${BASELINE_DIR}/${label}.ttf`, Buffer.from(data.buffer));
/** 用完整字体和子集字体分别渲染,保存像素数据 */
const fullPixels = renderText(FONT_NAME, text, 48);
subsetFontCounter++;
const familyName = await registerSubsetFont(data.buffer, subsetFontCounter);
const subsetPixels = renderText(familyName, text, 48);
const ssim = calculateSSIM(fullPixels, subsetPixels);
const { buffer: _, ...structData } = data;
baseline[label] = {
...structData,
fullPixels: Array.from(fullPixels),
subsetPixels: Array.from(subsetPixels),
ssim,
};
console.log(` ${label}: glyf=${data.glyfCount}, output=${data.outputSize} bytes, ssim=${ssim.toFixed(4)}`);
}
await writeFile(`${BASELINE_DIR}/verify_baseline.json`, JSON.stringify(baseline, null, 2));
console.log("\n基准已生成含完整字体+子集字体渲染像素数据及相似度)");
} else {
const baselineRaw = await readFile(`${BASELINE_DIR}/verify_baseline.json`, "utf-8");
const baseline = JSON.parse(baselineRaw);
let allPassed = true;
for (const { text, label } of testCases) {
const expected = baseline[label];
if (!expected) { console.log(`? ${label}: 无基准数据`); continue; }
const subset = [...text].map((c) => c.codePointAt(0)!);
const font = Font.create(fontBuffer, { type: "ttf", subset });
const actual = extractFontData(font);
const errors: string[] = [];
/** 1. 结构化对比 */
if (actual.glyfCount !== expected.glyfCount) {
errors.push(`glyfCount: ${actual.glyfCount} != ${expected.glyfCount}`);
}
for (let i = 0; i < Math.max(actual.glyf.length, expected.glyf.length); i++) {
const a = actual.glyf[i];
const e = expected.glyf[i];
if (!a || !e) { errors.push(`glyf[${i}]: 缺失`); continue; }
if (a.contours !== e.contours) errors.push(`glyf[${i}].contours: ${a.contours} != ${e.contours}`);
if (a.pts !== e.pts) errors.push(`glyf[${i}].pts: ${a.pts} != ${e.pts}`);
if (a.compound !== e.compound) errors.push(`glyf[${i}].compound: ${a.compound} != ${e.compound}`);
if (JSON.stringify(a.unicode) !== JSON.stringify(e.unicode)) errors.push(`glyf[${i}].unicode: ${JSON.stringify(a.unicode)} != ${JSON.stringify(e.unicode)}`);
if (a.advanceWidth !== e.advanceWidth) errors.push(`glyf[${i}].advanceWidth: ${a.advanceWidth} != ${e.advanceWidth}`);
if (JSON.stringify(a.contourHeads) !== JSON.stringify(e.contourHeads)) errors.push(`glyf[${i}].contourHeads: 不一致`);
}
/** 2. 渲染相似度对比:当前子集字体 vs 基准完整字体 */
const fullPixels = new Uint8Array(expected.fullPixels);
subsetFontCounter++;
const familyName = await registerSubsetFont(actual.buffer, subsetFontCounter);
const currentPixels = renderText(familyName, text, 48);
const ssim = calculateSSIM(fullPixels, currentPixels);
if (ssim < SIMILARITY_THRESHOLD) {
errors.push(`渲染相似度: ${ssim.toFixed(4)} < 阈值 ${SIMILARITY_THRESHOLD}`);
}
if (errors.length === 0) {
console.log(`${label}: PASS (glyf=${actual.glyfCount}, output=${actual.outputSize} bytes, ssim=${ssim.toFixed(4)})`);
} else {
allPassed = false;
console.log(`${label}: FAIL (${errors.length} errors)`);
for (const err of errors) console.log(` ${err}`);
}
}
console.log(`\n${allPassed ? "ALL PASSED ✓" : "SOME CHECKS FAILED ✗"}`);
if (!allPassed) process.exit(1);
}

View File

@ -71,7 +71,7 @@ async function main() {
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");
console.log("\n子集字体已保存");
}
main();

View File

@ -1,115 +0,0 @@
/**
* 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();