feat: 新增 woff2 输出格式支持,默认使用 woff2

- 后端支持 outType URL 参数(woff2/ttf),Node.js 默认 woff2,LLRT 默认 ttf
- woff2 wasm 延迟初始化,仅首次请求时加载(~8ms)
- SDK 三个入口函数支持 outType 选项,默认 woff2
- 前端 CSS、下载、使用说明适配 woff2
- 基准测试新增 woff2 测试(耗时、体积、压缩率)
- 构建脚本自动复制 woff2.wasm 到 dist 目录

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
崮生(子虚) 2026-04-09 11:48:07 +08:00
parent 6d34a5e53a
commit 820fc71166
6 changed files with 99 additions and 28 deletions

View File

@ -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默认 ttfNode.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<string, string> = {
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",
},
}),

View File

@ -29,11 +29,27 @@ export const optimizeFont = (font: ReturnType<typeof Font.create>) => {
return optimized;
};
/** woff2 wasm 初始化 Promise延迟初始化只执行一次 */
let woff2InitPromise: Promise<void> | null = null;
/** 确保 woff2 wasm 已初始化,首次调用时加载 711KB wasm */
async function ensureWoff2Init(): Promise<void> {
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<ReturnType<typeof Font.create>["optimize"]>,
outType: FontEditor.FontType,
) => {
): Promise<Uint8Array> => {
if (outType === "woff2") {
await ensureWoff2Init();
}
const result = font.write({ type: outType });
if (typeof result !== "string") {
return new Uint8Array(result);

View File

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

View File

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

View File

@ -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() {
<pre style={{ ...s.pre, "font-size": "12px", "margin-top": "4px" }}>{`<style>
@font-face {
font-family: "MyFont";
src: url("${location.origin}/api?font=字体名&text=你的文字") format("truetype");
src: url("${location.origin}/api?font=字体名&text=你的文字") format("woff2");
}
.title { font-family: "MyFont"; }
</style>

View File

@ -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 */