feat: webfont-sdk Node 模式 + leafer 版基准测试(双方案并存)

- webfont-sdk: 新增 Node 模式(node-registry),GlobalFonts 注册 + 每 chunk 唯一 family + 逗号链回退
- leafer-x-webfont: fontFamily 链写回(Node 端),scan 链归一化防套娃、rewriteFamily 跳过链形态防抹回
- 修复 pnpm 双实例 bug: @napi-rs/canvas 版本对齐 1.0.6(根与 SDK 同版本共享 .pnpm 条目)
- 基准测试重构: 用例表抽取为 基准测试用例.ts 共享,SSIM 计算收敛到 scripts/ssim.ts
- 新增 基准测试_leafer.test.ts: @leafer-ui/node + Skia 渲染验证(无浏览器),ttf 输出用例 SSIM=1.0000;woff2 用例仅计时(Skia 不吃 woff2,容器验证由 puppeteer 版守护)
- Node 端集成测试 scripts/test-leafer-node.mts(插件链路 SSIM=1.0000)
This commit is contained in:
崮生(子虚) 2026-08-15 15:51:42 +08:00
parent 0693521716
commit a0c2b4aafc
15 changed files with 1092 additions and 176 deletions

3
.gitignore vendored
View File

@ -40,3 +40,6 @@ benchmark_results
packages/*/dist
packages/*/dist-iife
packages/leafer-x-webfont/demo/vendor
# leafer 插件类型声明的 vendor从 npm @leafer-ui/interface 拷贝,体积大)
packages/leafer-x-webfont/vendor

View File

@ -23,6 +23,7 @@
"web-streams-polyfill": "^4.3.0"
},
"devDependencies": {
"@leafer-ui/node": "2.2.9",
"@types/node": "^26.1.1",
"@vitejs/plugin-vue": "^6.0.8",
"@xmldom/xmldom": "^0.9.10",
@ -39,5 +40,8 @@
"vitest": "^4.1.10",
"vue": "3.6.0-beta.17",
"vue-router": "^5.2.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^1.0.6"
}
}

View File

