feat: SDK 抽取为 webfont-sdk npm 包 + leafer-x-webfont 插件(浏览器验证通过)

- packages/webfont-sdk:TS 重写,IncrementalEngine 核心(去重/并发池/失败字符记忆/retryFailed)
  - CSS 模式(WebFont)API 与旧 public/webfont-sdk.js 完全兼容
  - FontFace 模式(WebFontCanvas)供 Canvas 场景,unicodeRange 注册 + onChunk 回调
  - tsdown 双产物:dist/(ESM+dts)+ dist-iife/(script 直引)
  - scripts/sync-public.mjs 构建后同步到 public/webfont-sdk.js
  - 修复 banner 内嵌套块注释导致的语法错误(/* 重绘 */ 提前闭合 banner)
- packages/leafer-x-webfont:LeaferJS 插件,依赖 webfont-sdk
  - 监听 property.change + layout.end 防抖扫描 Text 节点聚合字符
  - 新旧 API 兼容(on_/on__、off_/off__)
  - 注册后自动改写 fontFamily 为合法 CSS 名(去 .ttf 后缀)
  - demo/index.html:海报场景(标题/副标题/字体切换/导出 PNG)本地 vendor 验证通过
- backend/app.ts:fs 适配层改为 top-level await import(修复 dev 模式 mkdir 崩溃)
- doc/:商业化思路一/二评估 + 主动拓客文档
This commit is contained in:
崮生(子虚) 2026-08-15 13:07:32 +08:00
parent fe61bace16
commit 0693521716
20 changed files with 2382 additions and 522 deletions

6
.gitignore vendored
View File

@ -35,4 +35,8 @@ dist_backend_node
verify_font_baseline
benchmark_results
.claude
.claude
# packages 构建产物与 demo vendor可重建/可下载,不入库)
packages/*/dist
packages/*/dist-iife
packages/leafer-x-webfont/demo/vendor

View File

@ -1,9 +1,10 @@
/** 首先加载 fs 适配层,必须在所有其他导入之前!仅在 LLRT 运行时引入 */
(() => {
if (typeof __RUNTIME__ !== "undefined" && __RUNTIME__ === "llrt") {
require("./server/llrt");
}
})();
/** 首先加载 fs 适配层,必须在 main() 之前完成按运行时选择实现top-level await 保证注册先于一切使用) */
if (typeof __RUNTIME__ !== "undefined" && __RUNTIME__ === "llrt") {
await import("./server/llrt");
} else {
/** Node.jstsx 开发 / tsdown define "node" 构建)走 node 适配器 */
await import("./server/node");
}
import { mimeTypes } from "./server/mime_type";
import type { cMiddleware } from "./server/req_res";

View File

