mirror of
https://github.com/2234839/web-font.git
synced 2026-09-04 14:53:32 +08:00
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:
parent
5a7c6a9913
commit
09f434bbcf
@ -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,
|
||||
}),
|
||||
|
||||
@ -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) };
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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() };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
161
pnpm-lock.yaml
generated
@ -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)
|
||||
|
||||
@ -46,6 +46,29 @@ var WebFont = (function () {
|
||||
/** 待执行的字体加载任务队列(FIFO) */
|
||||
var fontLoadQueue = [];
|
||||
|
||||
/**
|
||||
* 子集化提供者 —— 决定如何获取增量字体片段
|
||||
*
|
||||
* 默认走 HTTP API(在线模式)。
|
||||
* 离线模式下通过 setSubsetProvider 注入本地裁剪函数,
|
||||
* SDK 负责增量管理(去重、unicode-range),provider 只管"给定文本→返回字体URL"。
|
||||
*
|
||||
* provider 签名:async function(fontName, text, outType) -> { url: string, format: string }
|
||||
* - url:字体文件的 URL(HTTP 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 精确追踪字体加载完成;
|
||||
* 不可用时退化为 setTimeout(3 秒兜底窗口,覆盖绝大多数裁剪+传输时间)。
|
||||
* 通过 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 精确追踪字体加载完成;
|
||||
* 不可用时退化为 setTimeout(3 秒兜底窗口,覆盖绝大多数裁剪+传输时间)。
|
||||
*/
|
||||
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
|
||||
};
|
||||
})();
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
<!-- 底部进度条:每轮询周期走一轮,走完触发下次刷新 -->
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
81
src/pages/DevPreview.vue
Normal 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>
|
||||
@ -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
359
src/pages/OfflineSubset.vue
Normal 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
34
src/shims/woff2-shim.ts
Normal 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
174
src/subset-client.ts
Normal 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);
|
||||
|
||||
/** OTF(CFF)输入走独立子集化路径 */
|
||||
if (sourceType === "otf") {
|
||||
const fontU8 = new Uint8Array(fontBuffer);
|
||||
/** outType 固定传 ttf:浏览器端只产出 TTF(otf-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";
|
||||
}
|
||||
127
vite.config.ts
127
vite.config.ts
@ -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 时崩溃。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user