feat: 离线裁剪页面 + 增量预览 + DEV预览页 + tempUploads统计 + SDK缓存破坏 + UI优化

- 新增离线裁剪页面 /offline-subset(纯浏览器端字体裁剪)
- SDK subsetProvider 抽象,离线页面注入本地裁剪 provider
- 预览用增量裁剪(SDK自动去重),下载用全量裁剪
- 拖放区域支持点击和拖拽选择字体文件
- 新增 DEV 预览页 /__dev(仅 dev 模式路由可达)
- FontDebugPreview 从首页提取为独立页面
- tempUploads 统计字段(全链路+持久化)
- webfont-sdk.js 构建时戳缓存破坏
- 首页导航栏新增离线裁剪入口
- UploadSection 离线裁剪入口合并到游客上传卡片
- SDK 文档代码示例补充 HTML 用法
- 导航栏窄屏换行适配
This commit is contained in:
崮生(子虚) 2026-08-03 15:18:01 +08:00
parent 5a7c6a9913
commit 09f434bbcf
19 changed files with 1093 additions and 47 deletions

View File

@ -10,6 +10,7 @@ export async function handleStats(req: Request, _res: Response) {
subsetRequests: stats.subsetRequests,
subsetCacheHits: stats.subsetCacheHits,
totalChars: stats.totalChars,
tempUploads: stats.tempUploads,
subsetCacheEntries: subsetCache.size,
fontBufferCacheEntries: fontBufferCache.size,
}),

View File

@ -1,4 +1,4 @@
import { jsonResponse, parseUrl } from "../shared";
import { jsonResponse, parseUrl, stats, markStatsDirty } from "../shared";
import { parseMultipart } from "../multipart";
import { handleTempUpload, handleAdminUpload } from "../upload";
@ -43,5 +43,9 @@ export async function handleUpload(req: Request, _res: Response) {
result = await handleTempUpload({ data: file.data, filename: file.filename });
console.log("[upload] temp result:", result);
if (result.success) {
stats.tempUploads++;
markStatsDirty();
}
return { req, res: jsonResponse(result, result.success ? 200 : 400) };
}

View File

@ -28,6 +28,8 @@ export const stats = {
subsetCacheHits: 0,
/** 累计裁剪文字字符数(持久化累计) */
totalChars: 0,
/** 临时文件上传次数(持久化累计) */
tempUploads: 0,
};
/**
@ -43,6 +45,7 @@ export async function initStats(): Promise<void> {
stats.subsetRequests = persisted.subsetRequests;
stats.subsetCacheHits = persisted.subsetCacheHits;
stats.totalChars = persisted.totalChars;
stats.tempUploads = persisted.tempUploads;
}
/** 取出需要持久化的累计计数字段 */
@ -52,6 +55,7 @@ export function snapshotStats(): PersistedStats {
subsetRequests: stats.subsetRequests,
subsetCacheHits: stats.subsetCacheHits,
totalChars: stats.totalChars,
tempUploads: stats.tempUploads,
};
}

View File

@ -22,6 +22,8 @@ export interface PersistedStats {
subsetRequests: number;
subsetCacheHits: number;
totalChars: number;
/** 临时文件上传次数(持久化累计) */
tempUploads: number;
}
/** 落盘防抖间隔(毫秒):在请求热路径之外的最低频写盘,平衡丢失量与 IO */
@ -48,11 +50,12 @@ export async function loadPersistedStats(): Promise<PersistedStats & { startTime
subsetRequests: parsed.subsetRequests ?? 0,
subsetCacheHits: parsed.subsetCacheHits ?? 0,
totalChars: parsed.totalChars ?? 0,
tempUploads: parsed.tempUploads ?? 0,
startTime: Date.now(),
};
} catch {
/** 首次启动 / 文件缺失 / JSON 损坏:从 0 起步 */
return { totalRequests: 0, subsetRequests: 0, subsetCacheHits: 0, totalChars: 0, startTime: Date.now() };
return { totalRequests: 0, subsetRequests: 0, subsetCacheHits: 0, totalChars: 0, tempUploads: 0, startTime: Date.now() };
}
}

View File

@ -13,7 +13,7 @@
</head>
<body>
<div id="app"></div>
<script src="/webfont-sdk.js"></script>
<script src="/webfont-sdk.js?v=%BUILD_TIME%"></script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -33,6 +33,7 @@
"typescript": "^7.0.2",
"undici": "^8.8.0",
"vite": "^8.1.5",
"vite-plugin-commonjs": "^0.10.4",
"vite-plugin-pilot": "^1.0.35",
"vite-ssg": "^28.3.0",
"vitest": "^4.1.10",

161
pnpm-lock.yaml generated
View File