@ -44,9 +44,10 @@
<div id="canvas-host"></div>
</div>
<script src="vendor/leafer-ui.min.js"></script>
<!-- leafer-ui 2.2.9(与项目 @leafer-ui/node 版本对齐CDN 版本与原 vendor 文件逐字节一致 -->
<script src="https://cdn.jsdelivr.net/npm/leafer-ui@2.2.9/dist/web.min.js"></script>
<!-- leafer 导出 PNG 需要额外插件(画布转 blob -->
<script src="vendor/leafer-in-export.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@leafer-in/export@2.2.9/dist/export.min.js"></script>
<!-- demo 引插件构建产物dist ESMwebfont-sdk 由相对路径 importmap 解析到同级包的 dist -->
<script type="importmap">
{

View File

@ -173,8 +173,12 @@ export class WebFontPlugin {
if (fontName) {
const family = normalizeFamily(fontName)
const entry = getOrCreate(groups, family, () => ({ loader: this.getLoader(fontName, family), fontName, family, chars: new Set() }))
/** 自动改写节点 fontFamily 为合法 CSS 名canvas font 串要求) */
if (this.config.rewriteFamily && node.fontFamily !== family) {
/**
* fontFamily CSS canvas font
* Node chunk base
* chunk 退layout.end scan
*/
if (this.config.rewriteFamily && node.fontFamily !== family && !isChunkChain(fontFamily)) {
;(node as { fontFamily: string }).fontFamily = family
}
for (const ch of text) entry.chars.add(ch)
@ -224,7 +228,8 @@ export class WebFontPlugin {
loader = this.mode.loadFontFace(
{ fontName, family },
() => {
/** 字体注册成功后强制重绘整个画布(文本 metrics 需要重新计算) */
/** 字体注册成功后Node 端更新 fontFamily 链,浏览器端仅重绘 */
this.applyNodeFontChain(family)
this.leafer?.forceRender?.()
},
)
@ -234,6 +239,36 @@ export class WebFontPlugin {
return loader
}
/**
* Node SDK fontFamily chunk family
* 使 family Text fontFamilyChain
* null no-op
*/
private applyNodeFontChain(family: string): void {
const loader = this.loaders.get(family)
const chain = loader?.fontFamilyChain()
if (!chain) return
/** 预编译匹配:原始 family 名或已改写链(链首 chunk family 以 `${family}__` 开头) */
const chainHead = new RegExp(`^["']?${escapeRegExp(family)}__\\d`)
const walk = (node: ILeaferNode | null | undefined): void => {
if (!node || node.destroyed) return
const fontFamily = node.fontFamily
if (typeof fontFamily === 'string' && fontFamily) {
const usesFamily = fontFamily === family || fontFamily.trim() === family || chainHead.test(fontFamily)
if (usesFamily && node.fontFamily !== chain) {
;(node as { fontFamily: string }).fontFamily = chain
}
}
const children = node.children
if (Array.isArray(children)) {
for (const child of children as ILeaferNode[]) walk(child)
}
}
walk(this.leafer)
}
private log(...args: unknown[]): void {
if (this.config.debug) console.log('[leafer-x-webfont]', ...args)
}
@ -280,9 +315,37 @@ export class WebFontPlugin {
*
* ============================================================ */
/** fontFamily 原始值 -> 合法 CSS family 名(去除文件后缀与首尾空白) */
/** 正则元字符转义family 名可能含中文/特殊符号,构造匹配器前先转义) */
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* chunk family Node chunk family`base__0``base__1`
* base family scan
*/
const CHUNK_SUFFIX_RE = /__(\d+)$/
/**
* fontFamily Node chunk
* `"base__0"` `"base__0", "base__1"`
* scan base
*/
function isChunkChain(fontFamily: string): boolean {
return fontFamily.includes('__') && /__\d+["']?$/.test(fontFamily.split(',')[0]!.trim())
}
/**
* fontFamily -> CSS family
* -
* - Node chunk `"base__0", "base__1"` base
* scan family loader bug
*/
function normalizeFamily(fontFamily: string): string {
return fontFamily.replace(FONT_EXT_RE, '').trim()
/** 取链首成员(逗号分隔),去掉首尾引号 */
const first = fontFamily.split(',')[0]!.trim().replace(/^["']|["']$/g, '')
const bare = first.replace(FONT_EXT_RE, '').trim()
return bare.replace(CHUNK_SUFFIX_RE, '')
}
/** Map 的 get-or-create 惯用封装 */

View File

@ -32,6 +32,14 @@
],
"repository": "https://github.com/2234839/web-font",
"license": "MIT",
"peerDependencies": {
"@napi-rs/canvas": ">=0.1"
},
"peerDependenciesMeta": {
"@napi-rs/canvas": {
"optional": true
}
},
"devDependencies": {
"tsdown": "^0.22.13",
"typescript": "^7.0.2"

View File

@ -6,6 +6,7 @@
* onReady
*/
import { IncrementalEngine, createHttpProvider, type SubsetProvider, type LoadedChunk, type IFontState } from './engine'
import { NodeFontRegistry, loadGlobalFonts, isNodeEnvironment } from './node-registry'
export interface IFontFaceOptions {
/** 字体文件名(如 '令东齐伋复刻体.ttf'),支持模糊匹配 */
@ -14,7 +15,7 @@ export interface IFontFaceOptions {
baseUrl?: string
/** 注册用的 family 名,默认去掉扩展名 */
family?: string
/** 输出格式,默认 woff2 */
/** 输出格式,默认 woff2Node 端自动降级 ttf见 loadFontFace */
outType?: 'woff2' | 'ttf'
}
@ -27,14 +28,27 @@ export interface IFontFaceLoader {
/** 清除失败记录(重试场景) */
retryFailed(): void
dispose(): void
/**
* fontFamily Node
* Node chunk familySkia 退
* chunk Text fontFamily
* nullFontFace unicodeRange
*/
fontFamilyChain(): string | null
}
export class WebFontFontFaceMode {
private engine: IncrementalEngine
/** 未显式传 baseUrl 时的默认服务地址 */
private defaultBaseUrl = 'https://webfont.shenzilong.cn'
/** family -> 已注册的 FontFacedispose 时从 document.fonts 删除) */
/** family -> 已注册的 FontFacedispose 时从 document.fonts 删除;浏览器模式用 */
private faces = new Map<string, FontFace[]>()
/** family -> Node 注册表Node 模式用;浏览器环境恒为空) */
private nodeRegistries = new Map<string, NodeFontRegistry>()
/** Node 模式懒加载的 GlobalFontsnull = 未加载或加载失败) */
private globalFonts: Awaited<ReturnType<typeof loadGlobalFonts>> = null
/** 是否 Node 环境(构造时探测一次,后续分支依据) */
private readonly nodeEnv: boolean
constructor(config: { baseUrl?: string; maxConcurrent?: number; provider?: SubsetProvider | null } = {}) {
this.engine = new IncrementalEngine({
@ -42,6 +56,7 @@ export class WebFontFontFaceMode {
provider: config.provider ?? null,
})
if (config.baseUrl) this.defaultBaseUrl = config.baseUrl
this.nodeEnv = isNodeEnvironment()
}
getEngine(): IncrementalEngine {
@ -53,18 +68,23 @@ export class WebFontFontFaceMode {
}
/**
* FontFace
* FontFace
* FontFace(unicodeRange)Node @napi-rs/canvas
* GlobalFonts chunk family fontFamilyChain
*
* @param options
* @param onChunk
* @param onChunk / fontFamily
*/
loadFontFace(options: IFontFaceOptions, onChunk?: (chunk: LoadedChunk) => void): IFontFaceLoader {
const fontName = options.fontName
const family = options.family ?? fontName.replace(/\.(ttf|otf|woff2?|ttc)$/i, '').trim()
const key = IncrementalEngine.fontKey(fontName, family)
const baseUrl = options.baseUrl ?? this.defaultBaseUrl
/** Node 模式统一用 ttfwoff2 注册返回 null 不可靠(探针实证) */
const outType = this.nodeEnv ? 'ttf' : options.outType ?? 'woff2'
const handleChunk = async (chunk: LoadedChunk): Promise<void> => {
/** 浏览器分支FontFace + unicodeRange多 chunk 同 family 按字符精确生效) */
const handleChunkBrowser = async (chunk: LoadedChunk): Promise<void> => {
const unicodeRanges = chunk.chars
.map((c) => 'U+' + c.codePointAt(0)!.toString(16).padStart(4, '0'))
.join(', ')
@ -79,10 +99,31 @@ export class WebFontFontFaceMode {
onChunk?.(chunk)
}
/**
* Node GlobalFonts.register chunk family退
* @napi-rs/canvas fail fast
*/
const handleChunkNode = async (chunk: LoadedChunk): Promise<void> => {
if (!this.globalFonts) this.globalFonts = await loadGlobalFonts()
if (!this.globalFonts) {
throw new Error('Node 环境未安装 @napi-rs/canvas无法注册字体pnpm add @napi-rs/canvas')
}
let registry = this.nodeRegistries.get(family)
if (!registry || !registry.bound) {
registry = new NodeFontRegistry(family)
registry.bind(this.globalFonts)
this.nodeRegistries.set(family, registry)
}
const res = await fetch(chunk.url)
const buffer = new Uint8Array(await res.arrayBuffer())
registry.registerChunk(chunk, buffer)
onChunk?.(chunk)
}
this.engine.ensureState(key, fontName, {
baseUrl,
outType: options.outType ?? 'woff2',
onLoadChunk: (chunk) => handleChunk(chunk),
outType,
onLoadChunk: (chunk) => (this.nodeEnv ? handleChunkNode(chunk) : handleChunkBrowser(chunk)),
})
let disposed = false
@ -100,7 +141,12 @@ export class WebFontFontFaceMode {
if (disposed) return
disposed = true
this.engine.removeState(key)
/** FontFace 不主动删除:其他画布可能还在用同 family保守策略 */
/** FontFace family
* Node GlobalFonts 退 */
},
fontFamilyChain: (): string | null => {
if (!this.nodeEnv) return null
return this.nodeRegistries.get(family)?.fontChain() ?? null
},
}
}
@ -119,4 +165,5 @@ export class WebFontFontFaceMode {
}
export { createHttpProvider, IncrementalEngine }
export { NodeFontRegistry, loadGlobalFonts, isNodeEnvironment }
export type { SubsetProvider, LoadedChunk, IFontState }

View File

@ -14,6 +14,8 @@ import { WebFontFontFaceMode } from './fontface-mode'
export { WebFontCSSMode, WebFontFontFaceMode }
export { IncrementalEngine, createHttpProvider } from './engine'
export type { SubsetProvider, LoadedChunk, IFontState, IEngineConfig } from './engine'
export { NodeFontRegistry, loadGlobalFonts, isNodeEnvironment } from './node-registry'
export type { IGlobalFontsLike, INodeRegistryEntry } from './node-registry'
export type {
IWebFontOptions, ILoadFontOptions, IObserveFontOptions, ILoadTextOptions,
ITextLoader, IObserveTask,

View File

@ -0,0 +1,139 @@
/**
* Node @napi-rs/canvas GlobalFonts
*
* benchmark_results/debug/
* 1. family register Skia chunk
* 2. remove(key) family Skia
* 3. woff2 buffer register null
*
* chunk family + 退
* - chunk family`{base}__{n}`
* - fontFamily chunk family
* - Skia A B
* - remove(key) FontKey chunk
*/
import type { LoadedChunk } from './engine'
/** @napi-rs/canvas 的 GlobalFonts 最小接口(避免硬依赖,保持 optional peerDep */
export interface IGlobalFontsLike {
register: (data: Uint8Array | ArrayBuffer, family: string) => unknown
remove: (key: unknown) => boolean
}
/**
* import specifier catch null
*
*/
const NAPI_CANVAS_MODULE = '@napi-rs/canvas'
/** Node 注册器状态 */
export interface INodeRegistryEntry {
/** chunk 索引0 起) */
index: number
/** 唯一 family 名(`${base}__${n}` */
family: string
/** register 返回的 FontKeyremove 用woff2 注册时可能为 null */
fontKey: unknown
/** chunk 字符集dispose 时无需用,仅调试) */
chars: string[]
}
/** 一个逻辑字体(用户视角的 family的 Node 端注册表 */
export class NodeFontRegistry {
/** 基础 family 名(用户设置的原始名,去后缀) */
readonly base: string
/** 已注册 chunk 列表(顺序即 fontFamily 链顺序) */
private entries: INodeRegistryEntry[] = []
/** family 名 -> entry快速查重 */
private familyIndex = new Map<string, INodeRegistryEntry>()
/** 注入的 GlobalFonts浏览器环境为 null构造后由 ensure 注入) */
private globalFonts: IGlobalFontsLike | null = null
constructor(base: string) {
this.base = base
}
/** 绑定 GlobalFonts懒加载 @napi-rs/canvas 成功后调用) */
bind(globalFonts: IGlobalFontsLike): void {
this.globalFonts = globalFonts
}
/** 是否已绑定(未绑定时 registerChunk 会抛错) */
get bound(): boolean {
return this.globalFonts !== null
}
/**
* chunk family + 退
* @returns fontFamily Text
*/
registerChunk(chunk: LoadedChunk, buffer: Uint8Array): string {
if (!this.globalFonts) throw new Error('NodeFontRegistry not bound to GlobalFonts')
const index = this.entries.length
const family = `${this.base}__${index}`
const fontKey = this.globalFonts.register(buffer, family)
const entry: INodeRegistryEntry = { index, family, fontKey, chars: chunk.chars }
this.entries.push(entry)
this.familyIndex.set(family, entry)
return this.fontChain()
}
/**
* fontFamily /
* `"base__0", "base__1", ...`
* base base /
*/
fontChain(): string {
if (this.entries.length === 0) return quote(this.base)
return this.entries.map((e) => quote(e.family)).join(', ')
}
/** 已注册 chunk 数(调试用) */
get size(): number {
return this.entries.length
}
/**
* chunkdispose
* FontKey null woff2 null remove
*/
dispose(): void {
if (!this.globalFonts) return
for (const entry of this.entries) {
if (entry.fontKey != null) {
try {
this.globalFonts.remove(entry.fontKey)
} catch {
/** remove 失败不阻塞 dispose字体可能已被外部清理 */
}
}
}
this.entries.length = 0
this.familyIndex.clear()
}
}
/** family 名加引号CSS font 串规范,中文名必须带引号) */
function quote(family: string): string {
return `"${family}"`
}
/** 动态加载 @napi-rs/canvas 的 GlobalFonts未安装/非 Node 环境返回 null */
export async function loadGlobalFonts(): Promise<IGlobalFontsLike | null> {
try {
/** 动态 specifier避免打包器静态解析未安装时也能构建 */
const specifier = NAPI_CANVAS_MODULE
const mod = (await import(/* @vite-ignore */ /* webpackIgnore: true */ specifier)) as {
GlobalFonts?: IGlobalFontsLike
}
return mod.GlobalFonts ?? null
} catch {
return null
}
}
/** 当前是否 Node 环境(无 document 且有 node 进程标记,用于模式分支) */
export function isNodeEnvironment(): boolean {
const g = globalThis as { document?: unknown; process?: { versions?: { node?: string } } }
return typeof g.document === 'undefined' && !!g.process?.versions?.node
}

186
pnpm-lock.yaml generated
View File

@ -18,6 +18,9 @@ importers:
specifier: ^4.3.0
version: 4.3.0
devDependencies:
'@leafer-ui/node':
specifier: 2.2.9
version: 2.2.9
'@types/node':
specifier: ^26.1.1
version: 26.1.1
@ -66,6 +69,10 @@ importers:
vue-router:
specifier: ^5.2.0
version: 5.2.0(@vue/compiler-sfc@3.5.40)(esbuild@0.27.7)(rolldown@1.2.0)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(terser@5.49.0)(yaml@2.9.0))(vue@3.6.0-beta.17(typescript@7.0.2))
optionalDependencies:
'@napi-rs/canvas':
specifier: ^1.0.6
version: 1.0.6
packages/leafer-x-webfont:
dependencies:
@ -77,6 +84,10 @@ importers:
version: link:../webfont-sdk
packages/webfont-sdk:
dependencies:
'@napi-rs/canvas':
specifier: '>=0.1'
version: 1.0.6
devDependencies:
tsdown:
specifier: ^0.22.13
@ -394,6 +405,13 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@leafer-in/export@2.2.9':
resolution: {integrity: sha512-18kOtU7vqfTjBAENc20TcB9aAYOpEzcvuaMTZPpMQS3PvX/1HddPijU/2vmzZhj+SvWwKtPnPUDOzsD2goePAw==}
peerDependencies:
'@leafer-in/interface': ^2.2.9
'@leafer-ui/draw': ^2.2.9
'@leafer-ui/interface': ^2.2.9
'@leafer-in/interface@2.2.9':
resolution: {integrity: sha512-6T0Gf1EDKxS4a8sZCT8Q2PZ0a5JhHWswyWfsSXNHrvdJvapHcUE4YordpzXFQeki0KcCiAy2HIrOZ7mvFJ9spQ==}
peerDependencies:
@ -448,6 +466,9 @@ packages:
'@leafer-ui/interface@2.2.9':
resolution: {integrity: sha512-AWnRhnQOaMk8kNOJwzymuZpNb4b28CsMUxvr4pVjGwmlXWG1qINJrlWYWrXbV3R44+zhK0VIJ8T7szsoO2qj7Q==}
'@leafer-ui/node@2.2.9':
resolution: {integrity: sha512-WwXlrBlM8YJ4kNDjvjKZFx3xK6F/imz0gtww+gLFuTav1I3IM8Cs6dx48rzN7GEcDLN8WFJ1TwOB55ifu9OY7w==}
'@leafer-ui/paint@2.2.9':
resolution: {integrity: sha512-/scOodHPSs3hJo73Scvk556ntXn9dJI8/7GlaWRMbprruvB+ujc/vyWUc5DInOqikxxAQb9eEfnKX3GoFUGiSA==}
@ -463,6 +484,9 @@ packages:
'@leafer-ui/web@2.2.9':
resolution: {integrity: sha512-0QgYEK3wuUFWHz0BM6HPMboh0uiRR5fRLqrPEopO+ofJLygWi0v6FIjU3OE4pOsHRdqrbBIuhOioRmIasBBgmg==}
'@leafer/canvas-node@2.2.9':
resolution: {integrity: sha512-2b1zPjVt5+vnxHChEJs8o6hZq+ARNA8dF07tsWWQsAEZixZDOTV4vzE8KL4myNGEn8qATNmPhq0eVOxNH4VA+A==}
'@leafer/canvas-web@2.2.9':
resolution: {integrity: sha512-kj58yyHdqXq7VnZm18aYzXBTX8HrGooPOunBsQwbEnUn8bVe4JtkNaUgAWIjaDIJT0TuMwQ5kH/9E+6hpwHEPA==}
@ -496,6 +520,9 @@ packages:
'@leafer/helper@2.2.9':
resolution: {integrity: sha512-OVMB9k3IP5w2ARrKIzrDhrc2PTjFppIKtZpvLIFf24tH6Itq0/Hn6J8pa7gKEzdltpgOJD5lYT7IQNtVbJQVXg==}
'@leafer/image-node@2.2.9':
resolution: {integrity: sha512-st5Nau27rFLVXPOjIt07OBtPoHvk/CN21xLZrVGyvkVur3DWD0jRQt0mY9y+chwK8sJIqrZFnuuMWlf011OUbA==}
'@leafer/image-web@2.2.9':
resolution: {integrity: sha512-ctMDVYoDi16rniIOffrqXGfOR1qWJe+lm185l+mjsqfcQo//QZ9cml4m+FfChnzwUqB6yuJvhaP3idg59ARFVA==}
@ -517,6 +544,9 @@ packages:
'@leafer/math@2.2.9':
resolution: {integrity: sha512-kkybyaR+76e0dsgAxk0Mpeb1coMNM3/mR/JvDBNzcF2iFCemYcOFCiI8+uhJyqUKLfYzLjD7CW5gFa0NmtbgDQ==}
'@leafer/node-core@2.2.9':
resolution: {integrity: sha512-/e8t2jRFJJzy/u7jWlNQ0Nl5QRqIHiCJe2O1O3QTa/aw601ya0gIQjaiKImmcZBzZZ7e5PPttHSuagMqvOZ64w==}
'@leafer/partner@2.2.9':
resolution: {integrity: sha512-W2CcbVPoGomvVQNve1CWdhq1B5zOmUKUJqKssO8eBZF6cIXtFW9Ab1CxZR1CcLCi+OelDUvcYFhjU3keLCnNdg==}
@ -541,6 +571,81 @@ packages:
'@leafer/web-core@2.2.9':
resolution: {integrity: sha512-d/m46qw6zjAjf+X/MBx4q0ztd5SxSs/mYORdaNjiOZRg6qFXsBGtUUSUyN5dIprCjKFkdVge4ow3yLxv2RvdMw==}
'@napi-rs/canvas-android-arm64@1.0.6':
resolution: {integrity: sha512-CZUmZEyN/cmSN1OhRJ44vBE6xyb5ekBbsIF8dqNgizbnAiHSFNi8eAA4oyyIAe5KzJ72K16OKC2lAynZLhv32Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@napi-rs/canvas-darwin-arm64@1.0.6':
resolution: {integrity: sha512-FGJsIngRfS9LDaX4t/lFthqn2Km6b61ONzjJFC6+yuabL1NgaK7x0fwrj8276OQFkjCvSpJZ7+CeZWgcy03F2w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@napi-rs/canvas-darwin-x64@1.0.6':
resolution: {integrity: sha512-wLDrtfX9V4bu6O55/WwGvPVCjfunL2bEm59lzIFk9ZQOt7oJrPzdx/ytZiQr6bMZ9XOFwvXujszYtpoCK7yUbg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@napi-rs/canvas-linux-arm-gnueabihf@1.0.6':
resolution: {integrity: sha512-8TllEP/9UPDVxrBN/tnbGBsxxwG8Eo2tgyW8wDy+Gi9rm9si2I6MsDv9MlfI3a7JOuzkBcurHYDHZxwLgzAIdA==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@napi-rs/canvas-linux-arm64-gnu@1.0.6':
resolution: {integrity: sha512-YF15AKoGrgU7aoA03CR+cATCRPQcVt6L14Lb/a9hqK7pT7/DhIkqh4qyl+a2GLfqx++yIIJfBuZ3sHM3zlXM1Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-arm64-musl@1.0.6':
resolution: {integrity: sha512-zXeVWNFpj720HZln0bfAs+1/nP3Wy6Z5hUw60UJu+cvgips5jZtuX2+EjDHFk98q3BY35GaE1TQLwE9S244vPA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-linux-riscv64-gnu@1.0.6':
resolution: {integrity: sha512-kvy1Ypq4ODEI2WRNGvs588qTMcWCjlEIuZRyW8JXpiBnMUirgxQukIDFVDQEcrLlHP5cqZ9cvvfOkBJ7koOktA==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-gnu@1.0.6':
resolution: {integrity: sha512-czHnfLgSCl48iluS6bNrytGYhsbSTUuIvMucAwO0mPEj1uhkHk6SUiRI0xoqv0PSFzpGFfSnQvFpm7vV+tDO+A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-musl@1.0.6':
resolution: {integrity: sha512-afsg6GyfUx8qZzjsywjimXUEWcSWETY5F80hmMb2FWVSXkRbor8IpftY2gXnasq+Y42Xug/76qTHwqvti7HRTw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-win32-arm64-msvc@1.0.6':
resolution: {integrity: sha512-byzha357piy0wgK6IZOQtU4plpt94f6CxHFS7LhJpWYrDWWT/AitQNxRMfCNNaQHZXUB3BwNhQSfH2RpiZ0WaQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@napi-rs/canvas-win32-x64-msvc@1.0.6':
resolution: {integrity: sha512-gWLOgKGZz3hZhzg49q2PJIURwy2pCWh1zvNFXmLO1yineOPhEpoL3EV6/VIjOiKa1l2P6ieP12Eo385u1MLLVQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@napi-rs/canvas@1.0.6':
resolution: {integrity: sha512-Ud4d0jLr9vSaakvfOTIjgP3hoY8kMIYgHC4mtE4L0bP3St9lf6TyCVsInvFIOEofzcUeMfDydHDy8Fs2WuWz+A==}
engines: {node: '>= 10'}
'@napi-rs/wasm-runtime@1.1.6':
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
peerDependencies:
@ -2501,6 +2606,12 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@leafer-in/export@2.2.9(@leafer-in/interface@2.2.9(@leafer-ui/interface@2.2.9)(@leafer/interface@2.2.9))(@leafer-ui/draw@2.2.9)(@leafer-ui/interface@2.2.9)':
dependencies:
'@leafer-in/interface': 2.2.9(@leafer-ui/interface@2.2.9)(@leafer/interface@2.2.9)
'@leafer-ui/draw': 2.2.9
'@leafer-ui/interface': 2.2.9
'@leafer-in/interface@2.2.9(@leafer-ui/interface@2.2.9)(@leafer/interface@2.2.9)':
dependencies:
'@leafer-ui/interface': 2.2.9
@ -2593,6 +2704,18 @@ snapshots:
dependencies:
'@leafer/interface': 2.2.9
'@leafer-ui/node@2.2.9':
dependencies:
'@leafer-in/export': 2.2.9(@leafer-in/interface@2.2.9(@leafer-ui/interface@2.2.9)(@leafer/interface@2.2.9))(@leafer-ui/draw@2.2.9)(@leafer-ui/interface@2.2.9)
'@leafer-in/interface': 2.2.9(@leafer-ui/interface@2.2.9)(@leafer/interface@2.2.9)
'@leafer-ui/core': 2.2.9
'@leafer-ui/draw': 2.2.9
'@leafer-ui/interface': 2.2.9
'@leafer-ui/partner': 2.2.9
'@leafer/interface': 2.2.9
'@leafer/node-core': 2.2.9
'@leafer/partner': 2.2.9
'@leafer-ui/paint@2.2.9':
dependencies:
'@leafer-ui/draw': 2.2.9
@ -2626,6 +2749,10 @@ snapshots:
'@leafer/partner': 2.2.9
'@leafer/web-core': 2.2.9
'@leafer/canvas-node@2.2.9':
dependencies:
'@leafer/core': 2.2.9
'@leafer/canvas-web@2.2.9':
dependencies:
'@leafer/core': 2.2.9
@ -2713,6 +2840,10 @@ snapshots:
'@leafer/math': 2.2.9
'@leafer/platform': 2.2.9
'@leafer/image-node@2.2.9':
dependencies:
'@leafer/core': 2.2.9
'@leafer/image-web@2.2.9':
dependencies:
'@leafer/core': 2.2.9
@ -2742,6 +2873,14 @@ snapshots:
'@leafer/math@2.2.9': {}
'@leafer/node-core@2.2.9':
dependencies:
'@leafer/canvas-node': 2.2.9
'@leafer/core': 2.2.9
'@leafer/image-node': 2.2.9
'@leafer/interface': 2.2.9
'@leafer/partner': 2.2.9
'@leafer/partner@2.2.9':
dependencies:
'@leafer/core': 2.2.9
@ -2788,6 +2927,53 @@ snapshots:
'@leafer/interface': 2.2.9
'@leafer/partner': 2.2.9
'@napi-rs/canvas-android-arm64@1.0.6':
optional: true
'@napi-rs/canvas-darwin-arm64@1.0.6':
optional: true
'@napi-rs/canvas-darwin-x64@1.0.6':
optional: true
'@napi-rs/canvas-linux-arm-gnueabihf@1.0.6':
optional: true
'@napi-rs/canvas-linux-arm64-gnu@1.0.6':
optional: true
'@napi-rs/canvas-linux-arm64-musl@1.0.6':
optional: true
'@napi-rs/canvas-linux-riscv64-gnu@1.0.6':
optional: true
'@napi-rs/canvas-linux-x64-gnu@1.0.6':
optional: true
'@napi-rs/canvas-linux-x64-musl@1.0.6':
optional: true
'@napi-rs/canvas-win32-arm64-msvc@1.0.6':
optional: true
'@napi-rs/canvas-win32-x64-msvc@1.0.6':
optional: true
'@napi-rs/canvas@1.0.6':
optionalDependencies:
'@napi-rs/canvas-android-arm64': 1.0.6
'@napi-rs/canvas-darwin-arm64': 1.0.6
'@napi-rs/canvas-darwin-x64': 1.0.6
'@napi-rs/canvas-linux-arm-gnueabihf': 1.0.6
'@napi-rs/canvas-linux-arm64-gnu': 1.0.6
'@napi-rs/canvas-linux-arm64-musl': 1.0.6
'@napi-rs/canvas-linux-riscv64-gnu': 1.0.6
'@napi-rs/canvas-linux-x64-gnu': 1.0.6
'@napi-rs/canvas-linux-x64-musl': 1.0.6
'@napi-rs/canvas-win32-arm64-msvc': 1.0.6
'@napi-rs/canvas-win32-x64-msvc': 1.0.6
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0

View File

@ -376,6 +376,95 @@
for (const el of elements) el.style.fontFamily = `"${family}", sans-serif`;
}
//#endregion
//#region src/node-registry.ts
/**
* 动态 import 的模块 specifier包未安装时不参与类型解析运行时 catch 返回 null
* 用变量间接引用打包器不会尝试静态解析该路径
*/
const NAPI_CANVAS_MODULE = "@napi-rs/canvas";
/** 一个逻辑字体(用户视角的 family的 Node 端注册表 */
var NodeFontRegistry = class {
constructor(base) {
this.entries = [];
this.familyIndex = /* @__PURE__ */ new Map();
this.globalFonts = null;
this.base = base;
}
/** 绑定 GlobalFonts懒加载 @napi-rs/canvas 成功后调用) */
bind(globalFonts) {
this.globalFonts = globalFonts;
}
/** 是否已绑定(未绑定时 registerChunk 会抛错) */
get bound() {
return this.globalFonts !== null;
}
/**
* 注册一个新 chunk唯一 family + 逗号链回退
* @returns 新的 fontFamily 调用方把它设置到 Text 节点上
*/
registerChunk(chunk, buffer) {
if (!this.globalFonts) throw new Error("NodeFontRegistry not bound to GlobalFonts");
const index = this.entries.length;
const family = `${this.base}__${index}`;
const entry = {
index,
family,
fontKey: this.globalFonts.register(buffer, family),
chars: chunk.chars
};
this.entries.push(entry);
this.familyIndex.set(family, entry);
return this.fontChain();
}
/**
* 当前完整的 fontFamily 逗号分隔带引号防中文名/特殊字符问题
* `"base__0", "base__1", ...`
* 最后追加原始 base 此时 base 未注册仅作为语义占位/调试可读性
*/
fontChain() {
if (this.entries.length === 0) return quote(this.base);
return this.entries.map((e) => quote(e.family)).join(", ");
}
/** 已注册 chunk 数(调试用) */
get size() {
return this.entries.length;
}
/**
* 卸载全部 chunkdispose
* FontKey null woff2 首注册返回 null 的场景跳过 remove
*/
dispose() {
if (!this.globalFonts) return;
for (const entry of this.entries) if (entry.fontKey != null) try {
this.globalFonts.remove(entry.fontKey);
} catch {}
this.entries.length = 0;
this.familyIndex.clear();
}
};
/** family 名加引号CSS font 串规范,中文名必须带引号) */
function quote(family) {
return `"${family}"`;
}
/** 动态加载 @napi-rs/canvas 的 GlobalFonts未安装/非 Node 环境返回 null */
async function loadGlobalFonts() {
try {
return (await import(
/* @vite-ignore */
/* webpackIgnore: true */
NAPI_CANVAS_MODULE
)).GlobalFonts ?? null;
} catch {
return null;
}
}
/** 当前是否 Node 环境(无 document 且有 node 进程标记,用于模式分支) */
function isNodeEnvironment() {
const g = globalThis;
return typeof g.document === "undefined" && !!g.process?.versions?.node;
}
//#endregion
//#region src/fontface-mode.ts
/**
@ -389,11 +478,14 @@
constructor(config = {}) {
this.defaultBaseUrl = "https://webfont.shenzilong.cn";
this.faces = /* @__PURE__ */ new Map();
this.nodeRegistries = /* @__PURE__ */ new Map();
this.globalFonts = null;
this.engine = new IncrementalEngine({
maxConcurrent: config.maxConcurrent ?? 4,
provider: config.provider ?? null
});
if (config.baseUrl) this.defaultBaseUrl = config.baseUrl;
this.nodeEnv = isNodeEnvironment();
}
getEngine() {
return this.engine;
@ -402,17 +494,22 @@
this.engine.setProvider(provider);
}
/**
* 创建或复用一个字体的 FontFace 增量加载器
* 创建或复用一个字体的 FontFace 增量加载器
* 自动按环境分支浏览器走 FontFace(unicodeRange)Node @napi-rs/canvas
* GlobalFonts chunk 唯一 family调用方需读 fontFamilyChain 写回节点
*
* @param options 字体选项
* @param onChunk 单个片段注册完成后回调调用方在此触发画布重绘
* @param onChunk 单个片段注册完成后回调调用方在此触发画布重绘 / 更新 fontFamily
*/
loadFontFace(options, onChunk) {
const fontName = options.fontName;
const family = options.family ?? fontName.replace(/\.(ttf|otf|woff2?|ttc)$/i, "").trim();
const key = IncrementalEngine.fontKey(fontName, family);
const baseUrl = options.baseUrl ?? this.defaultBaseUrl;
const handleChunk = async (chunk) => {
/** Node 模式统一用 ttfwoff2 注册返回 null 不可靠(探针实证) */
const outType = this.nodeEnv ? "ttf" : options.outType ?? "woff2";
/** 浏览器分支FontFace + unicodeRange多 chunk 同 family 按字符精确生效) */
const handleChunkBrowser = async (chunk) => {
const unicodeRanges = chunk.chars.map((c) => "U+" + c.codePointAt(0).toString(16).padStart(4, "0")).join(", ");
const buffer = await (await fetch(chunk.url)).arrayBuffer();
const face = new FontFace(family, buffer, { unicodeRange: unicodeRanges });
@ -423,10 +520,28 @@
this.faces.set(family, list);
onChunk?.(chunk);
};
/**
* Node 分支GlobalFonts.register chunk 唯一 family逗号链回退
* 首次调用时懒加载 @napi-rs/canvas未安装则抛错开发期 fail fast
*/
const handleChunkNode = async (chunk) => {
if (!this.globalFonts) this.globalFonts = await loadGlobalFonts();
if (!this.globalFonts) throw new Error("Node 环境未安装 @napi-rs/canvas无法注册字体pnpm add @napi-rs/canvas");
let registry = this.nodeRegistries.get(family);
if (!registry || !registry.bound) {
registry = new NodeFontRegistry(family);
registry.bind(this.globalFonts);
this.nodeRegistries.set(family, registry);
}
const res = await fetch(chunk.url);
const buffer = new Uint8Array(await res.arrayBuffer());
registry.registerChunk(chunk, buffer);
onChunk?.(chunk);
};
this.engine.ensureState(key, fontName, {
baseUrl,
outType: options.outType ?? "woff2",
onLoadChunk: (chunk) => handleChunk(chunk)
outType,
onLoadChunk: (chunk) => this.nodeEnv ? handleChunkNode(chunk) : handleChunkBrowser(chunk)
});
let disposed = false;
return {
@ -443,7 +558,12 @@
if (disposed) return;
disposed = true;
this.engine.removeState(key);
/** FontFace 不主动删除:其他画布可能还在用同 family保守策略 */
/** FontFace family
* Node GlobalFonts 进程级共享同样保留进程退出自然释放 */
},
fontFamilyChain: () => {
if (!this.nodeEnv) return null;
return this.nodeRegistries.get(family)?.fontChain() ?? null;
}
};
}

92
scripts/ssim.ts Normal file
View File

@ -0,0 +1,92 @@
/**
* SSIM Wang 2004
*
* vs leafer Node
* 使 11x11 + 0~1
*
* RGBA width/height
*width * height * 4 === data.length 0
*/
export function calculateSSIM(a: Uint8Array, b: Uint8Array, width: number, height: number): number {
if (a.length !== b.length) return 0;
if (width * height * 4 !== a.length) return 0;
if (width === 0 || height === 0) return 0;
/** 转灰度并提取到独立数组 */
const N = width * height;
const grayA = new Float64Array(N);
const grayB = new Float64Array(N);
for (let i = 0; i < N; i++) {
const idx = i * 4;
grayA[i] = 0.299 * a[idx]! + 0.587 * a[idx + 1]! + 0.114 * a[idx + 2]!;
grayB[i] = 0.299 * b[idx]! + 0.587 * b[idx + 1]! + 0.114 * b[idx + 2]!;
}
/** 构建积分图: S(x,y) = sum of gray[0..x-1, 0..y-1] */
const w1 = width + 1;
const intA = new Float64Array(w1 * (height + 1));
const intA2 = new Float64Array(w1 * (height + 1));
const intB = new Float64Array(w1 * (height + 1));
const intB2 = new Float64Array(w1 * (height + 1));
const intAB = new Float64Array(w1 * (height + 1));
for (let y = 0; y < height; y++) {
const rowOff = y * width;
const irowOff = (y + 1) * w1;
for (let x = 0; x < width; x++) {
const va = grayA[rowOff + x]!;
const vb = grayB[rowOff + x]!;
const ip = irowOff + x + 1;
intA[ip] = va + intA[ip - 1]! + intA[ip - w1]! - intA[ip - w1 - 1]!;
intA2[ip] = va * va + intA2[ip - 1]! + intA2[ip - w1]! - intA2[ip - w1 - 1]!;
intB[ip] = vb + intB[ip - 1]! + intB[ip - w1]! - intB[ip - w1 - 1]!;
intB2[ip] = vb * vb + intB2[ip - 1]! + intB2[ip - w1]! - intB2[ip - w1 - 1]!;
intAB[ip] = va * vb + intAB[ip - 1]! + intAB[ip - w1]! - intAB[ip - w1 - 1]!;
}
}
/**
* [x1, x2) x [y1, y2)
* (x2-x1) * (y2-y1)
*/
const rectSum = (img: Float64Array, x1: number, y1: number, x2: number, y2: number) =>
img[y2 * w1 + x2]! - img[y1 * w1 + x2]! - img[y2 * w1 + x1]! + img[y1 * w1 + x1]!;
/** 11x11 窗口, 半径=5 */
const R = 5;
/** (0.01 * 255)^2 */
const C1 = 6.5025;
/** (0.03 * 255)^2 */
const C2 = 58.5225;
let ssimSum = 0;
let windowCount = 0;
for (let y = R; y < height - R; y++) {
for (let x = R; x < width - R; x++) {
const x1 = x - R, x2 = x + R + 1;
const y1 = y - R, y2 = y + R + 1;
const n = (2 * R + 1) * (2 * R + 1);
const sA = rectSum(intA, x1, y1, x2, y2);
const sA2 = rectSum(intA2, x1, y1, x2, y2);
const sB = rectSum(intB, x1, y1, x2, y2);
const sB2 = rectSum(intB2, x1, y1, x2, y2);
const sAB = rectSum(intAB, x1, y1, x2, y2);
const muA = sA / n;
const muB = sB / n;
const sigmaA2 = sA2 / n - muA * muA;
const sigmaB2 = sB2 / n - muB * muB;
const sigmaAB = sAB / n - muA * muB;
const num = (2 * muA * muB + C1) * (2 * sigmaAB + C2);
const den = (muA * muA + muB * muB + C1) * (sigmaA2 + sigmaB2 + C2);
ssimSum += num / den;
windowCount++;
}
}
return windowCount > 0 ? ssimSum / windowCount : 0;
}

View File

@ -0,0 +1,85 @@
/**
* Node leafer-x-webfont
*
* @leafer-ui/node WebFontPlugin webfont-sdk Node
* HTTP API GlobalFonts family fontFamily 退
* Leafer PNG SSIM
*
* npx tsx scripts/test-leafer-node.mts
*/
import { Leafer, Text, useCanvas } from '@leafer-ui/node'
import { Canvas as NapiCanvas, GlobalFonts, loadImage } from '@napi-rs/canvas'
import { readFileSync, writeFileSync } from 'node:fs'
/** 必须在创建任何 Leafer 实例之前初始化 napi canvas 平台 */
useCanvas('napi', { Canvas: NapiCanvas, loadImage })
const { WebFontPlugin } = await import('../packages/leafer-x-webfont/src/index.ts')
const { fontSubset } = await import('../backend/font_util/font.ts')
/** 本地后端服务8087 已运行) */
const BASE_URL = 'http://localhost:8087'
const FONT_FILE = 'font/令东齐伋复刻体.ttf'
const TEXT = '静心茶舍'
const OUT_DIR = 'benchmark_results/debug'
/** leafer.export('png') 返回 { data: 'data:image/png;base64,...', width, height } */
async function renderAndExport(family: string, outFile: string): Promise<number> {
const leafer = new Leafer({ width: 400, height: 120, fill: '#ffffff' })
leafer.add(new Text({ text: TEXT, fontFamily: family, fontSize: 64, fill: '#000000', x: 20, y: 20 }))
await new Promise((r) => setTimeout(r, 200))
const out = (await leafer.export('png', { pixelRatio: 1 })) as { data: string }
const buf = Buffer.from(out.data.split(',')[1]!, 'base64')
writeFileSync(outFile, buf)
leafer.destroy()
return buf.length
}
async function main(): Promise<void> {
/* ---------- 1. 全量基准图:直接 GlobalFonts 注册(不经插件) ---------- */
const full = readFileSync(FONT_FILE)
const fullSubset = await fontSubset(full.buffer.slice(0), TEXT, { sourceType: 'ttf', outType: 'ttf' })
GlobalFonts.register(new Uint8Array(fullSubset), 'BaselineFont')
const baselineSize = await renderAndExport('BaselineFont', `${OUT_DIR}/node_leafer_baseline.png`)
console.log('[1] 基准图:', baselineSize, 'bytes')
/* ---------- 2. 插件链路HTTP API + 增量 + 链回退 ---------- */
const leafer = new Leafer({ width: 400, height: 120, fill: '#ffffff' })
const webfont = new WebFontPlugin(leafer as never, { baseUrl: BASE_URL, debug: true })
const text = new Text({ text: TEXT, fontFamily: '令东齐伋复刻体.ttf', fontSize: 64, fill: '#000000', x: 20, y: 20 })
leafer.add(text)
webfont.refresh()
await webfont.ready()
await new Promise((r) => setTimeout(r, 200))
console.log('[2] 节点 fontFamily =', JSON.stringify(text.fontFamily))
const chainOk = /__\d/.test(String(text.fontFamily))
console.log('[2] fontFamily 链写回:', chainOk ? '✓' : '✗ 链未生效')
await new Promise((r) => setTimeout(r, 200))
const out = (await leafer.export('png', { pixelRatio: 1 })) as { data: string }
const buf = Buffer.from(out.data.split(',')[1]!, 'base64')
writeFileSync(`${OUT_DIR}/node_leafer_test.png`, buf)
console.log('[2] 插件链路图:', buf.length, 'bytes')
/* ---------- 3. SSIM 对比 ---------- */
const { calculateSSIM } = await import('./ssim.ts')
const { PNG } = await import('pngjs')
const a = PNG.sync.read(readFileSync(`${OUT_DIR}/node_leafer_baseline.png`))
const b = PNG.sync.read(readFileSync(`${OUT_DIR}/node_leafer_test.png`))
if (a.width !== b.width || a.height !== b.height) {
console.log(`[3] 尺寸不一致: ${a.width}x${a.height} vs ${b.width}x${b.height}`)
return
}
const ssim = calculateSSIM(a.data, b.data, a.width, a.height)
console.log(`[3] SSIM = ${ssim.toFixed(4)}`, ssim > 0.95 ? '✓ 通过' : '✗ 未达 0.95')
webfont.destroy()
leafer.destroy()
}
main().catch((err) => {
console.error('测试失败:', err)
process.exit(1)
})

View File

@ -22,6 +22,8 @@ import { createServer, type Server, type IncomingMessage, type ServerResponse }
import { performance } from "node:perf_hooks";
import puppeteer, { type Page } from "puppeteer";
import { fontSubset } from "./backend/font_util/font.js";
import { testCases } from "./基准测试用例.js";
import { calculateSSIM } from "./scripts/ssim.js";
import { PNG } from "pngjs";
const BENCHMARK_DIR = "benchmark_results";
@ -143,162 +145,8 @@ async function renderTextViaBrowser(
return { pixels, screenshot: Buffer.from(screenshot), inkPixels, width, height };
}
/**
* SSIM (Wang et al. 2004)
* 使 11x11 + 0~1
*
* Math.sqrt() width
* 740×72sqrt width230
* SSIM OTF
* width/height
*/
function calculateSSIM(a: Uint8Array, b: Uint8Array, width: number, height: number): number {
if (a.length !== b.length) return 0;
if (width * height * 4 !== a.length) return 0;
if (width === 0 || height === 0) return 0;
// ======== 测试配置(用例表见 基准测试用例.ts与 leafer 版共享) ========
/** 转灰度并提取到独立数组 */
const N = width * height;
const grayA = new Float64Array(N);
const grayB = new Float64Array(N);
for (let i = 0; i < N; i++) {
const idx = i * 4;
grayA[i] = 0.299 * a[idx] + 0.587 * a[idx + 1] + 0.114 * a[idx + 2];
grayB[i] = 0.299 * b[idx] + 0.587 * b[idx + 1] + 0.114 * b[idx + 2];
}
/** 构建积分图: S(x,y) = sum of gray[0..x-1, 0..y-1] */
const w1 = width + 1;
const intA = new Float64Array(w1 * (height + 1));
const intA2 = new Float64Array(w1 * (height + 1));
const intB = new Float64Array(w1 * (height + 1));
const intB2 = new Float64Array(w1 * (height + 1));
const intAB = new Float64Array(w1 * (height + 1));
for (let y = 0; y < height; y++) {
const rowOff = y * width;
const irowOff = (y + 1) * w1;
for (let x = 0; x < width; x++) {
const va = grayA[rowOff + x];
const vb = grayB[rowOff + x];
const ip = irowOff + x + 1;
intA[ip] = va + intA[ip - 1] + intA[ip - w1] - intA[ip - w1 - 1];
intA2[ip] = va * va + intA2[ip - 1] + intA2[ip - w1] - intA2[ip - w1 - 1];
intB[ip] = vb + intB[ip - 1] + intB[ip - w1] - intB[ip - w1 - 1];
intB2[ip] = vb * vb + intB2[ip - 1] + intB2[ip - w1] - intB2[ip - w1 - 1];
intAB[ip] = va * vb + intAB[ip - 1] + intAB[ip - w1] - intAB[ip - w1 - 1];
}
}
/**
* [x1, x2) x [y1, y2)
* (x2-x1) * (y2-y1)
*/
const rectSum = (img: Float64Array, x1: number, y1: number, x2: number, y2: number) =>
img[y2 * w1 + x2] - img[y1 * w1 + x2] - img[y2 * w1 + x1] + img[y1 * w1 + x1];
/** 11x11 窗口, 半径=5 */
const R = 5;
const C1 = 6.5025; // (0.01 * 255)^2
const C2 = 58.5225; // (0.03 * 255)^2
let ssimSum = 0;
let windowCount = 0;
for (let y = R; y < height - R; y++) {
for (let x = R; x < width - R; x++) {
const x1 = x - R, x2 = x + R + 1;
const y1 = y - R, y2 = y + R + 1;
const n = (2 * R + 1) * (2 * R + 1);
const sA = rectSum(intA, x1, y1, x2, y2);
const sA2 = rectSum(intA2, x1, y1, x2, y2);
const sB = rectSum(intB, x1, y1, x2, y2);
const sB2 = rectSum(intB2, x1, y1, x2, y2);
const sAB = rectSum(intAB, x1, y1, x2, y2);
const muA = sA / n;
const muB = sB / n;
const sigmaA2 = sA2 / n - muA * muA;
const sigmaB2 = sB2 / n - muB * muB;
const sigmaAB = sAB / n - muA * muB;
const num = (2 * muA * muB + C1) * (2 * sigmaAB + C2);
const den = (muA * muA + muB * muB + C1) * (sigmaA2 + sigmaB2 + C2);
ssimSum += num / den;
windowCount++;
}
}
return windowCount > 0 ? ssimSum / windowCount : 0;
}
// ======== 测试配置 ========
/**
*
*/
const QIANZIWEN_MID = "墨悲丝染诗赞羔羊景行维贤克念作圣德建名立形端表正空谷传声虚堂习听祸因恶积福缘善庆尺璧非宝寸阴是竞资父事君曰严与敬孝当竭力忠则尽命临深履薄夙兴温凊似兰斯馨如松之盛川流不息渊澄取映";
const testCases = [
/** ===== 令东齐伋复刻体TTF楷书复古字体主基准 ===== */
{ label: "8个汉字", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天地玄黄宇宙洪荒", sourceType: "ttf" as const, outType: "ttf" as const, fullFormat: "truetype" },
{ label: "8个汉字", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天地玄黄宇宙洪荒", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
{ label: "拉丁+数字", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "Hello World 123", sourceType: "ttf" as const, outType: "ttf" as const, fullFormat: "truetype" },
{ 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" },
/** 重复字符:守护 codePoints 去重逻辑,相同字形不应重复输出 */
{ label: "重复字符", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天天天天地地地地", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
/** ===== 思源黑体TTF无衬线黑体字形简洁 ===== */
{ label: "思源黑体-8字", fontPath: "font/思源黑体.ttf", fontName: "思源黑体", text: "天地玄黄宇宙洪荒", sourceType: "ttf" as const, outType: "ttf" as const, fullFormat: "truetype" },
{ label: "思源黑体-8字", fontPath: "font/思源黑体.ttf", fontName: "思源黑体", text: "天地玄黄宇宙洪荒", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
/** 纯标点:非汉字字形 + 组合标记的渲染守护 */
{ label: "思源黑体-标点", fontPath: "font/思源黑体.ttf", fontName: "思源黑体", text: ",。!?、;:“”‘’", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
/** CJK 扩展 B 罕见字(代理对):守护 textToCodePoints 的代理对跳过逻辑 */
{ label: "思源黑体-扩展B", fontPath: "font/思源黑体.ttf", fontName: "思源黑体", text: "𠮷𡧑𢀖𤍤𥝹", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
/** 千字文中段:超长文本压力测试(>100 字) */
{ label: "思源黑体-千字文中段", fontPath: "font/思源黑体.ttf", fontName: "思源黑体", text: QIANZIWEN_MID, sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
/** ===== Yi山碑篆体TTF笔画极其复杂的篆书字形数据量大 ===== */
{ label: "篆体-8字", fontPath: "font/temp/YiShanBeiZhuanTi.ttf", fontName: "Yi山碑篆体", text: "天地玄黄宇宙洪荒", sourceType: "ttf" as const, outType: "ttf" as const, fullFormat: "truetype" },
{ label: "篆体-8字", fontPath: "font/temp/YiShanBeiZhuanTi.ttf", fontName: "Yi山碑篆体", text: "天地玄黄宇宙洪荒", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
/** ===== OTF 字体(含三点水等复杂笔画字,守护 OTF→TTF 转换正确性) ===== */
{ label: "otf-五个汉字", fontPath: "font/temp/BaiHuOTFJiaoYuHanZi-2.otf", fontName: "白狐教育汉字", text: "天地黄宇宙法海波", sourceType: "otf" as const, outType: "ttf" as const, fullFormat: "opentype" },
/** OTF→woff2 输出路径守护(之前仅测 ttf 输出) */
{ label: "otf-五个汉字", fontPath: "font/temp/BaiHuOTFJiaoYuHanZi-2.otf", fontName: "白狐教育汉字", text: "天地黄宇宙法海波", sourceType: "otf" as const, outType: "woff2" as const, fullFormat: "opentype" },
{ label: "otf-思源黑体", fontPath: "font/temp/SourceHanSans-Regular.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: "woff2" as const, fullFormat: "opentype" },
/** type3 AlternateSubst coverage gid +
* serializeAlternateSubst coverage gid coverage undefined bug */
{ label: "otf-白狐千字文", fontPath: "font/temp/BaiHuOTFJiaoYuHanZi-2.otf", fontName: "白狐教育汉字", text: "天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳", sourceType: "otf" as const, outType: "woff2" as const, fullFormat: "opentype" },
/** ===== 小字号渲染size=24守护 SSIM 在小字号下不退化) ===== */
{ label: "小字号-8字", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天地玄黄宇宙洪荒", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype", fontSize: 24 },
{ label: "小字号-篆体", fontPath: "font/temp/YiShanBeiZhuanTi.ttf", fontName: "Yi山碑篆体", text: "天地玄黄宇宙洪荒", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype", fontSize: 24 },
/**
* ===== D:\ =====
* + GPOS
* GPOS lookup PairPos() ChainedContextPos(type8)
* (OTF) MarkBasePos(type4) GPOS
*/
{ label: "得意黑-汉字标点", fontPath: "/mnt/d/字体资源/得意黑/SmileySans-Oblique.ttf", fontName: "得意黑", text: "你好,世界!今天天气不错。", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
{ label: "霞鹜文楷-汉字标点", fontPath: "/mnt/d/字体资源/霞鹜文楷/LXGWWenKai-Regular.ttf", fontName: "霞鹜文楷", text: "你好,世界!今天天气不错。", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
/** 初夏明朝TTF宋体/明朝风格衬线字,含标点压缩) */
{ label: "初夏明朝-汉字标点", fontPath: "/mnt/d/字体资源/初夏明朝/初夏明朝-Regular.ttf", fontName: "初夏明朝", text: "你好,世界!今天天气不错。", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
{ label: "霞鹜文楷-纯标点", fontPath: "/mnt/d/字体资源/霞鹜文楷/LXGWWenKai-Regular.ttf", fontName: "霞鹜文楷", text: ",。!?、;:“”‘’", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
{ label: "初夏明朝-纯标点", fontPath: "/mnt/d/字体资源/初夏明朝/初夏明朝-Regular.ttf", fontName: "初夏明朝", text: ",。!?、;:“”‘’", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
{ label: "得意黑-纯标点", fontPath: "/mnt/d/字体资源/得意黑/SmileySans-Oblique.ttf", fontName: "得意黑", text: ",。!?、;:“”‘’", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
/** 鸿蒙黑体TTF现代无衬线GPOS 仅 SinglePos/PairPos */
{ label: "鸿蒙黑体-汉字标点", fontPath: "/mnt/d/字体资源/鸿蒙字体/HarmonyOS_Sans_SC_Black.ttf", fontName: "鸿蒙黑体", text: "你好,世界!今天天气不错。", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
/** 优设标题黑TTF艺术黑体 */
{ label: "优设标题黑-汉字标点", fontPath: "/mnt/d/字体资源/优设标题黑/优设标题黑.ttf", fontName: "优设标题黑", text: "你好,世界!今天天气不错。", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
/** FiraCode拉丁等宽GPOS 做 ligature非 CJK 标点,验证降级路径不破坏拉丁字体) */
{ label: "FiraCode-代码", fontPath: "/mnt/d/字体资源/FiraCode/FiraCode-Medium.ttf", fontName: "FiraCode", text: "=> !== >= <= ===", sourceType: "ttf" as const, outType: "woff2" as const, fullFormat: "truetype" },
];
// ======== 主测试 ========
await mkdir(`${BENCHMARK_DIR}/json`, { recursive: true });

225
基准测试_leafer.test.ts Normal file
View File

@ -0,0 +1,225 @@
/**
* leafer Node
* 运行: pnpx tsx _leafer.test.ts
*
* .test.tspuppeteer .ts
* @leafer-ui/node@napi-rs/canvas Skia HTTP
* 1. backend/font_util/font.ts fontSubset API
* 2. full GlobalFonts.register leafer Text
* 3. subsetfontSubset family
* 4. leafer.export('png') pngjs SSIMscripts/ssim.ts
*
*
* - SkiaGlobalFonts sfntttf/otf woff2 woff2
* /woff2 puppeteer SSIM
* SSIM outType=ttf otfttfCFF otf
* - / HTTP Node CI
* leafer-x-webfont Node
*/
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { existsSync } from "node:fs";
import { performance } from "node:perf_hooks";
import { Leafer, Text, useCanvas } from "@leafer-ui/node";
import { Canvas as NapiCanvas, GlobalFonts, loadImage } from "@napi-rs/canvas";
import { fontSubset } from "./backend/font_util/font.js";
import { testCases } from "./基准测试用例.js";
import { calculateSSIM } from "./scripts/ssim.js";
import { PNG } from "pngjs";
/** 必须在创建任何 Leafer 实例之前注入 napi canvas 平台 */
useCanvas("napi", { Canvas: NapiCanvas, loadImage });
const BENCHMARK_DIR = "benchmark_results";
const SCREENSHOT_DIR = `${BENCHMARK_DIR}/screenshots_leafer`;
const ROUNDS = 10;
/** family 注册序号(每字体一个唯一 family规避 Skia 同名二次注册不可靠问题) */
let familySeq = 0;
/**
* leafer Text PNG
* renderTextViaBrowser charWidth = fontSize*1.5
* x=10 y=fontSize*1.2*0.1+ #text padding(top=fontSize*0.1, left=10)
* line-height 1.2 leafer lineHeight full/subset
*/
async function renderTextViaLeafer(
family: string,
text: string,
fontSize: number,
): Promise<{ pixels: Uint8Array; png: 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);
const leafer = new Leafer({ width, height, fill: "#ffffff" });
leafer.add(
new Text({
text,
fontFamily: family,
fontSize,
fill: "#000000",
x: 10,
y: Math.ceil(fontSize * 0.1),
}),
);
/** 等一帧布局+渲染落盘leafer 异步渲染) */
await new Promise((r) => setTimeout(r, 60));
const out = (await leafer.export("png", { pixelRatio: 1 })) as { data: string };
leafer.destroy();
const png = Buffer.from(out.data.split(",")[1]!, "base64");
const decoded = PNG.sync.read(png);
const pixels = new Uint8Array(decoded.data);
let inkPixels = 0;
for (let i = 0; i < pixels.length; i += 4) {
if (pixels[i]! < 128) inkPixels++;
}
if (inkPixels === 0) {
throw new Error(`字体渲染无墨水像素 (${family}),字体可能未正确加载`);
}
/** 用导出图真实尺寸leafer 可能按 hiDPI/裁剪调整画布,传预设尺寸会触发 SSIM 尺寸守卫返回 0 */
return { pixels, png, inkPixels, width: decoded.width, height: decoded.height };
}
/** 注册字体 buffer 到唯一 family返回 family 名 */
function registerFamily(buf: Uint8Array): string {
const family = `BenchFont_${familySeq++}`;
/** 注册失败返回 nullwoff2 等非法输入),成功返回 FontKey 对象 */
if (!GlobalFonts.register(buf, family)) {
throw new Error(`GlobalFonts.register 失败 (family=${family})`);
}
/** Skia 注册成功 ≠ 按 alias 可查(同 family 二次注册会被忽略),双保险校验 */
if (!GlobalFonts.has(family)) {
throw new Error(`GlobalFonts.has(${family}) = false字体未按别名生效`);
}
return family;
}
// ======== 主测试 ========
await mkdir(`${BENCHMARK_DIR}/json`, { recursive: true });
await mkdir(SCREENSHOT_DIR, { recursive: true });
console.log("\n=== 字体裁剪基准测试leafer Node 版)===\n");
const results: Array<{
label: string;
sourceType: string;
outType: string;
avg: number;
min: number;
max: number;
outputSize: number;
/** woff2 用例为 null本版不做 woff2 渲染验证,见文件头说明) */
ssim: number | null;
fullInk: number | null;
subsetInk: number | null;
}> = [];
for (const tc of testCases) {
/** 字体文件缺失(如 font/temp/ 已清理、/mnt/d 未挂载)的用例跳过并标注,不视为失败 */
if (!existsSync(tc.fontPath)) {
console.log(` [跳过] ${tc.label}: 字体文件不存在 ${tc.fontPath}`);
continue;
}
const raw = await readFile(tc.fontPath);
const buf = new Uint8Array(raw).buffer.slice(0) as ArrayBuffer;
/** --- 子集化计时(与浏览器版相同流程) --- */
const times: number[] = [];
let lastSize = 0;
let lastBuffer: Uint8Array | null = null;
for (let i = 0; i < ROUNDS; i++) {
const t0 = performance.now();
const subsetBuf = await fontSubset(buf, tc.text, { sourceType: tc.sourceType, outType: tc.outType });
const t1 = performance.now();
times.push(t1 - t0);
lastSize = subsetBuf.byteLength;
if (i === 0) {
lastBuffer = subsetBuf;
}
}
const avg = times.reduce((a, b) => a + b, 0) / times.length;
const min = Math.min(...times);
const max = Math.max(...times);
/** --- 渲染对比:仅裸 sfnt 输出ttf 输出,含 otf 输入woff2 输出跳过) --- */
let ssim: number | null = null;
let fullInk: number | null = null;
let subsetInk: number | null = null;
if (lastBuffer && tc.outType === "ttf") {
const safeLabel = tc.label.replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, "_");
/**
* maxp
* maxPoints/maxContours 0
* OTFCFF maxp 0.5
*/
if (tc.sourceType !== "otf") {
const ttfView = new DataView(lastBuffer.buffer, lastBuffer.byteOffset, lastBuffer.byteLength);
const numTbl = ttfView.getUint16(4, false);
for (let ti = 0; ti < numTbl; ti++) {
const toff = 12 + ti * 16;
const tag = String.fromCharCode(
ttfView.getUint8(toff),
ttfView.getUint8(toff + 1),
ttfView.getUint8(toff + 2),
ttfView.getUint8(toff + 3),
);
if (tag === "maxp") {
const moff = ttfView.getUint32(toff + 8, false);
const maxPoints = ttfView.getUint16(moff + 6, false);
const maxContours = ttfView.getUint16(moff + 8, false);
if (maxPoints === 0 || maxContours === 0) {
throw new Error(`子集字体 maxp 异常: maxPoints=${maxPoints} maxContours=${maxContours}`);
}
break;
}
}
}
/** full原始字体文件未做任何转换注册为基准 family */
const fullFamily = registerFamily(new Uint8Array(raw));
const renderSize = tc.fontSize ?? 48;
const fullResult = await renderTextViaLeafer(fullFamily, tc.text, renderSize);
/** subsetfontSubset 产物otf 输入 → CFF 轮廓裸 sfntttf 输入 → glyf sfnt注册另一 family */
const subsetFamily = registerFamily(lastBuffer);
const subsetResult = await renderTextViaLeafer(subsetFamily, tc.text, renderSize);
await writeFile(`${SCREENSHOT_DIR}/${safeLabel}_full.png`, fullResult.png);
await writeFile(`${SCREENSHOT_DIR}/${safeLabel}_subset.png`, subsetResult.png);
fullInk = fullResult.inkPixels;
subsetInk = subsetResult.inkPixels;
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 });
const tag = tc.sourceType === "otf" ? "otf→ttf" : tc.outType;
const ssimText = ssim === null ? " ssim=-(woff2跳过)" : ` ssim=${ssim.toFixed(4)}`;
const inkText = fullInk === null ? "" : ` ink=${fullInk}/${subsetInk}`;
console.log(` [${tag}] ${tc.label}: avg=${avg.toFixed(1)}ms min=${min.toFixed(1)}ms max=${max.toFixed(1)}ms 输出=${lastSize.toLocaleString()} bytes${ssimText}${inkText}`);
}
/** 保存结果到 JSON */
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const resultFile = `${BENCHMARK_DIR}/json/benchmark_leafer_${timestamp}.json`;
await writeFile(resultFile, JSON.stringify({ timestamp: new Date().toISOString(), renderer: "leafer-node", rounds: ROUNDS, results }, null, 2));
console.log(`\n结果已保存到 ${resultFile}`);
console.log(`渲染对比图片已保存到 ${SCREENSHOT_DIR}/ 目录\n`);
/** 汇总:有 SSIM 的用例全须 ≥0.99(与浏览器版验收标准一致) */
const ssimCases = results.filter((r) => r.ssim !== null);
const failed = ssimCases.filter((r) => (r.ssim as number) < 0.99);
if (failed.length > 0) {
console.error(`\n✗ ${failed.length} 个用例 SSIM < 0.99:`);
for (const f of failed) {
console.error(` ${f.label} (${f.sourceType}${f.outType}): ssim=${f.ssim}`);
}
process.exit(1);
}
console.log(`✓ 全部 ${ssimCases.length} 个渲染用例 SSIM ≥ 0.99`);

93
基准测试用例.ts Normal file
View File

@ -0,0 +1,93 @@
/**
*
*
* .test.tspuppeteer _leafer.test.ts
* @leafer-ui/node + Skia
*
* TestCase fullFormat @font-face format 使
*/
/**
*
*/
const QIANZIWEN_MID = "墨悲丝染诗赞羔羊景行维贤克念作圣德建名立形端表正空谷传声虚堂习听祸因恶积福缘善庆尺璧非宝寸阴是竞资父事君曰严与敬孝当竭力忠则尽命临深履薄夙兴温凊似兰斯馨如松之盛川流不息渊澄取映";
/** 单条基准用例 */
export interface TestCase {
/** 结果展示标签(同名不同 outType 视为同场景的两种输出) */
label: string;
/** 字体文件路径(相对项目根,或绝对路径) */
fontPath: string;
/** 字体名(仅展示用) */
fontName: string;
/** 裁剪保留的文本 */
text: string;
/** 输入字体轮廓类型ttf(glyf) / otf(CFF) */
sourceType: "ttf" | "otf";
/** 输出容器:裸 ttf / woff2 */
outType: "ttf" | "woff2";
/** 浏览器 @font-face 的 format 提示(仅 puppeteer 版使用) */
fullFormat: "truetype" | "opentype";
/** 渲染字号(默认 48小字号用例守护低分辨率下 SSIM 不退化 */
fontSize?: number;
}
export const testCases: TestCase[] = [
/** ===== 令东齐伋复刻体TTF楷书复古字体主基准 ===== */
{ label: "8个汉字", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天地玄黄宇宙洪荒", sourceType: "ttf", outType: "ttf", fullFormat: "truetype" },
{ label: "8个汉字", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天地玄黄宇宙洪荒", sourceType: "ttf", outType: "woff2", fullFormat: "truetype" },
{ label: "拉丁+数字", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "Hello World 123", sourceType: "ttf", outType: "ttf", fullFormat: "truetype" },
{ label: "拉丁+数字", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "Hello World 123", sourceType: "ttf", outType: "woff2", fullFormat: "truetype" },
{ label: "千字文前段", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈剑号巨阙珠称夜光果珍李柰菜重芥姜海咸河淡鳞潜羽翔", sourceType: "ttf", outType: "ttf", fullFormat: "truetype" },
{ label: "千字文前段", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈剑号巨阙珠称夜光果珍李柰菜重芥姜海咸河淡鳞潜羽翔", sourceType: "ttf", outType: "woff2", fullFormat: "truetype" },
/** 重复字符:守护 codePoints 去重逻辑,相同字形不应重复输出 */
{ label: "重复字符", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天天天天地地地地", sourceType: "ttf", outType: "woff2", fullFormat: "truetype" },
/** ===== 思源黑体TTF无衬线黑体字形简洁 ===== */
{ label: "思源黑体-8字", fontPath: "font/思源黑体.ttf", fontName: "思源黑体", text: "天地玄黄宇宙洪荒", sourceType: "ttf", outType: "ttf", fullFormat: "truetype" },
{ label: "思源黑体-8字", fontPath: "font/思源黑体.ttf", fontName: "思源黑体", text: "天地玄黄宇宙洪荒", sourceType: "ttf", outType: "woff2", fullFormat: "truetype" },
/** 纯标点:非汉字字形 + 组合标记的渲染守护 */
{ label: "思源黑体-标点", fontPath: "font/思源黑体.ttf", fontName: "思源黑体", text: ",。!?、;:“”‘’", sourceType: "ttf", outType: "woff2", fullFormat: "truetype" },
/** CJK 扩展 B 罕见字(代理对):守护 textToCodePoints 的代理对跳过逻辑 */
{ label: "思源黑体-扩展B", fontPath: "font/思源黑体.ttf", fontName: "思源黑体", text: "𠮷𡧑𢀖𤍤𥝹", sourceType: "ttf", outType: "woff2", fullFormat: "truetype" },
/** 千字文中段:超长文本压力测试(>100 字) */
{ label: "思源黑体-千字文中段", fontPath: "font/思源黑体.ttf", fontName: "思源黑体", text: QIANZIWEN_MID, sourceType: "ttf", outType: "woff2", fullFormat: "truetype" },
/** ===== Yi山碑篆体TTF笔画极其复杂的篆书字形数据量大 ===== */
{ label: "篆体-8字", fontPath: "font/temp/YiShanBeiZhuanTi.ttf", fontName: "Yi山碑篆体", text: "天地玄黄宇宙洪荒", sourceType: "ttf", outType: "ttf", fullFormat: "truetype" },
{ label: "篆体-8字", fontPath: "font/temp/YiShanBeiZhuanTi.ttf", fontName: "Yi山碑篆体", text: "天地玄黄宇宙洪荒", sourceType: "ttf", outType: "woff2", fullFormat: "truetype" },
/** ===== OTF 字体(含三点水等复杂笔画字,守护 OTF→TTF 转换正确性) ===== */
{ label: "otf-五个汉字", fontPath: "font/temp/BaiHuOTFJiaoYuHanZi-2.otf", fontName: "白狐教育汉字", text: "天地黄宇宙法海波", sourceType: "otf", outType: "ttf", fullFormat: "opentype" },
/** OTF→woff2 输出路径守护(之前仅测 ttf 输出) */
{ label: "otf-五个汉字", fontPath: "font/temp/BaiHuOTFJiaoYuHanZi-2.otf", fontName: "白狐教育汉字", text: "天地黄宇宙法海波", sourceType: "otf", outType: "woff2", fullFormat: "opentype" },
{ label: "otf-思源黑体", fontPath: "font/temp/SourceHanSans-Regular.otf", fontName: "思源黑体", text: "天地玄黄宇宙洪法海波", sourceType: "otf", outType: "ttf", fullFormat: "opentype" },
{ label: "otf-思源黑体", fontPath: "font/temp/SourceHanSans-Regular.otf", fontName: "思源黑体", text: "天地玄黄宇宙洪法海波", sourceType: "otf", outType: "woff2", fullFormat: "opentype" },
/** type3 AlternateSubst coverage gid +
* serializeAlternateSubst coverage gid coverage undefined bug */
{ label: "otf-白狐千字文", fontPath: "font/temp/BaiHuOTFJiaoYuHanZi-2.otf", fontName: "白狐教育汉字", text: "天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳", sourceType: "otf", outType: "woff2", fullFormat: "opentype" },
/** ===== 小字号渲染size=24守护 SSIM 在小字号下不退化) ===== */
{ label: "小字号-8字", fontPath: "font/令东齐伋复刻体.ttf", fontName: "令东齐伋复刻体", text: "天地玄黄宇宙洪荒", sourceType: "ttf", outType: "woff2", fullFormat: "truetype", fontSize: 24 },
{ label: "小字号-篆体", fontPath: "font/temp/YiShanBeiZhuanTi.ttf", fontName: "Yi山碑篆体", text: "天地玄黄宇宙洪荒", sourceType: "ttf", outType: "woff2", fullFormat: "truetype", fontSize: 24 },
/**
* ===== D:\ =====
* + GPOS
* GPOS lookup PairPos() ChainedContextPos(type8)
* (OTF) MarkBasePos(type4) GPOS
*/
{ label: "得意黑-汉字标点", fontPath: "/mnt/d/字体资源/得意黑/SmileySans-Oblique.ttf", fontName: "得意黑", text: "你好,世界!今天天气不错。", sourceType: "ttf", outType: "woff2", fullFormat: "truetype" },
{ label: "霞鹜文楷-汉字标点", fontPath: "/mnt/d/字体资源/霞鹜文楷/LXGWWenKai-Regular.ttf", fontName: "霞鹜文楷", text: "你好,世界!今天天气不错。", sourceType: "ttf", outType: "woff2", fullFormat: "truetype" },
/** 初夏明朝TTF宋体/明朝风格衬线字,含标点压缩) */
{ label: "初夏明朝-汉字标点", fontPath: "/mnt/d/字体资源/初夏明朝/初夏明朝-Regular.ttf", fontName: "初夏明朝", text: "你好,世界!今天天气不错。", sourceType: "ttf", outType: "woff2", fullFormat: "truetype" },
{ label: "霞鹜文楷-纯标点", fontPath: "/mnt/d/字体资源/霞鹜文楷/LXGWWenKai-Regular.ttf", fontName: "霞鹜文楷", text: ",。!?、;:“”‘’", sourceType: "ttf", outType: "woff2", fullFormat: "truetype" },
{ label: "初夏明朝-纯标点", fontPath: "/mnt/d/字体资源/初夏明朝/初夏明朝-Regular.ttf", fontName: "初夏明朝", text: ",。!?、;:“”‘’", sourceType: "ttf", outType: "woff2", fullFormat: "truetype" },
{ label: "得意黑-纯标点", fontPath: "/mnt/d/字体资源/得意黑/SmileySans-Oblique.ttf", fontName: "得意黑", text: ",。!?、;:“”‘’", sourceType: "ttf", outType: "woff2", fullFormat: "truetype" },
/** 鸿蒙黑体TTF现代无衬线GPOS 仅 SinglePos/PairPos */
{ label: "鸿蒙黑体-汉字标点", fontPath: "/mnt/d/字体资源/鸿蒙字体/HarmonyOS_Sans_SC_Black.ttf", fontName: "鸿蒙黑体", text: "你好,世界!今天天气不错。", sourceType: "ttf", outType: "woff2", fullFormat: "truetype" },
/** 优设标题黑TTF艺术黑体 */
{ label: "优设标题黑-汉字标点", fontPath: "/mnt/d/字体资源/优设标题黑/优设标题黑.ttf", fontName: "优设标题黑", text: "你好,世界!今天天气不错。", sourceType: "ttf", outType: "woff2", fullFormat: "truetype" },
/** FiraCode拉丁等宽GPOS 做 ligature非 CJK 标点,验证降级路径不破坏拉丁字体) */
{ label: "FiraCode-代码", fontPath: "/mnt/d/字体资源/FiraCode/FiraCode-Medium.ttf", fontName: "FiraCode", text: "=> !== >= <= ===", sourceType: "ttf", outType: "woff2", fullFormat: "truetype" },
];