-
Notifications
You must be signed in to change notification settings - Fork 20
Expand file tree
/
Copy pathverify_font.ts
More file actions
242 lines (205 loc) · 9.22 KB
/
verify_font.ts
File metadata and controls
242 lines (205 loc) · 9.22 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
/**
* 字体裁剪正确性验证工具
*
* 用法:
* pnpm tsx verify_font.ts baseline — 生成基准(用当前代码裁剪 + 渲染图片)
* pnpm tsx verify_font.ts — 对比当前代码与基准
*
* 验证方式:
* 1. 结构化对比(glyf contours/pts/unicode/advanceWidth)
* 2. 渲染相似度对比(skia-canvas 渲染,SSIM 相似度阈值)
*/
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { Font } from "./vendor/fonteditor-core/lib/ttf/font.js";
import { Canvas, FontLibrary } from "skia-canvas";
const isBaseline = process.argv[2] === "baseline";
const FONT_PATH = "font/令东齐伋复刻体.ttf";
const FONT_NAME = "令东齐伋复刻体";
const SIMILARITY_THRESHOLD = 0.98;
const BASELINE_DIR = "benchmark_results";
const raw = await readFile(FONT_PATH);
const fontBuffer = new Uint8Array(raw).buffer;
FontLibrary.use(FONT_NAME, FONT_PATH);
const testCases = [
{ text: "天地玄黄宇宙洪荒", label: "8个汉字" },
{ text: "Hello World 123", label: "拉丁+数字" },
{ text: "天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈剑号巨阙珠称夜光果珍李柰菜重芥姜海咸河淡鳞潜羽翔", label: "千字文前段" },
];
/** 子集字体注册计数器 */
let subsetFontCounter = 0;
/** 渲染文字到纯图片数据(不保存文件),返回 Uint8Array */
function renderText(fontFamily: string, text: string, fontSize: number): Uint8Array {
const charWidth = Math.ceil(fontSize * 1.5);
const width = text.length * charWidth + 20;
const height = Math.ceil(fontSize * 1.5);
const canvas = new Canvas(width, height);
const ctx = canvas.getContext("2d");
ctx.fillStyle = "white";
ctx.fillRect(0, 0, width, height);
ctx.font = `${fontSize}px ${fontFamily}`;
ctx.fillStyle = "black";
ctx.fillText(text, 10, Math.ceil(fontSize * 1.2));
const imgData = ctx.getImageData(0, 0, width, height);
return new Uint8Array(imgData.data.buffer);
}
/**
* 计算两张图片的结构相似度(简化版 SSIM)
* 返回 0~1 之间的值,1 表示完全相同
*/
function calculateSSIM(a: Uint8Array, b: Uint8Array): number {
if (a.length !== b.length) return 0;
/** 转灰度 */
const toGray = (data: Uint8Array, offset: number) =>
0.299 * data[offset] + 0.587 * data[offset + 1] + 0.114 * data[offset + 2];
const pixelCount = a.length / 4;
let sumA = 0, sumB = 0, sumA2 = 0, sumB2 = 0, sumAB = 0;
for (let i = 0; i < pixelCount; i++) {
const idx = i * 4;
const ga = toGray(a, idx);
const gb = toGray(b, idx);
sumA += ga;
sumB += gb;
sumA2 += ga * ga;
sumB2 += gb * gb;
sumAB += ga * gb;
}
const meanA = sumA / pixelCount;
const meanB = sumB / pixelCount;
const varA = sumA2 / pixelCount - meanA * meanA;
const varB = sumB2 / pixelCount - meanB * meanB;
const covAB = sumAB / pixelCount - meanA * meanB;
const C1 = 6.5025;
const C2 = 58.5225;
const ssim =
(2 * meanA * meanB + C1) * (2 * covAB + C2) /
((meanA * meanA + meanB * meanB + C1) * (varA + varB + C2));
return ssim;
}
/** 从 Font 对象中提取可对比的结构数据 */
function extractFontData(font: any) {
const d = font.data;
const glyf = d.glyf.map((g: any, i: number) => {
/** 兼容扁平格式 [x,y,onCurve,...] 和对象格式 [{x,y,onCurve},...] */
const toPoints = (c: any[]) => {
if (!c || !c.length) return [];
if (typeof c[0] === "number") {
const pts: [number, number, boolean][] = [];
for (let k = 0; k < c.length; k += 3) pts.push([c[k], c[k + 1], !!c[k + 2]]);
return pts;
}
return c.slice(0, 3).map((p: any) => [p.x, p.y, !!p.onCurve] as [number, number, boolean]);
};
const contourHeads = g.contours ? g.contours.map(toPoints) : [];
const pointCount = (c: any[]) => {
if (!c || !c.length) return 0;
return typeof c[0] === "number" ? c.length / 3 : c.length;
};
return {
index: i,
contours: g.contours?.length || 0,
pts: g.contours ? g.contours.reduce((s: number, c: any[]) => s + pointCount(c), 0) : 0,
compound: !!g.compound,
unicode: g.unicode ? [...g.unicode].sort((a: number, b: number) => a - b) : [],
advanceWidth: g.advanceWidth,
leftSideBearing: g.leftSideBearing,
contourHeads,
};
});
const out = font.write({ type: "ttf" });
const buf = out instanceof ArrayBuffer ? out : new Uint8Array(out as any).buffer;
const view = new DataView(buf);
const numTables = view.getUint16(4, false);
const tables: Record<string, { offset: number; length: number; checksum: number }> = {};
for (let i = 0; i < numTables; i++) {
const base = 12 + i * 16;
const tag = String.fromCharCode(
view.getUint8(base), view.getUint8(base + 1),
view.getUint8(base + 2), view.getUint8(base + 3)
);
tables[tag] = {
offset: view.getUint32(base + 8, false),
length: view.getUint32(base + 12, false),
checksum: view.getUint32(base + 4, false),
};
}
return { glyfCount: d.glyf.length, glyf, outputSize: buf.byteLength, tables, buffer: buf };
}
/** 注册子集字体并返回 fontFamily 名 */
async function registerSubsetFont(ttfBuffer: ArrayBuffer, counter: number): Promise<string> {
const fontPath = `${BASELINE_DIR}/_verify_${counter}.ttf`;
await writeFile(fontPath, Buffer.from(ttfBuffer));
const familyName = `SubsetFont_${counter}`;
FontLibrary.use(familyName, [fontPath]);
return familyName;
}
await mkdir(BASELINE_DIR, { recursive: true });
if (isBaseline) {
const baseline: Record<string, any> = {};
for (const { text, label } of testCases) {
const subset = [...text].map((c) => c.codePointAt(0)!);
const font = Font.create(fontBuffer, { type: "ttf", subset });
const data = extractFontData(font);
await writeFile(`${BASELINE_DIR}/${label}.ttf`, Buffer.from(data.buffer));
/** 用完整字体和子集字体分别渲染,保存像素数据 */
const fullPixels = renderText(FONT_NAME, text, 48);
subsetFontCounter++;
const familyName = await registerSubsetFont(data.buffer, subsetFontCounter);
const subsetPixels = renderText(familyName, text, 48);
const ssim = calculateSSIM(fullPixels, subsetPixels);
const { buffer: _, ...structData } = data;
baseline[label] = {
...structData,
fullPixels: Array.from(fullPixels),
subsetPixels: Array.from(subsetPixels),
ssim,
};
console.log(` ${label}: glyf=${data.glyfCount}, output=${data.outputSize} bytes, ssim=${ssim.toFixed(4)}`);
}
await writeFile(`${BASELINE_DIR}/verify_baseline.json`, JSON.stringify(baseline, null, 2));
console.log("\n基准已生成(含完整字体+子集字体渲染像素数据及相似度)");
} else {
const baselineRaw = await readFile(`${BASELINE_DIR}/verify_baseline.json`, "utf-8");
const baseline = JSON.parse(baselineRaw);
let allPassed = true;
for (const { text, label } of testCases) {
const expected = baseline[label];
if (!expected) { console.log(`? ${label}: 无基准数据`); continue; }
const subset = [...text].map((c) => c.codePointAt(0)!);
const font = Font.create(fontBuffer, { type: "ttf", subset });
const actual = extractFontData(font);
const errors: string[] = [];
/** 1. 结构化对比 */
if (actual.glyfCount !== expected.glyfCount) {
errors.push(`glyfCount: ${actual.glyfCount} != ${expected.glyfCount}`);
}
for (let i = 0; i < Math.max(actual.glyf.length, expected.glyf.length); i++) {
const a = actual.glyf[i];
const e = expected.glyf[i];
if (!a || !e) { errors.push(`glyf[${i}]: 缺失`); continue; }
if (a.contours !== e.contours) errors.push(`glyf[${i}].contours: ${a.contours} != ${e.contours}`);
if (a.pts !== e.pts) errors.push(`glyf[${i}].pts: ${a.pts} != ${e.pts}`);
if (a.compound !== e.compound) errors.push(`glyf[${i}].compound: ${a.compound} != ${e.compound}`);
if (JSON.stringify(a.unicode) !== JSON.stringify(e.unicode)) errors.push(`glyf[${i}].unicode: ${JSON.stringify(a.unicode)} != ${JSON.stringify(e.unicode)}`);
if (a.advanceWidth !== e.advanceWidth) errors.push(`glyf[${i}].advanceWidth: ${a.advanceWidth} != ${e.advanceWidth}`);
if (JSON.stringify(a.contourHeads) !== JSON.stringify(e.contourHeads)) errors.push(`glyf[${i}].contourHeads: 不一致`);
}
/** 2. 渲染相似度对比:当前子集字体 vs 基准完整字体 */
const fullPixels = new Uint8Array(expected.fullPixels);
subsetFontCounter++;
const familyName = await registerSubsetFont(actual.buffer, subsetFontCounter);
const currentPixels = renderText(familyName, text, 48);
const ssim = calculateSSIM(fullPixels, currentPixels);
if (ssim < SIMILARITY_THRESHOLD) {
errors.push(`渲染相似度: ${ssim.toFixed(4)} < 阈值 ${SIMILARITY_THRESHOLD}`);
}
if (errors.length === 0) {
console.log(`✓ ${label}: PASS (glyf=${actual.glyfCount}, output=${actual.outputSize} bytes, ssim=${ssim.toFixed(4)})`);
} else {
allPassed = false;
console.log(`✗ ${label}: FAIL (${errors.length} errors)`);
for (const err of errors) console.log(` ${err}`);
}
}
console.log(`\n${allPassed ? "ALL PASSED ✓" : "SOME CHECKS FAILED ✗"}`);
if (!allPassed) process.exit(1);
}