@ -48,6 +48,9 @@ importers:
vite:
specifier: ^8.1.5
version: 8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(terser@5.49.0)(yaml@2.9.0)
vite-plugin-commonjs:
specifier: ^0.10.4
version: 0.10.4
vite-plugin-pilot:
specifier: ^1.0.35
version: 1.0.35(@vue/compiler-dom@3.5.40)(magic-string@1.1.0)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(terser@5.49.0)(yaml@2.9.0))
@ -379,6 +382,18 @@ packages:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
'@nodelib/fs.stat@2.0.5':
resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
engines: {node: '>= 8'}
'@nodelib/fs.walk@1.2.8':
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@oxc-project/types@0.127.0':
resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==}
@ -1120,6 +1135,10 @@ packages:
birpc@2.9.0:
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@ -1240,6 +1259,9 @@ packages:
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
engines: {node: '>=20.19.0'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
es-module-lexer@2.3.1:
resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==}
@ -1265,6 +1287,13 @@ packages:
exsolve@1.1.1:
resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==}
fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@ -1274,6 +1303,10 @@ packages:
picomatch:
optional: true
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -1291,6 +1324,10 @@ packages:
resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==}
engines: {node: '>=20.20.0'}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
@ -1321,6 +1358,18 @@ packages:
resolution: {integrity: sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==}
engines: {node: ^22.18.0 || >=24.0.0}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-number@7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
@ -1454,6 +1503,14 @@ packages:
mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
@ -1503,6 +1560,10 @@ packages:
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@2.3.2:
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
picomatch@4.0.5:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'}
@ -1543,6 +1604,9 @@ packages:
quansync@1.0.0:
resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
readdirp@5.0.0:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
@ -1558,6 +1622,10 @@ packages:
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
reusify@1.1.0:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
rolldown-plugin-dts@0.27.13:
resolution: {integrity: sha512-DeVZJbbB0ajp5q6vABqC8ZCJzxftlxbiV60Bk96GFdQaysGVpgTTVjQu0lUt4Lb+aRCtejfOixtQKDRol7IuVQ==}
engines: {node: ^22.18.0 || >=24.11.0}
@ -1592,6 +1660,9 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
@ -1657,6 +1728,10 @@ packages:
resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==}
hasBin: true
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
tough-cookie@6.0.2:
resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==}
engines: {node: '>=16'}
@ -1785,6 +1860,12 @@ packages:
resolution: {integrity: sha512-WqkT8n3hqizuCu71W3bUzf5fjBmkbXcudsehe/NbxA8PgqoKnSOY5K0Ba2ckg1qaRaSpSz7as/n9K1R9JXjQKg==}
engines: {node: '>=18.12.0'}
vite-plugin-commonjs@0.10.4:
resolution: {integrity: sha512-eWQuvQKCcx0QYB5e5xfxBNjQKyrjEWZIR9UOkOV6JAgxVhtbZvCOF+FNC2ZijBJ3U3Px04ZMMyyMyFBVWIJ5+g==}
vite-plugin-dynamic-import@1.6.0:
resolution: {integrity: sha512-TM0sz70wfzTIo9YCxVFwS8OA9lNREsh+0vMHGSkWDTZ7bgd1Yjs5RV8EgB634l/91IsXJReg0xtmuQqP0mf+rg==}
vite-plugin-pilot@1.0.35:
resolution: {integrity: sha512-ft20yhPqCTHu/d+3+VZusPC5ONzaihDM11jx5cn1WjrNgZzqgmj86P+wsRkN+7lTgMmQWIC1dJ08DNCf1DHhXw==}
engines: {node: '>=18.0.0'}
@ -2273,6 +2354,18 @@ snapshots:
'@tybys/wasm-util': 0.10.3
optional: true
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
run-parallel: 1.2.0
'@nodelib/fs.stat@2.0.5': {}
'@nodelib/fs.walk@1.2.8':
dependencies:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1
'@oxc-project/types@0.127.0':
optional: true
@ -2803,6 +2896,10 @@ snapshots:
birpc@2.9.0: {}
braces@3.0.3:
dependencies:
fill-range: 7.1.1
buffer-from@1.1.2: {}
cac@6.7.14: {}
@ -2896,6 +2993,8 @@ snapshots:
entities@8.0.0: {}
es-module-lexer@1.7.0: {}
es-module-lexer@2.3.1: {}
esbuild@0.27.7:
@ -2940,10 +3039,26 @@ snapshots:
exsolve@1.1.1: {}
fast-glob@3.3.3:
dependencies:
'@nodelib/fs.stat': 2.0.5
'@nodelib/fs.walk': 1.2.8
glob-parent: 5.1.2
merge2: 1.4.1
micromatch: 4.0.8
fastq@1.20.1:
dependencies:
reusify: 1.1.0
fdir@6.5.0(picomatch@4.0.5):
optionalDependencies:
picomatch: 4.0.5
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
fsevents@2.3.3:
optional: true
@ -2955,6 +3070,10 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
hookable@5.5.3: {}
hookable@6.1.1: {}
@ -2995,6 +3114,14 @@ snapshots:
import-without-cache@0.4.0: {}
is-extglob@2.1.1: {}
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
is-number@7.0.0: {}
is-potential-custom-element-name@1.0.1: {}
jsdom@28.1.0:
@ -3132,6 +3259,13 @@ snapshots:
mdn-data@2.27.1: {}
merge2@1.4.1: {}
micromatch@4.0.8:
dependencies:
braces: 3.0.3
picomatch: 2.3.2
mitt@3.0.1: {}
mlly@1.8.2:
@ -3178,6 +3312,8 @@ snapshots:
picocolors@1.1.1: {}
picomatch@2.3.2: {}
picomatch@4.0.5: {}
pinyin-pro@3.28.2: {}
@ -3236,6 +3372,8 @@ snapshots:
quansync@1.0.0: {}
queue-microtask@1.2.3: {}
readdirp@5.0.0: {}
relateurl@0.2.7: {}
@ -3244,6 +3382,8 @@ snapshots:
resolve-pkg-maps@1.0.0: {}
reusify@1.1.0: {}
rolldown-plugin-dts@0.27.13(rolldown@1.2.0)(typescript@7.0.2):
dependencies:
dts-resolver: 3.0.0
@ -3322,6 +3462,10 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.2.0
'@rolldown/binding-win32-x64-msvc': 1.2.0
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
@ -3379,6 +3523,10 @@ snapshots:
dependencies:
tldts-core: 7.4.9
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
tough-cookie@6.0.2:
dependencies:
tldts: 7.4.9
@ -3481,6 +3629,19 @@ snapshots:
verkit@0.1.2: {}
vite-plugin-commonjs@0.10.4:
dependencies:
acorn: 8.18.0
magic-string: 0.30.21
vite-plugin-dynamic-import: 1.6.0
vite-plugin-dynamic-import@1.6.0:
dependencies:
acorn: 8.18.0
es-module-lexer: 1.7.0
fast-glob: 3.3.3
magic-string: 0.30.21
vite-plugin-pilot@1.0.35(@vue/compiler-dom@3.5.40)(magic-string@1.1.0)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(terser@5.49.0)(yaml@2.9.0)):
dependencies:
vite: 8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(terser@5.49.0)(yaml@2.9.0)

View File

@ -46,6 +46,29 @@ var WebFont = (function () {
/** 待执行的字体加载任务队列FIFO */
var fontLoadQueue = [];
/**
* 子集化提供者 决定如何获取增量字体片段
*
* 默认走 HTTP API在线模式
* 离线模式下通过 setSubsetProvider 注入本地裁剪函数
* SDK 负责增量管理去重unicode-rangeprovider 只管"给定文本→返回字体URL"
*
* provider 签名async function(fontName, text, outType) -> { url: string, format: string }
* - url字体文件的 URLHTTP URL blob: URL
* - format字体格式"woff2" / "truetype"
*/
var subsetProvider = null;
/**
* 默认在线 provider 构造 HTTP API URL
*/
function httpProvider(baseUrl) {
return function (fontName, text, outType) {
var url = baseUrl + "/api?font=" + encodeURIComponent(fontName) + "&text=" + encodeURIComponent(text) + "&outType=" + outType;
return Promise.resolve({ url: url, format: outType === "woff2" ? "woff2" : "truetype" });
};
}
/**
* 设置最大并发请求数
*
@ -160,33 +183,40 @@ var WebFont = (function () {
var text = newChars.join("");
var outType = loader.outType || "woff2";
var url = baseUrl + "/api?font=" + encodeURIComponent(fontName) + "&text=" + encodeURIComponent(text) + "&outType=" + outType;
var formatStr = outType === "woff2" ? "woff2" : "truetype";
var unicodeRanges = newChars
.map(function (c) { return "U+" + c.codePointAt(0).toString(16).padStart(4, "0"); })
.join(", ");
var style = document.createElement("style");
style.textContent =
'@font-face {\n' +
' font-family: "' + family + '";\n' +
' src: url("' + url + '") format("' + formatStr + '");\n' +
' unicode-range: ' + unicodeRanges + ';\n' +
'}\n';
document.head.appendChild(style);
loader.injectedStyles.push(style);
/**
* 释放并发槽位的策略
*
* 优先用 FontFaceSet API 精确追踪字体加载完成
* 不可用时退化为 setTimeout3 秒兜底窗口覆盖绝大多数裁剪+传输时间
* 通过 subsetProvider 获取字体片段 URL
* 默认走 HTTP API离线模式下由本地裁剪 provider 提供 blob: URL
*/
if (document.fonts && document.fonts.load) {
document.fonts.load(outType === "woff2" ? "16px \"" + family + "\"" : "16px \"" + family + "\"").then(done, function () { done(); });
} else {
setTimeout(done, 3000);
}
var provider = subsetProvider || httpProvider(baseUrl);
provider(fontName, text, outType).then(function (result) {
var style = document.createElement("style");
style.textContent =
'@font-face {\n' +
' font-family: "' + family + '";\n' +
' src: url("' + result.url + '") format("' + result.format + '");\n' +
' unicode-range: ' + unicodeRanges + ';\n' +
'}\n';
document.head.appendChild(style);
loader.injectedStyles.push(style);
/**
* 释放并发槽位的策略
*
* 优先用 FontFaceSet API 精确追踪字体加载完成
* 不可用时退化为 setTimeout3 秒兜底窗口覆盖绝大多数裁剪+传输时间
*/
if (document.fonts && document.fonts.load) {
document.fonts.load("16px \"" + family + "\"").then(done, function () { done(); });
} else {
setTimeout(done, 3000);
}
}).catch(function () {
done();
});
});
}
@ -486,12 +516,25 @@ var WebFont = (function () {
}
}
/**
* 注入自定义子集化提供者离线裁剪等场景
*
* provider 签名async function(fontName, text, outType) -> { url: string, format: string }
* - 离线场景provider 调用本地裁剪函数生成 blob: URL 返回
* - null 恢复默认 HTTP 行为
*/
function setSubsetProvider(provider) {
subsetProvider = provider;
}
return {
loadFont: loadFont,
observeFont: observeFont,
loadText: loadText,
disposeAll: disposeAll,
/** 设置客户端最大并发字体请求数(默认 4 */
setMaxConcurrent: setMaxConcurrent
setMaxConcurrent: setMaxConcurrent,
/** 注入自定义子集化提供者(离线裁剪等场景) */
setSubsetProvider: setSubsetProvider
};
})();

