mirror of
https://github.com/2234839/web-font.git
synced 2026-04-29 21:00:45 +08:00
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:
parent
6d34a5e53a
commit
820fc71166
@ -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<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",
|
||||
},
|
||||
}),
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
50
基准测试.test.ts
50
基准测试.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 */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user