fix: 修复 OTF→TTF 转换字体在浏览器中渲染空白的问题

根因:optimizettf 中 maxPoints/maxContours 只在 TypedArray 格式(_xArr)
的字形分支中统计,OTF→TTF 转换的字形使用对象 contours 格式,
导致 maxp 表中这两个值为 0,浏览器据此跳过渲染。

同时包含 OTF 解析路径的多项性能优化和清理冗余测试文件。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
崮生(子虚) 2026-04-09 20:46:48 +08:00
parent 18e60fe940
commit 97f4d72e6a
11 changed files with 251 additions and 186 deletions

View File

@ -1,48 +0,0 @@
/**
*
*/
import { readFile } from "node:fs/promises";
import { Font } from "./vendor/fonteditor-core/lib/ttf/font.js";
const FONT_PATH = "font/令东齐伋复刻体.ttf";
const raw = await readFile(FONT_PATH);
const fontBuffer = new Uint8Array(raw).buffer;
const testCases = [
{ label: "8个汉字", subset: [..."天地玄黄宇宙洪荒"].map(c => c.codePointAt(0)!) },
{ label: "千字文前段", subset: [..."天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈剑号巨阙珠称夜光果珍李柰菜重芥姜海咸河淡鳞潜羽翔"].map(c => c.codePointAt(0)!) },
];
const ROUNDS = 30;
for (const { label, subset } of testCases) {
let createSum = 0, optimizeSum = 0, sortSum = 0, writeSum = 0;
for (let i = 0; i < ROUNDS; i++) {
let t0 = performance.now();
const font = Font.create(fontBuffer, { type: "ttf", subset });
let t1 = performance.now();
createSum += t1 - t0;
const optimized = font.optimize();
let t2 = performance.now();
optimizeSum += t2 - t1;
const sorted = optimized.sort();
let t3 = performance.now();
sortSum += t3 - t2;
const result = sorted.write({ type: "ttf" });
let t4 = performance.now();
writeSum += t4 - t3;
}
const total = createSum + optimizeSum + sortSum + writeSum;
console.log(`${label} (${ROUNDS} rounds):`);
console.log(` create: ${createSum.toFixed(1)}ms (${(createSum / total * 100).toFixed(1)}%)`);
console.log(` optimize: ${optimizeSum.toFixed(1)}ms (${(optimizeSum / total * 100).toFixed(1)}%)`);
console.log(` sort: ${sortSum.toFixed(1)}ms (${(sortSum / total * 100).toFixed(1)}%)`);
console.log(` write: ${writeSum.toFixed(1)}ms (${(writeSum / total * 100).toFixed(1)}%)`);
console.log(` total: ${total.toFixed(1)}ms`);
console.log();
}

View File

@ -1,51 +0,0 @@
/**
*
*/
import { readFile } from "node:fs/promises";
const FONT_PATH = "font/令东齐伋复刻体.ttf";
const raw = await readFile(FONT_PATH);
const fontBuffer = new Uint8Array(raw).buffer;
const ROUNDS = 20;
const tableTimes: Record<string, number> = {};
for (let r = 0; r < ROUNDS; r++) {
const ReaderModule = await import("./vendor/fonteditor-core/lib/ttf/reader.js");
const Reader = (ReaderModule as any).default || ReaderModule;
const DirectoryModule = await import("./vendor/fonteditor-core/lib/ttf/table/directory.js");
const Directory = (DirectoryModule as any).default || DirectoryModule;
const supportModule = await import("./vendor/fonteditor-core/lib/ttf/table/support.js");
const support = (supportModule as any).default || supportModule;
const reader = new Reader(fontBuffer, 0, fontBuffer.byteLength, false);
const ttf: any = {};
ttf.version = reader.readFixed(0);
ttf.numTables = reader.readUint16();
ttf.searchRange = reader.readUint16();
ttf.entrySelector = reader.readUint16();
ttf.rangeShift = reader.readUint16();
ttf.tables = new Directory(reader.offset).read(reader, ttf);
ttf.readOptions = { subset: [..."天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈剑号巨阙珠称夜光果珍李柰菜重芥姜海咸河淡鳞潜羽翔"].map(c => c.codePointAt(0)) };
for (const tableName of Object.keys(support)) {
if (ttf.tables[tableName]) {
const offset = ttf.tables[tableName].offset;
const t0 = performance.now();
ttf[tableName] = new support[tableName](offset).read(reader, ttf);
const t1 = performance.now();
tableTimes[tableName] = (tableTimes[tableName] || 0) + (t1 - t0);
}
}
reader.dispose();
}
const total = Object.values(tableTimes).reduce((a: number, b: number) => a + b, 0);
const sorted = Object.entries(tableTimes).sort((a, b) => b[1] - a[1]);
console.log(`表读取时间 (${ROUNDS} rounds):`);
for (const [name, time] of sorted) {
console.log(` ${name.padEnd(8)} ${time.toFixed(1).padStart(8)}ms ${(time / total * 100).toFixed(1).padStart(3)}%`);
}
console.log(` ${'total'.padEnd(8)} ${total.toFixed(1).padStart(8)}ms`);

