mirror of
https://github.com/2234839/web-font.git
synced 2026-06-27 09:18:15 +08:00
feat(v1.9.0): OTF 子集化支持 + 法字笔画修复 + subsetCache 版本指纹
性能(vs v1.7.0 基线,基准测试全用例通过): - 千字文 woff2 346ms → 9.8ms(35x) - 8汉字 woff2 39.7ms → 2.8ms(14x) - 千字文 ttf 6.0ms → 4.4ms - 8汉字 ttf 2.7ms → 2.1ms OTF 正确性: - 修复 CFF.js readCFFIndexObject 漏读 off[idx+1] 导致子程序读成 0 字节, 思源黑体「法」字三点水丢笔画的问题 - 修复非 subset 模式 cmap format12 崩溃(空数组误判为 subset) 缓存健壮性: - subsetCache key 加版本指纹(package version + process.uptime), 杜绝代码已修但缓存返回旧错误结果 基准测试: - 新增白狐/思源 OTF 用例(含法海波等复杂笔画字)守护 OTF→TTF 转换 - 修复 calculateSSIM 的 width 推断 bug(sqrt 假设正方形) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4a6c7e64e1
commit
168fc66544
@ -1,7 +1,20 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { fontSubset } from "../font_util/font";
|
||||
import type { FontEditor } from "../../vendor/fonteditor-core/lib/ttf/font.js";
|
||||
import { parseUrl, jsonResponse, stats, subsetCache, findFontPath, readFontBuffer } from "../shared";
|
||||
|
||||
/**
|
||||
* 子集化版本指纹
|
||||
*
|
||||
* 纳入 subsetCache 的 key,让旧缓存条目在以下两种场景自动失效,无需手动清缓存:
|
||||
* - 生产:发版 bump package.json 的 version,旧缓存自然过期(语义版本失效)
|
||||
* - 开发:pnpm dev 重启进程时进程启动时间戳变化,内存缓存整体重置
|
||||
*
|
||||
* 杜绝「子集化代码已修但缓存返回旧错误结果」的陷阱。
|
||||
*/
|
||||
const packageVersion: string = createRequire(import.meta.url)("../../package.json").version;
|
||||
const SUBSET_CACHE_KEY = `${packageVersion}:${process.uptime()}`;
|
||||
|
||||
/** GET /api?font=...&text=... — 字体裁剪 */
|
||||
export async function handleFontSubset(req: Request, res: Response) {
|
||||
const url = parseUrl(req);
|
||||
@ -28,7 +41,8 @@ export async function handleFontSubset(req: Request, res: Response) {
|
||||
const outType = (outTypeParam === "woff2" || outTypeParam === "ttf") ? outTypeParam : "ttf";
|
||||
|
||||
/** 查询裁剪结果缓存 */
|
||||
const cacheKey = `${fontPath}:${outType}:${text}`;
|
||||
/** 版本指纹纳入 key:代码变更后旧缓存自动失效 */
|
||||
const cacheKey = `${SUBSET_CACHE_KEY}:${fontPath}:${outType}:${text}`;
|
||||
stats.subsetRequests++;
|
||||
stats.totalChars += text.length;
|
||||
const cached = subsetCache.get(cacheKey);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webfont",
|
||||
"private": true,
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "pnpx tsx scripts/dev-all.ts",
|
||||
|
||||
@ -21,7 +21,6 @@ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { de
|
||||
* @return {Object} ttfObject对象
|
||||
*/
|
||||
function otf2ttfobject(otfBuffer, options) {
|
||||
var __t0 = process.hrtime.bigint();
|
||||
var otfObject;
|
||||
if (otfBuffer instanceof ArrayBuffer) {
|
||||
var otfReader = new _otfreader.default(options);
|
||||
@ -57,7 +56,5 @@ function otf2ttfobject(otfBuffer, options) {
|
||||
/** 优化245: delete → null 赋值,避免 V8 隐藏类转换 */
|
||||
otfObject.CFF = null;
|
||||
otfObject.VORG = null;
|
||||
var __t1 = process.hrtime.bigint();
|
||||
console.error('otf2ttfobject: ' + Number(__t1 - __t0) / 1e6 + 'ms');
|
||||
return otfObject;
|
||||
}
|
||||
6
vendor/fonteditor-core/lib/ttf/otfreader.js
vendored
6
vendor/fonteditor-core/lib/ttf/otfreader.js
vendored
@ -41,7 +41,6 @@ var OTFReader = exports.default = /*#__PURE__*/function () {
|
||||
return _createClass(OTFReader, [{
|
||||
key: "readBuffer",
|
||||
value: function readBuffer(buffer) {
|
||||
var __t0 = process.hrtime.bigint();
|
||||
var reader = new _reader.default(buffer, 0, buffer.byteLength, false);
|
||||
var font = {};
|
||||
|
||||
@ -85,8 +84,6 @@ var OTFReader = exports.default = /*#__PURE__*/function () {
|
||||
_error.default.raise(10303);
|
||||
}
|
||||
reader.dispose();
|
||||
var __t1 = process.hrtime.bigint();
|
||||
console.error('OTFREADER.readBuffer: ' + Number(__t1 - __t0) / 1e6 + 'ms');
|
||||
return font;
|
||||
}
|
||||
|
||||
@ -98,7 +95,6 @@ var OTFReader = exports.default = /*#__PURE__*/function () {
|
||||
}, {
|
||||
key: "resolveGlyf",
|
||||
value: function resolveGlyf(font) {
|
||||
var __t0 = process.hrtime.bigint();
|
||||
var codes = font.cmap;
|
||||
var glyf = font.CFF.glyf;
|
||||
var subsetMap = font.readOptions.subset ? font.subsetMap : null;
|
||||
@ -207,8 +203,6 @@ var OTFReader = exports.default = /*#__PURE__*/function () {
|
||||
glyf = subGlyf;
|
||||
}
|
||||
font.glyf = glyf;
|
||||
var __t1 = process.hrtime.bigint();
|
||||
console.error('OTFREADER.resolveGlyf: ' + Number(__t1 - __t0) / 1e6 + 'ms');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
4
vendor/fonteditor-core/lib/ttf/table/CFF.js
vendored
4
vendor/fonteditor-core/lib/ttf/table/CFF.js
vendored
@ -264,8 +264,10 @@ function readCFFIndexObject(reader, indexInfo, idx) {
|
||||
/**
|
||||
* 优化303+307: 子集模式下 off[idx]/off[idx+1] 可能为 undefined(未预读),
|
||||
* 直接用 DataView 读取(绕过 reader 原型方法 + seek 边界检查)。命中槽位直接复用。
|
||||
* 注意:必须同时检查 off[idx+1],因为 off[idx] 可能被上一次 idx-1 的顺带读取填充,
|
||||
* 此时若仅判 off[idx] 会跳过 off[idx+1] 的读取,导致切片 end 为 undefined。
|
||||
*/
|
||||
if (off && off[idx] === undefined) {
|
||||
if (off && (off[idx] === undefined || off[idx + 1] === undefined)) {
|
||||
var base = indexInfo._offsetArrayBase;
|
||||
var os = indexInfo._offsetSize;
|
||||
var dv = reader.view;
|
||||
|
||||
@ -165,7 +165,7 @@ function readSubTable(reader, ttf, subTable, cmapOffset) {
|
||||
* 思源等大 CID 字体 format12 有 1.5 万+ group,全量展开需 4.5 万次 getUint32。
|
||||
* subset 仅查找少数 cp,lookupFormat12 直接从 view 二分查找(group 已升序、每项 12 字节)。
|
||||
*/
|
||||
var isSubset12 = ttf.readOptions && ttf.readOptions.subset;
|
||||
var isSubset12 = ttf.readOptions && ttf.readOptions.subset && ttf.readOptions.subset.length > 0;
|
||||
if (isSubset12) {
|
||||
format12._cmapView = view;
|
||||
format12._groupsOffset = vOffset;
|
||||
|
||||
22
基准测试.test.ts
22
基准测试.test.ts
@ -106,7 +106,7 @@ async function renderTextViaBrowser(
|
||||
text: string,
|
||||
fontSize: number,
|
||||
fontFormat: string = "truetype",
|
||||
): Promise<{ pixels: Uint8Array; screenshot: Buffer; inkPixels: number }> {
|
||||
): Promise<{ pixels: Uint8Array; screenshot: Buffer; inkPixels: number; width: number; height: number }> {
|
||||
const charWidth = Math.ceil(fontSize * 1.5);
|
||||
const width = text.length * charWidth + 20;
|
||||
const height = Math.ceil(fontSize * 1.5);
|
||||
@ -139,17 +139,21 @@ async function renderTextViaBrowser(
|
||||
throw new Error(`字体渲染无墨水像素 (${fontFamily}),字体可能未正确加载`);
|
||||
}
|
||||
|
||||
return { pixels, screenshot: Buffer.from(screenshot), inkPixels };
|
||||
return { pixels, screenshot: Buffer.from(screenshot), inkPixels, width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算两张图片的标准 SSIM (Wang et al. 2004)
|
||||
* 使用 11x11 均匀滑动窗口 + 积分图加速,返回 0~1
|
||||
*
|
||||
* 修复:原实现用 Math.sqrt(像素数) 推断 width,假设图片为正方形。
|
||||
* 但渲染截图是宽长条(如 740×72),sqrt 会得到错误 width(230),
|
||||
* 导致像素坐标错位、SSIM 系统性偏低(尤其 OTF 用例被放大偏差)。
|
||||
* 改为由调用方传入真实 width/height。
|
||||
*/
|
||||
function calculateSSIM(a: Uint8Array, b: Uint8Array): number {
|
||||
function calculateSSIM(a: Uint8Array, b: Uint8Array, width: number, height: number): number {
|
||||
if (a.length !== b.length) return 0;
|
||||
const width = Math.sqrt(a.length / 4) | 0;
|
||||
const height = (a.length / 4 / width) | 0;
|
||||
if (width * height * 4 !== a.length) return 0;
|
||||
if (width === 0 || height === 0) return 0;
|
||||
|
||||
/** 转灰度并提取到独立数组 */
|
||||
@ -238,9 +242,9 @@ const testCases = [
|
||||
{ label: "拉丁+数字", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "Hello World 123", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
|
||||
{ label: "千字文前段", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈剑号巨阙珠称夜光果珍李柰菜重芥姜海咸河淡鳞潜羽翔", sourceType: "ttf" as const, outType: "ttf" as const, fullFormat: "truetype" },
|
||||
{ label: "千字文前段", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈剑号巨阙珠称夜光果珍李柰菜重芥姜海咸河淡鳞潜羽翔", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
|
||||
/** OTF 字体 */
|
||||
{ label: "otf-五个汉字", fontPath: "font/temp/BaiHuOTFJiaoYuHanZi-2.otf", fontName: "白狐教育汉字", text: "天地黄宇宙", sourceType: "otf" as const, outType: "ttf" as const, fullFormat: "opentype" },
|
||||
{ label: "otf-思源黑体", fontPath: "font/temp/SourceHanSans-Regular.otf", fontName: "思源黑体", text: "天地玄黄宇宙", sourceType: "otf" as const, outType: "ttf" as const, fullFormat: "opentype" },
|
||||
/** OTF 字体(含三点水等复杂笔画字,守护 OTF→TTF 转换正确性) */
|
||||
{ label: "otf-五个汉字", fontPath: "font/temp/BaiHuOTFJiaoYuHanZi-2.otf", fontName: "白狐教育汉字", text: "天地黄宇宙法海波", sourceType: "otf" as const, outType: "ttf" as const, fullFormat: "opentype" },
|
||||
{ label: "otf-思源黑体", fontPath: "font/temp/SourceHanSans-Regular.otf", fontName: "思源黑体", text: "天地玄黄宇宙洪法海波", sourceType: "otf" as const, outType: "ttf" as const, fullFormat: "opentype" },
|
||||
];
|
||||
|
||||
// ======== 主测试 ========
|
||||
@ -370,7 +374,7 @@ for (const tc of testCases) {
|
||||
|
||||
fullInk = fullResult.inkPixels;
|
||||
subsetInk = subsetResult.inkPixels;
|
||||
ssim = calculateSSIM(fullResult.pixels, subsetResult.pixels);
|
||||
ssim = calculateSSIM(fullResult.pixels, subsetResult.pixels, fullResult.width, fullResult.height);
|
||||
}
|
||||
|
||||
results.push({ label: tc.label, sourceType: tc.sourceType, outType: tc.outType, avg, min, max, outputSize: lastSize, ssim, fullInk, subsetInk });
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user