@ -0,0 +1,79 @@
# leafer-x-webfont
> LeaferJS 中文字体插件 —— 画布里的 Text 用什么字就只加载那几个字6 字 ≈ 6KB而非 16MB
## 为什么需要它
Leafer 的 `Text` 元素渲染时直接拼 `canvas.font = fontFamily`,依赖浏览器字体系统。
中文字体动辄 10MB+`FontFace` 注册又慢又耗流量,海报/设计器场景根本没法用。
本插件订阅画布内 `Text``text` / `fontFamily` 变化,只对**实际用到的字符**调用
[webfont](https://github.com/2234839/web-font) 子集化 API注册 KB 级子集字体后自动重渲染画布:
```
new Text({ text: '静心茶舍', fontFamily: '令东齐伋复刻体.ttf' })
→ 服务端裁剪 → 返回 ~6KB 子集 → FontFace 注册 → 画布自动重绘
```
## 安装
```bash
npm install leafer-x-webfont
```
## 使用
```ts
import { Leafer, Text } from 'leafer-ui'
import { WebFontPlugin } from 'leafer-x-webfont'
const leafer = new Leafer({ view: window })
// 一行接入
const webfont = new WebFontPlugin(leafer)
leafer.add(new Text({ text: '静心茶舍', fontFamily: '令东齐伋复刻体.ttf', fontSize: 64 }))
// 字体到位后画布自动重渲染
```
### 导出图片前
```ts
await webfont.ready() // 等待所有已用字符的子集注册完成
const blob = await leafer.export('png', { pixelRatio: 2 })
```
### 配置项
```ts
new WebFontPlugin(leafer, {
baseUrl: 'https://webfont.shenzilong.cn', // 自部署时改这里
outType: 'woff2',
debounceMs: 120,
watch: true, // 持续监听画布变化;静态海报导出可关掉
debug: false,
resolveFont: null, // 自定义 fontFamily 解析规则,返回 null 跳过
})
```
## 特性
- **零配置**:任意 `fontFamily`(含 `xxx.ttf` 文件名写法)自动识别,字体不存在时静默回退
- **增量去重**:同一字体下字符集只增不减,改一个字只多发一个字符的子集请求
- **失败记忆**:字体不含的字符自动记入失败集,不反复 404
- **导出友好**`webfont.ready()` 保证 `leafer.export()` 时字体已注册
## 本地开发
```bash
# 仓库根目录
pnpm install
pnpm dev # 起前后端
# 打开 demo插件源码直引无需构建
open packages/leafer-x-webfont/demo/index.html
```
## License
MIT

View File

@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>leafer-x-webfont —— LeaferJS 海报中文字体 Demo</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; background: #f5f5f4; color: #1c1917; }
.wrap { max-width: 960px; margin: 0 auto; padding: 32px 20px 80px; }
h1 { font-size: 24px; margin-bottom: 8px; }
.sub { color: #78716c; font-size: 14px; margin-bottom: 24px; line-height: 1.7; }
.sub a { color: #0d9488; }
.panel { background: #fff; border: 1px solid #e7e5e4; border-radius: 12px; padding: 16px; margin-bottom: 16px; display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
label { font-size: 13px; color: #57534e; }
input[type="text"], select { padding: 8px 10px; border: 1px solid #d6d3d1; border-radius: 8px; font-size: 14px; outline: none; }
input[type="text"] { width: 280px; }
input[type="text"]:focus, select:focus { border-color: #0d9488; }
button { padding: 8px 16px; background: #0d9488; color: #fff; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; }
button:hover { background: #0f766e; }
#canvas-host { width: 100%; height: 480px; background: #fff; border: 1px solid #e7e5e4; border-radius: 12px; overflow: hidden; }
.status { font-size: 12px; color: #78716c; }
.ok { color: #0d9488; font-weight: 600; }
</style>
</head>
<body>
<div class="wrap">
<h1>leafer-x-webfont</h1>
<p class="sub">
LeaferJS 中文字体插件 —— 画布里的 Text 用什么字体就只加载那几个字的子集6 字 ≈ 6KB而非 16MB
服务端:<a href="https://webfont.shenzilong.cn" target="_blank">webfont.shenzilong.cn</a>
/ <a href="https://github.com/2234839/web-font" target="_blank">GitHub</a>
</p>
<div class="panel">
<label>标题文字</label>
<input id="title-input" type="text" value="静心茶舍" maxlength="12">
<label>字体</label>
<select id="font-select"></select>
<button id="export-btn">导出 PNG</button>
<span class="status" id="status">初始化…</span>
</div>
<div id="canvas-host"></div>
</div>
<script src="vendor/leafer-ui.min.js"></script>
<!-- leafer 导出 PNG 需要额外插件(画布转 blob -->
<script src="vendor/leafer-in-export.min.js"></script>
<!-- demo 引插件构建产物dist ESMwebfont-sdk 由相对路径 importmap 解析到同级包的 dist -->
<script type="importmap">
{
"imports": {
"webfont-sdk": "../../webfont-sdk/dist/index.js"
}
}
</script>
<script type="module">
import { WebFontPlugin } from '../dist/index.js'
const { Leafer, Text, Rect, Ellipse, Image } = LeaferUI
const status = document.getElementById('status')
const FONTS = [
{ name: '令东齐伋复刻体.ttf', label: '令东齐伋复刻体(古籍宋)' },
{ name: '霞鹜文楷.ttf', label: '霞鹜文楷(楷书)' },
{ name: '得意黑.ttf', label: '得意黑(标题黑体)' },
{ name: '演示佛系体.ttf', label: '演示佛系体' },
{ name: '源界明朝.ttf', label: '源界明朝(日式明朝)' },
{ name: '三极泼墨体.ttf', label: '三极泼墨体' },
]
const leafer = new Leafer({ view: 'canvas-host', fill: '#fafaf9' })
const webfont = new WebFontPlugin(leafer, { debug: true, baseUrl: 'http://localhost:8087' })
/* ---------- 海报场景 ---------- */
const bg = new Rect({ x: 0, y: 0, width: 900, height: 480, fill: { type: 'linear', from: { x: 0, y: 0 }, to: { x: 1, y: 1 }, stops: [{ offset: 0, color: '#fef3c7' }, { offset: 1, color: '#fde68a' }] } })
const deco = new Ellipse({ x: 660, y: -80, width: 360, height: 360, fill: 'rgba(255,255,255,0.35)' })
const deco2 = new Ellipse({ x: -100, y: 320, width: 300, height: 300, fill: 'rgba(255,255,255,0.25)' })
const title = new Text({
x: 80, y: 150,
text: '静心茶舍',
fontFamily: '令东齐伋复刻体.ttf',
fontSize: 96,
fill: '#1c1917',
letterSpacing: 8,
})
const subtitle = new Text({
x: 84, y: 290,
text: '以茶为媒 · 观自在',
fontFamily: '霞鹜文楷.ttf',
fontSize: 28,
fill: '#57534e',
letterSpacing: 4,
})
leafer.add(bg)
leafer.add(deco)
leafer.add(deco2)
leafer.add(title)
leafer.add(subtitle)
/* ---------- 控件交互 ---------- */
const fontSelect = document.getElementById('font-select')
for (const f of FONTS) {
const opt = document.createElement('option')
opt.value = f.name
opt.textContent = f.label
fontSelect.appendChild(opt)
}
fontSelect.onchange = () => {
title.fontFamily = fontSelect.value
status.textContent = '字体切换中…'
}
document.getElementById('title-input').oninput = (e) => {
title.text = e.target.value || ' '
}
document.getElementById('export-btn').onclick = async () => {
status.textContent = '等待字体就绪…'
await webfont.ready()
/** leafer.export 返回 { data: dataURL, ... },转 Blob 下载 */
const { data } = await leafer.export('png', { pixelRatio: 2 })
const blob = await (await fetch(data)).blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `海报-${title.text}.png`
a.click()
URL.revokeObjectURL(url)
status.textContent = '已导出 ✓'
setTimeout(() => status.textContent = '', 2000)
}
/* ---------- 首屏字体就绪提示 ---------- */
setTimeout(async () => {
await webfont.ready()
const count = webfont.families.length
status.innerHTML = `<span class="ok">就绪</span> · 已加载 ${count} 种字体的子集(打开 DevTools Network 查看 API 请求体积)`
}, 600)
</script>
</body>
</html>

View File

@ -0,0 +1,47 @@
{
"name": "leafer-x-webfont",
"version": "0.1.0",
"description": "LeaferJS 字体插件 —— Text 元素自动按需加载字体子集6 字 ≈ 6KB让画布与导出图用上任意中文字体",
"type": "module",
"main": "./dist/leafer-x-webfont.esm.js",
"module": "./dist/leafer-x-webfont.esm.js",
"types": "./types/index.d.ts",
"exports": {
".": {
"types": "./types/index.d.ts",
"import": "./dist/leafer-x-webfont.esm.js"
}
},
"files": [
"dist",
"types",
"README.md",
"LICENSE"
],
"scripts": {
"build": "tsdown",
"typecheck": "tsc --noEmit"
},
"keywords": [
"leafer",
"leaferjs",
"webfont",
"font-subset",
"canvas-font",
"chinese-font",
"poster"
],
"repository": "https://github.com/2234839/web-font",
"license": "MIT",
"peerDependencies": {
"leafer-ui": "^2.0.0"
},
"peerDependenciesMeta": {
"leafer-ui": {
"optional": true
}
},
"dependencies": {
"webfont-sdk": "workspace:*"
}
}

View File

@ -0,0 +1,303 @@
/**
* leafer-x-webfont LeaferJS
*
* Leafer Text `canvas.font = fontFamily`
* 10MB+
* Text text / fontFamily
* webfont-sdkFontFace fetch buffer
* FontFace(unicodeRange) forceRender
*
* / / / provider webfont-sdk
* Leafer
* - walk family
* - property.change + layout.end
* - fontFamily 'xx.ttf' 'xx'canvas font
* - forceRender
*
*
* ```ts
* import { Leafer, Text } from 'leafer-ui'
* import { WebFontPlugin } from 'leafer-x-webfont'
*
* const leafer = new Leafer({ view: window })
* const webfont = new WebFontPlugin(leafer)
* leafer.add(new Text({ text: '静心茶舍', fontFamily: '令东齐伋复刻体.ttf', fontSize: 64 }))
* // 字体到位后画布自动重渲染;导出前 await webfont.ready()
* ```
*/
import { WebFontFontFaceMode, type IFontFaceLoader } from 'webfont-sdk'
/** 插件配置 */
export interface IWebFontPluginConfig {
/** 子集化服务基地址,默认官方在线服务 */
baseUrl?: string
/**
* fontFamily
* - fontFamily '令东齐伋复刻体.ttf''霞鹜文楷' API
* webfont-sdk
* - null
*/
resolveFont?: (fontFamily: string) => string | null
/** 请求子集时的输出格式 */
outType?: 'woff2' | 'ttf'
/** 文本变化防抖ms打字场景避免每敲一键发一次请求 */
debounceMs?: number
/**
* true
*
*/
watch?: boolean
/** 是否在控制台输出调试日志 */
debug?: boolean
/**
* fontFamily CSS family .ttf
* truecanvas font family退
*/
rewriteFamily?: boolean
}
/** fontFamily 里的文件后缀(注册 FontFace / CSS 都不认) */
const FONT_EXT_RE = /\.(ttf|otf|woff2?|ttc)$/i
/** 泛型族名没有对应字体文件,跳过 */
const GENERIC_FAMILY_RE = /^(sans-serif|serif|monospace|caption|system-ui|cursive|fantasy)$/i
/** Leafer 节点最小结构(避免硬依赖 leafer-ui 类型,保持 peerDep 可选) */
interface ILeaferNode {
__tag?: string
text?: unknown
fontFamily?: unknown
children?: unknown
destroyed?: boolean
forceRender?: () => void
/** on_ 为公开事件订阅(返回 id旧版 leafer 用 on__下划线为内部 id 绑定) */
on_?: (type: string, listener: (e: unknown) => void, bind?: unknown) => number
off_?: (ids: number[]) => void
/** 旧版 API 兼容on__ 在新版本已更名 on_ */
on__?: (type: string, listener: (e: unknown) => void, bind?: unknown) => number
off__?: (ids: number[]) => void
waitViewReady?: (cb: () => void) => void
}
export class WebFontPlugin {
/** 宿主 Leafer 实例 */
private leafer: ILeaferNode | null
private config: Required<Pick<IWebFontPluginConfig, 'debounceMs' | 'watch' | 'debug' | 'rewriteFamily'>> &
IWebFontPluginConfig
/** SDK FontFace 模式(增量引擎在这层:去重/并发/失败记忆) */
private mode: WebFontFontFaceMode
/** family -> 增量加载器(由 SDK 管理) */
private loaders = new Map<string, IFontFaceLoader>()
/** 防抖定时器 */
private debounceTimer: ReturnType<typeof setTimeout> | null = null
/** 解绑事件用的 id 列表 */
private eventIds: number[] = []
/** 统一的事件解绑函数on_/off_ 新旧版别名解析后的句柄) */
private offEvents: ((ids: number[]) => void) | null = null
constructor(leafer: ILeaferNode, config: IWebFontPluginConfig = {}) {
this.leafer = leafer
this.config = {
debounceMs: config.debounceMs ?? 120,
watch: config.watch ?? true,
debug: config.debug ?? false,
rewriteFamily: config.rewriteFamily ?? true,
baseUrl: config.baseUrl,
outType: config.outType,
resolveFont: config.resolveFont,
}
this.mode = new WebFontFontFaceMode({
baseUrl: config.baseUrl,
provider: null,
})
this.bindEvents()
/** 画布初始化完成后做一次全量扫描 */
leafer.waitViewReady?.(() => this.scan())
}
/* ============================================================
* Text
* ============================================================ */
private bindEvents(): void {
if (!this.config.watch) return
/**
* leafer-ui on_/off_ on__/off__
* on_/off_
*/
const leafer = this.leafer!
const on = leafer.on_ ?? leafer.on__
const off = leafer.off_ ?? leafer.off__
this.offEvents = off ? (ids) => off.call(leafer, ids) : null
/**
* Leafer PropertyEvent.CHANGE = 'property.change' Leaf
* emit leafer leafer LeafDataProxy.emitPropertyEvent
* `leafer.emitEvent(event)` text / fontFamily
* leafer-ui peerDep
*/
this.eventIds.push(
on!.call(leafer, 'property.change', (e) => {
const ev = e as { attrName?: string }
if (ev.attrName === 'text' || ev.attrName === 'fontFamily') {
this.schedule()
}
}),
)
/** 布局结束(新增/删除节点都会触发布局)——覆盖新增 Text、海报模板切换等场景 */
this.eventIds.push(
on!.call(leafer, 'layout.end', () => this.schedule()),
)
}
/* ============================================================
*
* ============================================================ */
/** 全量扫描画布中所有 Text 的 text + fontFamily按 family 聚合新字符 */
private scan(): void {
const groups = new Map<string, { loader: IFontFaceLoader; fontName: string; family: string; chars: Set<string> }>()
const walk = (node: ILeaferNode | null | undefined): void => {
if (!node || node.destroyed) return
const isText = node.__tag === 'Text' || (typeof node.text === 'string' && typeof node.fontFamily === 'string')
if (isText) {
const fontFamily: string = node.fontFamily as string
const text: string = String(node.text ?? '')
if (fontFamily && text) {
const fontName = this.resolveFontName(fontFamily)
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) {
;(node as { fontFamily: string }).fontFamily = family
}
for (const ch of text) entry.chars.add(ch)
}
}
}
const children = node.children
if (Array.isArray(children)) {
for (const child of children as ILeaferNode[]) walk(child)
}
}
walk(this.leafer)
for (const [, { loader, chars }] of groups) {
loader.update(charsToString(chars))
}
}
/** 防抖触发扫描 */
private schedule(): void {
if (this.debounceTimer) clearTimeout(this.debounceTimer)
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null
this.scan()
}, this.config.debounceMs)
}
/* ============================================================
*
* ============================================================ */
/**
* fontFamily
* resolveFont
*/
private resolveFontName(fontFamily: string): string | null {
if (this.config.resolveFont) return this.config.resolveFont(fontFamily)
if (GENERIC_FAMILY_RE.test(fontFamily.trim())) return null
return fontFamily
}
/** 获取或创建family 对应的 SDK 增量加载器;注册成功后重绘画布 */
private getLoader(fontName: string, family: string): IFontFaceLoader {
let loader = this.loaders.get(family)
if (!loader) {
loader = this.mode.loadFontFace(
{ fontName, family },
() => {
/** 字体注册成功后强制重绘整个画布(文本 metrics 需要重新计算) */
this.leafer?.forceRender?.()
},
)
this.loaders.set(family, loader)
this.log('new font loader:', fontName, '->', family)
}
return loader
}
private log(...args: unknown[]): void {
if (this.config.debug) console.log('[leafer-x-webfont]', ...args)
}
/* ============================================================
* API
* ============================================================ */
/** 立即做一次全量扫描(外部手动改完画布内容后调用) */
public refresh(): void {
this.scan()
}
/**
*
* ```ts
* await webfont.ready()
* const blob = await leafer.export('png')
* ```
* + FontFace
*/
public async ready(): Promise<void> {
await this.mode.ready()
}
/** 已加载(或加载中)的字体 family 列表(调试 / 状态展示用) */
public get families(): string[] {
return [...this.loaders.keys()]
}
/** 销毁插件解绑事件、丢弃加载器FontFace 保留在 document.fonts 供继续渲染) */
public destroy(): void {
if (this.debounceTimer) clearTimeout(this.debounceTimer)
this.offEvents?.(this.eventIds)
this.eventIds.length = 0
this.offEvents = null
for (const loader of this.loaders.values()) loader.dispose()
this.loaders.clear()
this.leafer = null
}
}
/* ============================================================
*
* ============================================================ */
/** fontFamily 原始值 -> 合法 CSS family 名(去除文件后缀与首尾空白) */
function normalizeFamily(fontFamily: string): string {
return fontFamily.replace(FONT_EXT_RE, '').trim()
}
/** Map 的 get-or-create 惯用封装 */
function getOrCreate<K, V>(map: Map<K, V>, key: K, create: () => V): V {
let v = map.get(key)
if (!v) {
v = create()
map.set(key, v)
}
return v
}
/** 字符集合 -> 字符串(保持插入序,便于日志与请求参数稳定) */
function charsToString(set: Set<string>): string {
let s = ''
for (const c of set) s += c
return s
}

View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": [],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "tsdown.config.ts"]
}

View File

@ -0,0 +1,10 @@
import { defineConfig } from 'tsdown'
export default defineConfig({
entry: ['src/index.ts'],
format: 'esm',
dts: true,
clean: true,
outDir: 'dist',
platform: 'browser',
})

View File

@ -0,0 +1,39 @@
{
"name": "webfont-sdk",
"version": "0.1.0",
"description": "Web 字体按需加载 SDK —— 只加载实际用到的字符,增量去重、无闪烁,支持 DOM(CSS) 与 Canvas(FontFace) 两种模式",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"scripts": {
"build": "tsdown && node scripts/sync-public.mjs",
"typecheck": "tsc --noEmit"
},
"keywords": [
"webfont",
"font-subset",
"font-face",
"incremental-font",
"canvas-font",
"chinese-font"
],
"repository": "https://github.com/2234839/web-font",
"license": "MIT",
"devDependencies": {
"tsdown": "^0.22.13",
"typescript": "^7.0.2"
}
}

View File

@ -0,0 +1,43 @@
/**
* 构建后同步脚本 IIFE 产物带用法 banner 写入主站 public/webfont-sdk.js
* index.html 引用 /webfont-sdk.js?v=%BUILD_TIME%产物路径不变零改动
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
const pkgRoot = dirname(dirname(fileURLToPath(import.meta.url)))
const source = resolve(pkgRoot, 'dist-iife/iife.iife.js')
const target = resolve(pkgRoot, '../../public/webfont-sdk.js')
const banner = `/**
* WebFont SDK 按需增量加载字体片段无闪烁本文件由 packages/webfont-sdk 构建勿手改
*
* 架构核心增量引擎 + 两种注册模式
* - 核心IncrementalEngine fontKey 管理字符集只请求增量失败字符自动记忆不重试
* - CSS 模式WebFontloadFont轮询/ observeFontDOM 事件/ loadText手动传文本
* - FontFace 模式WebFontCanvasCanvas/canvas 场景FontFace + unicodeRange 注册
*
* 用法
* // 轮询模式
* WebFont.loadFont({ fontName, selector, family, interval });
*
* // 事件驱动模式
* var obs = WebFont.observeFont({ fontName, selector, family });
* obs.dispose();
*
* // 直接传文本模式
* var loader = WebFont.loadText({ fontName, text: "你好世界", family });
* loader.update("追加文字");
* loader.dispose();
*
* // Canvas 模式leafer / 原生 canvas
* var face = WebFontCanvas.loadFontFace({ fontName }, function (chunk) { 在此重绘 });
* face.update("画布上的文字");
* await WebFontCanvas.ready();
*/
`
writeFileSync(target, banner + readFileSync(source, 'utf8'))
console.log(`synced: ${target} (from ${source})`)

View File

@ -0,0 +1,327 @@
/**
* CSS @font-face + unicode-range DOM
*
* public/webfont-sdk.js
* - <style> unicode-range
* - document.fonts.load
* - loadFont/ observeFontMutationObserver/ loadText
*/
import { IncrementalEngine, createHttpProvider, type SubsetProvider, type LoadedChunk } from './engine'
/** 通用选项 */
export interface IWebFontOptions {
/** 字体文件名(如 '令东齐伋复刻体.ttf'),支持模糊匹配 */
fontName: string
/** 服务基地址,默认当前 origin */
baseUrl?: string
/** CSS font-family 名,默认去掉扩展名的字体名 */
family?: string
/** 输出格式,默认 woff2 */
outType?: 'woff2' | 'ttf'
}
export interface ILoadFontOptions extends IWebFontOptions {
/** DOM 选择器 */
selector: string
/** 轮询间隔 ms默认 1000 */
interval?: number
}
export interface IObserveFontOptions extends IWebFontOptions {
/** DOM 选择器 */
selector: string
/** 防抖 ms默认 50 */
debounceMs?: number
}
export interface ILoadTextOptions extends IWebFontOptions {
/** 初始文本 */
text: string
}
/** loadText 返回的手动加载器 */
export interface ITextLoader {
/** 追加文本(自动去重) */
update(text: string): void
dispose(): void
}
/** observeFont 返回的观察任务 */
export interface IObserveTask {
dispose(): void
}
/** 跨域 baseUrl 注入 preconnect首个片段延迟从 ~90ms 降到 ~30ms */
const preconnectedOrigins = new Set<string>()
function ensurePreconnect(baseUrl: string): void {
let origin: string
try {
origin = new URL(baseUrl, location.href).origin
} catch {
return
}
if (origin === location.origin) return
if (preconnectedOrigins.has(origin)) return
preconnectedOrigins.add(origin)
const link = document.createElement('link')
link.rel = 'preconnect'
link.crossOrigin = 'anonymous'
link.href = origin
document.head.appendChild(link)
}
export class WebFontCSSMode {
private engine: IncrementalEngine
/** 每个加载器注入的 <style> 元素(销毁时移除) */
private injectedStyles = new Map<string, HTMLStyleElement[]>()
private pollTasks = new Map<string, { timer: ReturnType<typeof setInterval> }>()
private observeTasks = new Map<string, IObserveTask>()
constructor(config: { baseUrl?: string; maxConcurrent?: number; provider?: SubsetProvider | null } = {}) {
this.engine = new IncrementalEngine({
maxConcurrent: config.maxConcurrent ?? 4,
provider: config.provider ?? null,
})
}
/** 底层引擎FontFace 模式共用场景) */
getEngine(): IncrementalEngine {
return this.engine
}
/** 注入自定义子集提供者离线裁剪等null 恢复 HTTP */
setSubsetProvider(provider: SubsetProvider | null): void {
this.engine.setProvider(provider)
}
setMaxConcurrent(n: number): void {
this.engine.setMaxConcurrent(n)
}
/* ---------- 内部:片段就绪回调(注入 CSS ---------- */
private makeOnLoadChunk(key: string, family: string) {
return (chunk: LoadedChunk): void => {
const unicodeRanges = chunk.chars
.map((c) => 'U+' + c.codePointAt(0)!.toString(16).padStart(4, '0'))
.join(', ')
const style = document.createElement('style')
style.textContent =
'@font-face {\n' +
` font-family: "${family}";\n` +
` src: url("${chunk.url}") format("${chunk.format}");\n` +
' unicode-range: ' + unicodeRanges + ';\n' +
'}\n'
document.head.appendChild(style)
const list = this.injectedStyles.get(key) ?? []
list.push(style)
this.injectedStyles.set(key, list)
/** 注入后等待字体真正可用于渲染,让 document.fonts 状态机推进(无 API 时定时器兑底) */
this.waitFontLoaded(family)
}
}
/** 用 document.fonts.load 触发加载与就绪状态推进(无 API 时定时器兑底) */
private waitFontLoaded(family: string): void {
if (document.fonts && document.fonts.load) {
void document.fonts.load(`16px "${family}"`)
} else {
setTimeout(() => {}, 3000)
}
}
private resolveDefaults(options: IWebFontOptions): { baseUrl: string; family: string; key: string } {
const baseUrl = options.baseUrl ?? location.origin
const family = options.family ?? options.fontName.replace(/\.[^.]+$/, '')
return { baseUrl, family, key: IncrementalEngine.fontKey(options.fontName, family) }
}
/* ---------- 1. loadFont轮询模式 ---------- */
loadFont(options: ILoadFontOptions): void {
const { baseUrl, family, key } = this.resolveDefaults(options)
ensurePreconnect(baseUrl)
this.engine.ensureState(key, options.fontName, {
baseUrl,
outType: options.outType ?? 'woff2',
onLoadChunk: this.makeOnLoadChunk(key, family),
})
if (this.pollTasks.has(options.selector)) {
clearInterval(this.pollTasks.get(options.selector)!.timer)
}
let applied = false
const tick = (): void => {
const charSet = collectChars(options.selector)
const had = this.engine.getState(key)
this.engine.submitText(key, charsToString(charSet))
if (had && !applied) {
applied = true
applyFamily(options.selector, family)
}
}
tick()
const timer = setInterval(tick, options.interval ?? 1000)
this.pollTasks.set(options.selector, { timer })
}
/* ---------- 2. observeFontMutationObserver 模式 ---------- */
observeFont(options: IObserveFontOptions): IObserveTask {
const { baseUrl, family, key } = this.resolveDefaults(options)
ensurePreconnect(baseUrl)
this.engine.ensureState(key, options.fontName, {
baseUrl,
outType: options.outType ?? 'woff2',
onLoadChunk: this.makeOnLoadChunk(key, family),
})
if (this.observeTasks.has(options.selector)) {
this.observeTasks.get(options.selector)!.dispose()
}
let applied = false
let debounceTimer: ReturnType<typeof setTimeout> | null = null
const doLoad = (): void => {
const had = this.engine.getState(key)
this.engine.submitText(key, charsToString(collectChars(options.selector)))
if (had && !applied) {
applied = true
applyFamily(options.selector, family)
}
}
const debouncedLoad = (): void => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(doLoad, options.debounceMs ?? 50)
}
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'childList' || m.type === 'characterData') {
debouncedLoad()
return
}
}
})
const inputHandler = (): void => debouncedLoad()
const elements = document.querySelectorAll(options.selector)
observer.observe(document.body ?? document.documentElement, {
childList: true,
subtree: true,
characterData: true,
})
for (const el of elements) {
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
el.addEventListener('input', inputHandler)
}
}
doLoad()
let disposed = false
const task: IObserveTask = {
dispose: (): void => {
if (disposed) return
disposed = true
observer.disconnect()
for (const el of elements) {
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
el.removeEventListener('input', inputHandler)
}
}
if (debounceTimer) clearTimeout(debounceTimer)
this.observeTasks.delete(options.selector)
},
}
this.observeTasks.set(options.selector, task)
return task
}
/* ---------- 3. loadText手动文本模式 ---------- */
loadText(options: ILoadTextOptions): ITextLoader {
const { baseUrl, family, key } = this.resolveDefaults(options)
ensurePreconnect(baseUrl)
this.engine.ensureState(key, options.fontName, {
baseUrl,
outType: options.outType ?? 'woff2',
onLoadChunk: this.makeOnLoadChunk(key, family),
})
this.engine.submitText(key, options.text)
let disposed = false
return {
update: (text: string): void => {
if (disposed) return
this.engine.submitText(key, text)
},
dispose: (): void => {
if (disposed) return
disposed = true
/** 移除该 loader 注入的所有 @font-face 样式,避免同名 family 的 CSS 优先级冲突 */
const styles = this.injectedStyles.get(key)
if (styles) {
for (const s of styles) s.remove()
this.injectedStyles.delete(key)
}
this.engine.removeState(key)
},
}
}
/** 清理所有任务与注入样式(页面卸载时调用) */
disposeAll(): void {
for (const { timer } of this.pollTasks.values()) clearInterval(timer)
for (const task of this.observeTasks.values()) task.dispose()
this.pollTasks.clear()
this.observeTasks.clear()
for (const styles of this.injectedStyles.values()) {
for (const s of styles) s.remove()
}
this.injectedStyles.clear()
}
/** 供 IIFE 全局导出对齐旧 API 名 */
static createHttpProvider = createHttpProvider
}
/* ---------- DOM 辅助 ---------- */
/** 收集选择器匹配元素中的所有字符 */
function collectChars(selector: string): Set<string> {
const charSet = new Set<string>()
const elements = document.querySelectorAll(selector)
for (const el of elements) {
const text = getText(el)
for (const ch of text) charSet.add(ch)
}
return charSet
}
function charsToString(set: Set<string>): string {
let s = ''
for (const c of set) s += c
return s
}
function getText(el: Element): string {
const tag = el.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
const input = el as HTMLInputElement
/** 同时收集 value 和 placeholder确保占位文本的字体也被加载 */
return (input.value ?? '') + (input.placeholder ?? '')
}
return el.textContent ?? ''
}
/** 应用字体到元素 */
function applyFamily(selector: string, family: string): void {
const elements = document.querySelectorAll(selector)
for (const el of elements) {
;(el as HTMLElement).style.fontFamily = `"${family}", sans-serif`
}
}

View File

@ -0,0 +1,211 @@
/**
* fontKey
*
* CSS / FontFace
* 1.
* 2. provider +
*
* 3. / failedChars
* 4. provider HTTP API线 provider
*
* CSS @font-face / FontFace
*/
/** 子集提供者:给定字体名+文本,返回字体片段 URL 与格式HTTP 或 blob: */
export type SubsetProvider = (fontName: string, text: string, outType: string) => Promise<{ url: string; format: string }>
/** 单个字体的增量状态 */
export interface IFontState {
/** 字体名API 查询用,如 '令东齐伋复刻体.ttf'),服务端支持模糊匹配 */
fontName: string
/** 该字体的服务基地址HTTP provider 用) */
baseUrl: string
/** 输出格式 */
outType: string
/** 已成功加载的字符集 */
loadedChars: Set<string>
/** 加载失败过的字符集(字体不含此字 / 网络错误),避免反复请求 */
failedChars: Set<string>
/** 正在请求中的字符集(防重复并发) */
pendingChars: Set<string>
/**
*
* Promise ready()
*/
onLoadChunk: ((chunk: LoadedChunk) => void | Promise<void>) | null
}
/** 一次成功加载的增量片段 */
export interface LoadedChunk {
/** 字体名 */
fontName: string
/** 本片段包含的字符 */
chars: string[]
/** 字体文件 URL */
url: string
/** 字体格式woff2 / truetype */
format: string
}
/** 引擎配置 */
export interface IEngineConfig {
/** 全局最大并发子集请求(含注册)数,默认 4 */
maxConcurrent: number
/** 自定义子集提供者离线裁剪场景null 表示走默认 HTTP */
provider: SubsetProvider | null
}
/** 创建字体状态的初始选项 */
export interface IEnsureStateOptions {
/** 服务基地址HTTP provider 用) */
baseUrl: string
/** 输出格式 */
outType: string
/** 注册回调(可后补) */
onLoadChunk?: (chunk: LoadedChunk) => void | Promise<void>
}
export function createHttpProvider(baseUrl: string): SubsetProvider {
return (fontName, text, outType) => {
const url = `${baseUrl}/api?font=${encodeURIComponent(fontName)}&text=${encodeURIComponent(text)}&outType=${outType}`
return Promise.resolve({ url, format: outType === 'woff2' ? 'woff2' : 'truetype' })
}
}
export class IncrementalEngine {
/** fontKey -> 字体状态 */
private states = new Map<string, IFontState>()
private config: IEngineConfig
/** 并发池 */
private active = 0
private queue: Array<() => void> = []
/** 在途任务数provider 请求 + 注册回调hasPending / ready 用 */
private flying = 0
constructor(config: Partial<IEngineConfig> = {}) {
this.config = {
maxConcurrent: config.maxConcurrent ?? 4,
provider: config.provider ?? null,
}
}
/** fontKeyfontName + family 唯一确定一个增量组 */
static fontKey(fontName: string, family: string): string {
return fontName + '|' + family
}
setProvider(provider: SubsetProvider | null): void {
this.config.provider = provider
}
getState(key: string): IFontState | undefined {
return this.states.get(key)
}
/** 获取或创建字体状态;已存在时按传入项更新 baseUrl / outType / 回调 */
ensureState(key: string, fontName: string, options: IEnsureStateOptions): IFontState {
let state = this.states.get(key)
if (!state) {
state = {
fontName,
baseUrl: options.baseUrl,
outType: options.outType,
loadedChars: new Set(),
failedChars: new Set(),
pendingChars: new Set(),
onLoadChunk: options.onLoadChunk ?? null,
}
this.states.set(key, state)
return state
}
state.baseUrl = options.baseUrl
state.outType = options.outType
if (options.onLoadChunk) state.onLoadChunk = options.onLoadChunk
return state
}
/** 删除状态(销毁时) */
removeState(key: string): void {
this.states.delete(key)
}
/** 是否还有在途任务请求中或注册中ready() 轮询用) */
hasPending(): boolean {
if (this.flying > 0) return true
for (const s of this.states.values()) {
if (s.pendingChars.size > 0) return true
}
return false
}
/** 清除失败记录(下次遇到这些字符会重新请求) */
retryFailed(key: string): void {
this.states.get(key)?.failedChars.clear()
}
/**
*
* pending loaded failed
*/
submitText(key: string, text: string): void {
const state = this.states.get(key)
if (!state) return
const newChars: string[] = []
for (const ch of text) {
if (state.loadedChars.has(ch) || state.pendingChars.has(ch) || state.failedChars.has(ch)) continue
/** 跳过控制字符 */
if (ch.charCodeAt(0) < 0x20) continue
newChars.push(ch)
state.pendingChars.add(ch)
}
if (newChars.length === 0) return
this.enqueue(() => this.loadChunk(state, newChars))
}
/** 执行一次子集请求 + 注册(在并发槽内完成) */
private async loadChunk(state: IFontState, chars: string[]): Promise<void> {
this.flying++
try {
const text = chars.join('')
const provider = this.config.provider ?? createHttpProvider(state.baseUrl)
const result = await provider(state.fontName, text, state.outType)
/** 注册完成后才把字符记为已加载:注册失败可走 failedChars 重试路径 */
await state.onLoadChunk?.({
fontName: state.fontName,
chars,
url: result.url,
format: result.format,
})
for (const ch of chars) {
state.loadedChars.add(ch)
state.pendingChars.delete(ch)
}
} catch {
for (const ch of chars) {
state.pendingChars.delete(ch)
state.failedChars.add(ch)
}
} finally {
this.flying--
}
}
/** 并发池:超出 maxConcurrent 的任务排队等待 */
private enqueue(fn: () => Promise<void>): void {
const run = (): void => {
this.active++
fn().finally(() => {
this.active--
const next = this.queue.shift()
if (next) run()
})
}
if (this.active < this.config.maxConcurrent) run()
else this.queue.push(() => undefined)
}
setMaxConcurrent(n: number): void {
this.config.maxConcurrent = Math.max(1, n | 0)
}
}

View File

@ -0,0 +1,122 @@
/**
* FontFace Canvas leafer / fabric / konva / canvas
*
* CSS <style> fetch buffer FontFace API
* unicodeRange
* onReady
*/
import { IncrementalEngine, createHttpProvider, type SubsetProvider, type LoadedChunk, type IFontState } from './engine'
export interface IFontFaceOptions {
/** 字体文件名(如 '令东齐伋复刻体.ttf'),支持模糊匹配 */
fontName: string
/** 服务基地址 */
baseUrl?: string
/** 注册用的 family 名,默认去掉扩展名 */
family?: string
/** 输出格式,默认 woff2 */
outType?: 'woff2' | 'ttf'
}
/** 单个 FontFace 字体的增量加载器leafer 插件等持有) */
export interface IFontFaceLoader {
/** 提交文本(自动去重,只请求新字符) */
update(text: string): void
/** 该字体是否有片段在请求中 */
isPending(): boolean
/** 清除失败记录(重试场景) */
retryFailed(): void
dispose(): void
}
export class WebFontFontFaceMode {
private engine: IncrementalEngine
/** 未显式传 baseUrl 时的默认服务地址 */
private defaultBaseUrl = 'https://webfont.shenzilong.cn'
/** family -> 已注册的 FontFacedispose 时从 document.fonts 删除) */
private faces = new Map<string, FontFace[]>()
constructor(config: { baseUrl?: string; maxConcurrent?: number; provider?: SubsetProvider | null } = {}) {
this.engine = new IncrementalEngine({
maxConcurrent: config.maxConcurrent ?? 4,
provider: config.provider ?? null,
})
if (config.baseUrl) this.defaultBaseUrl = config.baseUrl
}
getEngine(): IncrementalEngine {
return this.engine
}
setSubsetProvider(provider: SubsetProvider | null): void {
this.engine.setProvider(provider)
}
/**
* FontFace
*
* @param options
* @param onChunk
*/
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
const handleChunk = async (chunk: LoadedChunk): Promise<void> => {
const unicodeRanges = chunk.chars
.map((c) => 'U+' + c.codePointAt(0)!.toString(16).padStart(4, '0'))
.join(', ')
const res = await fetch(chunk.url)
const buffer = await res.arrayBuffer()
const face = new FontFace(family, buffer, { unicodeRange: unicodeRanges })
await face.load()
document.fonts.add(face)
const list = this.faces.get(family) ?? []
list.push(face)
this.faces.set(family, list)
onChunk?.(chunk)
}
this.engine.ensureState(key, fontName, {
baseUrl,
outType: options.outType ?? 'woff2',
onLoadChunk: (chunk) => handleChunk(chunk),
})
let disposed = false
return {
update: (text: string): void => {
if (disposed) return
this.engine.submitText(key, text)
},
isPending: (): boolean => {
const s = this.engine.getState(key)
return !!s && s.pendingChars.size > 0
},
retryFailed: (): void => this.engine.retryFailed(key),
dispose: (): void => {
if (disposed) return
disposed = true
this.engine.removeState(key)
/** FontFace 不主动删除:其他画布可能还在用同 family保守策略 */
},
}
}
/** 是否有片段在请求/注册中(导出图片前轮询用) */
hasPending(): boolean {
return this.engine.hasPending()
}
/** 等待所有 pending 片段就绪(导出图片前调用) */
async ready(): Promise<void> {
while (this.hasPending()) {
await new Promise((r) => setTimeout(r, 50))
}
}
}
export { createHttpProvider, IncrementalEngine }
export type { SubsetProvider, LoadedChunk, IFontState }

View File

@ -0,0 +1,19 @@
/**
* IIFE public/webfont-sdk.jsscript
*
* WebFontAPI
* WebFont.loadFont / observeFont / loadText / disposeAll /
* setMaxConcurrent / setSubsetProvider
* WebFont.canvasFontFace
*/
import { WebFont, WebFontCanvas } from './index'
const g = globalThis as unknown as {
WebFont: typeof WebFont
WebFontCanvas: typeof WebFontCanvas
/** WebFont 上也挂一份 canvas 引用WebFont.canvas.loadFontFace方便 Canvas 场景一处取用 */
}
g.WebFont = WebFont
g.WebFontCanvas = WebFontCanvas
/** 补充别名与文档注释一致WebFont.canvas 即 FontFace 模式实例 */
;(WebFont as unknown as { canvas: typeof WebFontCanvas }).canvas = WebFontCanvas

View File

@ -0,0 +1,29 @@
/**
* webfont-sdk Web SDK
*
* / / / provider
* - `WebFont`CSS DOM @font-face + unicode-rangeAPI
* 线 webfont-sdk.js loadFont / observeFont / loadText / disposeAll /
* setMaxConcurrent / setSubsetProvider
* - `WebFontCanvas`FontFace Canvas leafer / fabric / canvas
* fetch buffer + FontFace(unicodeRange) onChunk
*/
import { WebFontCSSMode } from './css-mode'
import { WebFontFontFaceMode } from './fontface-mode'
export { WebFontCSSMode, WebFontFontFaceMode }
export { IncrementalEngine, createHttpProvider } from './engine'
export type { SubsetProvider, LoadedChunk, IFontState, IEngineConfig } from './engine'
export type {
IWebFontOptions, ILoadFontOptions, IObserveFontOptions, ILoadTextOptions,
ITextLoader, IObserveTask,
} from './css-mode'
export type { IFontFaceOptions, IFontFaceLoader } from './fontface-mode'
/**
* CSS webfont-sdk.js WebFont API
*/
export const WebFont = new WebFontCSSMode()
/** FontFace 模式默认实例Canvas 场景) */
export const WebFontCanvas = new WebFontFontFaceMode()

View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": [],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "tsdown.config.ts"]
}

View File

@ -0,0 +1,23 @@
import { defineConfig } from 'tsdown'
export default [
/** ESM + d.ts —— npm 包主产物leafer 插件 / bundler 用户) */
defineConfig({
entry: ['src/index.ts'],
format: 'esm',
dts: true,
clean: true,
outDir: 'dist',
platform: 'browser',
}),
/** IIFE —— 构建为 public/webfont-sdk.jsscript 标签直引,全局 WebFont */
defineConfig({
entry: ['src/iife.ts'],
format: 'iife',
globalName: 'WebFontBundle',
clean: false,
outDir: 'dist-iife',
platform: 'browser',
minify: false,
}),
]

468
pnpm-lock.yaml generated
View File

@ -67,6 +67,24 @@ importers:
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))
packages/leafer-x-webfont:
dependencies:
leafer-ui:
specifier: ^2.0.0
version: 2.2.9
webfont-sdk:
specifier: workspace:*
version: link:../webfont-sdk
packages/webfont-sdk:
devDependencies:
tsdown:
specifier: ^0.22.13
version: 0.22.13(typescript@7.0.2)(unrun@0.2.37)
typescript:
specifier: ^7.0.2
version: 7.0.2
packages:
'@acemir/cssom@0.9.31':
@ -376,6 +394,153 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@leafer-in/interface@2.2.9':
resolution: {integrity: sha512-6T0Gf1EDKxS4a8sZCT8Q2PZ0a5JhHWswyWfsSXNHrvdJvapHcUE4YordpzXFQeki0KcCiAy2HIrOZ7mvFJ9spQ==}
peerDependencies:
'@leafer-ui/interface': ^2.2.9
'@leafer/interface': ^2.2.9
'@leafer-ui/app@2.2.9':
resolution: {integrity: sha512-SIxCEIx09LQBSSuj7qPGlpY7kIZfRvn6gGaa2dqAzeB4unn05eBFdU8rcM+R1pM8k0VEDGQZpDOyGqe02Kn9CQ==}
'@leafer-ui/bounds@2.2.9':
resolution: {integrity: sha512-BjZxWPQcPf/SQ+L6Edk7PNyiy3LXbsbQZp6kuC1uN0fgOiQtyib2i0M+zKa1+WTrP7aNFQWkZwSk6+QS6m0vmQ==}
'@leafer-ui/color@2.2.9':
resolution: {integrity: sha512-xBvAmKgRR6YRlkRUG6i5Qmav0WUy/EuydzLv6GM1VkATSkU9W42KDcPp9mkg3P7w2Bwc+sJGj4q6LzsuGr5QtQ==}
'@leafer-ui/core@2.2.9':
resolution: {integrity: sha512-N1qwviYnISzWf9vjLClRFqOkGQBqDTdwiURbNpwmQJqMfP0LMfn4+9yQ6cixoVVg+jRVt0K6izsGbzzNAEiLnw==}
'@leafer-ui/data@2.2.9':
resolution: {integrity: sha512-omNVf2e+Gt6baKqXsHlch0PVQwIyowjMSHlyPyNIsL74PkAceYfsrRDBVztsX/EzDVvF22ecdb2w/VmAhu1nyw==}
'@leafer-ui/decorator@2.2.9':
resolution: {integrity: sha512-98v7ib76MbilkP+QLd6G8IKs7eHnn2LOAEd08kjOqkwTfnZ0Hj3s8AKqYWEAr1rfwp6IjBEM9Rmp2Gr1vR+AGA==}
'@leafer-ui/display-module@2.2.9':
resolution: {integrity: sha512-ZpsuJ5L0QT/d2ZuTxsSj0dOGE4n1B0xvm9qJXQ5ThuWlQ0Opm8ySIPQrvIDas35pwyEjBcru6IF6a9KdwC8J7Q==}
'@leafer-ui/display@2.2.9':
resolution: {integrity: sha512-3sM8N3j6E+EZcrGXuGhilTCpH2pYs5Med5hZs+xfxWstw3V4TOJdTHnqCI8weCJAQ9o7QLH6I7+akSs7urz35A==}
'@leafer-ui/draw@2.2.9':
resolution: {integrity: sha512-76FF8teAGnTk8Y+irWOuHrfsR0hJSBMu87a6UlfNU3eda8ZnkKUg0c6lRzO2aUD9gatVgmOh9XaHY3VP8iRTFQ==}
'@leafer-ui/effect@2.2.9':
resolution: {integrity: sha512-k/9tBYWy8VLkDufkvohKATTX0joSlh4v0zi/I/jZ+XP4AVYbIDViYkU+Nt5bpeQnXi2LBGT1t/ipDsqLLlUvPA==}
'@leafer-ui/event@2.2.9':
resolution: {integrity: sha512-WbX3DMmo3JrGHLVWigNz7NPQwduSM5PFLakYlIpy8QbnGYMcRRml/1vlPQUvnYDXi4rRMOlO82EbMvnt736qsg==}
'@leafer-ui/external@2.2.9':
resolution: {integrity: sha512-Vp6VGhKTo07y0M1BGlfOg3CxHj1Svd9ad5mGj0+J8h4eqQQarT5GhH7uaF46WtD4di5JVbwruKj62SFq6uzPeQ==}
'@leafer-ui/hit@2.2.9':
resolution: {integrity: sha512-Qmmz1gCzWOGrGvkt4v6kfnddqp2o9FEiokuj4/zgUoo8Q+0C0D1Y44wzrSGbgC6aQBxa6B8XJ44HLIWf09FVSg==}
'@leafer-ui/interaction-web@2.2.9':
resolution: {integrity: sha512-QLBk68qpXbdBrwtTERmc+Mb4z7ZOXKZf1KCj+OANlO2dsajnxRvc6Sf2RvwT+kzq5ic9Czk0N+UrGudPG1HR7Q==}
'@leafer-ui/interaction@2.2.9':
resolution: {integrity: sha512-MPtScWDfD0rEiXaJ5qdIfJPNupOT+g3ieQywG1Lu6RVfmf+Vwn3RaxKvGk/R94rEJKSL9FQSWoQLuSXGBh/6Og==}
'@leafer-ui/interface@2.2.9':
resolution: {integrity: sha512-AWnRhnQOaMk8kNOJwzymuZpNb4b28CsMUxvr4pVjGwmlXWG1qINJrlWYWrXbV3R44+zhK0VIJ8T7szsoO2qj7Q==}
'@leafer-ui/paint@2.2.9':
resolution: {integrity: sha512-/scOodHPSs3hJo73Scvk556ntXn9dJI8/7GlaWRMbprruvB+ujc/vyWUc5DInOqikxxAQb9eEfnKX3GoFUGiSA==}
'@leafer-ui/partner@2.2.9':
resolution: {integrity: sha512-Z88858CvaHmg0w0oyESHaOCeHyPUScjNSi6Z8sadKU6QV3gYrNxZ85eWfv2mtf+h1D6TyEe3SQCTQc6xdnV27A==}
'@leafer-ui/render@2.2.9':
resolution: {integrity: sha512-hi15h0nGHQpmSVxBY1ReLUD9vbHOnZYtRJsvaBOlZZEHwvsvRWWTVlwGrwMpGpbDWdy5FcwHmPp4GezaPa18QQ==}
'@leafer-ui/text@2.2.9':
resolution: {integrity: sha512-pKUUhxnkrdM+5ul6xI7nM9yr9jwj9AuIu18oVKIba3/gekrZRWBanpCeCwMR1IaQa5X97MI3bd/eVIZ892FX5g==}
'@leafer-ui/web@2.2.9':
resolution: {integrity: sha512-0QgYEK3wuUFWHz0BM6HPMboh0uiRR5fRLqrPEopO+ofJLygWi0v6FIjU3OE4pOsHRdqrbBIuhOioRmIasBBgmg==}
'@leafer/canvas-web@2.2.9':
resolution: {integrity: sha512-kj58yyHdqXq7VnZm18aYzXBTX8HrGooPOunBsQwbEnUn8bVe4JtkNaUgAWIjaDIJT0TuMwQ5kH/9E+6hpwHEPA==}
'@leafer/canvas@2.2.9':
resolution: {integrity: sha512-bWSqq2ty/khi/qDVK7UCGIZOcmsEycAYPCZkXZFWOxXXCmRxI86CA5SXBJa5UFLeAXKT9I30E+UiDc3+jAONNg==}
'@leafer/core@2.2.9':
resolution: {integrity: sha512-zXQv21ydpeELHBRj8fF770AQj3Ma0Dz81QfX5ERjZUQ2C1LcQCXSWHbc0fQwKeKy+PS+4FZDn8VRZSlM/FC2Ww==}
'@leafer/data@2.2.9':
resolution: {integrity: sha512-BcCRDYH2+jYqdWv8saQXZZ4X80GlBI6H51bTR1FqdRHXTuGCaF3QMWFhyTyb7/+CTtvMU6Isk0qUaEXaSLEZLw==}
'@leafer/debug@2.2.9':
resolution: {integrity: sha512-YQGZcCCDZ888JvidE/HpN9eqw4KxV5oVKQVTSkWAaWcARt9IX8RU4QTyF+JC4P8OXwstOZP0Y6a0IqMzAijUig==}
'@leafer/decorator@2.2.9':
resolution: {integrity: sha512-reA3NafAm0PSYkSG44QuX8sYXOf/4hdlR3IfhpHwovNf+g3M/R1ibQ+2kROhRe28APUvpaN5N/0tzj5zeOFMqA==}
'@leafer/display-module@2.2.9':
resolution: {integrity: sha512-aBL/ciSFgYWKRAd1jmPq3qkxW5vn8Njp61li4pO4jwy6pQvbznT0ENX145cp+nIzfp8lxbYfiY79E2SFB7sLTA==}
'@leafer/display@2.2.9':
resolution: {integrity: sha512-FiZ0Vq9G3achrldVHDuiVa9Phr0AWhyWRJhfXuDN829CVbrS7liJYysJ+Y//SGdq3sI7igyKHJbsT1V6OAk2EQ==}
'@leafer/event@2.2.9':
resolution: {integrity: sha512-jbjcaSxG6ifFyaQtoRFVfhvcWuChCCxGQJSRqVQvXLDEZfQHACdjrZNWkfgRWkMxsNRGTGo4r+A/0Cjgz6eiRw==}
'@leafer/file@2.2.9':
resolution: {integrity: sha512-6kZLM4mmDa3qNMrG2A8MxiVVgHJo+5Yjd/yy6yNEweDoKg9QzNacf8mrILdj2CUjuZ3IPBoRIRGFASZpEmUycg==}
'@leafer/helper@2.2.9':
resolution: {integrity: sha512-OVMB9k3IP5w2ARrKIzrDhrc2PTjFppIKtZpvLIFf24tH6Itq0/Hn6J8pa7gKEzdltpgOJD5lYT7IQNtVbJQVXg==}
'@leafer/image-web@2.2.9':
resolution: {integrity: sha512-ctMDVYoDi16rniIOffrqXGfOR1qWJe+lm185l+mjsqfcQo//QZ9cml4m+FfChnzwUqB6yuJvhaP3idg59ARFVA==}
'@leafer/image@2.2.9':
resolution: {integrity: sha512-hnl7pLM1kOVUo/qdR9Sonvt40ywt9796JDyinV3BQkVh1lfiWQXJY2wb/iQXCUPyDvhiD0cSC+sDR3x9VIFyCw==}
'@leafer/interface@2.2.9':
resolution: {integrity: sha512-XmwjsgV2u1AeAFdjeKOSAlo4zj1ascUKnlEhi0SGL+01iBomDeORjcNm50AoFMZRxY0SqQSIjvDWe6CdxrDcNw==}
'@leafer/layout@2.2.9':
resolution: {integrity: sha512-y/22wyyRVR8OQf9tQe+7Koqmmw5fUZkxUxJKzaeDUf1R6xauK6FtDaPDiDJlxBEgIYXFvVpUfQNEzFdyyMS2jw==}
'@leafer/layouter@2.2.9':
resolution: {integrity: sha512-JFHCtoUI4ELBTwVow6Nd+cxJxvR2H0zg8lq/1lTqgdppeOD1PRU7bP7L1JN5QrmtJ/yxDUi3iGeYg7z9VnWS9w==}
'@leafer/list@2.2.9':
resolution: {integrity: sha512-B9H7ydvE5I7jhdnnML8kox0q+r5/uwF/t0xNHZc3FzvynpHtvpAYTpuzE5zjK0E9C2UkfQi0T9DuUr9CsguMCA==}
'@leafer/math@2.2.9':
resolution: {integrity: sha512-kkybyaR+76e0dsgAxk0Mpeb1coMNM3/mR/JvDBNzcF2iFCemYcOFCiI8+uhJyqUKLfYzLjD7CW5gFa0NmtbgDQ==}
'@leafer/partner@2.2.9':
resolution: {integrity: sha512-W2CcbVPoGomvVQNve1CWdhq1B5zOmUKUJqKssO8eBZF6cIXtFW9Ab1CxZR1CcLCi+OelDUvcYFhjU3keLCnNdg==}
'@leafer/path@2.2.9':
resolution: {integrity: sha512-GP3IXKNUkUGZuZI/V2IkcJc+uU6Rs2mAvu1xtgyh73iRbkvdlk3T/MbxgOkGFmQ5cO7KKCiwizWRYyW5Z7Fb0g==}
'@leafer/platform@2.2.9':
resolution: {integrity: sha512-wSDawGKgam20Mrl10VWP4UcYrrrWU7wlKi4/T/0j0ljvu29ZSKcLA2o4//WYJxVcuDEqSwMl3EXdpkl7pYdHlg==}
'@leafer/renderer@2.2.9':
resolution: {integrity: sha512-CxCT5p/VMJDlbX1rBpSoc9gKrFmOIRygqMT6u1vYZynJY4XM2aqWMCItBGEUzCutRaGRRg8fK3I0J4Hux2qhCA==}
'@leafer/selector@2.2.9':
resolution: {integrity: sha512-YeMxiIlCfFNbCkNGwx7mIccwHlBQTROw9T/zXwhd34GZ52ESVsqPETY9Ot5YuVcExJAM9mEjA6MTQuTEHYjNWw==}
'@leafer/task@2.2.9':
resolution: {integrity: sha512-NrbrofDssz8erxoOBZ3TQEQI9pl3tAPnPtqceOtOpCFC3cGDr8HJWb7F4ZZNRhN0qutPGj+BevOWC9BYoHgMlg==}
'@leafer/watcher@2.2.9':
resolution: {integrity: sha512-v6nRfHh3+2DZs94MItG2i+WrLMHNOqnWD7cPQ8cxo1MVX/BghjWEE8LGy7hOcLIYIppDZ9ZHeVNEhtNTCRffug==}
'@leafer/web-core@2.2.9':
resolution: {integrity: sha512-d/m46qw6zjAjf+X/MBx4q0ztd5SxSs/mYORdaNjiOZRg6qFXsBGtUUSUyN5dIprCjKFkdVge4ow3yLxv2RvdMw==}
'@napi-rs/wasm-runtime@1.1.6':
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
peerDependencies:
@ -1401,6 +1566,9 @@ packages:
engines: {node: '>=6'}
hasBin: true
leafer-ui@2.2.9:
resolution: {integrity: sha512-z6GklKuOMV8gMCAxWvvRUDXOZdSLzOkBzJBFEiDMcjVuTMpceQD/HPsV+Sq7Dk8I1xqY6OcF+3yTLEWYMOauxA==}
lightningcss-android-arm64@1.33.0:
resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
engines: {node: '>= 12.0.0'}
@ -2333,6 +2501,293 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@leafer-in/interface@2.2.9(@leafer-ui/interface@2.2.9)(@leafer/interface@2.2.9)':
dependencies:
'@leafer-ui/interface': 2.2.9
'@leafer/interface': 2.2.9
'@leafer-ui/app@2.2.9':
dependencies:
'@leafer-ui/data': 2.2.9
'@leafer-ui/display': 2.2.9
'@leafer/core': 2.2.9
'@leafer-ui/bounds@2.2.9':
dependencies:
'@leafer/core': 2.2.9
'@leafer-ui/color@2.2.9':
dependencies:
'@leafer-ui/draw': 2.2.9
'@leafer/core': 2.2.9
'@leafer-ui/core@2.2.9':
dependencies:
'@leafer-ui/app': 2.2.9
'@leafer-ui/draw': 2.2.9
'@leafer-ui/event': 2.2.9
'@leafer-ui/hit': 2.2.9
'@leafer-ui/interaction': 2.2.9
'@leafer-ui/data@2.2.9':
dependencies:
'@leafer-ui/external': 2.2.9
'@leafer/core': 2.2.9
'@leafer-ui/decorator@2.2.9':
dependencies:
'@leafer/core': 2.2.9
'@leafer-ui/display-module@2.2.9':
dependencies:
'@leafer-ui/bounds': 2.2.9
'@leafer-ui/data': 2.2.9
'@leafer-ui/render': 2.2.9
'@leafer-ui/display@2.2.9':
dependencies:
'@leafer-ui/data': 2.2.9
'@leafer-ui/decorator': 2.2.9
'@leafer-ui/display-module': 2.2.9
'@leafer-ui/external': 2.2.9
'@leafer/core': 2.2.9
'@leafer-ui/draw@2.2.9':
dependencies:
'@leafer-ui/decorator': 2.2.9
'@leafer-ui/display': 2.2.9
'@leafer-ui/display-module': 2.2.9
'@leafer-ui/external': 2.2.9
'@leafer/core': 2.2.9
'@leafer-ui/effect@2.2.9':
dependencies:
'@leafer-ui/draw': 2.2.9
'@leafer/core': 2.2.9
'@leafer-ui/event@2.2.9':
dependencies:
'@leafer/core': 2.2.9
'@leafer-ui/external@2.2.9':
dependencies:
'@leafer/core': 2.2.9
'@leafer-ui/hit@2.2.9':
dependencies:
'@leafer-ui/draw': 2.2.9
'@leafer/core': 2.2.9
'@leafer-ui/interaction-web@2.2.9':
dependencies:
'@leafer-ui/core': 2.2.9
'@leafer/core': 2.2.9
'@leafer-ui/interaction@2.2.9':
dependencies:
'@leafer-ui/draw': 2.2.9
'@leafer-ui/event': 2.2.9
'@leafer/core': 2.2.9
'@leafer-ui/interface@2.2.9':
dependencies:
'@leafer/interface': 2.2.9
'@leafer-ui/paint@2.2.9':
dependencies:
'@leafer-ui/draw': 2.2.9
'@leafer/core': 2.2.9
'@leafer-ui/partner@2.2.9':
dependencies:
'@leafer-ui/color': 2.2.9
'@leafer-ui/draw': 2.2.9
'@leafer-ui/effect': 2.2.9
'@leafer-ui/paint': 2.2.9
'@leafer-ui/text': 2.2.9
'@leafer-ui/render@2.2.9':
dependencies:
'@leafer-ui/external': 2.2.9
'@leafer-ui/text@2.2.9':
dependencies:
'@leafer/core': 2.2.9
'@leafer-ui/web@2.2.9':
dependencies:
'@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/interaction-web': 2.2.9
'@leafer-ui/interface': 2.2.9
'@leafer-ui/partner': 2.2.9
'@leafer/interface': 2.2.9
'@leafer/partner': 2.2.9
'@leafer/web-core': 2.2.9
'@leafer/canvas-web@2.2.9':
dependencies:
'@leafer/core': 2.2.9
'@leafer/canvas@2.2.9':
dependencies:
'@leafer/data': 2.2.9
'@leafer/debug': 2.2.9
'@leafer/file': 2.2.9
'@leafer/list': 2.2.9
'@leafer/math': 2.2.9
'@leafer/path': 2.2.9
'@leafer/platform': 2.2.9
'@leafer/core@2.2.9':
dependencies:
'@leafer/canvas': 2.2.9
'@leafer/data': 2.2.9
'@leafer/debug': 2.2.9
'@leafer/decorator': 2.2.9
'@leafer/display': 2.2.9
'@leafer/display-module': 2.2.9
'@leafer/event': 2.2.9
'@leafer/file': 2.2.9
'@leafer/helper': 2.2.9
'@leafer/image': 2.2.9
'@leafer/layout': 2.2.9
'@leafer/list': 2.2.9
'@leafer/math': 2.2.9
'@leafer/path': 2.2.9
'@leafer/platform': 2.2.9
'@leafer/task': 2.2.9
'@leafer/data@2.2.9': {}
'@leafer/debug@2.2.9':
dependencies:
'@leafer/data': 2.2.9
'@leafer/math': 2.2.9
'@leafer/decorator@2.2.9':
dependencies:
'@leafer/data': 2.2.9
'@leafer/debug': 2.2.9
'@leafer/platform': 2.2.9
'@leafer/display-module@2.2.9':
dependencies:
'@leafer/data': 2.2.9
'@leafer/debug': 2.2.9
'@leafer/event': 2.2.9
'@leafer/helper': 2.2.9
'@leafer/math': 2.2.9
'@leafer/display@2.2.9':
dependencies:
'@leafer/data': 2.2.9
'@leafer/debug': 2.2.9
'@leafer/decorator': 2.2.9
'@leafer/display-module': 2.2.9
'@leafer/event': 2.2.9
'@leafer/helper': 2.2.9
'@leafer/image': 2.2.9
'@leafer/layout': 2.2.9
'@leafer/math': 2.2.9
'@leafer/platform': 2.2.9
'@leafer/event@2.2.9':
dependencies:
'@leafer/data': 2.2.9
'@leafer/decorator': 2.2.9
'@leafer/math': 2.2.9
'@leafer/platform': 2.2.9
'@leafer/file@2.2.9':
dependencies:
'@leafer/data': 2.2.9
'@leafer/debug': 2.2.9
'@leafer/platform': 2.2.9
'@leafer/task': 2.2.9
'@leafer/helper@2.2.9':
dependencies:
'@leafer/data': 2.2.9
'@leafer/math': 2.2.9
'@leafer/platform': 2.2.9
'@leafer/image-web@2.2.9':
dependencies:
'@leafer/core': 2.2.9
'@leafer/image@2.2.9':
dependencies:
'@leafer/data': 2.2.9
'@leafer/file': 2.2.9
'@leafer/math': 2.2.9
'@leafer/platform': 2.2.9
'@leafer/task': 2.2.9
'@leafer/interface@2.2.9': {}
'@leafer/layout@2.2.9':
dependencies:
'@leafer/data': 2.2.9
'@leafer/helper': 2.2.9
'@leafer/math': 2.2.9
'@leafer/platform': 2.2.9
'@leafer/layouter@2.2.9':
dependencies:
'@leafer/core': 2.2.9
'@leafer/list@2.2.9': {}
'@leafer/math@2.2.9': {}
'@leafer/partner@2.2.9':
dependencies:
'@leafer/core': 2.2.9
'@leafer/layouter': 2.2.9
'@leafer/renderer': 2.2.9
'@leafer/selector': 2.2.9
'@leafer/watcher': 2.2.9
'@leafer/path@2.2.9':
dependencies:
'@leafer/data': 2.2.9
'@leafer/debug': 2.2.9
'@leafer/math': 2.2.9
'@leafer/platform@2.2.9':
dependencies:
'@leafer/data': 2.2.9
'@leafer/debug': 2.2.9
'@leafer/renderer@2.2.9':
dependencies:
'@leafer/core': 2.2.9
'@leafer/selector@2.2.9':
dependencies:
'@leafer/core': 2.2.9
'@leafer/task@2.2.9':
dependencies:
'@leafer/debug': 2.2.9
'@leafer/math': 2.2.9
'@leafer/watcher@2.2.9':
dependencies:
'@leafer/data': 2.2.9
'@leafer/event': 2.2.9
'@leafer/list': 2.2.9
'@leafer/web-core@2.2.9':
dependencies:
'@leafer/canvas-web': 2.2.9
'@leafer/core': 2.2.9
'@leafer/image-web': 2.2.9
'@leafer/interface': 2.2.9
'@leafer/partner': 2.2.9
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
@ -3181,6 +3636,19 @@ snapshots:
json5@2.2.3: {}
leafer-ui@2.2.9:
dependencies:
'@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/interaction-web': 2.2.9
'@leafer-ui/interface': 2.2.9
'@leafer-ui/partner': 2.2.9
'@leafer-ui/web': 2.2.9
'@leafer/core': 2.2.9
'@leafer/interface': 2.2.9
'@leafer/partner': 2.2.9
lightningcss-android-arm64@1.33.0:
optional: true

View File

@ -1 +1,3 @@
packages:
- packages/*
approveBuilds: puppeteer

File diff suppressed because it is too large Load Diff