View File

@ -1,7 +1,7 @@
{
"name": "webfont",
"private": true,
"version": "1.3.3",
"version": "1.4.0",
"type": "module",
"scripts": {
"dev": "pnpx tsx scripts/dev-all.ts",

View File

@ -133,9 +133,10 @@ function App() {
const font = selectedFont();
const ot = outType();
if (!font) return "";
const formatStr = ot === "woff2" ? "woff2" : "truetype";
return `@font-face {
font-family: "CustomFont";
src: url("${location.origin}/api?font=${font}&text=${encodeURIComponent(text())}&outType=${ot}") format("${ot}");
src: url("${location.origin}/api?font=${font}&text=${encodeURIComponent(text())}&outType=${ot}") format("${formatStr}");
}
.custom-font {
color: red;

View File

@ -11,7 +11,7 @@ exports.default = bezierCubic2Q2;
* 改进递归分割三次贝塞尔直到可精确近似提高 SSIM
*/
var MAX_DEPTH = 5;
var MAX_DEPTH = 4;
function isFlatEnough(p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y) {
var ux = 3 * c1x - 2 * p1x - p2x;

View File

@ -7,17 +7,37 @@ exports.default = otf2ttfobject;
var _error = _interopRequireDefault(require("./error"));
var _otfreader = _interopRequireDefault(require("./otfreader"));
var _otfContours2ttfContours = _interopRequireDefault(require("./util/otfContours2ttfContours"));
var _computeBoundingBox = require("../graphics/computeBoundingBox");
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableSpread(); }
function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); }
function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); }
function _iterableToArray(iter) { if (typeof Symbol !== "undefined" && iter[Symbol.iterator] != null || iter["@@iterator"] != null) return Array.from(iter); }
function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) return _arrayLikeToArray(arr); }
function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i]; return arr2; } /**
/**
* @file otf格式转ttf格式对象
* @author mengke01(kekee000@gmail.com)
*/
/**
* 直接遍历 contours 计算包围盒避免合并数组
*/
function computeContoursBox(contours) {
var left, right, top, bottom;
var found = false;
for (var ci = 0, cl = contours.length; ci < cl; ci++) {
var contour = contours[ci];
for (var pi = 0, pl = contour.length; pi < pl; pi++) {
var p = contour[pi];
if (!found) {
left = right = p.x;
top = bottom = p.y;
found = true;
} else {
if (p.x < left) left = p.x;
else if (p.x > right) right = p.x;
if (p.y < top) top = p.y;
else if (p.y > bottom) bottom = p.y;
}
}
}
return found ? { x: left, y: top, width: right - left, height: bottom - top } : null;
}
/**
* otf格式转ttf格式对象
*
@ -38,9 +58,11 @@ function otf2ttfobject(otfBuffer, options) {
}
// 转换otf轮廓
otfObject.glyf.forEach(function (g) {
var glyf = otfObject.glyf;
for (var i = 0, l = glyf.length; i < l; i++) {
var g = glyf[i];
g.contours = (0, _otfContours2ttfContours.default)(g.contours);
var box = _computeBoundingBox.computePathBox.apply(void 0, _toConsumableArray(g.contours));
var box = computeContoursBox(g.contours);
if (box) {
g.xMin = box.x;
g.xMax = box.x + box.width;
@ -52,12 +74,21 @@ function otf2ttfobject(otfBuffer, options) {
g.yMin = 0;
g.yMax = 0;
}
});
}
otfObject.version = 0x1;
// 修改maxp相关配置
otfObject.maxp.version = 1.0;
otfObject.maxp.maxZones = otfObject.maxp.maxTwilightPoints ? 2 : 1;
/**
* OTFTTF 转换后字体没有 TrueType instructions
* 必须清除 head.flags "依赖 hinting"的标志位
* 否则浏览器会跳过渲染skia-canvas 不受影响
* 同时清除"lossless"标志因为三次二次贝塞尔转换是有损的
*/
otfObject.head.flags = (otfObject.head.flags || 0) & ~(0x0008 | 0x0800);
otfObject.head.fontDirectionHint = 2;
delete otfObject.CFF;
delete otfObject.VORG;
return otfObject;

View File

@ -97,6 +97,44 @@ function parseCFFIndex(reader, offset, conversionFn) {
};
}
/**
* 解析 CFF 索引的偏移表不读取实际数据
* 用于大字体的延迟读取避免一次性读取全部 charstring
*
* @param {Reader} reader 读取器
* @param {number} offset 偏移
* @return {Object} { offsets, count, dataStart, endOffset }
*/
function parseCFFIndexOffsets(reader, offset) {
if (offset) reader.seek(offset);
var start = reader.offset;
var count = reader.readUint16();
var offsets = null;
if (count !== 0) {
var offsetSize = reader.readUint8();
offsets = new Array(count + 1);
for (var i = 0; i <= count; i++) {
offsets[i] = getOffset(reader, offsetSize);
}
}
return { offsets: offsets, count: count, dataStart: reader.offset, endOffset: reader.offset };
}
/**
* 根据 parseCFFIndexOffsets 的结果按需读取第 idx object
*
* @param {Reader} reader 读取器
* @param {Object} indexInfo parseCFFIndexOffsets 返回的信息
* @param {number} idx object 索引0-based
* @return {Uint8Array} object 数据
*/
function readCFFIndexObject(reader, indexInfo, idx) {
var off = indexInfo.offsets;
var size = off[idx + 1] - off[idx];
reader.seek(indexInfo.dataStart + off[idx] - 1);
return reader.readBytes(size);
}
// Subroutines are encoded using the negative half of the number space.
// See type 2 chapter 4.7 "Subroutine operators".
function calcCFFSubroutineBias(subrs) {
@ -149,48 +187,67 @@ function parseRawTopDict(reader, start, length) {
}
/**
* 解析 FDSelect 返回 glyph index FD index 的映射
* 支持 format 0 format 3
* 解析 FDSelect 返回 ranges format
* subset 模式下不展开全量数组改用二分查找按需获取 FD index
*
* @param {Reader} reader 读取器
* @param {number} offset FDSelect 相对于 CFF 起始的偏移
* @param {number} nGlyphs glyph 总数
* @return {Array} FD index 数组fdSelect[i] = glyph i 对应的 FD index
* @return {Object} { format, ranges, flatData }
*/
function parseFDSelect(reader, offset, nGlyphs) {
function parseFDSelect(reader, offset) {
reader.seek(offset);
var format = reader.readUint8();
var fdSelect = [];
if (format === 0) {
for (var i = 0; i < nGlyphs; i++) {
fdSelect.push(reader.readUint8());
}
} else if (format === 3) {
var nRanges = reader.readUint16();
var ranges = [];
for (var r = 0; r < nRanges; r++) {
ranges.push({
first: reader.readUint16(),
fd: reader.readUint8()
});
}
/** sentinel = reader.readUint16(); */
/** 根据 ranges 构建 fdSelect 数组 */
for (var i = 0; i < nGlyphs; i++) {
var fd = 0;
for (var ri = ranges.length - 1; ri >= 0; ri--) {
if (i >= ranges[ri].first) {
fd = ranges[ri].fd;
break;
}
}
fdSelect.push(fd);
/** format 0每个 glyph 一个 uint8存储为扁平数组 */
var count = reader.readUint16();
var flatData = new Uint8Array(count);
for (var i = 0; i < count; i++) {
flatData[i] = reader.readUint8();
}
return { format: 0, ranges: null, flatData: flatData };
}
return fdSelect;
/** format 3range 列表,存储为扁平数组 [first, fd, first, fd, ...] */
var nRanges = reader.readUint16();
var ranges = new Uint8Array(nRanges * 3);
for (var r = 0; r < nRanges; r++) {
var idx = r * 3;
var first = reader.readUint16();
ranges[idx] = first & 0xFF;
ranges[idx + 1] = (first >> 8) & 0xFF;
ranges[idx + 2] = reader.readUint8();
}
return { format: 3, ranges: ranges, flatData: null };
}
/**
* 根据 parseFDSelect 的结果查找指定 glyph FD index
* format 0 直接索引format 3 二分查找
*/
function lookupFD(fdSelect, glyphIndex) {
if (fdSelect.format === 0) {
return fdSelect.flatData[glyphIndex] || 0;
}
/** format 3 二分查找 ranges */
var ranges = fdSelect.ranges;
var lo = 0;
var hi = (ranges.length / 3) - 1;
while (lo <= hi) {
var mid = (lo + hi) >> 1;
var idx = mid * 3;
var first = ranges[idx] | (ranges[idx + 1] << 8);
if (glyphIndex < first) {
hi = mid - 1;
} else {
/** 检查是否在当前 range 内(即 < 下一个 range 的 first */
if (mid === (ranges.length / 3) - 1 || glyphIndex < (ranges[idx + 3] | (ranges[idx + 4] << 8))) {
return ranges[idx + 2];
}
lo = mid + 1;
}
}
return 0;
}
/**
@ -245,15 +302,15 @@ var _default = exports.default = _table.default.create('cff', [], {
cff.gsubrsBias = calcCFFSubroutineBias(globalSubrIndex.objects);
// 顶级字典数据
var dictReader = new _reader.default(new Uint8Array(topDictIndex.objects[0]).buffer);
var topDictData = topDictIndex.objects[0];
var dictReader = new _reader.default(new Uint8Array(topDictData).buffer);
var rawTopDict = _parseCFFDict.default.parseCFFDict(dictReader, 0, dictReader.length);
/** 复用同一个 Reader 和解析结果构建 topDict避免创建第二个 Reader */
dictReader.seek(0);
var topDict = _parseCFFDict.default.parseTopDict(dictReader, 0, dictReader.length, stringIndex.objects);
cff.topDict = topDict;
/** 解析原始 Top DICT 获取 CID-keyed 字段 (FDArray/FDSelect) */
var rawTopDict = parseRawTopDict(
new _reader.default(new Uint8Array(topDictIndex.objects[0]).buffer),
0, new Uint8Array(topDictIndex.objects[0]).buffer.byteLength
);
/** 从已解析的原始 Top DICT 获取 CID-keyed 字段 (FDArray/FDSelect) */
var fdArrayOffset = rawTopDict[1236]; // 12 36
var fdSelectOffset = rawTopDict[1237]; // 12 37
var isCID = !!(fdArrayOffset && fdSelectOffset);
@ -262,10 +319,11 @@ var _default = exports.default = _table.default.create('cff', [], {
var fdSelect = null;
var fdPrivates = null;
if (isCID) {
var charStringsIndex = parseCFFIndex(reader, offset + topDict.charStrings);
var nGlyphs = charStringsIndex.objects.length;
/** 优化:只读取偏移表,不读取全部 charstring 数据 */
var charStringsInfo = parseCFFIndexOffsets(reader, offset + topDict.charStrings);
var nGlyphs = charStringsInfo.count;
fdSelect = parseFDSelect(reader, offset + fdSelectOffset, nGlyphs);
fdSelect = parseFDSelect(reader, offset + fdSelectOffset);
/** 解析 FDArray */
var fdArrayIndex = parseCFFIndex(reader, offset + fdArrayOffset);
@ -301,11 +359,11 @@ var _default = exports.default = _table.default.create('cff', [], {
}
cff.privateDict = privateDict;
// 解析glyf数据和名字
// 解析glyf数据和名字(统一使用延迟读取,避免大字体一次性读取全部 charstring
if (!isCID) {
var charStringsIndex = parseCFFIndex(reader, offset + topDict.charStrings);
var charStringsInfo = parseCFFIndexOffsets(reader, offset + topDict.charStrings);
}
var nGlyphs = charStringsIndex.objects.length;
var nGlyphs = charStringsInfo.count;
if (topDict.charset < 3) {
cff.charset = _cffStandardStrings.default;
@ -331,7 +389,7 @@ var _default = exports.default = _table.default.create('cff', [], {
*/
function getGlyphFont(glyphIndex) {
if (isCID && fdSelect && fdPrivates) {
var fdIdx = fdSelect[glyphIndex] || 0;
var fdIdx = lookupFD(fdSelect, glyphIndex);
var fd = fdPrivates[fdIdx];
return {
subrs: fd.subrs,
@ -364,7 +422,8 @@ var _default = exports.default = _table.default.create('cff', [], {
font.subsetMap = subsetMap;
Object.keys(subsetMap).forEach(function (i) {
i = +i;
var glyf = (0, _parseCFFGlyph.default)(charStringsIndex.objects[i], getGlyphFont(i), i);
var charstring = readCFFIndexObject(reader, charStringsInfo, i);
var glyf = (0, _parseCFFGlyph.default)(charstring, getGlyphFont(i), i);
glyf.name = cff.charset[i];
cff.glyf[i] = glyf;
});
@ -372,7 +431,8 @@ var _default = exports.default = _table.default.create('cff', [], {
// parse all
else {
for (var i = 0, l = nGlyphs; i < l; i++) {
var glyf = (0, _parseCFFGlyph.default)(charStringsIndex.objects[i], getGlyphFont(i), i);
var charstring = readCFFIndexObject(reader, charStringsInfo, i);
var glyf = (0, _parseCFFGlyph.default)(charstring, getGlyphFont(i), i);
glyf.name = cff.charset[i];
cff.glyf.push(glyf);
}

View File

@ -490,11 +490,10 @@ function parseCFFCharstring(code, font, index) {
}
parse(code);
var glyf = {
// 移除重复的起点和终点
contours: contours.map(function (contour) {
var last = contour.length - 1;
if (contour[0].x === contour[last].x && contour[0].y === contour[last].y) {
contour.splice(last, 1);
if (last > 0 && contour[0].x === contour[last].x && contour[0].y === contour[last].y) {
contour.length = last;
}
return contour;
}),

View File

@ -320,6 +320,16 @@ function optimizettf(ttf) {
if (glyf._flatContours) {
ceilReduceAndSizeFlat(glyf);
} else {
/* 对象 contours 格式也需要收集 maxPoints/maxContours */
var numC = glyf.contours.length;
if (numC > 0) {
if (numC > m_maxContours) m_maxContours = numC;
var totalPts = 0;
for (var ci = 0; ci < numC; ci++) {
totalPts += glyf.contours[ci].length;
}
if (totalPts > m_maxPoints) m_maxPoints = totalPts;
}
glyf.contours.forEach(function (contour) {
(0, _pathCeil.default)(contour);
});

View File

@ -21,50 +21,48 @@ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { de
/**
* CFF contour 转换为标准 [onCurve, offCurve, offCurve, onCurve, ...] 序列
* 处理隐含端点和连续 offCurve 点的情况
* 优化原地操作 otfContour减少中间数组分配
*/
function normalizeContour(otfContour) {
if (!otfContour.length) return [];
var len = otfContour.length;
if (len < 2) return otfContour;
var points = [];
for (var i = 0; i < otfContour.length; i++) {
var p = otfContour[i];
points.push({ x: p.x, y: p.y, onCurve: !!p.onCurve });
/** 确保 onCurve 标志规范化 */
for (var i = 0; i < len; i++) {
otfContour[i].onCurve = !!otfContour[i].onCurve;
}
if (points.length < 2) return points;
/** 如果第一个点不是 onCurve需要回绕处理 */
if (!points[0].onCurve) {
var last = points[points.length - 1];
if (!otfContour[0].onCurve) {
var last = otfContour[len - 1];
if (last.onCurve) {
/** 隐含端点 = 最后一个 onCurve 点(回绕起点) */
points.unshift({ x: last.x, y: last.y, onCurve: true });
otfContour.unshift({ x: last.x, y: last.y, onCurve: true });
} else {
/** 首尾都是 offCurve隐含端点 = 首尾中点 */
points.unshift({
x: (points[0].x + last.x) * 0.5,
y: (points[0].y + last.y) * 0.5,
otfContour.unshift({
x: (otfContour[0].x + last.x) * 0.5,
y: (otfContour[0].y + last.y) * 0.5,
onCurve: true
});
}
len = otfContour.length;
}
/** 处理连续的 offCurve 点:在它们之间插入隐含端点 */
var normalized = [];
for (var i = 0; i < points.length; i++) {
var p = points[i];
normalized.push(p);
if (!p.onCurve && i + 1 < points.length && !points[i + 1].onCurve) {
/** 两个连续 offCurve隐含端点 = 中点 */
normalized.push({
x: (p.x + points[i + 1].x) * 0.5,
y: (p.y + points[i + 1].y) * 0.5,
for (var i = 0; i < len; i++) {
var p = otfContour[i];
if (!p.onCurve && i + 1 < len && !otfContour[i + 1].onCurve) {
var next = otfContour[i + 1];
otfContour.splice(i + 1, 0, {
x: (p.x + next.x) * 0.5,
y: (p.y + next.y) * 0.5,
onCurve: true
});
len++;
i++;
}
}
return normalized;
return otfContour;
}
/**

View File

@ -194,10 +194,75 @@ for (const { label, text } of testCases) {
console.log(` [woff2] ${label}: avg=${woff2Avg.toFixed(1)}ms min=${woff2Min.toFixed(1)}ms max=${woff2Max.toFixed(1)}ms 输出=${lastWoff2Size.toLocaleString()} bytes 压缩率=${compressionRatio}%`);
}
/** --- OTF 测试(直接 OTF 子集化SSIM 对比原始 OTF 渲染 vs 子集 TTF 渲染) --- */
const OTF_FONT_PATH = "font/temp/SourceHanSans-Regular.otf";
let otfTestResults: string[] = [];
try {
const otfRaw = await readFile(OTF_FONT_PATH);
const otfBuffer = new Uint8Array(otfRaw).buffer;
/** 注册原始 OTF 字体作为渲染基准 */
const OTF_FONT_NAME = `OTF_Bench_${Date.now()}`;
FontLibrary.use(OTF_FONT_NAME, OTF_FONT_PATH);
const otfTestCases = [
{ label: "otf-8个汉字", text: "天地玄黄宇宙洪荒" },
{ label: "otf-拉丁+数字", text: "Hello World 123" },
];
for (const { label, text } of otfTestCases) {
const subset = [...text].map((c) => c.codePointAt(0)!);
const otfTimes: number[] = [];
let lastOtfTtfSize = 0;
let lastOtfTtfBuffer: ArrayBuffer | null = null;
for (let i = 0; i < ROUNDS; i++) {
const t0 = performance.now();
const font = Font.create(otfBuffer, { type: "otf", subset });
const optimized = font.optimize().sort();
const result = optimized.write({ type: "ttf" });
const t1 = performance.now();
otfTimes.push(t1 - t0);
lastOtfTtfSize = typeof result === "string" ? result.length : result.byteLength;
if (i === 0) {
lastOtfTtfBuffer = result instanceof ArrayBuffer ? result : new Uint8Array(result as any).buffer;
}
}
const otfAvg = otfTimes.reduce((a, b) => a + b, 0) / otfTimes.length;
const otfMin = Math.min(...otfTimes);
const otfMax = Math.max(...otfTimes);
/** OTF SSIM对比原始 OTF 渲染 vs 子集 TTF 渲染 */
let otfSsim = 0;
if (lastOtfTtfBuffer) {
subsetFontCounter++;
const familyName = await registerSubsetFont(lastOtfTtfBuffer, subsetFontCounter);
const safeLabel = label.replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, "_");
await renderTextToPng(OTF_FONT_NAME, text, 48, `${BENCHMARK_DIR}/${safeLabel}_otf_full.png`);
await renderTextToPng(familyName, text, 48, `${BENCHMARK_DIR}/${safeLabel}_otf_subset.png`);
const fullPixels = renderText(OTF_FONT_NAME, text, 48);
const subsetPixels = renderText(familyName, text, 48);
otfSsim = calculateSSIM(fullPixels, subsetPixels);
}
otfTestResults.push(` [otf] ${label}: avg=${otfAvg.toFixed(1)}ms min=${otfMin.toFixed(1)}ms max=${otfMax.toFixed(1)}ms 输出=${lastOtfTtfSize.toLocaleString()} bytes ssim=${otfSsim.toFixed(4)}`);
}
} catch {
otfTestResults.push(" [otf] 跳过(未找到 OTF 测试字体 font/temp/SourceHanSans-Regular.otf");
}
/** 保存结果到 JSON */
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const resultFile = `${BENCHMARK_DIR}/benchmark_${timestamp}.json`;
await writeFile(resultFile, JSON.stringify({ timestamp: new Date().toISOString(), rounds: ROUNDS, results }, null, 2));
console.log(`\n结果已保存到 ${resultFile}`);
console.log(`渲染对比图片已保存到 ${BENCHMARK_DIR}/ 目录`);
if (otfTestResults.length) {
console.log("\n--- OTF→TTF 子集化 ---");
for (const line of otfTestResults) console.log(line);
}
console.log("");