From 258d9f9746478d35f4b57e6ea7eb6185ce54244c 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, 16 Apr 2026 17:30:09 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E7=A7=BB=E9=99=A4=20skia-canvas=20?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=EF=BC=8C=E6=B8=85=E7=90=86=E5=BA=9F=E5=BC=83?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- package.json | 3 +- pnpm-lock.yaml | 39 -------- verify_font.ts | 242 --------------------------------------------- 基准测试_llrt.ts | 2 +- 基准测试_verify.ts | 115 --------------------- 5 files changed, 2 insertions(+), 399 deletions(-) delete mode 100644 verify_font.ts delete mode 100644 基准测试_verify.ts diff --git a/package.json b/package.json index f90a8bd..6523cc3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af6c8ca..7c06692 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/verify_font.ts b/verify_font.ts deleted file mode 100644 index f719aaf..0000000 --- a/verify_font.ts +++ /dev/null @@ -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 = {}; - 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 { - 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 = {}; - - 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); -} diff --git a/基准测试_llrt.ts b/基准测试_llrt.ts index f5ddda3..07688ec 100644 --- a/基准测试_llrt.ts +++ b/基准测试_llrt.ts @@ -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(); diff --git a/基准测试_verify.ts b/基准测试_verify.ts deleted file mode 100644 index 247a33d..0000000 --- a/基准测试_verify.ts +++ /dev/null @@ -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();