mirror of
https://github.com/2234839/web-font.git
synced 2026-09-04 14:53:32 +08:00
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:
parent
0693521716
commit
a0c2b4aafc
3
.gitignore
vendored
3
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 ESM),webfont-sdk 由相对路径 importmap 解析到同级包的 dist -->
|
||||
<script type="importmap">
|
||||
{
|
||||
|
||||
@ -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 惯用封装 */
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 */
|
||||
/** 输出格式,默认 woff2(Node 端自动降级 ttf,见 loadFontFace) */
|
||||
outType?: 'woff2' | 'ttf'
|
||||
}
|
||||
|
||||
@ -27,14 +28,27 @@ export interface IFontFaceLoader {
|
||||
/** 清除失败记录(重试场景) */
|
||||
retryFailed(): void
|
||||
dispose(): void
|
||||
/**
|
||||
* 当前生效的 fontFamily 链(仅 Node 模式有意义)。
|
||||
* Node 端每个 chunk 注册为唯一 family,Skia 链回退按字形匹配;
|
||||
* 新 chunk 就绪后链会变长,调用方需把它写回 Text 节点的 fontFamily。
|
||||
* 浏览器模式恒返回 null(FontFace unicodeRange 天然支持增量)。
|
||||
*/
|
||||
fontFamilyChain(): string | null
|
||||
}
|
||||
|
||||
export class WebFontFontFaceMode {
|
||||
private engine: IncrementalEngine
|
||||
/** 未显式传 baseUrl 时的默认服务地址 */
|
||||
private defaultBaseUrl = 'https://webfont.shenzilong.cn'
|
||||
/** family -> 已注册的 FontFace(dispose 时从 document.fonts 删除) */
|
||||
/** family -> 已注册的 FontFace(dispose 时从 document.fonts 删除;浏览器模式用) */
|
||||
private faces = new Map<string, FontFace[]>()
|
||||
/** family -> Node 注册表(Node 模式用;浏览器环境恒为空) */
|
||||
private nodeRegistries = new Map<string, NodeFontRegistry>()
|
||||
/** Node 模式懒加载的 GlobalFonts(null = 未加载或加载失败) */
|
||||
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 模式统一用 ttf:woff2 注册返回 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 }
|
||||
|
||||
@ -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,
|
||||
|
||||
139
packages/webfont-sdk/src/node-registry.ts
Normal file
139
packages/webfont-sdk/src/node-registry.ts
Normal 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 返回的 FontKey(remove 用;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
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载全部 chunk(dispose 用)。
|
||||
* 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
186
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
/**
|
||||
* 卸载全部 chunk(dispose 用)。
|
||||
* 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 模式统一用 ttf:woff2 注册返回 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
92
scripts/ssim.ts
Normal 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;
|
||||
}
|
||||
85
scripts/test-leafer-node.mts
Normal file
85
scripts/test-leafer-node.mts
Normal 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)
|
||||
})
|
||||
|
||||
158
基准测试.test.ts
158
基准测试.test.ts
@ -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×72),sqrt 会得到错误 width(230),
|
||||
* 导致像素坐标错位、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
225
基准测试_leafer.test.ts
Normal file
@ -0,0 +1,225 @@
|
||||
/**
|
||||
* 字体裁剪基准测试(leafer Node 版)
|
||||
* 运行: pnpx tsx 基准测试_leafer.test.ts
|
||||
*
|
||||
* 与 基准测试.test.ts(puppeteer 浏览器版)共用 基准测试用例.ts 的用例表,
|
||||
* 渲染层换为 @leafer-ui/node(@napi-rs/canvas Skia 后端),无需浏览器与 HTTP 服务器:
|
||||
* 1. 直接调用 backend/font_util/font.ts 的 fontSubset(与 API 完全一致)
|
||||
* 2. full 基准:原始字体文件 GlobalFonts.register 后用 leafer Text 渲染(基准参照原则与浏览器版一致)
|
||||
* 3. subset:fontSubset 产物注册到唯一 family 后同参数渲染
|
||||
* 4. leafer.export('png') 导出位图,pngjs 解码后算 SSIM(scripts/ssim.ts)
|
||||
*
|
||||
* 与浏览器版的分工(为什么双方案并存):
|
||||
* - Skia(GlobalFonts)只吃裸 sfnt(ttf/otf),不吃 woff2 容器——woff2 用例在本版
|
||||
* 只测裁剪耗时/产物体积(woff2 是无损容器,编码正确性由 puppeteer 版 SSIM 守护),
|
||||
* SSIM 渲染验证只做 outType=ttf 的用例(含 otf→ttf:CFF 轮廓裸 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++}`;
|
||||
/** 注册失败返回 null(woff2 等非法输入),成功返回 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 会导致渲染器跳过渲染。
|
||||
* OTF(CFF)输入产出 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);
|
||||
|
||||
/** subset:fontSubset 产物(otf 输入 → CFF 轮廓裸 sfnt;ttf 输入 → 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
93
基准测试用例.ts
Normal file
@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 字体裁剪基准测试共享用例表
|
||||
*
|
||||
* 被 基准测试.test.ts(puppeteer 浏览器渲染)与 基准测试_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" },
|
||||
];
|
||||
Loading…
x
Reference in New Issue
Block a user