diff --git a/backend/app.ts b/backend/app.ts index 8d98692..0147a22 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -4,6 +4,7 @@ function parseUrl(req: Request): URL { } import { fontSubset } from "./font_util/font"; +import type { FontEditor } from "../vendor/fonteditor-core/lib/ttf/font.js"; import { mimeTypes } from "./server/mime_type"; import type { cMiddleware } from "./server/req_res"; import { SimpleHttpServer } from "./server/server"; @@ -296,7 +297,7 @@ async function handleFontSubset(req: Request, res: Response) { }; } - const fontType = fontPath.split(".").pop() as "ttf"; + const fontType = fontPath.split(".").pop() as FontEditor.FontType; let oldFontBuffer: ArrayBuffer; try { oldFontBuffer = await readFontBuffer(fontPath); @@ -310,18 +311,29 @@ async function handleFontSubset(req: Request, res: Response) { }; } - const outType = "ttf"; + /** LLRT 不支持 wasm,默认 ttf;Node.js 默认 woff2(体积更小) */ + const isLlrt = release_name === "llrt"; + const outTypeParam = params.get("outType") || ""; + const outType = (outTypeParam === "woff2" || outTypeParam === "ttf") + ? (isLlrt && outTypeParam === "woff2" ? "ttf" : outTypeParam) + : (isLlrt ? "ttf" : "woff2"); + const newFont = await fontSubset(oldFontBuffer, text, { outType: outType, sourceType: fontType, }); + const contentTypes: Record = { + ttf: "font/ttf", + woff2: "font/woff2", + }; + return { req, res: new Response(newFont, { status: 200, headers: { - "Content-Type": "font/ttf", + "Content-Type": contentTypes[outType] || "font/ttf", "Cache-Control": "public, max-age=31536000, immutable", }, }), diff --git a/backend/font_util/font.ts b/backend/font_util/font.ts index 74e88e9..f180446 100644 --- a/backend/font_util/font.ts +++ b/backend/font_util/font.ts @@ -29,11 +29,27 @@ export const optimizeFont = (font: ReturnType) => { return optimized; }; +/** woff2 wasm 初始化 Promise(延迟初始化,只执行一次) */ +let woff2InitPromise: Promise | null = null; + +/** 确保 woff2 wasm 已初始化,首次调用时加载 711KB wasm */ +async function ensureWoff2Init(): Promise { + if (!woff2InitPromise) { + const woff2Module = await import("../../vendor/fonteditor-core/woff2/index.js"); + const mod = (woff2Module as any).default || woff2Module; + woff2InitPromise = mod.init().then(() => {}); + } + return woff2InitPromise; +} + /** 序列化为指定格式的二进制数据 */ -export const writeFont = ( +export const writeFont = async ( font: ReturnType["optimize"]>, outType: FontEditor.FontType, -) => { +): Promise => { + if (outType === "woff2") { + await ensureWoff2Init(); + } const result = font.write({ type: outType }); if (typeof result !== "string") { return new Uint8Array(result); diff --git a/public/webfont-sdk.js b/public/webfont-sdk.js index 093b17d..d52542f 100644 --- a/public/webfont-sdk.js +++ b/public/webfont-sdk.js @@ -40,7 +40,7 @@ var WebFont = (function () { /** * 获取或创建对应 fontKey 的加载器 */ - function getLoader(fontName, baseUrl, family) { + function getLoader(fontName, baseUrl, family, outType) { var key = fontKey(fontName, family); if (!loaders[key]) { loaders[key] = { @@ -49,7 +49,8 @@ var WebFont = (function () { applied: false, fontName: fontName, family: family, - baseUrl: baseUrl + baseUrl: baseUrl, + outType: outType || "woff2" }; } return loaders[key]; @@ -69,7 +70,9 @@ var WebFont = (function () { var loadedChars = loader.loadedChars; var text = newChars.join(""); - var url = baseUrl + "/api?font=" + encodeURIComponent(fontName) + "&text=" + encodeURIComponent(text); + var outType = loader.outType || "woff2"; + var url = baseUrl + "/api?font=" + encodeURIComponent(fontName) + "&text=" + encodeURIComponent(text) + "&outType=" + outType; + var formatStr = outType === "woff2" ? "woff2" : "truetype"; var unicodeRanges = newChars .map(function (c) { return "U+" + c.codePointAt(0).toString(16).padStart(4, "0"); }) .join(", "); @@ -78,7 +81,7 @@ var WebFont = (function () { style.textContent = '@font-face {\n' + ' font-family: "' + family + '";\n' + - ' src: url("' + url + '") format("truetype");\n' + + ' src: url("' + url + '") format("' + formatStr + '");\n' + ' unicode-range: ' + unicodeRanges + ';\n' + '}\n'; document.head.appendChild(style); @@ -210,7 +213,8 @@ var WebFont = (function () { clearInterval(pollTasks[selector].timer); } - var loader = getLoader(fontName, baseUrl, family); + var outType = options.outType || "woff2"; + var loader = getLoader(fontName, baseUrl, family, outType); var applied = false; function tick() { @@ -251,7 +255,8 @@ var WebFont = (function () { observeTasks[selector].dispose(); } - var loader = getLoader(fontName, baseUrl, family); + var outType = options.outType || "woff2"; + var loader = getLoader(fontName, baseUrl, family, outType); var applied = false; var debounceTimer = null; @@ -333,7 +338,8 @@ var WebFont = (function () { var baseUrl = options.baseUrl || location.origin; var family = options.family || fontName.replace(/\.[^.]+$/, ""); - var loader = getLoader(fontName, baseUrl, family); + var outType = options.outType || "woff2"; + var loader = getLoader(fontName, baseUrl, family, outType); processText(loader, options.text); diff --git a/scripts/build-backend.ts b/scripts/build-backend.ts index 98448fe..a6a3b7c 100644 --- a/scripts/build-backend.ts +++ b/scripts/build-backend.ts @@ -6,7 +6,7 @@ */ import { execSync } from "node:child_process"; import { existsSync } from "node:fs"; -import { mkdir, writeFile, rm } from "node:fs/promises"; +import { copyFile, mkdir, writeFile, rm } from "node:fs/promises"; import { arch, platform } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -100,6 +100,14 @@ function runTsup() { execSync("pnpm tsup", { stdio: "inherit", cwd: ROOT_DIR }); } +/** 复制 woff2.wasm 到 tsup 输出目录(打包后 __dirname 指向此处) */ +async function copyWoff2Wasm() { + const src = join(ROOT_DIR, "vendor/fonteditor-core/woff2/woff2.wasm"); + const dst = join(ROOT_DIR, "dist_backend/backend/woff2.wasm"); + await copyFile(src, dst); + console.log("Copied woff2.wasm to dist_backend/backend/"); +} + /** 使用 LLRT compile 生成 .lrt 文件 */ function runLlrtCompile() { console.log("\n--- Running LLRT compile ---"); @@ -118,6 +126,7 @@ function runLlrtCompile() { async function main() { await ensureLlrt(); runTsup(); + await copyWoff2Wasm(); runLlrtCompile(); } diff --git a/src/App.tsx b/src/App.tsx index eb5ba92..568494c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -126,7 +126,7 @@ function App() { if (!font) return ""; return `@font-face { font-family: "CustomFont"; - src: url("${location.origin}/api?font=${font}&text=${encodeURIComponent(text())}") format("truetype"); + src: url("${location.origin}/api?font=${font}&text=${encodeURIComponent(text())}&outType=woff2") format("woff2"); } .custom-font { color: red; @@ -229,8 +229,8 @@ function App() { style={{ ...s.btn, padding: "3px 12px", "font-size": "12px" }} onClick={() => { const a = document.createElement("a"); - a.href = `/api?font=${selectedFont()}&text=${encodeURIComponent(text())}`; - a.download = selectedFont().replace(/\.[^.]+$/, "") + "_subset.ttf"; + a.href = `/api?font=${selectedFont()}&text=${encodeURIComponent(text())}&outType=woff2`; + a.download = selectedFont().replace(/\.[^.]+$/, "") + "_subset.woff2"; a.click(); }} > @@ -266,7 +266,7 @@ function App() {
{`
diff --git a/基准测试.test.ts b/基准测试.test.ts
index 51e3bba..d59ee27 100644
--- a/基准测试.test.ts
+++ b/基准测试.test.ts
@@ -10,6 +10,7 @@
 import { readFile, writeFile, mkdir } from "node:fs/promises";
 import { performance } from "node:perf_hooks";
 import { Font } from "./vendor/fonteditor-core/lib/ttf/font.js";
+import woff2Module from "./vendor/fonteditor-core/woff2/index.js";
 import { Canvas, FontLibrary } from "skia-canvas";
 
 const FONT_PATH = "font/令东齐伋复刻体.ttf";
@@ -20,6 +21,12 @@ const raw = await readFile(FONT_PATH);
 const fontBuffer = new Uint8Array(raw).buffer;
 FontLibrary.use(FONT_NAME, FONT_PATH);
 
+/** 初始化 woff2 wasm 并测量耗时 */
+const wasmInitStart = performance.now();
+await woff2Module.init();
+const wasmInitTime = performance.now() - wasmInitStart;
+console.log(`  woff2 wasm 初始化: ${wasmInitTime.toFixed(1)}ms`);
+
 const testCases = [
   { label: "8个汉字", text: "天地玄黄宇宙洪荒" },
   { label: "拉丁+数字", text: "Hello World 123" },
@@ -125,8 +132,10 @@ const results: Array<{
 
 for (const { label, text } of testCases) {
   const subset = [...text].map((c) => c.codePointAt(0)!);
-  const times: number[] = [];
-  let lastOutputSize = 0;
+
+  /** --- ttf 测试 --- */
+  const ttfTimes: number[] = [];
+  let lastTtfSize = 0;
   let lastTtfBuffer: ArrayBuffer | null = null;
 
   for (let i = 0; i < ROUNDS; i++) {
@@ -135,24 +144,42 @@ for (const { label, text } of testCases) {
     const optimized = font.optimize().sort();
     const result = optimized.write({ type: "ttf" });
     const t1 = performance.now();
-    times.push(t1 - t0);
-    lastOutputSize = typeof result === "string" ? result.length : result.byteLength;
+    ttfTimes.push(t1 - t0);
+    lastTtfSize = typeof result === "string" ? result.length : result.byteLength;
     if (i === 0) {
       lastTtfBuffer = result instanceof ArrayBuffer ? result : new Uint8Array(result as any).buffer;
     }
   }
 
-  const avg = times.reduce((a, b) => a + b, 0) / times.length;
-  const min = Math.min(...times);
-  const max = Math.max(...times);
+  const ttfAvg = ttfTimes.reduce((a, b) => a + b, 0) / ttfTimes.length;
+  const ttfMin = Math.min(...ttfTimes);
+  const ttfMax = Math.max(...ttfTimes);
 
-  /** 计算渲染相似度 */
+  /** --- woff2 测试 --- */
+  const woff2Times: number[] = [];
+  let lastWoff2Size = 0;
+
+  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: "woff2" });
+    const t1 = performance.now();
+    woff2Times.push(t1 - t0);
+    lastWoff2Size = typeof result === "string" ? result.length : result.byteLength;
+  }
+
+  const woff2Avg = woff2Times.reduce((a, b) => a + b, 0) / woff2Times.length;
+  const woff2Min = Math.min(...woff2Times);
+  const woff2Max = Math.max(...woff2Times);
+  const compressionRatio = ((1 - lastWoff2Size / lastTtfSize) * 100).toFixed(1);
+
+  /** 计算渲染相似度(使用 ttf) */
   let ssim = 0;
   if (lastTtfBuffer) {
     subsetFontCounter++;
     const familyName = await registerSubsetFont(lastTtfBuffer, subsetFontCounter);
 
-    /** 保存渲染对比图片 */
     const safeLabel = label.replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, "_");
     await renderTextToPng(FONT_NAME, text, 48, `${BENCHMARK_DIR}/${safeLabel}_full.png`);
     await renderTextToPng(familyName, text, 48, `${BENCHMARK_DIR}/${safeLabel}_subset.png`);
@@ -162,8 +189,9 @@ for (const { label, text } of testCases) {
     ssim = calculateSSIM(fullPixels, subsetPixels);
   }
 
-  results.push({ label, avg, min, max, outputSize: lastOutputSize, ssim });
-  console.log(`  ${label}: avg=${avg.toFixed(1)}ms  min=${min.toFixed(1)}ms  max=${max.toFixed(1)}ms  输出=${lastOutputSize.toLocaleString()} bytes  ssim=${ssim.toFixed(4)}`);
+  results.push({ label, avg: ttfAvg, min: ttfMin, max: ttfMax, outputSize: lastTtfSize, ssim });
+  console.log(`  [ttf]   ${label}: avg=${ttfAvg.toFixed(1)}ms  min=${ttfMin.toFixed(1)}ms  max=${ttfMax.toFixed(1)}ms  输出=${lastTtfSize.toLocaleString()} bytes  ssim=${ssim.toFixed(4)}`);
+  console.log(`  [woff2] ${label}: avg=${woff2Avg.toFixed(1)}ms  min=${woff2Min.toFixed(1)}ms  max=${woff2Max.toFixed(1)}ms  输出=${lastWoff2Size.toLocaleString()} bytes  压缩率=${compressionRatio}%`);
 }
 
 /** 保存结果到 JSON */