View File

@ -27,6 +27,16 @@ onMounted(async () => {
}
});
/** 面板是否展开 */
const expanded = ref(false);
/** 所有字体的字体卡片是否展开(独立折叠) */
const expandedFonts = ref<Record<string, boolean>>({});
function toggleFont(name: string) {
expandedFonts.value[name] = !expandedFonts.value[name];
}
onUnmounted(() => {
for (const loader of loaders.values()) loader.dispose();
loaders.clear();
@ -40,23 +50,34 @@ function fontFamily(font: FontInfo, ot: string) {
<template>
<section style="margin-bottom: 28px; padding: 16px; border: 2px dashed #e6a700; border-radius: 8px; background: #fffdf5">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 12px">
<div
@click="expanded = !expanded"
style="display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none"
>
<span style="font-size: 13px; font-weight: 600; color: #e6a700">DEV 字体调试预览</span>
<span style="font-size: 11px; color: #aaa">所有字体的 woff2 / ttf 渲染效果</span>
<span style="margin-left: auto; font-size: 12px; color: #e6a700">{{ expanded ? '收起 ▲' : '展开 ▼' }}</span>
</div>
<div v-for="font in fonts" :key="font.name" style="margin-bottom: 12px; padding: 8px 12px; background: #fff; border: 1px solid #e8e8e8; border-radius: 6px">
<div style="font-size: 11px; color: #999; margin-bottom: 6px; display: flex; justify-content: space-between">
<span style="font-weight: 500; color: #555">{{ font.name }}</span>
<span style="color: #bbb">{{ font.dir }}</span>
</div>
<div v-for="ot in (['woff2', 'ttf'] as const)" :key="ot" style="margin-bottom: 4px; display: flex; align-items: baseline; gap: 8px">
<span style="font-size: 11px; color: #bbb; min-width: 40px; flex: none">{{ ot }}</span>
<div
:style="{ fontSize: '22px', lineHeight: '1.5', color: '#1a1a1a', minHeight: '36px', fontFamily: fontFamily(font, ot) }"
>
{{ PREVIEW_TEXT }}
<template v-if="expanded">
<div v-for="font in fonts" :key="font.name" style="margin-top: 12px; padding: 8px 12px; background: #fff; border: 1px solid #e8e8e8; border-radius: 6px">
<div @click="toggleFont(font.name)" style="font-size: 11px; color: #999; margin-bottom: 6px; display: flex; justify-content: space-between; cursor: pointer; user-select: none">
<span style="font-weight: 500; color: #555">{{ font.name }}</span>
<span style="display: flex; align-items: center; gap: 8px">
<span style="color: #bbb">{{ font.dir }}</span>
<span style="color: #e6a700">{{ expandedFonts[font.name] ? '▲' : '▼' }}</span>
</span>
</div>
<template v-if="expandedFonts[font.name]">
<div v-for="ot in (['woff2', 'ttf'] as const)" :key="ot" style="margin-bottom: 4px; display: flex; align-items: baseline; gap: 8px">
<span style="font-size: 11px; color: #bbb; min-width: 40px; flex: none">{{ ot }}</span>
<div
:style="{ fontSize: '22px', lineHeight: '1.5', color: '#1a1a1a', minHeight: '36px', fontFamily: fontFamily(font, ot) }"
>
{{ PREVIEW_TEXT }}
</div>
</div>
</template>
</div>
</div>
</template>
</section>
</template>

View File

@ -119,6 +119,7 @@ onUnmounted(() => {
<span><b style="color: #333">{{ t('requests') }}</b> {{ data.totalRequests }} {{ t('times') }}</span>
<span><b style="color: #333">{{ t('subset') }}</b> {{ data.subsetRequests }} {{ t('times') }}</span>
<span><b style="color: #333">{{ t('chars') }}</b> {{ data.totalChars }} {{ t('charUnit') }}</span>
<span><b style="color: #333">上传</b> {{ data.tempUploads ?? 0 }} </span>
<span><b style="color: #333">{{ t('cacheHit') }}</b> {{ data.subsetRequests > 0 ? ((data.subsetCacheHits / data.subsetRequests) * 100).toFixed(1) : '0.0' }}%</span>
</div>
<!-- 底部进度条每轮询周期走一轮走完触发下次刷新 -->

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import { RouterLink } from "vue-router";
import { uploadFont, type UploadResult, type ServerConfig } from "./api";
import { t } from "./i18n";
@ -106,6 +107,19 @@ function onFileSelect(e: Event, target: ReturnType<typeof useUpload>) {
{{ temp.uploading.value ? '...' : t('upload') }}
</button>
</div>
<!-- 离线裁剪入口不想上传试试纯浏览器端方案 -->
<router-link
to="/offline-subset"
style="display: flex; align-items: center; gap: 6px; text-decoration: none; font-size: 12px; color: #1677ff; margin-top: 12px; padding-top: 10px; border-top: 1px dashed #eee; transition: color 0.2s"
onmouseover="this.style.color='#0958d9'"
onmouseout="this.style.color='#1677ff'"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink: 0">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
</svg>
不想上传服务器试试离线裁剪
</router-link>
</div>
<div v-if="config.adminUploadEnabled" style="padding: 16px; border: 1px solid #e8e8e8; border-radius: 8px; margin-bottom: 16px">

View File

@ -94,6 +94,8 @@ const messages = {
upload: "上传",
uploadSuccess: "上传成功",
uploadFailed: "上传失败",
/** 离线裁剪导航链接 */
offlineSubsetLink: "🔒 离线裁剪(字体不上传服务器)",
// StatsPanel.vue
serverStatus: "服务状态",
@ -169,6 +171,8 @@ const messages = {
upload: "Upload",
uploadSuccess: "Upload successful",
uploadFailed: "Upload failed",
/** 离线裁剪导航链接 */
offlineSubsetLink: "🔒 Offline Subsetting (no upload)",
// StatsPanel.vue
serverStatus: "Server Status",

View File

@ -13,6 +13,7 @@ import App from "./App.vue";
*/
const routes = [
{ path: "/", component: () => import("./pages/Home.vue") },
{ path: "/offline-subset", component: () => import("./pages/OfflineSubset.vue") },
{ path: "/demo", component: () => import("./pages/Demo.vue") },
{ path: "/fonts", component: () => import("./pages/FontList.vue") },
/**
@ -23,6 +24,12 @@ const routes = [
path: "/fonts/:slug",
component: () => import("./pages/FontDetail.vue"),
},
/**
* DEV dev
*/
...(import.meta.env.DEV
? [{ path: "/__dev", component: () => import("./pages/DevPreview.vue") }]
: []),
];
export const createApp = ViteSSG(

81
src/pages/DevPreview.vue Normal file
View File

@ -0,0 +1,81 @@
<script setup lang="ts">
/**
* 开发环境专用字体预览页 展示所有字体的 woff2 / ttf 渲染效果
*
* dev 模式下路由可达生产构建不包含此页面
*/
import { ref, onMounted, onUnmounted } from "vue";
import { usePageSeo } from "../useSeo";
usePageSeo({
title: "DEV 字体预览",
description: "开发环境专用字体渲染预览",
path: "/__dev",
priority: 0,
changefreq: "never",
});
import type { FontInfo } from "../api";
const PREVIEW_TEXT = `天地无极乾坤借法:"":"" 0123456789 ABCDEF`;
const fonts = ref<FontInfo[]>([]);
const loaders = new Map<string, { update: (text: string) => void; dispose: () => void }>();
onMounted(async () => {
const res = await fetch("/api/fonts");
const fontList: FontInfo[] = await res.json();
const usableFonts = fontList.filter((f) => /\.(ttf|otf)$/i.test(f.name));
fonts.value = usableFonts;
for (const font of usableFonts) {
const base = font.name.replace(/\.[^.]+$/, "");
for (const ot of ["woff2", "ttf"] as const) {
const family = `DevPreview_${base}_${ot}`;
const loader = (globalThis as any).WebFont?.loadText({
fontName: font.name,
text: PREVIEW_TEXT,
family,
outType: ot,
});
if (loader) loaders.set(`${font.name}|${ot}`, loader);
}
}
});
onUnmounted(() => {
for (const loader of loaders.values()) loader.dispose();
loaders.clear();
});
function fontFamily(font: FontInfo, ot: string) {
const base = font.name.replace(/\.[^.]+$/, "");
return `"DevPreview_${base}_${ot}", "楷体", KaiTi, STKaiti, serif`;
}
</script>
<template>
<div style="max-width: 960px; margin: 0 auto; padding: 32px 24px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #1a1a1a; line-height: 1.6">
<h1 style="font-size: 22px; font-weight: 600; margin: 0 0 4px 0">
DEV 字体预览
<span style="font-size: 13px; color: #999; font-weight: 400">所有字体的 woff2 / ttf 渲染效果</span>
</h1>
<router-link to="/" style="display: inline-block; margin-bottom: 20px; font-size: 13px; color: #1677ff; text-decoration: none; border: 1px solid #1677ff; border-radius: 6px; padding: 4px 12px">
返回首页
</router-link>
<div v-for="font in fonts" :key="font.name" style="margin-bottom: 12px; padding: 12px 16px; background: #fff; border: 1px solid #e8e8e8; border-radius: 6px">
<div style="font-size: 13px; color: #999; margin-bottom: 8px; display: flex; justify-content: space-between">
<span style="font-weight: 500; color: #555">{{ font.name }}</span>
<span style="color: #bbb">{{ font.dir }}</span>
</div>
<div v-for="ot in (['woff2', 'ttf'] as const)" :key="ot" style="margin-bottom: 4px; display: flex; align-items: baseline; gap: 8px">
<span style="font-size: 11px; color: #bbb; min-width: 40px; flex: none">{{ ot }}</span>
<div
:style="{ fontSize: '22px', lineHeight: '1.5', color: '#1a1a1a', minHeight: '36px', fontFamily: fontFamily(font, ot) }"
>
{{ PREVIEW_TEXT }}
</div>
</div>
</div>
</div>
</template>

View File

@ -24,6 +24,7 @@ usePageSeo({
});
const router = useRouter();
/** dev 模式标志 —— 控制 DEV 预览入口可见性 */
const isDev = import.meta.env.DEV;
/** 站点 origin —— SSG 构建期为空串,客户端挂载后修正 */
@ -34,7 +35,6 @@ const showDemoEntry = ref(false);
import UploadSection from "../UploadSection.vue";
import StatsPanel from "../StatsPanel.vue";
import SelectorRow from "../FontSelector.vue";
import FontDebugPreview from "../FontDebugPreview.vue";
import CodeBlock from "../components/CodeBlock.vue";
const text = ref("天地无极,乾坤借法");
@ -110,7 +110,7 @@ const basicUsageCode = computed(() => {
/** JS SDK 代码示例 */
const jsSdkCode = computed(() => {
return '<script src="' + origin.value + '/webfont-sdk.js"><\/script>\n<script>\n WebFont.loadFont({\n fontName: "\u5b57\u4f53\u6587\u4ef6\u540d.ttf",\n selector: ".my-element",\n family: "MyFont",\n interval: 1000,\n });\n<\/script>';
return '<script src="' + origin.value + '/webfont-sdk.js"><\/script>\n\n<h1 class="title">你的文字</h1>\n<p class="content">输入任意文字SDK 自动裁剪加载</p>\n\n<script>\n WebFont.loadFont({\n fontName: "字体文件名.ttf",\n selector: ".title, .content",\n family: "MyFont",\n interval: 1000,\n });\n<\/script>';
});
let textLoader: { update: (text: string) => void; dispose: () => void } | null = null;
@ -190,7 +190,7 @@ async function refreshFonts() {
<div style="max-width: 720px; margin: 0 auto; padding: 48px 24px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #1a1a1a; line-height: 1.6">
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px">
<h1 style="font-size: 22px; font-weight: 600; margin: 0 0 4px 0">Web Font</h1>
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: nowrap; flex-shrink: 0">
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: flex-end">
<button @click="toggleLocale" style="font-size: 13px; border: 1px solid #d9d9d9; border-radius: 6px; padding: 4px 12px; cursor: pointer; background: #fff; color: #333; white-space: nowrap; flex-shrink: 0; line-height: 1.6">
{{ locale === 'zh' ? 'EN' : '中' }}
</button>
@ -200,6 +200,16 @@ async function refreshFonts() {
<router-link to="/demo" style="font-size: 13px; color: #8b7355; text-decoration: none; border: 1px solid #8b7355; border-radius: 6px; padding: 4px 12px; display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; flex-shrink: 0">
{{ t('agentSkillDemo') }}
</router-link>
<router-link to="/offline-subset" style="font-size: 13px; color: #16a34a; text-decoration: none; border: 1px solid #16a34a; border-radius: 6px; padding: 4px 12px; display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; flex-shrink: 0">
离线裁剪
</router-link>
<router-link
v-if="isDev"
to="/__dev"
style="font-size: 13px; color: #e6a700; text-decoration: none; border: 1px dashed #e6a700; border-radius: 6px; padding: 4px 12px; display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; flex-shrink: 0"
>
DEV 预览
</router-link>
<a
href="https://github.com/2234839/web-font"
target="_blank"
@ -225,7 +235,10 @@ async function refreshFonts() {
</section>
<section style="margin-bottom: 28px">
<label style="display: block; font-size: 13px; color: #555; margin-bottom: 6px">{{ t('inputLabel') }}</label>
<label style="display: block; font-size: 13px; color: #555; margin-bottom: 6px">
{{ t('inputLabel') }}
<span style="font-size: 12px; color: #999; font-weight: 400"> 输入即时触发<a href="#sdk-doc" style="color: #1677ff; text-decoration: none">增量加载</a>只裁剪用到的字符非全量下载</span>
</label>
<textarea
id="webfont-preview"
:rows="textareaRows"
@ -257,13 +270,11 @@ async function refreshFonts() {
<CodeBlock :code="cssStyle" lang="css" />
</section>
<FontDebugPreview v-if="isDev" />
<UploadSection :config="serverConfig" :onUploaded="refreshFonts" />
<StatsPanel />
<section style="margin-bottom: 28px; font-size: 12px; color: #aaa; line-height: 1.8">
<section id="sdk-doc" style="margin-bottom: 28px; font-size: 12px; color: #aaa; line-height: 1.8">
<p><b>{{ t('principle') }}</b>{{ t('principleText') }}</p>
<p><b>{{ t('basicUsage') }}</b>{{ t('basicUsageText') }}</p>
<div style="margin-top: 4px"><CodeBlock :code="basicUsageCode" lang="html" /></div>

359
src/pages/OfflineSubset.vue Normal file
View File

@ -0,0 +1,359 @@
<script setup lang="ts">
/**
* 离线裁剪页面 纯浏览器端字体裁剪零服务器依赖
*
* 核心特点
* - 字体不上传服务器隐私安全
* - 裁剪在本地完成零带宽消耗
* - 实时预览裁剪后字体效果
*
* 架构预览用增量裁剪通过 WebFont SDK 管理字符去重
* 下载用全量裁剪一次性输出完整子集字体文件
* 裁剪引擎通过动态 import() 懒加载不进入其他页面的初始 bundle
*/
import { ref, computed, onUnmounted } from "vue";
/** 文件选择 input 的引用(用于拖放区域点击触发) */
const fileInput = ref<HTMLInputElement | null>(null);
/** 拖放高亮状态 */
const dragActive = ref(false);
import { usePageSeo } from "../useSeo";
import { t, toggleLocale, locale } from "../i18n";
usePageSeo({
title: "离线字体裁剪 | 纯浏览器端 | 隐私安全",
description:
"纯浏览器端字体裁剪工具 — 字体文件不上传服务器,在本地完成裁剪,隐私安全、零带宽消耗。支持 TTF/OTF 输入,实时预览裁剪效果。",
path: "/offline-subset",
priority: 0.8,
changefreq: "weekly",
});
/** 裁剪核心模块的动态 import 类型 */
type SubsetFn = typeof import("../subset-client");
/** 选中的字体文件 */
const fontFile = ref<File | null>(null);
/** 原始字体 ArrayBuffer读入后缓存避免重复读取 */
const fontBuffer = ref<ArrayBuffer | null>(null);
/** 字体文件名(用于下载命名和格式推断) */
const fontFileName = ref("");
/** 字体类型 */
const fontType = ref<"ttf" | "otf" | "woff" | "woff2">("ttf");
/** 裁剪文本 */
const text = ref("天地无极,乾坤借法");
/** 全量裁剪后的字体 Blob URL仅用于下载 */
const subsetUrl = ref("");
/** 全量裁剪后的字体大小(字节) */
const subsetSize = ref(0);
/** 原始字体大小(字节) */
const originalSize = ref(0);
/** 全量裁剪耗时(毫秒) */
const subsetTime = ref(0);
/** 是否正在裁剪 */
const subsetting = ref(false);
/** 是否正在加载裁剪引擎 */
const engineLoading = ref(false);
/** 错误信息 */
const errorMsg = ref("");
/** 裁剪引擎(懒加载后缓存) */
let subsetModule: SubsetFn | null = null;
/** SDK 的 loadText 实例(增量预览管理器) */
let textLoader: { update: (text: string) => void; dispose: () => void } | null = null;
/** 增量预览用的字体 family 名 */
const PREVIEW_FAMILY = "OfflineSubsetFont";
/** 支持的文件类型 */
const ACCEPT = ".ttf,.otf,.woff,.woff2";
/** 选择字体文件 */
async function onFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (file) await loadFontFile(file);
input.value = "";
}
/** 拖放字体文件 */
async function onDrop(e: DragEvent) {
dragActive.value = false;
const file = e.dataTransfer?.files?.[0];
if (file) await loadFontFile(file);
}
/** 载入字体文件的公共逻辑 */
async function loadFontFile(file: File) {
errorMsg.value = "";
fontFile.value = file;
fontFileName.value = file.name;
originalSize.value = file.size;
/** 推断字体类型 */
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
fontType.value = (ext as any) || "ttf";
try {
fontBuffer.value = await file.arrayBuffer();
} catch {
errorMsg.value = "文件读取失败";
return;
}
/** 初始化增量预览 + 全量裁剪 */
errorMsg.value = "";
await initIncrementalPreview();
await doFullSubset();
}
/**
* 初始化增量预览 注入本地裁剪 provider SDK创建 loadText 管理器
*
* SDK 负责字符去重和 unicode-range CSS 注入
* provider 负责对新增字符执行本地裁剪并返回 Blob URL
*/
async function initIncrementalPreview() {
if (!fontBuffer.value) return;
/** 首次使用时懒加载裁剪引擎 */
if (!subsetModule) {
engineLoading.value = true;
subsetModule = await import("../subset-client");
engineLoading.value = false;
}
/** 字体 buffer 的副本provider 每次裁剪需要独立的 ArrayBuffer */
const sourceBuffer = fontBuffer.value;
const sourceType = fontType.value as any;
/**
* 本地裁剪 provider SDK 传入新增字符返回裁剪后的 Blob URL
* 签名(fontName, text, outType) -> { url, format }
*
* 注意这里不更新 subsetSize/subsetTime那些统计信息由 doFullSubset 管理
* 始终反映全量裁剪的结果供下载参考
*/
const localProvider = (_fontName: string, charsText: string, _outType: string) => {
/** 每次裁剪都从原始 buffer 副出一份(裁剪会消费 buffer */
const bufCopy = sourceBuffer.slice(0);
const result = subsetModule!.subsetFontInBrowser(bufCopy, charsText, sourceType);
const blob = new Blob([result.buffer as ArrayBuffer], { type: "font/ttf" });
return Promise.resolve({ url: URL.createObjectURL(blob), format: "truetype" });
};
/** 注入 provider 到 SDK */
(window as any).WebFont.setSubsetProvider(localProvider);
/** 创建 loadText 管理器SDK 自动去重字符并增量裁剪 */
textLoader = (window as any).WebFont.loadText({
fontName: fontFileName.value,
text: text.value,
family: PREVIEW_FAMILY,
outType: "truetype",
});
}
/**
* 全量裁剪 用于下载完整子集字体文件
*/
async function doFullSubset() {
if (!fontBuffer.value || !text.value) return;
subsetting.value = true;
try {
if (!subsetModule) {
engineLoading.value = true;
subsetModule = await import("../subset-client");
engineLoading.value = false;
}
const t0 = performance.now();
const result = subsetModule.subsetFontInBrowser(
fontBuffer.value.slice(0),
text.value,
fontType.value as any,
);
const t1 = performance.now();
subsetTime.value = Math.round(t1 - t0);
if (subsetUrl.value) URL.revokeObjectURL(subsetUrl.value);
const blob = new Blob([result.buffer as ArrayBuffer], { type: "font/ttf" });
subsetUrl.value = URL.createObjectURL(blob);
subsetSize.value = result.byteLength;
} catch (err) {
errorMsg.value = `裁剪失败:${err instanceof Error ? err.message : String(err)}`;
console.error("[offline-subset]", err);
} finally {
subsetting.value = false;
engineLoading.value = false;
}
}
/** 防抖:文本变化时增量更新预览,延迟全量裁剪 */
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
function onTextChange(value: string) {
text.value = value;
if (!fontBuffer.value) return;
/** SDK 增量更新:自动去重新字符,只裁剪新增部分 */
textLoader?.update(value);
/** 延迟全量裁剪(供下载用) */
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => doFullSubset(), 600);
}
/** 下载裁剪后的字体 */
function downloadSubset() {
if (!subsetUrl.value || !fontFileName.value) return;
const a = document.createElement("a");
a.href = subsetUrl.value;
const baseName = fontFileName.value.replace(/\.[^.]+$/, "");
a.download = `${baseName}_subset.ttf`;
a.click();
}
/** 压缩率百分比 */
const compressionRate = computed(() => {
if (!originalSize.value || !subsetSize.value) return 0;
return Math.round((1 - subsetSize.value / originalSize.value) * 100);
});
/** 格式化文件大小 */
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
/** textarea 行数 */
const textareaRows = computed(() => {
const lines = text.value.split("\n").length;
return Math.max(2, Math.min(lines, 10));
});
/** 离开页面时清理资源 */
onUnmounted(() => {
textLoader?.dispose();
(window as any).WebFont?.setSubsetProvider(null);
if (subsetUrl.value) URL.revokeObjectURL(subsetUrl.value);
});
</script>
<template>
<div style="max-width: 720px; margin: 0 auto; padding: 48px 24px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #1a1a1a; line-height: 1.6">
<!-- 标题栏 -->
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-bottom: 24px">
<div>
<h1 style="font-size: 22px; font-weight: 600; margin: 0 0 4px 0">离线字体裁剪</h1>
<p style="font-size: 13px; color: #999; margin: 0">字体不上传服务器纯浏览器端完成裁剪</p>
</div>
<div style="display: flex; gap: 8px; align-items: center">
<button @click="toggleLocale" style="font-size: 13px; border: 1px solid #d9d9d9; border-radius: 6px; padding: 4px 12px; cursor: pointer; background: #fff; color: #333">
{{ locale === 'zh' ? 'EN' : '中' }}
</button>
<router-link to="/" style="font-size: 13px; color: #1677ff; text-decoration: none; border: 1px solid #1677ff; border-radius: 6px; padding: 4px 12px">
{{ t('back') }}
</router-link>
</div>
</div>
<!-- 隐私安全提示 -->
<div style="background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 8px; padding: 12px 16px; margin-bottom: 24px; font-size: 13px; color: #0369a1">
🔒 <strong>隐私安全</strong>字体文件和裁剪文本全程留在浏览器不上传任何服务器裁剪在你的设备上本地完成
</div>
<!-- 文件选择 -->
<div style="margin-bottom: 20px">
<label style="display: block; font-size: 14px; font-weight: 500; margin-bottom: 8px">选择字体文件</label>
<label style="display: inline-flex; align-items: center; padding: 8px 24px; font-size: 14px; border: 1px solid #1677ff; border-radius: 6px; cursor: pointer; background: #1677ff; color: #fff; font-weight: 500; transition: background 0.2s">
{{ fontFile ? '重新选择' : '选择字体' }}
<input type="file" :accept="ACCEPT" style="display: none" @change="onFileSelect" />
</label>
<span v-if="fontFile" style="font-size: 13px; color: #666; margin-left: 12px">
{{ fontFileName }}{{ formatSize(originalSize) }}
</span>
</div>
<!-- 文本输入 -->
<div v-if="fontFile" style="margin-bottom: 20px">
<label style="display: block; font-size: 14px; font-weight: 500; margin-bottom: 8px">{{ t('inputLabel') }}</label>
<textarea
:value="text"
@input="onTextChange(($event.target as HTMLTextAreaElement).value)"
:rows="textareaRows"
:placeholder="t('inputPlaceholder')"
:style="{ width: '100%', padding: '12px', fontSize: '28px', border: '1px solid #d9d9d9', borderRadius: '8px', outline: 'none', resize: 'vertical', boxSizing: 'border-box', fontFamily: textLoader ? PREVIEW_FAMILY + ', sans-serif' : 'inherit', lineHeight: '1.8' }"
/>
</div>
<!-- 裁剪状态 -->
<div v-if="fontFile" style="margin-bottom: 20px">
<!-- 加载引擎中 -->
<div v-if="engineLoading" style="padding: 16px; text-align: center; color: #999; font-size: 13px">
正在加载裁剪引擎首次约 200KB后续走浏览器缓存...
</div>
<!-- 裁剪中 -->
<div v-else-if="subsetting" style="padding: 16px; text-align: center; color: #999; font-size: 13px">
裁剪中...
</div>
<!-- 错误 -->
<div v-else-if="errorMsg" style="padding: 12px 16px; background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; font-size: 13px; color: #b91c1c">
{{ errorMsg }}
</div>
<!-- 裁剪结果 -->
<div v-else-if="subsetUrl" style="padding: 16px; border: 1px solid #e8e8e8; border-radius: 8px">
<!-- 统计信息 -->
<div style="display: flex; gap: 24px; flex-wrap: wrap; margin-bottom: 16px; font-size: 13px; color: #666">
<div>
<span style="color: #999">裁剪后</span>
<strong style="color: #1677ff">{{ formatSize(subsetSize) }}</strong>
<span v-if="compressionRate > 0" style="color: #16a34a; margin-left: 4px"> {{ compressionRate }}%</span>
</div>
<div v-if="subsetTime > 0">
<span style="color: #999">耗时</span>
<strong>{{ subsetTime }}ms</strong>
</div>
</div>
<!-- 下载按钮 -->
<button
@click="downloadSubset"
style="padding: 8px 24px; font-size: 14px; border: none; border-radius: 6px; cursor: pointer; background: #16a34a; color: #fff; font-weight: 500; display: inline-flex; align-items: center; gap: 6px"
>
下载裁剪字体{{ formatSize(subsetSize) }}
</button>
</div>
</div>
<!-- 拖放区域支持点击选择和拖拽放入 -->
<div
v-if="!fontFile"
@click="fileInput?.click()"
@dragover.prevent="dragActive = true"
@dragleave.prevent="dragActive = false"
@drop.prevent="onDrop"
:style="{
padding: '48px 16px',
textAlign: 'center',
color: dragActive ? '#1677ff' : '#999',
fontSize: '14px',
border: '2px dashed ' + (dragActive ? '#1677ff' : '#e0e0e0'),
borderRadius: '8px',
cursor: 'pointer',
transition: 'border-color 0.2s, color 0.2s, background 0.2s',
background: dragActive ? '#f0f9ff' : 'transparent',
}"
>
<div style="margin-bottom: 8px; font-size: 32px">📁</div>
<div style="font-weight: 500; margin-bottom: 4px">{{ dragActive ? '松开以载入字体' : '点击或拖拽字体文件到此处' }}</div>
<span style="font-size: 12px">支持 .ttf / .otf / .woff / .woff2 格式输出 TTF</span>
</div>
<input
ref="fileInput"
type="file"
:accept="ACCEPT"
style="display: none"
@change="onFileSelect"
/>
</div>
</template>

34
src/shims/woff2-shim.ts Normal file
View File

@ -0,0 +1,34 @@
/**
* woff2 fonteditor-core woff2 import
*
* fonteditor-core font.js import ttftowoff2 woff2/index woff2-encode
* Node.js zlib.brotliCompressSync
*
* shim 使 import
* outType ttf woff2
* woff2 woff2
* 线 ttf/otf woff2 fonteditor-core woff2tottf.js
*/
export function encodeTTFToWOFF2() {
throw new Error("WOFF2 encoding is not supported in browser. Use TTF output.");
}
export function decodeWOFF2ToTTF() {
throw new Error("WOFF2 decoding is not supported in browser shim.");
}
export default {
isInited() {
return false;
},
init() {
return Promise.resolve();
},
encode() {
throw new Error("WOFF2 encoding is not supported in browser.");
},
decode() {
throw new Error("WOFF2 decoding is not supported in browser.");
},
};

174
src/subset-client.ts Normal file
View File

@ -0,0 +1,174 @@
/**
*
*
* fonteditor-core + GPOS/GSUB/CFF
* TTF Node zlib WOFF2
*
* 线 import()
* bundle
*
*
* - fonteditor-core SUPPORT_BUFFER Node Buffer
* - gpos/gsub/cff/otf-subset JS Node API
* - WOFF2 zlib import
*/
// fonteditor-core 是 CJS 格式,通过 vite-plugin-commonjs 插件即时转为 ESM
// @ts-ignore — vendor 目录的 JS 无类型声明
import { Font } from "../vendor/fonteditor-core/lib/ttf/font";
/** 字体类型标识(与 fonteditor-core 的 FontType 对齐) */
type FontType = "ttf" | "otf" | "woff" | "woff2";
import { subsetGPOS } from "../backend/font_util/gpos-subset.js";
import { subsetGSUB } from "../backend/font_util/gsub-subset.js";
import { collectReachableGsubTargets } from "../backend/font_util/gsub-reachable.js";
import { probeGsubAndCmap } from "../backend/font_util/gsub-probe.js";
import { subsetOTF } from "../backend/font_util/otf-subset.js";
/** TextEncoder 模块级单例 */
const textEncoder = new TextEncoder();
/** 从字符串提取 Unicode 码点数组 */
const textToCodePoints = (text: string) => {
const result: number[] = [];
for (let i = 0; i < text.length; i++) {
const cp = text.codePointAt(i) as number;
result.push(cp);
if (cp > 0xffff) i++;
}
return result;
};
/**
* GPOS/GSUB glyphId
*
* rewriteLayoutTablesForSubset
*/
function rewriteLayoutTablesForSubset(
subsetOptimized: ReturnType<ReturnType<typeof Font.create>["optimize"]>,
subsetGids: number[],
): void {
const ttf = (subsetOptimized as any).get();
const origToNew = new Map<number, number>();
for (let i = 0; i < subsetGids.length; i++) origToNew.set(subsetGids[i], i);
const origGPOS = ttf.GPOS;
if (origGPOS) {
const gposBytes = origGPOS instanceof Uint8Array ? origGPOS : new Uint8Array(origGPOS);
if (gposBytes.byteLength > 0) {
const rewritten = subsetGPOS(gposBytes, origToNew);
if (rewritten) ttf.GPOS = rewritten;
}
}
const origGSUB = ttf.GSUB;
if (origGSUB) {
const gsubBytes = origGSUB instanceof Uint8Array ? origGSUB : new Uint8Array(origGSUB);
if (gsubBytes.byteLength > 0) {
ttf.GSUB = subsetGSUB(gsubBytes, origToNew);
}
}
}
/**
* fontSubset woff2
*
* @param fontBuffer File.arrayBuffer() fetch
* @param subString
* @param sourceType ttf / otf / woff / woff2
* @returns TTF Uint8Array
*/
export function subsetFontInBrowser(
fontBuffer: ArrayBuffer,
subString: string,
sourceType: FontType,
): Uint8Array {
const codePoints = textToCodePoints(subString);
/** OTFCFF输入走独立子集化路径 */
if (sourceType === "otf") {
const fontU8 = new Uint8Array(fontBuffer);
/** outType 固定传 ttf浏览器端只产出 TTFotf-subset 内部 ttf 模式返回裸 OTF sfnt */
const otfBytes = subsetOTF(fontU8, codePoints, true, "ttf");
if (otfBytes !== null) {
return otfBytes;
}
/** subsetOTF 不支持时降级到 fonteditor 路径 */
}
/** GSUB 连字 target glyph 保留探测 */
let extraSubsetGids: number[] | undefined;
const probe = probeGsubAndCmap(fontBuffer, codePoints, sourceType);
let probedSeedGids: Set<number> | undefined;
let probedGsubBytes: Uint8Array | undefined;
let presetCmap: Record<number, number> | undefined;
if (probe.ok) {
probedGsubBytes = probe.gsubBytes;
probedSeedGids = new Set<number>();
probedSeedGids.add(0);
presetCmap = {};
for (const cp of codePoints) {
const gid = probe.lookup.get(cp);
if (gid !== undefined) {
probedSeedGids.add(gid);
presetCmap[cp] = gid;
}
}
} else if (probe.needsFallback) {
const probeFont = Font.create(fontBuffer, {
type: sourceType,
subset: codePoints,
kerning: true,
});
const probeTtf = (probeFont as any).get();
const origGSUB = probeTtf.GSUB;
const origCmap = probeTtf.cmap;
if (origGSUB && origCmap) {
probedGsubBytes = origGSUB instanceof Uint8Array ? origGSUB : new Uint8Array(origGSUB);
probedSeedGids = new Set<number>();
probedSeedGids.add(0);
for (const cp of codePoints) {
const gid = origCmap[cp];
if (gid !== undefined) probedSeedGids.add(gid);
}
}
}
if (probedGsubBytes && probedSeedGids) {
const reachable = collectReachableGsubTargets(probedGsubBytes, probedSeedGids);
if (reachable.size > 0) extraSubsetGids = [...reachable];
}
const font = Font.create(fontBuffer, {
type: sourceType,
subset: codePoints,
kerning: true,
extraSubsetGids,
presetCmap,
});
const preOptTtf = (font as any).get();
const subsetGids: number[] = preOptTtf.subsetGids ?? [];
const optimized = font.optimize();
rewriteLayoutTablesForSubset(optimized, subsetGids);
/** 序列化为 TTF浏览器端不支持 toBuffer强制 false */
const result = optimized.write({ type: "ttf", kerning: true, toBuffer: false });
if (typeof result === "string") {
return textEncoder.encode(result);
}
if (result instanceof Uint8Array) {
return result;
}
return new Uint8Array(result);
}
/** 根据文件扩展名推断字体类型 */
export function detectFontType(filename: string): FontType {
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
if (ext === "otf") return "otf";
if (ext === "woff") return "woff";
if (ext === "woff2") return "woff2";
return "ttf";
}

View File

@ -1,6 +1,8 @@
import { defineConfig } from "vite";
import { fileURLToPath } from "node:url";
import vue from "@vitejs/plugin-vue";
import { pilot } from "vite-plugin-pilot";
import commonjs from "vite-plugin-commonjs";
import { sitemapPlugin } from "./scripts/sitemap-plugin";
/**
* vite-ssg
@ -17,11 +19,131 @@ import type {} from "vite-ssg";
*/
const sitemapRoutes = [
{ path: "/", changefreq: "weekly" as const, priority: 1.0 },
{ path: "/offline-subset", changefreq: "weekly" as const, priority: 0.8 },
{ path: "/demo", changefreq: "monthly" as const, priority: 0.6 },
];
export default defineConfig({
plugins: [vue(), pilot({ locale: "zh" }), sitemapPlugin(sitemapRoutes)],
plugins: [
vue(),
pilot({ locale: "zh" }),
/**
* vendor/fonteditor-core CJS
*
* fonteditor-core Babel CJS exports.xxx / require
* vite dev node_modules CJS interop
* vendor CJS ESM
* 使 `import { Font } from "vendor/..."`
*/
commonjs({
filter(id) {
return id.includes("/vendor/fonteditor-core/");
},
advanced: {
/**
* merge require() import default
* Object.assign(ns.default, ns)使 .namedExport 访
* patch-interop _interopRequireDefault
*/
importRules: "merge",
},
}),
/**
* Patch vendor _interopRequireDefault
*
* fonteditor-core 使 Babel CJS 使
* var _x = _interopRequireDefault(require("./x")).namedExport;
*
* vite-plugin-commonjs require() import * as __CJS__import__N__
* ESM __esModule
* _interopRequireDefault { default: ns } ns
* .namedExport 访_checkSumArrayBuffer is not a function
*
* transform _interopRequireDefault
* default
* 使 .namedExport 访
*/
{
name: "patch-interop",
enforce: "post",
transform(code, id) {
if (!id.includes("/vendor/fonteditor-core/") || !code.includes("_interopRequireDefault")) {
return null;
}
/** 增强版 _interopRequireDefault合并 default 属性到对象本身 */
const patched =
"function _interopRequireDefault(obj) { " +
"if (obj && obj.__esModule) return obj; " +
"var result = obj && obj.default ? Object.assign({}, obj.default, obj) : (obj || {}); " +
"result.default = obj; " +
"result.__esModule = true; " +
"return result; " +
"}";
/**
* Babel _interopRequireDefault
* fonteditor-core
* function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
*/
return code.replace(
/function _interopRequireDefault\(obj\)\s*\{\s*return obj && obj\.__esModule \? obj : \{ default: obj \};\s*\}/,
patched,
);
},
},
/**
* woff2
*
* fonteditor-core woff2/index.js woff2/woff2-encode.js Node.js zlib
* resolveId src/shims/woff2-shim.ts
* 使 import 线 TTF woff2
*/
{
name: "woff2-browser-shim",
enforce: "pre",
resolveId(id, importer) {
/**
*
* - /home/.../vendor/fonteditor-core/woff2/index.js
* - ../../woff2/index ttftowoff2.js
* - fonteditor-core/woff2/index
*/
const isWoff2Module =
(id.includes("fonteditor-core/woff2/") ||
(id.includes("woff2/index") && importer?.includes("fonteditor-core")) ||
(id.includes("woff2/woff2-encode") && importer?.includes("fonteditor-core"))) &&
!id.includes("woff2-shim");
if (isWoff2Module) {
return fileURLToPath(new URL("./src/shims/woff2-shim.ts", import.meta.url));
}
return null;
},
load(id) {
if (
(id.includes("fonteditor-core/woff2/index") ||
id.includes("fonteditor-core/woff2/woff2-encode")) &&
!id.includes("woff2-shim")
) {
return `export function encodeTTFToWOFF2() { throw new Error("WOFF2 encoding is not supported in browser."); }
export function decodeWOFF2ToTTF() { throw new Error("WOFF2 decoding is not supported in browser shim."); }
export default { isInited: () => false, init: () => Promise.resolve(), encode: () => { throw new Error("not supported"); }, decode: () => { throw new Error("not supported"); } };`;
}
return null;
},
},
sitemapPlugin(sitemapRoutes),
/**
* index.html
*
* webfont-sdk.js public/ Vite
*
*/
{
name: "html-build-timestamp",
transformIndexHtml(html) {
return html.replace(/%BUILD_TIME%/g, Date.now().toString());
},
},
],
server: {
host: "0.0.0.0",
proxy: {
@ -31,6 +153,7 @@ export default defineConfig({
},
},
},
/**
* vite-ssg
*
@ -49,7 +172,7 @@ export default defineConfig({
* vite-ssg
* SSG /blog
*/
includedRoutes: () => ["/", "/demo", "/fonts", "/fonts/__FONT_NAME__"],
includedRoutes: () => ["/", "/offline-subset", "/demo", "/fonts", "/fonts/__FONT_NAME__"],
/**
* Node
* SSG 访 window/document