From 06935217167950b344e57849f08245b9557a9aa8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=B4=AE=E7=94=9F=EF=BC=88=E5=AD=90=E8=99=9A=EF=BC=89?=
<2234839456@qq.com>
Date: Sat, 15 Aug 2026 13:07:32 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20SDK=20=E6=8A=BD=E5=8F=96=E4=B8=BA=20web?=
=?UTF-8?q?font-sdk=20npm=20=E5=8C=85=20+=20leafer-x-webfont=20=E6=8F=92?=
=?UTF-8?q?=E4=BB=B6=EF=BC=88=E6=B5=8F=E8=A7=88=E5=99=A8=E9=AA=8C=E8=AF=81?=
=?UTF-8?q?=E9=80=9A=E8=BF=87=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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/:商业化思路一/二评估 + 主动拓客文档
---
.gitignore | 6 +-
backend/app.ts | 13 +-
packages/leafer-x-webfont/README.md | 79 ++
packages/leafer-x-webfont/demo/index.html | 144 +++
packages/leafer-x-webfont/package.json | 47 +
packages/leafer-x-webfont/src/index.ts | 303 ++++++
packages/leafer-x-webfont/tsconfig.json | 17 +
packages/leafer-x-webfont/tsdown.config.ts | 10 +
packages/webfont-sdk/package.json | 39 +
packages/webfont-sdk/scripts/sync-public.mjs | 43 +
packages/webfont-sdk/src/css-mode.ts | 327 ++++++
packages/webfont-sdk/src/engine.ts | 211 ++++
packages/webfont-sdk/src/fontface-mode.ts | 122 +++
packages/webfont-sdk/src/iife.ts | 19 +
packages/webfont-sdk/src/index.ts | 29 +
packages/webfont-sdk/tsconfig.json | 17 +
packages/webfont-sdk/tsdown.config.ts | 23 +
pnpm-lock.yaml | 468 +++++++++
pnpm-workspace.yaml | 2 +
public/webfont-sdk.js | 985 +++++++++----------
20 files changed, 2382 insertions(+), 522 deletions(-)
create mode 100644 packages/leafer-x-webfont/README.md
create mode 100644 packages/leafer-x-webfont/demo/index.html
create mode 100644 packages/leafer-x-webfont/package.json
create mode 100644 packages/leafer-x-webfont/src/index.ts
create mode 100644 packages/leafer-x-webfont/tsconfig.json
create mode 100644 packages/leafer-x-webfont/tsdown.config.ts
create mode 100644 packages/webfont-sdk/package.json
create mode 100644 packages/webfont-sdk/scripts/sync-public.mjs
create mode 100644 packages/webfont-sdk/src/css-mode.ts
create mode 100644 packages/webfont-sdk/src/engine.ts
create mode 100644 packages/webfont-sdk/src/fontface-mode.ts
create mode 100644 packages/webfont-sdk/src/iife.ts
create mode 100644 packages/webfont-sdk/src/index.ts
create mode 100644 packages/webfont-sdk/tsconfig.json
create mode 100644 packages/webfont-sdk/tsdown.config.ts
diff --git a/.gitignore b/.gitignore
index f6ed60d..e4e90fc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,4 +35,8 @@ dist_backend_node
verify_font_baseline
benchmark_results
-.claude
\ No newline at end of file
+.claude
+# packages 构建产物与 demo vendor(可重建/可下载,不入库)
+packages/*/dist
+packages/*/dist-iife
+packages/leafer-x-webfont/demo/vendor
diff --git a/backend/app.ts b/backend/app.ts
index 25ac93d..84b1043 100644
--- a/backend/app.ts
+++ b/backend/app.ts
@@ -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.js(tsx 开发 / tsdown define "node" 构建)走 node 适配器 */
+ await import("./server/node");
+}
import { mimeTypes } from "./server/mime_type";
import type { cMiddleware } from "./server/req_res";
diff --git a/packages/leafer-x-webfont/README.md b/packages/leafer-x-webfont/README.md
new file mode 100644
index 0000000..8594e8f
--- /dev/null
+++ b/packages/leafer-x-webfont/README.md
@@ -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
diff --git a/packages/leafer-x-webfont/demo/index.html b/packages/leafer-x-webfont/demo/index.html
new file mode 100644
index 0000000..81e8e78
--- /dev/null
+++ b/packages/leafer-x-webfont/demo/index.html
@@ -0,0 +1,144 @@
+
+
+
+
+
+leafer-x-webfont —— LeaferJS 海报中文字体 Demo
+
+
+
+
+
leafer-x-webfont
+
+ LeaferJS 中文字体插件 —— 画布里的 Text 用什么字体,就只加载那几个字的子集(6 字 ≈ 6KB,而非 16MB)。
+ 服务端:webfont.shenzilong.cn
+ / GitHub
+
+
+
+
+
+
+
+
+ 初始化…
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/leafer-x-webfont/package.json b/packages/leafer-x-webfont/package.json
new file mode 100644
index 0000000..fdeba52
--- /dev/null
+++ b/packages/leafer-x-webfont/package.json
@@ -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:*"
+ }
+}
diff --git a/packages/leafer-x-webfont/src/index.ts b/packages/leafer-x-webfont/src/index.ts
new file mode 100644
index 0000000..f903903
--- /dev/null
+++ b/packages/leafer-x-webfont/src/index.ts
@@ -0,0 +1,303 @@
+/**
+ * leafer-x-webfont —— LeaferJS 字体按需加载插件
+ *
+ * 原理:Leafer 的 Text 元素渲染时直接拼 `canvas.font = fontFamily`,
+ * 依赖浏览器字体系统。大字体(尤其中文字体 10MB+)无法直接注册。
+ * 本插件订阅画布内 Text 的 text / fontFamily 变化,把「实际用到的字符」
+ * 交给 webfont-sdk(FontFace 模式)增量加载: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 等后缀)。
+ * 默认 true:canvas 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> &
+ IWebFontPluginConfig
+
+ /** SDK FontFace 模式(增量引擎在这层:去重/并发/失败记忆) */
+ private mode: WebFontFontFaceMode
+ /** family -> 增量加载器(由 SDK 管理) */
+ private loaders = new Map()
+ /** 防抖定时器 */
+ private debounceTimer: ReturnType | 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 }>()
+
+ 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 {
+ 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(map: Map, 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 {
+ let s = ''
+ for (const c of set) s += c
+ return s
+}
diff --git a/packages/leafer-x-webfont/tsconfig.json b/packages/leafer-x-webfont/tsconfig.json
new file mode 100644
index 0000000..8ebc547
--- /dev/null
+++ b/packages/leafer-x-webfont/tsconfig.json
@@ -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"]
+}
diff --git a/packages/leafer-x-webfont/tsdown.config.ts b/packages/leafer-x-webfont/tsdown.config.ts
new file mode 100644
index 0000000..deab19c
--- /dev/null
+++ b/packages/leafer-x-webfont/tsdown.config.ts
@@ -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',
+})
diff --git a/packages/webfont-sdk/package.json b/packages/webfont-sdk/package.json
new file mode 100644
index 0000000..bba1c9e
--- /dev/null
+++ b/packages/webfont-sdk/package.json
@@ -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"
+ }
+}
diff --git a/packages/webfont-sdk/scripts/sync-public.mjs b/packages/webfont-sdk/scripts/sync-public.mjs
new file mode 100644
index 0000000..cb3dd56
--- /dev/null
+++ b/packages/webfont-sdk/scripts/sync-public.mjs
@@ -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 模式(WebFont):loadFont(轮询)/ observeFont(DOM 事件)/ loadText(手动传文本)
+ * - FontFace 模式(WebFontCanvas):Canvas/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})`)
diff --git a/packages/webfont-sdk/src/css-mode.ts b/packages/webfont-sdk/src/css-mode.ts
new file mode 100644
index 0000000..7e13796
--- /dev/null
+++ b/packages/webfont-sdk/src/css-mode.ts
@@ -0,0 +1,327 @@
+/**
+ * CSS 模式 —— 注入 @font-face + unicode-range 样式(DOM 场景)
+ *
+ * 与原 public/webfont-sdk.js 行为一致:
+ * - 片段就绪时注入