Compare commits

...

No commits in common. "v20260613" and "master" have entirely different histories.

230 changed files with 14930 additions and 24213 deletions

52
.gitignore vendored
View File

@ -1,37 +1,39 @@
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
# OS
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
llrt
font/*
tjs
app
*.tar
# Tests
/coverage
/.nyc_output
dist_backend
.pilot
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
verify_font_baseline
benchmark_results
.claude
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.cache
dist
asset/font
asset/dynamically

4
.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"deno.enable": false
}

View File

@ -1,7 +0,0 @@
pnpm dev 可以同时启动前后端,并且都会修改代码后自动重载
- pnpm release 构建并发布当前项目的docker镜像
## 浏览器测试vite-plugin-pilot
已安装。`npx pilot run '代码'` 执行 JS返回结果+日志+快照)、`npx pilot page` 页面状态
`npx pilot help` 查看pilot所有功能
常用:`__pilot_clickByText("文本")` 点击、`__pilot_typeByPlaceholder("提示文字", "值")` 输入、`__pilot_waitFor("文本")` 等待、`__pilot_findByText("文本")` 查找。snapshot 中 `#N` 是元素索引。
多 tab 时用 `npx pilot status` 查看实例列表,`npx pilot run '代码' instance:前缀` 指定目标实例(支持 ID 前缀模糊匹配)。

View File

@ -1,6 +0,0 @@
FROM scratch
WORKDIR /home/
COPY dist_backend/app.lrt /home/app.lrt
COPY llrt /home/llrt
COPY dist/ /home/dist/
CMD ["/home/llrt", "/home/app.lrt"]

View File

@ -1,179 +0,0 @@
# WebFont — Runtime Font Delivery + AI Chinese Font Skill
> Subset Chinese fonts on demand — 6 characters ≈ 6KB. Use premium Chinese fonts on any web page.
[中文](README.md)
**Chinese fonts are huge** (Source Han Sans: 16MB+). You can't just `@font-face` them like English fonts.
This project subsets fonts on the server side — pass in the text you need, get back only those glyphs. A poster with 20 characters? The font file is 20KB, not 16MB.
```html
<!-- One CSS block to use any Chinese font -->
<style>
@font-face {
font-family: "MyFont";
src: url("https://webfont.shenzilong.cn/api?font=令东齐伋复刻体&text=静心茶舍&outType=woff2") format("woff2");
}
.title { font-family: "MyFont", serif; }
</style>
<h1 class="title">静心茶舍</h1>
```
## Who Is This For
**Any project that needs Chinese Web Fonts:**
- Posters / H5 pages — a few characters subset to a few KB
- Brand websites — premium fonts no longer limited by file size
- Mini apps / PWA — bandwidth-sensitive, load on demand
- Static blogs / CMS — content is known, CSS-only output
- AI-generated pages — let AI output well-designed Chinese font solutions
## Features
> [Live Font Demo](https://webfont.shenzilong.cn/) — same content, different fonts, completely different feel
### Runtime Font Delivery
Server-side subsetting + client-side incremental loading.
```
6 characters → server subsets font → returns ~6KB (not 16MB)
```
JS SDK with three loading modes:
```html
<script src="https://webfont.shenzilong.cn/webfont-sdk.js"></script>
<script>
// Recommended: MutationObserver-driven, auto-loads new characters on DOM change
WebFont.observeFont({ fontName: "令东齐伋复刻体.ttf", selector: ".content", family: "MySerif" });
// Polling mode
WebFont.loadFont({ fontName: "令东齐伋复刻体.ttf", selector: ".title", family: "MySerif" });
// Manual text mode
var loader = WebFont.loadText({ fontName: "令东齐伋复刻体.ttf", text: "你好世界", family: "MySerif" });
loader.update("追加文字");
</script>
```
All modes share the same character set per font — zero duplicate requests, zero flicker.
### AI Chinese Font Skill
Inject Chinese font application capability into AI (Claude, Cursor, Copilot, etc.). With the Skill Prompt, AI can:
- Auto-select fonts matching the scene
- Handle Chinese-English mixed typesetting
- Build proper font hierarchy
- Generate fallback chains and runtime loading strategies
```
Prompt: "Build a zen-style tea brand website"
Without Skill: font-family: sans-serif → system default → looks generic
With Skill: zen style → serif heading + kai body → auto subset → visual quality leap
```
## Quick Start
### API Only
No SDK needed. One CSS block to use any Chinese font. Server returns only the character subset you need.
```css
@font-face {
font-family: "MyFont";
src: url("https://webfont.shenzilong.cn/api?font=令东齐伋复刻体&text=你的文字&outType=woff2") format("woff2");
}
```
### Self-hosted
```bash
# Node.js / LLRT
pnpm install && pnpm build && pnpm build_backend
node ./dist_backend/app.cjs
```
Docker (~30MB image):
```yaml
services:
webfont:
image: docker.io/llej0/web-font:latest
ports:
- "8087:8087"
volumes:
- ./fonts:/home/font
environment:
- ENABLE_TEMP_UPLOAD=true
- ADMIN_API_KEY=your-secret-key
- SUBSET_CACHE_MAX_SIZE=10485760
```
## API Reference
| Endpoint | Description |
|----------|-------------|
| `GET /api?font={name}&text={chars}&outType={woff2\|ttf}` | Subset font to specified characters |
| `GET /api/fonts` | List available fonts |
| `GET /api/config` | Get server configuration |
| `POST /api/upload?mode=temp` | Temporary font upload (auto-cleanup) |
| `POST /api/upload?mode=admin` | Permanent font upload (requires API key) |
Font name supports fuzzy matching: exact → prefix → contains.
## Architecture
```
┌──────────────────────────────────────────────────────────────┐
│ Use Cases │
│ Posters/H5 · Brand Sites · Blogs · Mini Apps · AI │
└──────────────────────────┬───────────────────────────────────┘
┌────────────┴────────────┐
│ AI Chinese Font Skill │ ← Chinese font application
│ + AI Skill Prompt │ ← Chinese font application
└────────────┬────────────┘
┌────────────┴────────────┐
│ Runtime Font Delivery │ ← On-demand subsetting + incremental loading
│ SDK + API │
└────────────┬────────────┘
┌────────────┴────────────┐
│ Subset Engine │ ← fonteditor-core
│ parse → subset → │ Font parsing, subsetting, format conversion
│ optimize → serialize │
└─────────────────────────┘
```
## Project Structure
```
web-font/
├── backend/ # Server: HTTP API + font subsetting engine
│ ├── routes/ # API route handlers
│ ├── font_util/ # Font subsetting core
│ └── server/ # HTTP server (Node.js + LLRT dual runtime)
├── src/ # Frontend: Vue 3 SPA
├── public/
│ └── webfont-sdk.js # Runtime Font Delivery SDK
├── skills/ # AI Chinese Font Skill
├── examples/ # Before/After demo pages
└── vendor/ # fonteditor-core
```
## Acknowledgments
- [kekee000/fonteditor-core](https://github.com/kekee000/fonteditor-core) — Font parsing, subsetting, and format conversion
- [字体天下](http://www.fonts.net.cn/commercial-free-32767/fonts-zh-1.html) — Free commercial-use Chinese fonts
- Featured in [阮一峰科技爱好者周刊第 100 期](https://www.ruuyifeng.com/blog/2020/03/weekly-issue-100.html)
## License
MIT © [崮生](https://shenzilong.cn)

224
README.md
View File

@ -1,179 +1,105 @@
# WebFont — 中文字体按需裁剪 + AI 字体技能
# web font 字体裁剪工具
> 按需裁剪中文字体6 个字 ≈ 6KB。让任何网页、海报、H5 都能用上高级中文字体。
## 起因
[English](README.en.md)
ui 需要展现一些特定的字体,但直接引入字体包又过大,于是想到了裁剪字体,一开始想的使用「字蛛」但他是针对静态网站的,而且实际他会多出许多英文的,估计是直接将源码中存在的文字都算进去了。
后来又找到阿里的「webfont」 但他的字体有限,项目又不开源,所以自己写了这个
**中文字体包太大**(思源黑体 16MB+),没法像英文字体那样直接 `@font-face` 引入。
## 尝试
本项目在服务端按需子集化字体——传什么文字,就只返回那些字符的字体子集。一张海报用了 20 个字?返回的字体文件就是 20KB而不是 16MB。
- [web font 在线站点](https://webfont.shenzilong.cn/)
```html
<!-- 一段 CSS 即可使用任意中文字体 -->
<style>
@font-face {
font-family: "MyFont";
src: url("https://webfont.shenzilong.cn/api?font=令东齐伋复刻体&text=静心茶舍&outType=woff2") format("woff2");
}
.title { font-family: "MyFont", serif; }
</style>
<h1 class="title">静心茶舍</h1>
```
- 基于 [malagu](https://github.com/cellbang/malagu) 搭建的 [web font **serverless** 版在线尝试地址](http://webfontserverless.shenzilong.cn/) [serverless代码分支](https://github.com/2234839/web-font/tree/serverless)
## 谁在用
请注意,由于这个服务器比较差,所以访问可能比较慢,且因为服务器空间问题我会不定时的清空生成的资源,所以请不要使用这个站点生成的在线资源,如有需要应当自行布设
**任何需要中文 Web Font 的场景:**
## 目的与功能
- 海报 / H5 活动页 — 几个字裁剪后只有几 KB
- 品牌官网 — 高级字体不再受限于体积
- 小程序 / PWA — 流量敏感,按需加载
- 静态博客 / CMS — 文字内容确定CSS 直出即可
- AI 生成网页 — 让 AI 也能输出有设计感的中文字体方案
1.裁剪字体包使其仅包含选中的字体
## 核心能力
例如 如下图生成的字体包仅包含 「天地无极乾坤借法」
![界面预览](./doc_img/页面截图.jpg)
> [在线体验中文字体效果对比](https://webfont.shenzilong.cn/?demo) — 同一内容,字体不同,体验天壤之别
<video src="./doc_img/功能演示.mkv" controls="controls" width:100% height:auto></video>
### Runtime Font Delivery
其体积自然十分之小
服务端按需子集化 + 客户端增量加载。
![体积展示](./doc_img/体积展示.jpg)
```
6 个字 → 服务端裁剪 → 返回约 6KB 子集字体(而非 16MB 完整字体)
```
2.另外可以生成 css 直接复制可用,部署在公网便可永久访问
JS SDK 三种加载模式:
```html
<script src="https://webfont.shenzilong.cn/webfont-sdk.js"></script>
<script>
// 推荐MutationObserver 事件驱动DOM 变化自动加载新字符
WebFont.observeFont({ fontName: "令东齐伋复刻体.ttf", selector: ".content", family: "MySerif" });
// 轮询模式
WebFont.loadFont({ fontName: "令东齐伋复刻体.ttf", selector: ".title", family: "MySerif" });
// 手动传入文本
var loader = WebFont.loadText({ fontName: "令东齐伋复刻体.ttf", text: "你好世界", family: "MySerif" });
loader.update("追加文字");
</script>
```
同一字体下所有模式共享字符集,零重复请求,零闪烁。
### AI Chinese Font Skill
给 AIClaude、Cursor、Copilot 等注入中文字体应用能力。AI 读取 Skill Prompt 后可以:
- 自动选择匹配场景的中文字体
- 正确处理中英混排
- 建立字体层级Font Hierarchy
- 生成 fallback 链和 Runtime 加载策略
```
Prompt: "生成一个东方禅意风格的茶品牌官网"
无 Skill: font-family: sans-serif → 系统黑体 → 廉价感
有 Skill: zen 风格 → 宋体标题 + 楷体正文 → 自动子集化 → 质感显著提升
```
## 快速开始
### 直接调用 API
无需 SDK一段 CSS 即可。服务端只返回你需要的字符子集。
例如
```css
@font-face {
font-family: "MyFont";
src: url("https://webfont.shenzilong.cn/api?font=令东齐伋复刻体&text=你的文字&outType=woff2") format("woff2");
font-family: "QIJIC";
src: url("http://127.0.0.1:3000/asset/font/1584680576469/令东齐伋复刻体.eot"); /* IE9 */
src: url("http://127.0.0.1:3000/asset/font/1584680576469/令东齐伋复刻体.eot?#iefix") format("embedded-opentype"), /* IE6-IE8 */
url("http://127.0.0.1:3000/asset/font/1584680576469/令东齐伋复刻体.woff") format("woff"), /* chrome, firefox */
url("http://127.0.0.1:3000/asset/font/1584680576469/令东齐伋复刻体.ttf") format("truetype"), /* chrome, firefox, opera, Safari, Android, iOS 4.2+ */
url("http://127.0.0.1:3000/asset/font/1584680576469/令东齐伋复刻体.svg#QIJIC") format("svg"); /* iOS 4.1- */
font-style: normal;
font-weight: normal;
}
```
### 自部署
3.将 ttf 的字体包放置在 ./asset/font_src/ 目录下自然可以检测到新的可用字体,无需重启服务
![路径预览](./doc_img/路径展示.jpg)
4.提供 zip 的整体下载方案
![下载展示](./doc_img/下载展示.jpg)
## 提供的服务
### 查询可用字体列表
![font_list](./doc_img/api/font_list.jpg)
### 生成压缩字体包
![fontmin](./doc_img/api/fontmin.jpg)
如图可见每个返回的字体资源,访问即可下载。另外在访问该目录下的 asset.zip 可以直接下载全部的文件,生成的资源目录结构见下图
![fontmin](./doc_img/api/fontmin_post.jpg)
注意,此接口是还支持 post 方式访问的,这样可以一次请求多个类型的字体文件,而且不会如同 get 方法那样有长度限制
![生成的资源.jpg](./doc_img/生成的资源.jpg)
### 动态生成字体
![generate_fonts_dynamically](./doc_img/api/generate_fonts_dynamically.jpg)
#### 请注意
只支持生成 .ttf .eot .woff .svg 这几种格式
## 写项目时遇到的问题
1. 使用 svelte https://github.com/DeMoorJasper/parcel-plugin-svelte 通过这个插件使用 parcel 然后报 new 的错 需要限制 编译的版本在package.json browserslist 字段限制一下版本就好
2. parcel 对 post purgecss 支持好像有问题,需要修改 postcss.config.js 文件他才能正确的删除样式
## 启动
```bash
# Node.js / LLRT
pnpm install && pnpm build && pnpm build_backend
node ./dist_backend/app.cjs
npm i
npm run build
npm run start
```
Docker~30MB 极简镜像):
```yaml
services:
webfont:
image: docker.io/llej0/web-font:latest
ports:
- "8087:8087"
volumes:
- ./fonts:/home/font
environment:
- ENABLE_TEMP_UPLOAD=true
- ADMIN_API_KEY=your-secret-key
- SUBSET_CACHE_MAX_SIZE=10485760
```
## API
| 接口 | 说明 |
|------|------|
| `GET /api?font={name}&text={chars}&outType={woff2\|ttf}` | 裁剪字体,只返回指定字符的子集 |
| `GET /api/fonts` | 列出可用字体 |
| `GET /api/config` | 获取服务配置 |
| `POST /api/upload?mode=temp` | 临时上传(自动清理) |
| `POST /api/upload?mode=admin` | 永久上传(需 API Key |
字体名支持模糊匹配:精确 > 前缀 > 包含。
## 架构
```
┌──────────────────────────────────────────────────────────────┐
│ 使用场景 │
│ 海报/H5 · 品牌官网 · 博客 · 小程序 · AI 生成页面 │
└──────────────────────────┬───────────────────────────────────┘
┌────────────┴────────────┐
│ AI Chinese Font Skill │ ← 中文字体应用能力
│ + Skill Prompt │ ← 字体裁剪 + 加载策略
└────────────┬────────────┘
┌────────────┴────────────┐
│ Runtime Font Delivery │ ← 按需子集化 + 增量加载
│ SDK + API │
└────────────┬────────────┘
┌────────────┴────────────┐
│ Subset Engine │ ← fonteditor-core
│ parse → subset → │ 字体解析、子集化、格式转换
│ optimize → serialize │
└─────────────────────────┘
```
## 项目结构
```
web-font/
├── backend/ # 服务端HTTP API + 字体裁剪引擎
│ ├── routes/ # API 路由
│ ├── font_util/ # 字体裁剪核心
│ └── server/ # HTTP 服务器(兼容 Node.js + LLRT
├── src/ # 前端Vue 3 单页应用
├── public/
│ └── webfont-sdk.js # Runtime Font Delivery SDK
├── skills/ # AI Chinese Font Skill
├── examples/ # Before/After 对比演示
└── vendor/ # fonteditor-core
```
默认的访问地址是 http://127.0.0.1:3000
## 鸣谢
- [kekee000/fonteditor-core](https://github.com/kekee000/fonteditor-core) — 字体解析、子集化、格式转换
- [字体天下](http://www.fonts.net.cn/commercial-free-32767/fonts-zh-1.html) — 免费商用中文字体
- 入选[阮一峰科技爱好者周刊第 100 期](https://www.ruanyifeng.com/blog/2020/03/weekly-issue-100.html)
[字体天下](http://www.fonts.net.cn/commercial-free-32767/fonts-zh-1.html)
[fontmin](https://github.com/ecomfe/fontmin)
## License
MIT © [崮生](https://shenzilong.cn)
MIT © [崮生](https://shenzilong.cn/关于/mit.html)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,166 +0,0 @@
import { mimeTypes } from "./server/mime_type";
import type { cMiddleware } from "./server/req_res";
import { SimpleHttpServer } from "./server/server";
import { path_join, readFile, stat, readdir, mkdir } from "./interface";
import { parseUrl, jsonResponse, stats } from "./shared";
import { enableTempUpload, adminApiKey } from "./config";
import { handleListFonts } from "./routes/fonts";
import { handleGetConfig } from "./routes/config";
import { handleStats } from "./routes/stats";
import { handleUpload } from "./routes/upload";
import { handleFontSubset } from "./routes/subset";
import "./server/node";
import "./server/llrt";
const ROOT_DIR = "dist";
/** 启动时确保必要目录存在 */
async function ensureDirectories() {
for (const dir of ["font/temp", "font/admin"]) {
try {
await stat(dir);
} catch {
await mkdir(dir);
}
}
}
const logMiddleware: cMiddleware = async (req, res, next) => {
stats.totalRequests++;
const t1 = Date.now();
const r = await next(req, res);
const t2 = Date.now();
const url = parseUrl(req);
console.log(`[${t2 - t1}ms] ${req.method} ${url.pathname}`);
return r;
};
const staticFileMiddleware: cMiddleware = async function (req, res, next) {
let newRes: Response;
if (req.method === "GET") {
const url = parseUrl(req);
const filePath = path_join(ROOT_DIR, url.pathname === "/" ? "index.html" : url.pathname);
/** 防止路径穿越:规范化后必须仍在 dist 目录内 */
if (!filePath.startsWith(ROOT_DIR + "/") && filePath !== ROOT_DIR) {
newRes = new Response("403 Forbidden", {
status: 403,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
return next(req, newRes);
}
try {
const fileStat = await stat(filePath);
if (fileStat.isFile()) {
const fileContent = await readFile(filePath);
const extname = filePath.split(".").pop() ?? "";
newRes = new Response(fileContent, {
status: 200,
headers: {
"Content-Type": mimeTypes[extname] || "application/octet-stream",
"Content-Length": `${fileStat.size}`,
},
});
} else {
newRes = new Response("404 Not Found", {
status: 404,
headers: {
"Content-Type": "text/plain; charset=utf-8",
},
});
}
} catch (err) {
console.log("[err]", err);
newRes = new Response("500 Internal Server Error", {
status: 500,
headers: {
"Content-Type": "text/plain; charset=utf-8",
},
});
}
} else {
newRes = new Response("Method Not Allowed", {
status: 405,
headers: {
"Content-Type": "text/plain; charset=utf-8",
},
});
}
return next(req, newRes);
};
const corsMiddleware: cMiddleware = async (req, res, next) => {
if (req.method === "OPTIONS") {
return {
req,
res: new Response("", {
status: 204,
headers: {
"Content-Length": "0",
},
}),
};
} else {
const newRes = await next(req, res);
newRes.res.headers.append("Access-Control-Allow-Origin", "*");
newRes.res.headers.append("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
newRes.res.headers.append("Access-Control-Allow-Headers", "Content-Type, Authorization");
return newRes;
}
};
/** 统一的 API 路由中间件 */
const fontApiMiddleware: cMiddleware = async (req, res, next) => {
const url = parseUrl(req);
if (!url.pathname.startsWith("/api")) return next(req, res);
if (url.pathname === "/api/fonts" && req.method === "GET") {
return handleListFonts(req, res);
}
if (url.pathname === "/api/config" && req.method === "GET") {
return handleGetConfig(req, res);
}
if (url.pathname === "/api/stats" && req.method === "GET") {
return handleStats(req, res);
}
if (url.pathname === "/api/upload" && req.method === "POST") {
return handleUpload(req, res);
}
if (url.pathname === "/api" && req.method === "GET") {
return handleFontSubset(req, res);
}
return next(req, res);
};
/** 上传文件大小限制 50MB */
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024;
const uploadSizeMiddleware: cMiddleware = async (req, res, next) => {
if (req.method === "POST" && parseUrl(req).pathname === "/api/upload") {
const contentLength = parseInt(req.headers.get("Content-Length") ?? "0", 10);
if (contentLength > MAX_UPLOAD_SIZE) {
return {
req,
res: jsonResponse({ success: false, error: "文件过大,最大 50MB" }, 413),
};
}
}
return next(req, res);
};
async function main() {
await ensureDirectories();
const server = new SimpleHttpServer({ port: 8087 });
server.use(
logMiddleware,
corsMiddleware,
uploadSizeMiddleware,
fontApiMiddleware,
staticFileMiddleware,
);
console.log("[config] temp upload:", enableTempUpload);
console.log("[config] admin upload:", !!adminApiKey);
}
main();

View File

@ -1,22 +0,0 @@
/**
*
*/
const env = globalThis.process?.env ?? {};
/** 临时上传开关 */
export const enableTempUpload = env.ENABLE_TEMP_UPLOAD === "true";
/** 管理员 API Key为空则管理员上传不可用 */
export const adminApiKey: string = env.ADMIN_API_KEY ?? "";
/** 临时上传目录最大文件数 */
export const tempMaxFiles = parseInt(env.TEMP_MAX_FILES ?? "10", 10) || 10;
/** 临时上传目录总体积上限(字节),默认 200MB */
export const tempMaxTotalSize = parseInt(env.TEMP_MAX_TOTAL_SIZE ?? `${200 * 1024 * 1024}`, 10) || 200 * 1024 * 1024;
/** 字体裁剪结果内存缓存容量上限(字节),默认 10MB */
export const subsetCacheMaxSize = parseInt(env.SUBSET_CACHE_MAX_SIZE ?? `${10 * 1024 * 1024}`, 10) || 10 * 1024 * 1024;
/** 字体搜索目录按优先级排序admin > 普通 > 临时) */
export const fontDirs = ["font/admin", "font", "font/temp"] as const;

View File

@ -1,77 +0,0 @@
import { Font } from "../../vendor/fonteditor-core/lib/ttf/font.js";
import type { FontEditor } from "../../vendor/fonteditor-core/lib/ttf/font.js";
/** 优化291: TextEncoder 模块级单例 */
const textEncoder = new TextEncoder();
/**
*
* 便使
*/
/** 从字符串提取 Unicode 码点数组 */
export 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;
};
/**
* subset
*/
export const createSubsetFont = (
fontBuffer: ArrayBuffer,
codePoints: number[],
sourceType: FontEditor.FontType,
) =>
Font.create(fontBuffer, {
type: sourceType,
subset: codePoints,
});
/**
*
* subset TTFReader.resolveGlyf compound2simple
* optimizettf _unicodeSorted=truesortGlyf
*/
export const optimizeFont = (font: ReturnType<typeof Font.create>) => {
const optimized = font.optimize();
return optimized;
};
/** 序列化为指定格式的二进制数据 */
/** 优化291: 移除 async消除不必要的微任务调度 */
export const writeFont = (
font: ReturnType<ReturnType<typeof Font.create>["optimize"]>,
outType: FontEditor.FontType,
): Uint8Array => {
const result = font.write({ type: outType });
if (typeof result === "string") {
return textEncoder.encode(result);
}
/** 优化278: Buffer 是 Uint8Array 子类,直接返回避免多余拷贝 */
if (result instanceof Uint8Array) {
return result;
}
return new Uint8Array(result);
};
/**
*
* -> subset -> ->
* 优化293: 移除 async await Promise
*/
export const fontSubset = (
fontBuffer: ArrayBuffer,
subString: string,
option: { sourceType: FontEditor.FontType; outType: FontEditor.FontType },
): Uint8Array => {
const codePoints = textToCodePoints(subString);
const font = createSubsetFont(fontBuffer, codePoints, option.sourceType);
const optimized = optimizeFont(font);
return writeFont(optimized, option.outType);
};

View File

@ -1,67 +0,0 @@
export let stat: (path: string) => Promise<{
isFile: () => boolean;
size: number;
}>;
export let readFile: (path: string) => Promise<Uint8Array>;
export let writeFile: (path: string, data: Uint8Array) => Promise<void>;
export let readdir: (path: string) => Promise<{
isFile: () => boolean;
name: string;
}[]>;
export let mkdir: (path: string) => Promise<void>;
export let unlink: (path: string) => Promise<void>;
export const implInterface = (options: {
stat: typeof stat;
readFile: typeof readFile;
writeFile: typeof writeFile;
readdir: typeof readdir;
mkdir: typeof mkdir;
unlink: typeof unlink;
}) => {
stat = options.stat;
readFile = options.readFile;
writeFile = options.writeFile;
readdir = options.readdir;
mkdir = options.mkdir;
unlink = options.unlink;
};
export function path_join(...paths: string[]) {
const sep = "/";
function trimSlashes(p: string) {
return p.replace(/\/+$/, "").replace(/^\/+/, "");
}
/** 将路径按 / 分割并解析 . 和 .. 段 */
function normalizeSegments(segments: string[]) {
const resolved: string[] = [];
for (const seg of segments) {
if (seg === "..") {
resolved.pop();
} else if (seg !== "." && seg !== "") {
resolved.push(seg);
}
}
return resolved;
}
const isAbsolute = paths[0] && paths[0].startsWith(sep);
const segments = paths
.map((path) => trimSlashes(path))
.join(sep)
.split(sep);
const resolved = normalizeSegments(segments);
if (!resolved.length) return isAbsolute ? sep : ".";
const result = resolved.join(sep);
return isAbsolute ? sep + result : result;
}

View File

@ -1,80 +0,0 @@
/**
* Map LRU
*
*/
export class LruCache<V> {
private cache = new Map<string, V>();
/** 计算条目字节大小(仅按容量淘汰时需要) */
private readonly sizeFn?: (value: V) => number;
/** 最大条目数(按条目淘汰时使用) */
private readonly maxSize?: number;
/** 最大字节容量(按容量淘汰时使用) */
private readonly maxBytes?: number;
/** 当前已用字节 */
private usedBytes = 0;
/** 当前缓存条目数 */
get size() {
return this.cache.size;
}
constructor(options: { maxSize: number } | { maxBytes: number; sizeFn: (value: V) => number }) {
if ("maxSize" in options) {
this.maxSize = options.maxSize;
} else {
this.maxBytes = options.maxBytes;
this.sizeFn = options.sizeFn;
}
}
/** 获取缓存值命中时移到末尾LRU */
get(key: string): V | undefined {
const value = this.cache.get(key);
if (value === undefined) return undefined;
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
/** 写入缓存,超限时自动淘汰最久未使用的条目 */
set(key: string, value: V): void {
/** 如果 key 已存在,先移除旧值(更新大小) */
const existing = this.cache.get(key);
if (existing !== undefined) {
this.cache.delete(key);
if (this.sizeFn) {
this.usedBytes -= this.sizeFn(existing);
}
}
this.cache.set(key, value);
if (this.sizeFn) {
this.usedBytes += this.sizeFn(value);
this.evictByBytes();
} else if (this.maxSize !== undefined) {
this.evictByCount();
}
}
/** 按条目数淘汰 */
private evictByCount() {
while (this.cache.size > this.maxSize!) {
const oldest = this.cache.keys().next().value!;
this.cache.delete(oldest);
}
}
/** 按字节容量淘汰 */
private evictByBytes() {
while (this.usedBytes > this.maxBytes! && this.cache.size > 0) {
const oldest = this.cache.keys().next().value!;
const entry = this.cache.get(oldest)!;
this.usedBytes -= this.sizeFn!(entry);
this.cache.delete(oldest);
}
}
}

View File

@ -1,98 +0,0 @@
/**
* multipart/form-data
*/
export interface MultipartFile {
/** 表单字段名 */
name: string;
/** 原始文件名 */
filename: string;
/** 文件二进制数据 */
data: Uint8Array;
}
export interface MultipartParseResult {
files: MultipartFile[];
}
export function parseMultipart(contentType: string, body: ArrayBuffer): MultipartParseResult {
if (!body || body.byteLength === 0) {
return { files: [] };
}
const boundaryMatch = contentType.match(/boundary=([^\s;]+)/);
if (!boundaryMatch) throw new Error("No boundary found");
const boundary = boundaryMatch[1].replace(/^"(.*)"$/, "$1");
const encoder = new TextEncoder();
const decoder = new TextDecoder("utf-8");
const bodyBytes = new Uint8Array(body);
const delimiter = encoder.encode("\r\n--" + boundary);
/** 在字节数组中查找子串位置 */
function findBytes(haystack: Uint8Array, needle: Uint8Array, offset: number): number {
for (let i = offset; i <= haystack.length - needle.length; i++) {
let match = true;
for (let j = 0; j < needle.length; j++) {
if (haystack[i + j] !== needle[j]) {
match = false;
break;
}
}
if (match) return i;
}
return -1;
}
const files: MultipartFile[] = [];
// 跳过起始边界 "--boundary\r\n"
const startBoundary = encoder.encode("--" + boundary + "\r\n");
let pos = 0;
const sbPos = findBytes(bodyBytes, startBoundary, pos);
if (sbPos === -1) throw new Error("Invalid multipart: no start boundary");
pos = sbPos + startBoundary.length;
while (pos < bodyBytes.length) {
// 查找 headers 和 body 的分界 "\r\n\r\n"
const headerEnd = findBytes(bodyBytes, encoder.encode("\r\n\r\n"), pos);
if (headerEnd === -1) break;
const headerText = decoder.decode(bodyBytes.slice(pos, headerEnd));
const bodyStart = headerEnd + 4;
// 查找下一个 boundary
const nextBoundary = findBytes(bodyBytes, delimiter, bodyStart);
if (nextBoundary === -1) break;
// part body 去掉末尾的 "\r\n"
const partBody = bodyBytes.slice(bodyStart, nextBoundary);
const actualBody = partBody.length >= 2 && partBody[partBody.length - 1] === 10 && partBody[partBody.length - 2] === 13
? partBody.slice(0, partBody.length - 2)
: partBody;
// 从 Content-Disposition 中解析字段名和文件名
const nameMatch = headerText.match(/name="([^"]*)"/);
const filenameMatch = headerText.match(/filename="([^"]*)"/);
if (nameMatch) {
files.push({
name: nameMatch[1],
filename: filenameMatch?.[1] ?? "",
data: actualBody,
});
}
pos = nextBoundary + delimiter.length;
// 检查 boundary 后面是否紧跟 "--"(结束标记)
if (pos + 2 <= bodyBytes.length && bodyBytes[pos] === 45 && bodyBytes[pos + 1] === 45) {
break;
}
// 跳过 boundary 后的 "\r\n"
if (pos < bodyBytes.length && bodyBytes[pos] === 13 && bodyBytes[pos + 1] === 10) {
pos += 2;
}
}
return { files };
}

View File

@ -1,14 +0,0 @@
import { jsonResponse } from "../shared";
import { enableTempUpload, adminApiKey } from "../config";
/** GET /api/config — 返回公开配置 */
export async function handleGetConfig(req: Request, res: Response) {
return {
req,
res: jsonResponse({
enableTempUpload,
adminUploadEnabled: !!adminApiKey,
supportedOutTypes: ["woff2", "ttf"],
}),
};
}

View File

@ -1,21 +0,0 @@
import { jsonResponse, parseUrl } from "../shared";
import { readdir, stat } from "../interface";
import { fontDirs } from "../config";
/** GET /api/fonts — 列出所有可用字体 */
export async function handleListFonts(req: Request, res: Response) {
const allFonts: Array<{ name: string; temporary: boolean }> = [];
for (const dir of fontDirs) {
try {
const entries = await readdir(dir);
for (const entry of entries) {
if (entry.isFile() && /\.(ttf|otf|woff|woff2)$/i.test(entry.name)) {
allFonts.push({ name: entry.name, temporary: dir === "font/temp" });
}
}
} catch {}
}
return { req, res: jsonResponse(allFonts) };
}

View File

@ -1,17 +0,0 @@
import { jsonResponse, stats, subsetCache, fontBufferCache } from "../shared";
/** GET /api/stats — 返回运行时统计 */
export async function handleStats(req: Request, res: Response) {
return {
req,
res: jsonResponse({
uptime: Math.floor((Date.now() - stats.startTime) / 1000),
totalRequests: stats.totalRequests,
subsetRequests: stats.subsetRequests,
subsetCacheHits: stats.subsetCacheHits,
totalChars: stats.totalChars,
subsetCacheEntries: subsetCache.size,
fontBufferCacheEntries: fontBufferCache.size,
}),
};
}

View File

@ -1,86 +0,0 @@
import { fontSubset } from "../font_util/font";
import type { FontEditor } from "../../vendor/fonteditor-core/lib/ttf/font.js";
import { parseUrl, jsonResponse, stats, subsetCache, findFontPath, readFontBuffer } from "../shared";
/** GET /api?font=...&text=... — 字体裁剪 */
export async function handleFontSubset(req: Request, res: Response) {
const url = parseUrl(req);
const params = new URLSearchParams(url.search);
const font = params.get("font") || "";
const text = params.get("text") || "";
if (text.length === 0) {
return { req, res };
}
const fontPath = await findFontPath(font);
if (!fontPath) {
return {
req,
res: new Response(`Font not found: ${font}`, {
status: 404,
headers: { "Content-Type": "text/plain; charset=utf-8" },
}),
};
}
/** 默认 ttf兼容性最好 */
const outTypeParam = params.get("outType") || "";
const outType = (outTypeParam === "woff2" || outTypeParam === "ttf") ? outTypeParam : "ttf";
/** 查询裁剪结果缓存 */
const cacheKey = `${fontPath}:${outType}:${text}`;
stats.subsetRequests++;
stats.totalChars += text.length;
const cached = subsetCache.get(cacheKey);
if (cached) {
stats.subsetCacheHits++;
const contentTypes: Record<string, string> = { ttf: "font/ttf", woff2: "font/woff2" };
return {
req,
res: new Response(cached, {
status: 200,
headers: {
"Content-Type": contentTypes[outType] || "font/ttf",
"Cache-Control": "public, max-age=86400",
"X-Cache": "HIT",
},
}),
};
}
const fontType = fontPath.split(".").pop() as FontEditor.FontType;
let oldFontBuffer: ArrayBuffer;
try {
oldFontBuffer = await readFontBuffer(fontPath);
} catch {
return {
req,
res: new Response(`Font read error: ${font}`, {
status: 500,
headers: { "Content-Type": "text/plain; charset=utf-8" },
}),
};
}
const newFont = await fontSubset(oldFontBuffer, text, {
outType: outType,
sourceType: fontType,
});
/** 写入裁剪结果缓存 */
subsetCache.set(cacheKey, newFont as ArrayBuffer);
const contentTypes: Record<string, string> = { ttf: "font/ttf", woff2: "font/woff2" };
return {
req,
res: new Response(newFont, {
status: 200,
headers: {
"Content-Type": contentTypes[outType] || "font/ttf",
"Cache-Control": "public, max-age=86400",
"X-Cache": "MISS",
},
}),
};
}

View File

@ -1,47 +0,0 @@
import { jsonResponse, parseUrl } from "../shared";
import { parseMultipart } from "../multipart";
import { handleTempUpload, handleAdminUpload } from "../upload";
/** POST /api/upload?mode=temp|admin — 上传字体 */
export async function handleUpload(req: Request, res: Response) {
const url = parseUrl(req);
const mode = url.searchParams.get("mode") ?? "temp";
const contentType = req.headers.get("Content-Type") ?? "";
console.log("[upload] mode:", mode, "contentType:", contentType);
const body = (req as Request & { _bodyBuffer?: ArrayBuffer })._bodyBuffer;
if (!body || body.byteLength === 0) {
return { req, res: jsonResponse({ success: false, error: "请求体为空" }, 400) };
}
console.log("[upload] body size:", body.byteLength);
let parsed;
try {
parsed = parseMultipart(contentType, body);
console.log("[upload] parsed files:", parsed.files.length);
} catch (err) {
console.log("[upload] parse error:", err);
return { req, res: jsonResponse({ success: false, error: "无效的 multipart 数据" }, 400) };
}
if (!parsed.files || parsed.files.length === 0) {
return { req, res: jsonResponse({ success: false, error: "未提供文件" }, 400) };
}
const file = parsed.files[0];
console.log("[upload] file:", file.name, "filename:", file.filename, "data size:", file.data.length);
let result;
if (mode === "admin") {
const authHeader = req.headers.get("Authorization") ?? "";
const apiKey = authHeader.replace("Bearer ", "");
result = await handleAdminUpload({ data: file.data, filename: file.filename }, apiKey);
console.log("[upload] admin result:", result);
return { req, res: jsonResponse(result, result.success ? 200 : 403) };
}
result = await handleTempUpload({ data: file.data, filename: file.filename });
console.log("[upload] temp result:", result);
return { req, res: jsonResponse(result, result.success ? 200 : 400) };
}

View File

@ -1 +0,0 @@
import "web-streams-polyfill/polyfill";

View File

@ -1,16 +0,0 @@
// MIME 类型映射
export const mimeTypes: Record<string, string> = {
"html": "text/html",
"css": "text/css",
"js": "application/javascript; charset=utf-8",
"json": "application/json",
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"gif": "image/gif",
"svg": "image/svg+xml",
"woff": "font/woff",
"woff2": "font/woff2",
"ttf": "font/ttf",
"otf": "font/otf",
};

View File

@ -1,27 +0,0 @@
import { implInterface } from "../interface";
import { stat, readFile, writeFile, readdir, mkdir, unlink } from "fs/promises";
implInterface({
async stat(path) {
const r = await stat(path);
return r;
},
readFile(path) {
return readFile(path);
},
writeFile(path, data) {
return writeFile(path, data);
},
async readdir(path) {
const entries = await readdir(path, { withFileTypes: true });
return entries.map((entry) => ({
isFile: () => entry.isFile(),
name: entry.name,
}));
},
async mkdir(path) {
await mkdir(path, { recursive: true });
},
unlink(path) {
return unlink(path);
},
});

View File

@ -1,10 +0,0 @@
// 请求和响应模型
export type cRequest = Request;
export type cResponse = Response;
export type cNext = (
req: cRequest,
res: cResponse,
) => { req: cRequest; res: cResponse } | Promise<{ req: cRequest; res: cResponse }>;
// 中间件函数类型
export type cMiddleware = (req: cRequest, res: cResponse, next: cNext) => ReturnType<cNext>;

View File

@ -1,299 +0,0 @@
import { cMiddleware, cRequest, cResponse, type cNext } from "./req_res";
import { createTcpServer } from "./tcp_server";
// 配置
// 路由器类
export class cRouter {
private middleware: cMiddleware[] = [];
use(middleware: cMiddleware) {
this.middleware.push(middleware);
return this;
}
async handle(req: cRequest, res: cResponse) {
let index = -1;
const next = async (req: cRequest, res: cResponse) => {
index += 1;
// console.log(`开始执行第 ${index} ${this.middleware[index]?.name} 中间件`);
const r = (await this.middleware[index]?.(req, res, next)) ?? { req, res };
// console.log(`执行完毕第 ${index} 中间件`);
return r;
};
return next(req, res);
}
}
// 实现一个简化的 HTTP 服务器
export class SimpleHttpServer {
private router: cRouter = new cRouter();
constructor(options: { port: number; hostname?: string }) {
const release_name = globalThis?.process?.release?.name;
console.log("[release.name]", release_name);
if (release_name === "llrt" || release_name === "node") {
const server = createTcpServer((socket) => {
connectionHandle(socket, (req, res) => this.router.handle(req, res));
});
server.listen(options.port, options.hostname, () => {
console.log(`Server is listening on port ${options.port}`);
});
}
}
use(...middlewares: cMiddleware[]) {
middlewares.forEach((middleware) => this.router.use(middleware));
return this;
}
}
const decoder = new TextDecoder("utf-8");
const encoder = new TextEncoder();
/** 合并多个 Uint8Array 为单个 ArrayBuffer */
function mergeChunks(chunks: Uint8Array[], totalLength: number): ArrayBuffer {
const merged = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.length;
}
return merged.buffer.slice(merged.byteOffset, merged.byteOffset + merged.byteLength);
}
// 请求头终止符
const target = encoder.encode("\r\n\r\n");
async function connectionHandle(
connection: {
readable: ReadableStream<Uint8Array>;
writable: WritableStream<Uint8Array>;
close: () => void;
},
handle: cNext,
) {
try {
const { header, body } = await createStreamAfterTarget(connection.readable, target);
if (!header) {
return;
}
const httpHeaderText = decoder.decode(header);
const httpHeader = parseHttpRequest(httpHeaderText);
const hasBody = httpHeader.method !== "GET" && httpHeader.method !== "HEAD";
/** 大小写不敏感查找 header */
const getHeader = (name: string) => {
const lower = name.toLowerCase();
for (const key of Object.keys(httpHeader.headers)) {
if (key.toLowerCase() === lower) return httpHeader.headers[key];
}
return undefined;
};
/** 读取请求体 */
let bodyArrayBuffer: ArrayBuffer | undefined;
if (hasBody && body) {
const contentLength = parseInt(getHeader("Content-Length") ?? "0", 10);
if (contentLength > 0) {
/** 根据 Content-Length 读取指定长度的 body */
const chunks: Uint8Array[] = [];
let received = 0;
for await (const chunk of body) {
chunks.push(chunk);
received += chunk.length;
if (received >= contentLength) break;
}
body.cancel?.();
bodyArrayBuffer = mergeChunks(chunks, received);
} else if (getHeader("Transfer-Encoding") === "chunked") {
/** 解码 chunked transfer encoding */
const chunks: Uint8Array[] = [];
let totalLength = 0;
const chunkBuf: number[] = [];
let state: "size" | "data" | "crlf_after_data" = "size";
let chunkSize = 0;
let dataRead = 0;
let goto_done = false;
for await (const rawChunk of body) {
for (const byte of rawChunk) {
switch (state) {
case "size": {
if (byte === 13) continue; // \r
if (byte === 10) {
// \n — size 行结束
const sizeStr = new TextDecoder().decode(new Uint8Array(chunkBuf)).trim();
chunkSize = parseInt(sizeStr, 16);
chunkBuf.length = 0;
if (chunkSize === 0) {
state = "crlf_after_data"; // 最后一个空行
} else {
state = "data";
dataRead = 0;
}
} else {
chunkBuf.push(byte);
}
break;
}
case "data": {
chunkBuf.push(byte);
dataRead++;
if (dataRead >= chunkSize) {
const data = new Uint8Array(chunkBuf);
chunks.push(data);
totalLength += data.length;
chunkBuf.length = 0;
state = "crlf_after_data";
}
break;
}
case "crlf_after_data": {
// 跳过 trailing \r\n
if (byte === 10) {
if (chunkSize === 0) {
// 结束标记后的 \n
state = "size";
goto_done = true;
} else {
state = "size";
}
}
break;
}
}
if (goto_done) break;
}
if (goto_done) break;
}
if (totalLength > 0) {
bodyArrayBuffer = mergeChunks(chunks, totalLength);
}
body.cancel?.();
} else {
/** 无 Content-Length 且非 chunked暂不处理 */
}
}
const rawReq = new Request("http://" + (getHeader("Host") ?? "localhost") + httpHeader.url, {
method: httpHeader.method,
headers: httpHeader.headers,
});
/** 将 body 数据挂到 request 对象上,供中间件直接读取 */
(rawReq as Request & { _bodyBuffer?: ArrayBuffer })._bodyBuffer = bodyArrayBuffer;
const rawRes = new Response();
const { req, res } = await handle(rawReq, rawRes);
const resWriter = connection.writable.getWriter();
let headerText: string[] = [];
res.headers.forEach((value, key) => {
headerText.push(`${key}: ${value}`);
});
const resHeaertText = `HTTP/1.1 ${res.status} OK\r\n${headerText.join("\r\n")}\r\n\r\n`;
await resWriter.write(encoder.encode(resHeaertText));
if (res.body) {
/** node 运行时 */
resWriter.releaseLock();
await res.body?.pipeTo(connection.writable);
} else {
/** llrt 运行时 */
const buffer = new Uint8Array(await (await res.blob()).arrayBuffer());
await resWriter.write(buffer);
}
if (!resWriter.closed) {
await resWriter.close();
}
connection.close();
} catch (err) {
console.log("[connectionHandle error]", err);
try { connection.close(); } catch { /* ignore */ }
}
}
function parseHttpRequest(requestText: string) {
const lines = requestText.trim().split("\n");
if (lines.length === 0) {
throw new Error("Invalid HTTP request");
}
// 解析请求行
const [method, url, httpVersion] = lines[0].split(" ");
// 解析头部
const headers: Record<string, string> = {};
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (line === "" || line === "\r") break; // 空行表示头部结束
const colonIndex = line.indexOf(":");
if (colonIndex === -1) continue;
const key = line.slice(0, colonIndex).trim();
const value = line.slice(colonIndex + 1).trim();
headers[key] = value;
}
return {
method,
url,
httpVersion,
headers,
};
}
function createStreamAfterTarget(
originalStream: ReadableStream<Uint8Array>,
target: Uint8Array,
): Promise<{ header: Uint8Array | null; body: ReadableStream<Uint8Array> }> {
const reader = originalStream.getReader();
let buffer = new Uint8Array();
function containsTarget(buf: Uint8Array, tgt: Uint8Array): number {
for (let i = 0; i <= buf.length - tgt.length; i++) {
if (buf.slice(i, i + tgt.length).every((value, index) => value === tgt[index])) {
return i;
}
}
return -1;
}
return new Promise((resolve) => {
function pump() {
reader.read().then(({ done, value }) => {
if (done) {
resolve({ header: null, body: new ReadableStream<Uint8Array>() });
return;
}
const newBuffer = new Uint8Array(buffer.length + value.length);
newBuffer.set(buffer);
newBuffer.set(value, buffer.length);
buffer = newBuffer;
const targetIndex = containsTarget(buffer, target);
if (targetIndex === -1) {
pump();
return;
}
const start = targetIndex + target.length;
const header = buffer.slice(0, start);
const remainingData = buffer.slice(start);
/** body stream先写入剩余数据然后在后台继续从 originalStream 读取 */
let controller!: ReadableStreamDefaultController<Uint8Array>;
const body = new ReadableStream<Uint8Array>({
start(c) {
controller = c;
if (remainingData.length > 0) {
controller.enqueue(remainingData);
}
/** 后台继续读取 originalStream 并转发到 body stream */
(async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) { controller.close(); return; }
controller.enqueue(value);
}
} catch (err) {
/** body stream 被消费方 cancel 时预期会抛错 */
console.log("[createStreamAfterTarget] body stream closed:", err);
}
})();
},
});
resolve({ header, body });
});
}
pump();
});
}

View File

@ -1,56 +0,0 @@
import { createServer } from "net";
// 创建 TCP 服务器
export function createTcpServer(
onSocket: (socket: {
readable: ReadableStream<Uint8Array>;
writable: WritableStream<Uint8Array>;
close: () => void;
}) => void,
) {
const server = createServer((socket) => {
const readable = new ReadableStream<Uint8Array>({
start(controller) {
socket.on("data", (chunk) => {
controller.enqueue(new Uint8Array(chunk));
});
socket.on("error", (err) => {
controller.error(err);
});
socket.on("close", () => {
controller.close();
});
},
cancel() {
socket.destroy();
},
});
// 创建 WritableStream
const writable = new WritableStream<Uint8Array>({
write(chunk) {
return new Promise((resolve, reject) => {
socket.write(chunk, (err) => {
if (err) reject(err);
else resolve();
});
});
},
close() {
socket.end();
},
abort(reason) {
socket.destroy(reason);
},
});
// 实现 close 方法
function close() {
socket.end();
socket.destroy();
}
onSocket({ readable, writable, close });
});
return server;
}

View File

@ -1,86 +0,0 @@
import { fontDirs, subsetCacheMaxSize } from "./config";
import { LruCache } from "./lru_cache";
import { path_join, readFile, stat, readdir } from "./interface";
/** 解析请求 URLreq.url 只有路径,需要补全协议和主机才能用 URL API */
export function parseUrl(req: Request): URL {
return new URL(req.url, "http://localhost");
}
/** JSON 响应工具 */
export function jsonResponse(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}
/** 运行时统计 */
export const stats = {
/** 服务启动时间戳 */
startTime: Date.now(),
/** 总请求数 */
totalRequests: 0,
/** 字体裁剪请求次数(含缓存命中) */
subsetRequests: 0,
/** 字体裁剪缓存命中次数 */
subsetCacheHits: 0,
/** 累计裁剪文字字符数 */
totalChars: 0,
};
/** 字体文件 LRU 缓存,最多保留 3 个最近使用的字体 buffer按条目数淘汰 */
export const fontBufferCache = new LruCache<ArrayBuffer>({ maxSize: 3 });
/** 字体裁剪结果 LRU 缓存(按字节容量淘汰) */
export const subsetCache = new LruCache<ArrayBuffer>({ maxBytes: subsetCacheMaxSize, sizeFn: (v) => v.byteLength });
/**
*
* > >
*/
export async function findFontPath(filename: string): Promise<string | null> {
for (const dir of fontDirs) {
const filePath = path_join(dir, filename);
try {
const s = await stat(filePath);
if (s.isFile()) return filePath;
} catch {}
}
const allFonts: Array<{ basename: string; path: string }> = [];
for (const dir of fontDirs) {
try {
const entries = await readdir(dir);
for (const entry of entries) {
if (entry.isFile() && /\.(ttf|otf|woff|woff2)$/i.test(entry.name)) {
allFonts.push({
basename: entry.name.replace(/\.[^.]+$/, ""),
path: path_join(dir, entry.name),
});
}
}
} catch {}
}
const query = filename.replace(/\.[^.]+$/, "").toLowerCase();
for (const f of allFonts) {
if (f.basename.toLowerCase().startsWith(query)) return f.path;
}
for (const f of allFonts) {
if (f.basename.toLowerCase().includes(query)) return f.path;
}
return null;
}
/** 从缓存或磁盘读取字体 buffer */
export async function readFontBuffer(fontPath: string): Promise<ArrayBuffer> {
const cached = fontBufferCache.get(fontPath);
if (cached) return cached;
const buffer = new Uint8Array(await readFile(fontPath)).buffer;
fontBufferCache.set(fontPath, buffer);
return buffer;
}

View File

@ -1,20 +0,0 @@
// console.log("[global.tjs.engine]", global.tjs.engine.gc.enabled);
// global.tjs.engine.gc.threshold = 100;
// console.log("[global.tjs.engine]", global.tjs.engine.gc.threshold);
function runGCTests() {
let objects = [];
for (let i = 0; i < 100000; i++) {
objects[i] = { index: i, data: new Array(1000).fill(i) };
}
objects = null; // Dereference the objects to make them eligible for garbage collection
}
runGCTests();
setInterval(() => {
if (global.tjs) {
console.log("[global.tjs.engine]", global.tjs.engine.gc.run());
}
}, 3000);
setTimeout(() => {}, 1000000);
// 168064

View File

@ -1,128 +0,0 @@
import { writeFile, unlink, readdir, stat, mkdir, path_join } from "./interface";
import { enableTempUpload, adminApiKey, tempMaxFiles, tempMaxTotalSize } from "./config";
/** 允许的字体文件扩展名 */
const ALLOWED_EXTENSIONS = [".ttf", ".otf", ".woff", ".woff2"];
function isAllowedFontFile(filename: string): boolean {
const lower = filename.toLowerCase();
return ALLOWED_EXTENSIONS.some((ext) => lower.endsWith(ext));
}
/** 清理文件名,移除路径分隔符、危险字符和路径穿越成分 */
function sanitizeFilename(filename: string) {
let name = filename.replace(/[/\\]/g, "").replace(/[\x00-\x1f]/g, "");
/** 移除所有 .. 防止路径穿越 */
name = name.replace(/\.\./g, "");
/** 取基础文件名,防止以 . 开头的隐藏文件 */
name = name.replace(/^\./, "");
if (!name) return "unnamed.ttf";
return name;
}
/** 确保目录存在,不存在则创建 */
async function ensureDir(dir: string) {
try {
await stat(dir);
} catch {
await mkdir(dir);
}
}
export interface UploadResult {
success: boolean;
error?: string;
}
/**
* temp
* readdir FIFO
*/
async function getTempFontFiles(): Promise<Array<{ name: string; size: number }>> {
const entries = await readdir("font/temp");
const files: Array<{ name: string; size: number }> = [];
for (const entry of entries) {
if (entry.isFile() && isAllowedFontFile(entry.name)) {
const s = await stat(path_join("font/temp", entry.name));
files.push({ name: entry.name, size: s.size });
}
}
return files;
}
/**
* temp 使
* @param incomingSize
*/
async function evictIfNeeded(incomingSize: number) {
const files = await getTempFontFiles();
/** 数量超限时删除最早的文件 */
while (files.length >= tempMaxFiles) {
const oldest = files.shift();
if (!oldest) break;
try { await unlink(path_join("font/temp", oldest.name)); } catch { /* 删除失败不影响上传 */ }
}
/** 总体积超限时继续删除,直到腾出足够空间 */
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
const needed = totalSize + incomingSize - tempMaxTotalSize;
if (needed > 0) {
let freed = 0;
for (const file of files) {
if (freed >= needed) break;
try { await unlink(path_join("font/temp", file.name)); } catch { /* 删除失败不影响上传 */ }
freed += file.size;
}
}
}
export async function handleTempUpload(fileData: { data: Uint8Array; filename: string }): Promise<UploadResult> {
if (!enableTempUpload) {
return { success: false, error: "临时上传功能未启用" };
}
if (!isAllowedFontFile(fileData.filename)) {
return { success: false, error: "不支持的字体文件格式,仅支持 ttf/otf/woff/woff2" };
}
await ensureDir("font/temp");
const filename = sanitizeFilename(fileData.filename);
const filePath = path_join("font/temp", filename);
/** 同名文件直接覆盖(覆盖不算新增),否则检查限制并清理 */
try {
const existing = await stat(filePath);
/** 覆盖时,新文件可能比旧文件大,仍需检查总体积 */
await evictIfNeeded(fileData.data.length - existing.size);
} catch {
await evictIfNeeded(fileData.data.length);
}
await writeFile(filePath, fileData.data);
return { success: true };
}
export async function handleAdminUpload(
fileData: { data: Uint8Array; filename: string },
apiKey: string,
): Promise<UploadResult> {
if (!adminApiKey) {
return { success: false, error: "管理员上传功能未启用" };
}
if (apiKey !== adminApiKey) {
return { success: false, error: "API Key 无效" };
}
if (!isAllowedFontFile(fileData.filename)) {
return { success: false, error: "不支持的字体文件格式,仅支持 ttf/otf/woff/woff2" };
}
await ensureDir("font/admin");
const filename = sanitizeFilename(fileData.filename);
await writeFile(path_join("font/admin", filename), fileData.data);
return { success: true };
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

BIN
doc_img/api/font_list.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
doc_img/api/fontmin.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
doc_img/下载展示.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
doc_img/体积展示.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

BIN
doc_img/功能演示.mkv Normal file

Binary file not shown.

BIN
doc_img/生成的资源.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
doc_img/路径展示.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
doc_img/页面截图.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@ -1,15 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="在线字体裁剪工具 — 服务端按需裁剪字体子集,大小无限制,免费开源。支持自定义裁剪、增量加载 SDK轻松嵌入任何网站。" />
<title>WebFont — 在线字体裁剪 | 按需加载 | 免费开源</title>
</head>
<body>
<div id="root"></div>
<script src="/webfont-sdk.js"></script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

4
nest-cli.json Normal file
View File

@ -0,0 +1,4 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}

14099
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,35 +1,77 @@
{
"name": "webfont",
"private": true,
"version": "1.8.0",
"type": "module",
"version": "0.0.1",
"description": "",
"author": "",
"license": "MIT",
"scripts": {
"dev": "pnpx tsx scripts/dev-all.ts",
"dev:frontend": "vite",
"dev:backend": "pnpx tsx backend/app.ts",
"build": "vite build",
"build_backend": "pnpx tsx scripts/build-backend.ts",
"docker_build": "docker build -t llej0/web-font:${npm_package_version} -t llej0/web-font:latest .",
"docker_push": "docker push llej0/web-font:${npm_package_version} && docker push llej0/web-font:latest",
"preview": "vite preview",
"release": "pnpm build && pnpm build_backend && pnpm docker_build && pnpm docker_push"
"prebuild": "rimraf dist",
"build": "nest build && parcel build ./static/index.html",
"build:font": "nest build && parcel build ./static/index.html --public-url /font/",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start:web": "parcel ./static/index.html",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "tslint -p tsconfig.json -c tslint.json",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"vue": "3.6.0-beta.10",
"web-streams-polyfill": "^4.3.0"
"@babel/polyfill": "^7.8.7",
"@fullhuman/postcss-purgecss": "^2.1.0",
"@nestjs/common": "^6.7.2",
"@nestjs/core": "^6.7.2",
"@nestjs/platform-express": "^6.7.2",
"@nestjs/serve-static": "^2.1.0",
"font-spider": "^1.3.5",
"fontmin": "^0.9.8",
"parcel-bundler": "^1.12.4",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.0",
"rxjs": "^6.5.3",
"svelte": "^3.20.1",
"tailwindcss": "^1.2.0",
"zip-a-folder": "0.0.12",
"zip-folder": "^1.0.0"
},
"devDependencies": {
"@types/node": "^25.9.1",
"@vitejs/plugin-vue": "^6.0.7",
"@xmldom/xmldom": "^0.9.10",
"jsdom": "^29.1.1",
"pngjs": "^7.0.0",
"puppeteer": "^25.1.0",
"tsdown": "^0.22.1",
"typescript": "^6.0.3",
"undici": "^8.3.0",
"vite": "^8.0.14",
"vite-plugin-pilot": "^1.0.31",
"vitest": "^4.1.7"
}
}
"@nestjs/cli": "^6.9.0",
"@nestjs/schematics": "^6.7.0",
"@nestjs/testing": "^6.7.1",
"@types/express": "^4.17.1",
"@types/jest": "^24.0.18",
"@types/node": "^12.7.5",
"@types/supertest": "^2.0.8",
"cssnano": "^4.1.10",
"jest": "^24.9.0",
"parcel-plugin-svelte": "^4.0.6",
"prettier": "^1.18.2",
"supertest": "^4.0.2",
"ts-jest": "^24.1.0",
"ts-loader": "^6.1.1",
"ts-node": "^8.4.1",
"tsconfig-paths": "^3.9.0",
"tslint": "^5.20.0",
"typescript": "^3.6.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"browserslist": "last 10 chrome versions"
}

2536
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1 +0,0 @@
approveBuilds: puppeteer

23
postcss.config.js Normal file
View File

@ -0,0 +1,23 @@
const tailwindcss = require('tailwindcss');
const purgecss = require('@fullhuman/postcss-purgecss')({
// Specify the paths to all of the template files in your project
content: [
'./static/**/*.svelte',
'./static/**/*.js',
'./static/**/*.ts',
'./static/*.svelte',
'./static/*.js',
'./static/*.ts',
`111 `
],
// Include any special characters you're using in this regular expression
defaultExtractor: content => content.match(/[\w-/:]+(?<!:)/g) || [],
});
module.exports = {
plugins: [
tailwindcss,
...(process.env.NODE_ENV === 'development' ? [] : [purgecss]),
],
};

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 20 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,393 +0,0 @@
/**
* WebFont SDK 按需增量加载字体片段无闪烁
*
* 架构核心增量引擎 + 多种触发方式
* - 核心FontLoader fontKey 管理已加载字符集只生成增量 CSS
* - 触发器loadFont轮询observeFontDOM 事件loadText手动传文本
* - 同一 fontKey 下所有触发器共享字符集绝不会重复请求
*
* 用法
* // 轮询模式
* WebFont.loadFont({ fontName, selector, family, interval });
*
* // 事件驱动模式
* var obs = WebFont.observeFont({ fontName, selector, family });
* obs.dispose();
*
* // 直接传文本模式
* var loader = WebFont.loadText({ fontName, text: "你好世界", family });
* loader.update("追加文字");
* loader.dispose();
*
* // 清理全部
* WebFont.disposeAll();
*/
var WebFont = (function () {
/* ============================================================
* 核心增量引擎 fontKey 管理已加载字符集生成增量 CSS
* ============================================================ */
/** @type {Object.<string, { loadedChars: Object.<string,boolean>, injectedStyles: Element[], applied: boolean, fontName: string, family: string, baseUrl: string }>} */
var loaders = {};
/**
* 生成 fontKey同一字体+family 归入同一组
*/
function fontKey(fontName, family) {
return fontName + "|" + family;
}
/**
* 获取或创建对应 fontKey 的加载器
*/
function getLoader(fontName, baseUrl, family, outType) {
var key = fontKey(fontName, family);
if (!loaders[key]) {
loaders[key] = {
loadedChars: {},
injectedStyles: [],
applied: false,
fontName: fontName,
family: family,
baseUrl: baseUrl,
outType: outType || "woff2"
};
}
return loaders[key];
}
/**
* 差量加载新字符生成 unicode-range CSS 并注入
* @param {Object} loader - getLoader 返回的加载器对象
* @param {string[]} newChars - 待加载的新字符数组
*/
function loadChars(loader, newChars) {
if (newChars.length === 0) return;
var fontName = loader.fontName;
var family = loader.family;
var baseUrl = loader.baseUrl;
var loadedChars = loader.loadedChars;
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);
}
/**
* 从字符集中过滤出未加载的新字符标记为已加载并生成 CSS
* @param {Object} loader - getLoader 返回的加载器对象
* @param {Object.<string,boolean>} charSet - 待检查的字符集
* @returns {boolean} 是否有新字符被加载
*/
function processChars(loader, charSet) {
var loadedChars = loader.loadedChars;
var newChars = [];
for (var c in charSet) {
if (!loadedChars[c]) {
loadedChars[c] = true;
newChars.push(c);
}
}
loadChars(loader, newChars);
return newChars.length > 0;
}
/**
* 从字符串中过滤出未加载的新字符标记为已加载并生成 CSS
* @param {Object} loader - getLoader 返回的加载器对象
* @param {string} text - 待检查的文本
* @returns {boolean} 是否有新字符被加载
*/
function processText(loader, text) {
var loadedChars = loader.loadedChars;
var newChars = [];
for (var i = 0; i < text.length; i++) {
var c = text[i];
if (!loadedChars[c]) {
loadedChars[c] = true;
newChars.push(c);
}
}
loadChars(loader, newChars);
return newChars.length > 0;
}
/**
* 销毁加载器及其所有注入的样式
*/
function destroyLoader(key) {
var loader = loaders[key];
if (!loader) return;
for (var i = 0; i < loader.injectedStyles.length; i++) {
loader.injectedStyles[i].remove();
}
delete loaders[key];
}
/* ============================================================
* 辅助函数
* ============================================================ */
/**
* 获取元素的文本内容
*/
function getText(el) {
var tag = el.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") {
/** 同时收集 value 和 placeholder确保占位文本的字体也被加载 */
var val = el.value || "";
var ph = el.placeholder || "";
return val + ph;
}
return el.textContent || "";
}
/**
* 收集选择器匹配元素中的所有字符
*/
function collectChars(selector) {
var charSet = {};
var elements = document.querySelectorAll(selector);
for (var i = 0; i < elements.length; i++) {
var text = getText(elements[i]);
for (var j = 0; j < text.length; j++) {
charSet[text[j]] = true;
}
}
return charSet;
}
/**
* 应用字体到元素
*/
function applyFamily(selector, family) {
var elements = document.querySelectorAll(selector);
for (var i = 0; i < elements.length; i++) {
elements[i].style.fontFamily = '"' + family + '", sans-serif';
}
}
/* ============================================================
* 任务管理 各触发器的清理
* ============================================================ */
/** 按 selector 索引的 loadFont 任务 */
var pollTasks = {};
/** 按选择器索引的 observeFont 任务 */
var observeTasks = {};
/* ============================================================
* 1. loadFont 定时器轮询模式
* ============================================================ */
/**
* @param {Object} options
* @param {string} options.fontName
* @param {string} options.selector
* @param {string} [options.baseUrl]
* @param {string} [options.family]
* @param {number} [options.interval=1000] - 轮询间隔ms
*/
function loadFont(options) {
var selector = options.selector;
var fontName = options.fontName;
var baseUrl = options.baseUrl || location.origin;
var family = options.family || fontName.replace(/\.[^.]+$/, "");
var interval = options.interval || 1000;
/* 清理同一选择器的旧任务 */
if (pollTasks[selector]) {
clearInterval(pollTasks[selector].timer);
}
var outType = options.outType || "woff2";
var loader = getLoader(fontName, baseUrl, family, outType);
var applied = false;
function tick() {
var current = collectChars(selector);
if (processChars(loader, current) && !applied) {
applied = true;
applyFamily(selector, family);
}
}
tick();
var timer = setInterval(tick, interval);
pollTasks[selector] = { timer: timer };
}
/* ============================================================
* 2. observeFont MutationObserver 事件驱动模式
* ============================================================ */
/**
* @param {Object} options
* @param {string} options.fontName
* @param {string} options.selector
* @param {string} [options.baseUrl]
* @param {string} [options.family]
* @param {number} [options.debounceMs=50] - 防抖间隔ms
* @returns {{ dispose: function }}
*/
function observeFont(options) {
var selector = options.selector;
var fontName = options.fontName;
var baseUrl = options.baseUrl || location.origin;
var family = options.family || fontName.replace(/\.[^.]+$/, "");
var debounceMs = options.debounceMs || 50;
/* 清理同一选择器的旧任务 */
if (observeTasks[selector]) {
observeTasks[selector].dispose();
}
var outType = options.outType || "woff2";
var loader = getLoader(fontName, baseUrl, family, outType);
var applied = false;
var debounceTimer = null;
function doLoad() {
var current = collectChars(selector);
if (processChars(loader, current) && !applied) {
applied = true;
applyFamily(selector, family);
}
}
function debouncedLoad() {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(doLoad, debounceMs);
}
var observer = new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) {
if (mutations[i].type === "childList" || mutations[i].type === "characterData") {
debouncedLoad();
return;
}
}
});
var inputHandler = function () { debouncedLoad(); };
var elements = document.querySelectorAll(selector);
observer.observe(document.body || document.documentElement, {
childList: true,
subtree: true,
characterData: true,
});
for (var i = 0; i < elements.length; i++) {
var el = elements[i];
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
el.addEventListener("input", inputHandler);
}
}
doLoad();
var disposed = false;
var task = {
dispose: function () {
if (disposed) return;
disposed = true;
observer.disconnect();
for (var j = 0; j < elements.length; j++) {
var el2 = elements[j];
if (el2.tagName === "INPUT" || el2.tagName === "TEXTAREA") {
el2.removeEventListener("input", inputHandler);
}
}
if (debounceTimer) clearTimeout(debounceTimer);
delete observeTasks[selector];
}
};
observeTasks[selector] = task;
return task;
}
/* ============================================================
* 3. loadText 直接传文本模式
* ============================================================ */
/**
* @param {Object} options
* @param {string} options.fontName
* @param {string} options.text
* @param {string} [options.baseUrl]
* @param {string} [options.family]
* @returns {{ update: function(string): void, dispose: function(): void }}
*/
function loadText(options) {
var fontName = options.fontName;
var baseUrl = options.baseUrl || location.origin;
var family = options.family || fontName.replace(/\.[^.]+$/, "");
var outType = options.outType || "woff2";
var loader = getLoader(fontName, baseUrl, family, outType);
processText(loader, options.text);
var disposed = false;
return {
update: function (text) {
if (disposed) return;
processText(loader, text);
},
dispose: function () {
if (disposed) return;
disposed = true;
/** 移除该 loader 注入的所有 @font-face 样式,避免同名 family 的 CSS 优先级冲突 */
destroyLoader(fontKey(fontName, family));
}
};
}
/* ============================================================
* 公共 API
* ============================================================ */
/**
* 清理所有任务和加载器页面卸载时调用
*/
function disposeAll() {
for (var sel in pollTasks) {
clearInterval(pollTasks[sel].timer);
}
for (var oid in observeTasks) {
observeTasks[oid].dispose();
}
pollTasks = {};
observeTasks = {};
for (var key in loaders) {
destroyLoader(key);
}
}
return {
loadFont: loadFont,
observeFont: observeFont,
loadText: loadText,
disposeAll: disposeAll
};
})();

View File

@ -1,129 +0,0 @@
/**
*
* 1. LLRT
* 2. tsup
* 3. 使 LLRT compile .lrt
*/
import { execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdir, writeFile, rm } from "node:fs/promises";
import { arch, platform } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { EnvHttpProxyAgent, setGlobalDispatcher } from "undici";
/** 自动读取 http_proxy/https_proxy 环境变量配置全局代理 */
const proxyUrl = process.env.https_proxy || process.env.HTTPS_PROXY
|| process.env.http_proxy || process.env.HTTP_PROXY;
if (proxyUrl) {
setGlobalDispatcher(new EnvHttpProxyAgent());
console.log(`Using proxy: ${proxyUrl}`);
}
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = join(__dirname, "..");
const LLRT_BIN = join(ROOT_DIR, "llrt");
/** 映射 node arch 到 LLRT arch */
function getLlrtArch() {
const a = arch();
if (a === "x64") return "x64";
if (a === "arm64") return "arm64";
throw new Error(`Unsupported architecture: ${a}`);
}
/** 映射 node platform 到 LLRT platform */
function getLlrtPlatform() {
const p = platform();
if (p === "linux") return "linux";
if (p === "darwin") return "darwin";
if (p === "win32") return "windows";
throw new Error(`Unsupported platform: ${p}`);
}
/** 确保 llrt 二进制文件存在,不存在则下载 */
async function ensureLlrt() {
if (existsSync(LLRT_BIN)) {
console.log("LLRT binary found, skipping download.");
return;
}
const llrtArch = getLlrtArch();
const llrtPlatform = getLlrtPlatform();
console.log("Fetching latest LLRT release version...");
const res = await fetch("https://api.github.com/repos/awslabs/llrt/releases/latest");
/** 响应数据 */
const data = await res.json() as { tag_name: string };
const version = data.tag_name;
if (!version) {
throw new Error("Failed to fetch latest LLRT version from GitHub");
}
console.log(`Latest LLRT version: ${version}`);
const downloadUrl = `https://github.com/awslabs/llrt/releases/download/${version}/llrt-${llrtPlatform}-${llrtArch}-no-sdk.zip`;
console.log(`Downloading from ${downloadUrl} ...`);
const zipRes = await fetch(downloadUrl);
if (!zipRes.ok) {
throw new Error(`Failed to download LLRT: ${zipRes.status} ${zipRes.statusText}`);
}
/** 下载 zip 到临时文件 */
const tmpDir = join(ROOT_DIR, ".tmp-llrt");
await mkdir(tmpDir, { recursive: true });
const zipPath = join(tmpDir, "llrt.zip");
const arrayBuffer = await zipRes.arrayBuffer();
await writeFile(zipPath, Buffer.from(arrayBuffer));
/** 使用 unzip 命令解压Node 内置没有 zip 解压) */
const binaryName = platform() === "win32" ? "llrt.exe" : "llrt";
execSync(`unzip -o -j "${zipPath}" "${binaryName}" -d "${ROOT_DIR}"`, {
stdio: "inherit",
});
/** linux/mac 需要可执行权限 */
if (platform() !== "win32") {
execSync(`chmod +x "${LLRT_BIN}"`);
}
await rm(tmpDir, { recursive: true });
console.log(`LLRT ${version} installed successfully.`);
}
/** 运行 tsdown 编译 */
function runTsdown() {
console.log("\n--- Running tsdown build ---");
execSync("pnpm tsdown", { stdio: "inherit", cwd: ROOT_DIR });
}
/** woff2 已使用纯 JS 实现vendor/fonteditor-core/woff2/index.js无需复制 wasm */
/** 使用 LLRT compile 生成 .lrt 文件 */
function runLlrtCompile() {
console.log("\n--- Running LLRT compile ---");
execSync(`${LLRT_BIN} compile ./dist_backend/app.cjs ./dist_backend/app.lrt`, {
stdio: "inherit",
cwd: ROOT_DIR,
});
execSync(`${LLRT_BIN} compile "./dist_backend/基准测试_llrt.cjs" ./dist_backend/llrt_bench.lrt`, {
stdio: "inherit",
cwd: ROOT_DIR,
});
console.log("\nBackend build completed successfully!");
}
/** 主流程 */
async function main() {
await ensureLlrt();
runTsdown();
runLlrtCompile();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@ -1,42 +0,0 @@
import { readFile } from "node:fs/promises";
import { Font } from "../vendor/fonteditor-core/lib/ttf/font.js";
const raw = await readFile("font/temp/YiShanBeiZhuanTi.ttf");
const buf = new Uint8Array(raw).buffer;
const text = "你好世界";
const subset = [...text].map(c => c.codePointAt(0));
const font = Font.create(buf, { type: "ttf", subset });
const optimized = font.optimize().sort();
const result = optimized.write({ type: "ttf" });
const data = new Uint8Array(result);
console.log("=== TTF Header ===");
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
console.log("sfVersion:", "0x" + view.getUint32(0, false).toString(16));
console.log("numTables:", view.getUint16(4, false));
const tables: Array<{ tag: string; offset: number; length: number }> = [];
for (let i = 0; i < view.getUint16(4, false); i++) {
const offset = 12 + i * 16;
const tag = String.fromCharCode(view.getUint8(offset), view.getUint8(offset + 1), view.getUint8(offset + 2), view.getUint8(offset + 3));
const toffset = view.getUint32(offset + 8, false);
const tlength = view.getUint32(offset + 12, false);
tables.push({ tag, offset: toffset, length: tlength });
console.log(" ", tag, "offset=" + toffset, "length=" + tlength);
}
const headEntry = tables.find(t => t.tag === "head");
if (headEntry) {
const magic = view.getUint32(headEntry.offset + 12, false);
console.log("\nhead magicNumber:", "0x" + magic.toString(16), magic === 0x5F0F3CF5 ? "OK" : "INVALID!");
}
console.log("\nfile size:", data.length);
console.log("last table end:", Math.max(...tables.map(t => t.offset + t.length)));
/** 和原始字体对比 */
const origView = new DataView(buf);
console.log("\n=== 原始字体 ===");
console.log("sfVersion:", "0x" + origView.getUint32(0, false).toString(16));
console.log("numTables:", origView.getUint16(4, false));

View File

@ -1,56 +0,0 @@
/**
* (vite) (tsx backend/app.ts)
* Ctrl+C
*/
import { execSync, spawn } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = join(__dirname, "..");
/** 子进程列表,用于退出时统一清理 */
const children: ReturnType<typeof spawn>[] = [];
/** 清理所有子进程 */
function cleanup() {
for (const child of children) {
child.kill("SIGTERM");
}
}
/** 杀掉占用指定端口的进程 */
function killPort(port: number) {
try {
execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null`, { stdio: "ignore" });
} catch {
/** 端口没有被占用,忽略 */
}
}
process.on("SIGINT", () => {
cleanup();
process.exit(0);
});
process.on("SIGTERM", () => {
cleanup();
process.exit(0);
});
killPort(8087);
console.log("Starting frontend and backend dev servers...\n");
const backend = spawn("pnpx", ["tsx", "watch", "backend/app.ts"], {
cwd: ROOT_DIR,
stdio: "inherit",
shell: true,
env: { ...process.env, ENABLE_TEMP_UPLOAD: "true", ADMIN_API_KEY: "dev-key" },
});
children.push(backend);
const frontend = spawn("pnpm", ["dev:frontend"], {
cwd: ROOT_DIR,
stdio: "inherit",
shell: true,
});
children.push(frontend);

View File

@ -1,71 +0,0 @@
/**
*
* 运行: pnpm tsx scripts/test_font_valid.ts
*/
import { Font } from "../vendor/fonteditor-core/lib/ttf/font.js";
import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
const OUTPUT_DIR = "benchmark_results/font_test";
await mkdir(OUTPUT_DIR, { recursive: true });
/** 在所有字体目录中查找字体 */
import { stat } from "node:fs/promises";
const fontDirs = ["font/admin", "font", "font/temp"];
async function findFonts(): Promise<Array<{ name: string; path: string }>> {
const all: Array<{ name: string; path: string }> = [];
for (const dir of fontDirs) {
try {
const entries = await readdir(dir);
for (const entry of entries) {
const name = typeof entry === "string" ? entry : entry.name;
if (/\.(ttf|otf|woff|woff2)$/i.test(name)) {
const fullPath = `${dir}/${name}`;
try {
const s = await stat(fullPath);
if (s.isFile()) {
all.push({ name, path: fullPath });
}
} catch { /* skip */ }
}
}
} catch { /* skip */ }
}
return all;
}
const fonts = await findFonts();
const testText = "你好世界";
const codePoints = [...testText].map(c => c.codePointAt(0)!);
console.log("\n=== 字体裁剪验证 ===\n");
console.log(`测试文本: "${testText}"\n`);
for (const f of fonts) {
try {
const raw = await readFile(f.path);
const buf = new Uint8Array(raw).buffer;
const font = Font.create(buf, { type: "ttf", subset: codePoints });
const optimized = font.optimize().sort();
const result = optimized.write({ type: "ttf" });
const data = typeof result === "string"
? new TextEncoder().encode(result)
: new Uint8Array(result);
const outPath = `${OUTPUT_DIR}/${f.name.replace(/\.[^.]+$/, "")}_subset.ttf`;
await writeFile(outPath, data);
/** 检查 TTF 文件头 */
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
const sfVersion = view.getUint32(0, false);
const numTables = view.getUint16(4, false);
console.log(` ${f.name}: ${data.length.toLocaleString()} bytes, sfVersion=0x${sfVersion.toString(16)}, numTables=${numTables}`);
} catch (e: any) {
console.log(` ${f.name}: ERROR - ${e.message}`);
}
}
console.log(`\n输出目录: ${OUTPUT_DIR}/`);
console.log("请在 Windows 字体查看器中打开验证");

View File

@ -1,88 +0,0 @@
---
name: chinese-web-font
description: 中文字体按需裁剪与加载能力,可让 AI 在生成中文网页时引入特殊中文字体,按字符子集化加载,极大减少流量。
---
# Chinese Web Font Skill
## WebFont API
按需裁剪中文字体只返回页面实际使用的字符。6 个字 ≈ 6KB。
### CSS 直出
```html
<style>
@font-face {
font-family: "MyFont";
src: url("https://webfont.shenzilong.cn/api?font=令东齐伋复刻体&text=静心茶舍&outType=woff2") format("woff2");
}
</style>
```
`text` 参数写什么字,就只返回那些字的子集。
### 查看可用字体
```
GET https://webfont.shenzilong.cn/api/fonts
```
返回当前服务器上所有可用字体列表。响应中 `temporary: true` 表示临时字体,随时可能被清理,不要使用。使用前先调用此接口确认字体是否存在。
### 其他接口
```
GET https://webfont.shenzilong.cn/api?font={name}&text={chars}&outType=woff2 — 裁剪字体
GET https://webfont.shenzilong.cn/api/fonts — 列出可用字体
GET https://webfont.shenzilong.cn/api/config — 服务配置
POST https://webfont.shenzilong.cn/api/upload?mode=temp — 临时上传字体
```
字体名支持模糊匹配(精确 > 前缀 > 包含)。`outType` 支持 `woff2``ttf`
### SDK 增量加载
```html
<script src="https://webfont.shenzilong.cn/webfont-sdk.js"></script>
<script>
WebFont.observeFont({ fontName: "令东齐伋复刻体.ttf", selector: ".content", family: "MyFont" });
</script>
```
三种模式:`observeFont`推荐DOM 事件驱动)、`loadFont`(轮询)、`loadText`(手动文本)。
SDK 支持文字动态变化:当 DOM 内容改变时,`observeFont` 会自动检测新字符并增量加载,无需手动更新。适合 AI 生成内容、动态渲染等场景。
## 关键规则
- **行高**:中文正文 1.8,标题 1.3。中文笔画密集,比英文需要更大行距
- **字重**:中文 400/700 区分明显中间字重300/500表现弱不要依赖
- **中英混排**:中文与英文/数字之间加空格
- **字号级差**:标题间至少 4px 差距,否则层级模糊
- **首行缩进**`text-indent: 2em` 是中文正文的传统
## 可用字体
如果可以发请求,先调用 `GET https://webfont.shenzilong.cn/api/fonts` 查看最新可用字体列表。响应中 `temporary: true` 的字体随时可能被清理,可仅作演示,并提示用户。
如果不能发请求,公共服务永久字体有:
- `令东齐伋复刻体.ttf` — 宋体风格,古典优雅,适合传统文化、禅意
- `问藏书房.ttf` — 书法风格,适合文艺、文化场景
## Fallback 链
```css
/* 衬线 */ "CustomFont", "Noto Serif CJK SC", "STSong", "SimSun", serif;
/* 无衬线 */ "CustomFont", "Noto Sans CJK SC", "PingFang SC", "Microsoft YaHei", sans-serif;
/* 楷体 */ "CustomFont", "LXGW WenKai", "STKaiti", "KaiTi", cursive;
```
## 风格速查
**zen禅意**:衬线标题 + 楷体正文line-height 2.0,大量留白,暖色 #faf9f6
**modern-tech科技**无衬线粗标题letter-spacing -0.02em,强对比
**luxury奢侈**:衬线细字重(300),超大 letter-spacing 0.15em,极简
**editorial编辑**:衬线标题 + 无衬线正文,首行缩进,行长 60~80 字符
**warm温暖**楷体为主line-height 2.0,暖色系

View File

@ -1,235 +0,0 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from "vue";
import { fetchFonts, fetchConfig } from "./api";
import type { FontInfo, ServerConfig } from "./api";
import { t, toggleLocale, locale } from "./i18n";
const isDev = import.meta.env.DEV;
const origin = location.origin;
import UploadSection from "./UploadSection.vue";
import StatsPanel from "./StatsPanel.vue";
import SelectorRow from "./FontSelector.vue";
import FontDebugPreview from "./FontDebugPreview.vue";
import TypographyDemo from "./TypographyDemo.vue";
/** 是否展示 Typography Demo */
const showDemo = ref(location.search.includes("demo"));
const text = ref("天地无极,乾坤借法");
const fonts = ref<FontInfo[]>([]);
const selectedFont = ref("");
const outType = ref<"woff2" | "ttf">("ttf");
const serverConfig = ref<ServerConfig>({
enableTempUpload: false,
adminUploadEnabled: false,
supportedOutTypes: ["woff2", "ttf"],
});
onMounted(async () => {
const [fontList, config] = await Promise.all([
fetchFonts().catch(() => [] as FontInfo[]),
fetchConfig().catch((): ServerConfig => ({ enableTempUpload: false, adminUploadEnabled: false })),
]);
fonts.value = fontList;
serverConfig.value = config;
if (!config.supportedOutTypes?.includes(outType.value)) {
outType.value = config.supportedOutTypes?.[0] || "ttf";
}
if (fontList.length > 0) {
const usableFonts = fontList.filter((f) => /\.(ttf)$/i.test(f.name));
const randomFont = usableFonts[Math.floor(Math.random() * usableFonts.length)];
const sloganText = t("slogan");
(globalThis as any).WebFont?.loadText({
fontName: randomFont.name,
text: sloganText,
family: "SloganFont",
});
const sloganEl = document.getElementById("slogan");
if (sloganEl) {
sloganEl.style.fontFamily = '"SloganFont", sans-serif';
sloganEl.title = randomFont.name;
}
selectedFont.value = fontList[0].name;
}
});
const cssStyle = computed(() => {
const font = selectedFont.value;
const ot = outType.value;
if (!font) return "";
const formatStr = ot === "woff2" ? "woff2" : "truetype";
return `@font-face {
font-family: "CustomFont";
src: url("${origin}/api?font=${font}&text=${encodeURIComponent(text.value)}&outType=${ot}") format("${formatStr}");
}
.custom-font {
color: red;
font-family: "CustomFont";
}`;
});
let textLoader: { update: (text: string) => void; dispose: () => void } | null = null;
function onTextChange(value: string) {
text.value = value;
textLoader?.update(value);
}
const textareaRows = computed(() => {
const lines = text.value.split("\n").length;
return Math.max(2, Math.min(lines, 10));
});
let lastLoadKey = "";
function reloadFont(font: string, ot: "woff2" | "ttf") {
const key = `${font}|${ot}`;
if (!font || key === lastLoadKey) return;
lastLoadKey = key;
if (textLoader) textLoader.dispose();
textLoader = (globalThis as any).WebFont?.loadText({
fontName: font,
text: text.value,
family: "CustomFont",
outType: ot,
}) ?? null;
const el = document.getElementById("webfont-preview");
if (el) el.style.fontFamily = '"CustomFont", sans-serif';
}
function onFontChange(font: string) {
selectedFont.value = font;
}
watch([selectedFont, outType], ([font, ot]) => {
reloadFont(font, ot as "woff2" | "ttf");
});
async function refreshFonts() {
const fontList = await fetchFonts();
fonts.value = fontList;
if (fontList.length > 0 && !selectedFont.value) {
onFontChange(fontList[0].name);
}
}
</script>
<template>
<TypographyDemo v-if="showDemo" :onBack="() => showDemo = false" />
<div v-else 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">
<h1 style="font-size: 22px; font-weight: 600; margin: 0 0 4px 0">Web Font</h1>
<div style="display: flex; gap: 10px; align-items: center">
<button @click="toggleLocale" style="font-size: 12px; border: 1px solid #d9d9d9; border-radius: 6px; padding: 4px 10px; cursor: pointer; background: #fff; color: #333; min-width: 42px">
{{ locale === 'zh' ? 'EN' : '中' }}
</button>
<a href="#" @click.prevent="showDemo = true" 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">
{{ t('agentSkillDemo') }}
</a>
<a
href="https://github.com/2234839/web-font"
target="_blank"
rel="noopener noreferrer"
style="display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: #888; text-decoration: none; border: 1px solid #d9d9d9; border-radius: 6px; padding: 4px 10px"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
Star on GitHub
</a>
</div>
</div>
<p id="slogan" style="font-size: 24px; color: #888; margin: 0 0 36px 0">{{ t('slogan') }}</p>
<section style="margin-bottom: 28px">
<SelectorRow
:fonts="fonts"
:selectedFont="selectedFont"
:onFontChange="onFontChange"
:supportedOutTypes="serverConfig.supportedOutTypes || ['woff2', 'ttf']"
:outType="outType"
:onOutTypeChange="(v: 'woff2' | 'ttf') => outType = v"
/>
</section>
<section style="margin-bottom: 28px">
<label style="display: block; font-size: 13px; color: #555; margin-bottom: 6px">{{ t('inputLabel') }}</label>
<textarea
id="webfont-preview"
:rows="textareaRows"
:value="text"
@input="onTextChange(($event.target as HTMLTextAreaElement).value)"
:placeholder="t('inputPlaceholder')"
style="width: 100%; padding: 8px 12px; font-size: 32px; border: 1px solid #d9d9d9; border-radius: 6px; resize: none; box-sizing: border-box; outline: none; color: #e74c3c; line-height: 1.4"
/>
</section>
<section v-if="selectedFont" style="margin-bottom: 28px">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px">
<label style="display: block; font-size: 13px; color: #555; margin: 0">{{ t('cssLabel') }}</label>
<div style="display: flex; gap: 6px">
<button
style="padding: 3px 12px; font-size: 12px; border: 1px solid #d9d9d9; border-radius: 6px; cursor: pointer; background: #fff; color: #333"
@click="() => {
const a = document.createElement('a');
a.href = `/api?font=${selectedFont}&text=${encodeURIComponent(text)}&outType=${outType}`;
a.download = selectedFont.replace(/\.[^.]+$/, '') + `_subset.${outType}`;
a.click();
}"
>
{{ t('downloadFont') }}
</button>
<button
style="padding: 3px 12px; font-size: 12px; border: 1px solid #d9d9d9; border-radius: 6px; cursor: pointer; background: #fff; color: #333"
@click="async (e: MouseEvent) => {
const btn = e.currentTarget as HTMLButtonElement;
try {
await navigator.clipboard.writeText(cssStyle);
btn.textContent = t('copied');
setTimeout(() => { btn.textContent = t('copyCss'); }, 1500);
} catch {
btn.textContent = t('copyFailed');
setTimeout(() => { btn.textContent = t('copyCss'); }, 1500);
}
}"
>
{{ t('copyCss') }}
</button>
</div>
</div>
<pre style="background: #f7f7f8; padding: 16px; border-radius: 6px; font-size: 13px; font-family: 'SF Mono', Menlo, Consolas, monospace; overflow: auto; white-space: pre-wrap; word-break: break-all; line-height: 1.5; margin: 0">{{ cssStyle }}</pre>
</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">
<p><b>{{ t('principle') }}</b>{{ t('principleText') }}</p>
<p><b>{{ t('basicUsage') }}</b>{{ t('basicUsageText') }}</p>
<pre style="background: #f7f7f8; padding: 16px; border-radius: 6px; font-size: 13px; font-family: 'SF Mono', Menlo, Consolas, monospace; overflow: auto; white-space: pre-wrap; word-break: break-all; line-height: 1.5; margin-top: 4px">{{ `&lt;style&gt;\n@font-face {\n font-family: \"MyFont\";\n src: url(\"${origin}/api?font=字体名&text=你的文字\") format(\"woff2\");\n}\n.title { font-family: \"MyFont\"; }\n&lt;/style&gt;\n&lt;h1 class=\"title\"&gt;你的文字&lt;/h1&gt;` }}</pre>
<p style="margin-top: 12px"><b>{{ t('jsSdk') }}</b>{{ t('jsSdkText') }}<a href="/webfont-sdk.js" download="webfont-sdk.js">{{ t('downloadSdk') }}</a></p>
<pre style="background: #f7f7f8; padding: 16px; border-radius: 6px; font-size: 13px; font-family: 'SF Mono', Menlo, Consolas, monospace; overflow: auto; white-space: pre-wrap; word-break: break-all; line-height: 1.5; margin-top: 4px">{{ `&lt;script src=\"${origin}/webfont-sdk.js\"&gt;&lt;/script&gt;\n&lt;script&gt;\n WebFont.loadFont({\n fontName: \"字体文件名.ttf\",\n selector: \".my-element\",\n family: \"MyFont\",\n interval: 1000,\n });\n&lt;/script&gt;` }}</pre>
<p style="margin-top: 8px">{{ t('sdkModes') }}<code>WebFont.observeFont()</code>{{ t('observeFont') }}<code>WebFont.loadText()</code>{{ t('loadText') }}</p>
</section>
<footer style="margin-top: 48px; padding-top: 16px; border-top: 1px solid #eee; font-size: 12px; color: #999; text-align: center">
<p>{{ t('thanks') }}<a href="https://www.ruanyifeng.com/blog/2020/03/weekly-issue-100.html" target="_blank" rel="noopener noreferrer" style="color: #999">阮一峰科技爱好者周刊 100 </a> {{ t('thanksText') }}</p>
<p style="margin-top: 8px">{{ t('buyCoffee') }}<a href="https://shenzilong.cn/%E5%85%B3%E4%BA%8E/%E8%B5%9E%E5%8A%A9.html#" target="_blank" rel="noopener noreferrer" style="color: #e6a700; text-decoration: underline">{{ t('buyCoffeeAction') }}</a>{{ t('buyCoffeeSuffix') }}</p>
<p style="margin-top: 12px"><a href="https://github.com/2234839/web-font/blob/new/skills/chinese-web-font.md" target="_blank" style="color: #8b7355; text-decoration: underline">{{ t('viewSkill') }}</a></p>
</footer>
<a
href="https://shenzilong.cn/%E5%85%B3%E4%BA%8E/%E8%B5%9E%E5%8A%A9.html#"
target="_blank"
rel="noopener noreferrer"
style="position: fixed; right: 0; top: 50%; transform: translateY(-50%); background: #e6a700; color: #fff; padding: 12px 6px; font-size: 12px; writing-mode: vertical-rl; text-decoration: none; border-radius: 6px 0 0 6px; box-shadow: -2px 0 8px rgba(0,0,0,0.1); z-index: 999; transition: padding 0.2s"
onmouseover="this.style.paddingRight='10px'"
onmouseout="this.style.paddingRight='6px'"
>
{{ t('sponsor') }}
</a>
</div>
</template>

View File

@ -1,62 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
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>
<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">
<span style="font-size: 13px; font-weight: 600; color: #e6a700">DEV 字体调试预览</span>
<span style="font-size: 11px; color: #aaa">所有字体的 woff2 / ttf 渲染效果</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 }}
</div>
</div>
</div>
</section>
</template>

View File

@ -1,50 +0,0 @@
<script setup lang="ts">
import type { FontInfo } from "./api";
import { t } from "./i18n";
defineProps<{
fonts: FontInfo[];
selectedFont: string;
onFontChange: (font: string) => void;
supportedOutTypes: ("woff2" | "ttf")[];
outType: "woff2" | "ttf";
onOutTypeChange: (v: "woff2" | "ttf") => void;
}>();
const outTypeLabels: Record<string, () => string> = {
woff2: () => t("woff2Label"),
ttf: () => t("ttfLabel"),
};
const outTypeDescs: Record<string, () => string> = {
woff2: () => t("woff2Desc"),
ttf: () => t("ttfDesc"),
};
</script>
<template>
<div style="display: flex; gap: 12px">
<div style="flex: 1">
<label style="display: block; font-size: 13px; color: #555; margin-bottom: 6px">{{ t('selectFont') }}</label>
<select
:value="selectedFont"
@change="onFontChange(($event.target).value)"
style="width: 100%; padding: 8px 12px; font-size: 14px; border: 1px solid #d9d9d9; border-radius: 6px; outline: none; box-sizing: border-box; cursor: pointer; appearance: none; background-image: url(&quot;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M2 4l4 4 4-4' fill='none' stroke='%23999' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E&quot;); background-repeat: no-repeat; background-position: right 10px center; padding-right: 28px"
>
<option value="">{{ t('pleaseSelect') }}</option>
<option v-for="f in fonts" :key="f.name" :value="f.name">{{ f.name }}</option>
</select>
</div>
<div style="width: 160px">
<label style="display: block; font-size: 13px; color: #555; margin-bottom: 6px">{{ t('outputFormat') }}</label>
<select
:value="outType"
@change="onOutTypeChange(($event.target).value)"
style="width: 100%; padding: 8px 12px; font-size: 14px; border: 1px solid #d9d9d9; border-radius: 6px; outline: none; box-sizing: border-box; cursor: pointer; appearance: none; background-image: url(&quot;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M2 4l4 4 4-4' fill='none' stroke='%23999' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E&quot;); background-repeat: no-repeat; background-position: right 10px center; padding-right: 28px"
>
<option v-for="ot in supportedOutTypes" :key="ot" :value="ot">{{ outTypeLabels[ot]() }}</option>
</select>
<p style="font-size: 11px; color: #bbb; margin-top: 4px">{{ outTypeDescs[outType]() }}</p>
</div>
</div>
</template>

View File

@ -1,78 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { fetchStats, type ServerStats } from "./api";
import { t, locale } from "./i18n";
function formatUptime(seconds: number): string {
if (locale.value === "en") {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h < 24) return `${h}h ${m}m ${s}s`;
const d = Math.floor(h / 24);
return `${d}d ${h % 24}h`;
}
if (seconds < 60) return `${seconds}`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}${seconds % 60}`;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h < 24) return `${h}${m}${s}`;
const d = Math.floor(h / 24);
return `${d}${h % 24}${m}`;
}
const data = ref<ServerStats | null>(null);
let timer: ReturnType<typeof setInterval> | null = null;
async function load() {
const s = await fetchStats().catch(() => null);
if (s) data.value = s;
}
function startPolling() {
if (timer) return;
load();
timer = setInterval(load, 10_000);
}
function stopPolling() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
function onVisibilityChange() {
if (document.visibilityState === "visible") {
startPolling();
} else {
stopPolling();
}
}
onMounted(() => {
document.addEventListener("visibilitychange", onVisibilityChange);
startPolling();
});
onUnmounted(() => {
stopPolling();
document.removeEventListener("visibilitychange", onVisibilityChange);
});
</script>
<template>
<section v-if="data" style="margin-top: 24px; margin-bottom: 28px; padding: 12px 16px; background: #f0f0f0; border-radius: 8px">
<div style="font-size: 13px; font-weight: 600; color: #333; margin-bottom: 4px">{{ t('serverStatus') }}</div>
<div style="display: flex; gap: 20px; flex-wrap: wrap; font-size: 13px; color: #555; line-height: 2">
<span><b style="color: #333">{{ t('uptime') }}</b> {{ formatUptime(data.uptime) }}</span>
<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">{{ t('cacheHit') }}</b> {{ data.subsetRequests > 0 ? ((data.subsetCacheHits / data.subsetRequests) * 100).toFixed(1) : '0.0' }}%</span>
</div>
</section>
</template>

View File

@ -1,149 +0,0 @@
<script setup lang="ts">
/** Before/After Typography Demo — 同一内容,只有字体不同 */
import { onMounted } from "vue"
import { t } from "./i18n"
const props = defineProps<{ onBack: () => void }>()
declare global {
interface Window {
WebFont?: {
observeFont: (options: {
fontName: string
selector: string
family: string
outType?: string
}) => { dispose: () => void }
}
}
}
onMounted(() => {
if (!window.WebFont) return
window.WebFont.observeFont({
fontName: "令东齐伋复刻体.ttf",
selector: ".demo-after .zh-font",
family: "ZenSerif",
outType: "woff2",
})
})
</script>
<template>
<div style="min-height: 100vh">
<!-- 顶部导航 -->
<div style="position: sticky; top: 0; z-index: 100; background: rgba(255,255,255,0.95); backdrop-filter: blur(8px); border-bottom: 1px solid #eee; padding: 12px 24px; display: flex; justify-content: space-between; align-items: center">
<button @click="props.onBack" style="padding: 6px 16px; border: 1px solid #ddd; border-radius: 6px; background: #fff; cursor: pointer; font-size: 14px">
{{ t('back') }}
</button>
<span style="font-size: 13px; color: #888">{{ t('demoSlogan') }} · <a href="https://github.com/2234839/web-font/blob/new/skills/chinese-web-font.md" target="_blank" style="color: #8b7355; text-decoration: none">{{ t('viewSkillLink') }}</a></span>
</div>
<div style="display: flex; min-height: calc(100vh - 49px)">
<!-- ===== Before ===== -->
<div style="flex: 1; overflow-y: auto; border-right: 2px solid #eee; background: #fff">
<div style="display: inline-block; padding: 4px 12px; background: #f5f5f5; color: #999; font-size: 12px; border-radius: 4px; margin: 16px; font-family: -apple-system, sans-serif">
{{ t('beforeLabel') }}
</div>
<!-- Hero -->
<div style="position: relative; padding: 120px 32px 80px; text-align: center; overflow: hidden">
<div style="font-family: sans-serif; font-size: 200px; font-weight: 700; color: rgba(0,0,0,0.04); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); white-space: nowrap; pointer-events: none; line-height: 1">
静心
</div>
<h1 style="font-family: sans-serif; font-size: 48px; font-weight: 600; color: #2c2c2c; margin: 0; line-height: 1.3">
静心茶舍
</h1>
<p style="font-family: sans-serif; font-size: 18px; color: #888; margin: 24px 0 0; letter-spacing: 0.1em">
以茶为媒 · 静心观自在
</p>
</div>
<!-- 内容区 -->
<div style="padding: 0 48px 80px">
<h2 style="font-family: sans-serif; font-size: 24px; font-weight: 600; margin: 48px 0 20px; color: #3a3a3a">
一叶知秋
</h2>
<p style="font-family: sans-serif; font-size: 16px; line-height: 1.8; color: #4a4a4a; text-indent: 2em">
山间晨露未晞茶人已入林深处指尖轻捻择其嫩芽一二置于竹篮之中此乃一年之始亦是一叶与万物的初遇
</p>
<h2 style="font-family: sans-serif; font-size: 24px; font-weight: 600; margin: 48px 0 20px; color: #3a3a3a">
茶之六味
</h2>
<p style="font-family: sans-serif; font-size: 16px; line-height: 1.8; color: #4a4a4a; text-indent: 2em">
茶之六味恰似人生六境
</p>
<!-- 引用 -->
<div style="margin: 48px 0; padding: 32px; background: #f7f7f5; border-radius: 8px; text-align: center">
<p style="font-family: sans-serif; font-size: 20px; color: #666; line-height: 1.8; margin: 0">
茶不过一仰一俯之间<br>然天地之大尽在一盏之中
</p>
</div>
<!-- CTA -->
<div style="text-align: center; margin-top: 64px">
<span style="display: inline-block; padding: 14px 48px; border: 1px solid #8b7355; color: #8b7355; font-size: 15px; letter-spacing: 0.1em; border-radius: 4px">
预约品茗
</span>
</div>
</div>
</div>
<!-- ===== After ===== -->
<div class="demo-after" style="flex: 1; overflow-y: auto; background: #fff">
<div style="display: inline-block; padding: 4px 12px; background: #8b7355; color: #fff; font-size: 12px; border-radius: 4px; margin: 16px; font-family: -apple-system, sans-serif">
{{ t('afterLabel') }}
</div>
<!-- Hero -->
<div style="position: relative; padding: 120px 32px 80px; text-align: center; overflow: hidden">
<div class="zh-font" style="font-family: 'ZenSerif', 'Noto Serif CJK SC', 'STSong', serif; font-size: 200px; font-weight: 600; color: rgba(139,115,85,0.08); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); white-space: nowrap; pointer-events: none; line-height: 1; letter-spacing: 0.05em">
静心
</div>
<h1 class="zh-font" style="font-family: 'ZenSerif', 'Noto Serif CJK SC', 'STSong', serif; font-size: 48px; font-weight: 600; color: #2c2c2c; margin: 0; line-height: 1.3; letter-spacing: 0.08em">
静心茶舍
</h1>
<p class="zh-font" style="font-family: 'ZenSerif', 'Noto Serif CJK SC', 'STSong', serif; font-size: 18px; color: #888; margin: 24px 0 0; letter-spacing: 0.12em">
以茶为媒 · 静心观自在
</p>
</div>
<!-- 内容区 -->
<div style="padding: 0 48px 80px">
<h2 class="zh-font" style="font-family: 'ZenSerif', 'Noto Serif CJK SC', 'STSong', serif; font-size: 24px; font-weight: 600; margin: 48px 0 20px; color: #3a3a3a; letter-spacing: 0.06em">
一叶知秋
</h2>
<p style="font-family: sans-serif; font-size: 16px; line-height: 1.8; color: #4a4a4a; text-indent: 2em">
山间晨露未晞茶人已入林深处指尖轻捻择其嫩芽一二置于竹篮之中此乃一年之始亦是一叶与万物的初遇
</p>
<h2 class="zh-font" style="font-family: 'ZenSerif', 'Noto Serif CJK SC', 'STSong', serif; font-size: 24px; font-weight: 600; margin: 48px 0 20px; color: #3a3a3a; letter-spacing: 0.06em">
茶之六味
</h2>
<p style="font-family: sans-serif; font-size: 16px; line-height: 1.8; color: #4a4a4a; text-indent: 2em">
茶之六味恰似人生六境
</p>
<!-- 引用 -->
<div style="margin: 48px 0; padding: 32px; background: rgba(196,181,160,0.1); border-radius: 8px; text-align: center">
<p class="zh-font" style="font-family: 'ZenSerif', 'Noto Serif CJK SC', 'STSong', serif; font-size: 20px; color: #666; line-height: 1.8; margin: 0; letter-spacing: 0.05em">
茶不过一仰一俯之间<br>然天地之大尽在一盏之中
</p>
</div>
<!-- CTA -->
<div style="text-align: center; margin-top: 64px">
<span class="zh-font" style="display: inline-block; padding: 14px 48px; border: 1px solid #8b7355; color: #8b7355; font-size: 15px; letter-spacing: 0.15em; border-radius: 4px; font-family: 'ZenSerif', serif">
预约品茗
</span>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -1,117 +0,0 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import { uploadFont, type UploadResult, type ServerConfig } from "./api";
import { t } from "./i18n";
const ACCEPT = ".ttf,.otf,.woff,.woff2";
const props = defineProps<{
config: ServerConfig;
onUploaded: () => void;
}>();
function useUpload(onSuccess: () => void) {
const file = ref<File | null>(null);
const apiKey = ref("");
const uploading = ref(false);
const msg = ref<{ ok: boolean; text: string } | null>(null);
function showMsg(ok: boolean, text: string) {
msg.value = { ok, text };
setTimeout(() => { msg.value = null; }, 3000);
}
async function upload(mode: "temp" | "admin", key?: string) {
const f = file.value;
if (!f) return;
uploading.value = true;
const result: UploadResult = await uploadFont(f, mode, key);
uploading.value = false;
if (result.success) {
showMsg(true, t("uploadSuccess"));
file.value = null;
onSuccess();
} else {
showMsg(false, result.error ?? t("uploadFailed"));
}
}
return { file, apiKey, uploading, msg, upload };
}
const temp = useUpload(() => props.onUploaded());
const admin = useUpload(() => props.onUploaded());
const canUpload = computed(() => props.config.enableTempUpload || props.config.adminUploadEnabled);
function onFileSelect(e: Event, target: ReturnType<typeof useUpload>) {
const f = (e.target as HTMLInputElement).files?.[0];
if (f) target.file.value = f;
}
</script>
<template>
<section v-if="canUpload" style="margin-bottom: 28px">
<label style="display: block; font-size: 14px; font-weight: 500; margin-bottom: 12px">{{ t('uploadFont') }}</label>
<div style="font-size: 12px; color: #e6a700; margin-bottom: 12px">{{ t('uploadTip') }}</div>
<div
v-if="temp.msg.value"
:style="{
padding: '8px 12px',
marginBottom: '12px',
borderRadius: '6px',
fontSize: '13px',
background: temp.msg.value.ok ? '#f0faf0' : '#fef2f2',
color: temp.msg.value.ok ? '#166534' : '#b91c1c',
border: `1px solid ${temp.msg.value.ok ? '#bbf7d0' : '#fecaca'}`,
}"
>
{{ temp.msg.value.text }}
</div>
<div v-if="config.enableTempUpload" style="padding: 16px; border: 1px solid #e8e8e8; border-radius: 8px; margin-bottom: 16px">
<div style="font-size: 14px; font-weight: 500; margin-bottom: 4px">{{ t('guestUpload') }}</div>
<div style="font-size: 12px; color: #999; margin-bottom: 12px">{{ t('guestUploadDesc') }}</div>
<div style="display: flex; gap: 8px; align-items: center">
<label style="padding: 6px 20px; font-size: 14px; border: 1px solid #d9d9d9; border-radius: 6px; cursor: pointer; background: #fff; color: #333; display: inline-flex; align-items: center">
{{ t('selectFile') }}
<input type="file" :accept="ACCEPT" style="display: none" @change="onFileSelect($event, temp)" />
</label>
<span style="font-size: 13px; color: #666">{{ temp.file.value?.name ?? t('noFile') }}</span>
<button
:disabled="!temp.file.value || temp.uploading.value"
:style="{ padding: '6px 20px', fontSize: '14px', border: '1px solid #d9d9d9', borderRadius: '6px', cursor: temp.file.value && !temp.uploading.value ? 'pointer' : 'not-allowed', background: '#fff', color: '#333', opacity: temp.file.value && !temp.uploading.value ? 1 : 0.5 }"
@click="temp.upload('temp')"
>
{{ temp.uploading.value ? '...' : t('upload') }}
</button>
</div>
</div>
<div v-if="config.adminUploadEnabled" style="padding: 16px; border: 1px solid #e8e8e8; border-radius: 8px; margin-bottom: 16px">
<div style="font-size: 14px; font-weight: 500; margin-bottom: 4px">{{ t('adminUpload') }}</div>
<div style="font-size: 12px; color: #999; margin-bottom: 12px">{{ t('adminUploadDesc') }}</div>
<input
type="text"
autocomplete="off"
v-model="admin.apiKey.value"
placeholder="API Key"
style="padding: 6px 12px; font-size: 14px; border: 1px solid #d9d9d9; border-radius: 6px; outline: none; box-sizing: border-box; width: 100%; margin-bottom: 10px"
/>
<div style="display: flex; gap: 8px; align-items: center">
<label style="padding: 6px 20px; font-size: 14px; border: 1px solid #d9d9d9; border-radius: 6px; cursor: pointer; background: #fff; color: #333; display: inline-flex; align-items: center">
{{ t('selectFile') }}
<input type="file" :accept="ACCEPT" style="display: none" @change="onFileSelect($event, admin)" />
</label>
<span style="font-size: 13px; color: #666">{{ admin.file.value?.name ?? t('noFile') }}</span>
<button
:disabled="!admin.file.value || !admin.apiKey.value || admin.uploading.value"
:style="{ padding: '6px 20px', fontSize: '14px', border: '1px solid #d9d9d9', borderRadius: '6px', cursor: admin.file.value && admin.apiKey.value && !admin.uploading.value ? 'pointer' : 'not-allowed', background: '#fff', color: '#333', opacity: admin.file.value && admin.apiKey.value && !admin.uploading.value ? 1 : 0.5 }"
@click="admin.upload('admin', admin.apiKey.value)"
>
{{ admin.uploading.value ? '...' : t('upload') }}
</button>
</div>
</div>
</section>
</template>

View File

@ -1,61 +0,0 @@
export interface FontInfo {
name: string;
dir: string;
}
export interface ServerConfig {
enableTempUpload: boolean;
adminUploadEnabled: boolean;
supportedOutTypes: ("woff2" | "ttf")[];
}
export interface UploadResult {
success: boolean;
error?: string;
}
export interface ServerStats {
uptime: number;
totalRequests: number;
subsetRequests: number;
subsetCacheHits: number;
totalChars: number;
subsetCacheEntries: number;
fontBufferCacheEntries: number;
}
export async function fetchFonts(): Promise<FontInfo[]> {
const res = await fetch("/api/fonts");
return res.json();
}
export async function fetchConfig(): Promise<ServerConfig> {
const res = await fetch("/api/config");
return res.json();
}
export async function uploadFont(
file: File,
mode: "temp" | "admin",
apiKey?: string,
): Promise<UploadResult> {
const formData = new FormData();
formData.append("font", file);
const headers: Record<string, string> = {};
if (apiKey) {
headers["Authorization"] = `Bearer ${apiKey}`;
}
const res = await fetch(`/api/upload?mode=${mode}`, {
method: "POST",
body: formData,
headers,
});
return res.json();
}
export async function fetchStats(): Promise<ServerStats> {
const res = await fetch("/api/stats");
return res.json();
}

View File

@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

85
src/app.controller.ts Normal file
View File

@ -0,0 +1,85 @@
import {
Controller,
Get,
Param,
Query,
Response,
Res,
Req,
Post,
Body,
} from '@nestjs/common';
import { AppService } from './app.service';
import { join } from 'path';
import { promises as fs } from 'fs';
import { Request } from 'express';
import { Stream } from 'stream';
import { req_par } from './req.decorator';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
/** 压缩字体 */
@Get('fontmin')
font_min(@Query('text') text, @Query('font') font,@req_par('host_url') host_url:string) {
return this.appService.font_min(text, font,host_url);
}
@Post('fontmin')
font_min_post(@Body() body: { text: string; font: string }[],@req_par('host_url') host_url:string) {
const res = body.map(par => {
return new Promise((resolve, reject) => {
this.appService
.font_min(par.text, par.font,host_url)
.then(r => {
resolve({
font: par.font,
css:r,
status: 'success',
});
})
.catch(e => {
resolve({
font: par.font,
status: 'failure',
});
});
});
});
return Promise.all(res);
}
/** 返回字体列表 */
@Get('font_list')
font_list() {
return this.appService.font_list();
}
/** 压缩字体 */
@Get('generate_fonts_dynamically*')
async generate_fonts_dynamically(
@Req() req: Request,
@Res() res,
@Query('text') text: string,
@Query('font') font: string,
@Query('temp') temp: string,
) {
const type = req.url.match(/\.(.*)\?/)[1];
res.set({
'Content-Type': `font/${type}`,
});
if (!text) return ' ';
const file = await this.appService.generate_fonts_dynamically(
text,
font,
temp,
type,
);
const bufferStream = new Stream.PassThrough();
bufferStream.end(file);
bufferStream.pipe(res);
}
}
function promise_execute_all<T>(params: Promise<T>[]) {}

21
src/app.module.ts Normal file
View File

@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
console.log( join(__dirname, '..'));
@Module({
imports: [
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..'),
}),
ServeStaticModule.forRoot({
rootPath: join(__dirname, '../../asset'),
serveRoot: '/asset',
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

142
src/app.service.ts Normal file
View File

@ -0,0 +1,142 @@
import { Injectable } from '@nestjs/common';
import Fontmin from 'fontmin';
const { zip } = require('zip-a-folder');
import { join } from 'path';
import crypto from 'crypto';
import { config } from './config';
import { promises as fs } from 'fs';
import { req_par } from './req.decorator';
const font_src="./asset/font_src/"
@Injectable()
export class AppService {
font_list() {
const font_dir = join(__dirname, `../../${font_src}`);
return fs.readdir(font_dir);
}
async font_min(text: string, font: string,server_url:string) {
/** 因为 text 为 空或者是空格之类的 会导致 fontmin 运算很久 */
text+='●'
const srcPath = `${font_src}${font}.ttf`; // 字体源文件
const outPath = `asset/font/${Date.now()}/`;
const destPath = `./${outPath}`; // 输出路径
// 初始化
const fontmin = new Fontmin()
.src(srcPath) // 输入配置
.use(
Fontmin.glyph({
text, // 所需文字
}),
)
.use(Fontmin.ttf2eot()) // eot 转换插件
.use(Fontmin.ttf2woff()) // woff 转换插件
.use(Fontmin.ttf2svg()) // svg 转换插件
.use(Fontmin.css({ fontPath: `${server_url}${outPath}` })) // css 生成插件
.dest(destPath); // 输出配置
// 执行
return new Promise((resolve, reject) => {
fontmin.run(function(err, files, stream) {
if (err) {
// 异常捕捉
reject(err);
} else {
const css = files
.filter(f =>
(f.history[f.history.length - 1] as string).endsWith('.css'),
)
.map(f => f._contents.toString())[0];
zip(
join(__dirname, '../../', destPath),
join(__dirname, '../../', destPath, 'asset.zip'),
);
// resolve({code:0,fil:files.map(f=>f._contents.toString())}); // 成功
// resolve({code:0,files}); // 成功
resolve(css); // 成功
}
});
});
}
async generate_fonts_dynamically(
text: string,
font: string,
temp: string,
type: string,
) {
text+='●'
const hash = crypto.createHash('md5');
hash.update(`${type}${font}${text}`);
const hash_str = hash.digest('hex');
const srcPath = `${font_src}${font}.ttf`; // 字体源文件
const outPath = `asset/dynamically/${hash_str}`;
const destPath = `./${outPath}`; // 输出路径
const full_path = join(__dirname, '../../', destPath, `${font}.${type}`);
/** 需要持久化 */
if (temp !== 'true') {
try {
return await fs.readFile(full_path);
} catch (error) {
console.log(`开始生成 ${full_path}`,error);
}
}
// 初始化
const fontmin = new Fontmin()
.src(srcPath) // 输入配置
.use(
Fontmin.glyph({
text, // 所需文字
}),
);
if ('eot' === type) {
fontmin.use(Fontmin.ttf2eot()); // eot 转换插件
}
if ('woff' === type) {
fontmin.use(Fontmin.ttf2woff()); // eot 转换插件
}
if ('svg' === type) {
fontmin.use(Fontmin.ttf2svg()); // eot 转换插件
}
/** 缓存数据 */
if (temp !== 'true') {
fontmin.dest(destPath)
}
// 执行
return new Promise((resolve, reject) => {
fontmin.run(async function(err, files, stream) {
if (err) {
// 异常捕捉
reject(err);
} else {
const buffer = files.filter(f =>
/** 筛选需要的类型 */
(f.history[f.history.length - 1] as string).endsWith(type),
)[0]._contents;
resolve(buffer); // 成功
/** 存个日志 */
if (temp !== 'true') {
const content_path = join(
__dirname,
'../../',
destPath,
`content.txt`,
);
try {
await fs.appendFile(
content_path,
`type:${type}\nfont:${font}\ntext:${text}`,
);
} catch (error) {
console.error(`${content_path} 失败`);
}
}
}
});
});
}
}

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 166 155.3"><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" fill="#76b3e1"/><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="27.5" y1="3" x2="152" y2="63.5"><stop offset=".1" stop-color="#76b3e1"/><stop offset=".3" stop-color="#dcf2fd"/><stop offset="1" stop-color="#76b3e1"/></linearGradient><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" opacity=".3" fill="url(#a)"/><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" fill="#518ac8"/><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="95.8" y1="32.6" x2="74" y2="105.2"><stop offset="0" stop-color="#76b3e1"/><stop offset=".5" stop-color="#4377bb"/><stop offset="1" stop-color="#1f3b77"/></linearGradient><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" opacity=".3" fill="url(#b)"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="18.4" y1="64.2" x2="144.3" y2="149.8"><stop offset="0" stop-color="#315aa9"/><stop offset=".5" stop-color="#518ac8"/><stop offset="1" stop-color="#315aa9"/></linearGradient><path d="M134 80a45 45 0 00-48-15L24 85 4 120l112 19 20-36c4-7 3-15-2-23z" fill="url(#c)"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="75.2" y1="74.5" x2="24.4" y2="260.8"><stop offset="0" stop-color="#4377bb"/><stop offset=".5" stop-color="#1a336b"/><stop offset="1" stop-color="#1a336b"/></linearGradient><path d="M114 115a45 45 0 00-48-15L4 120s53 40 94 30l3-1c17-5 23-21 13-34z" fill="url(#d)"/></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

3
src/config.ts Normal file
View File

@ -0,0 +1,3 @@
export const config={
web_font_path:"//127.0.0.1:3000/"
}

View File

@ -1,166 +0,0 @@
import { ref, computed } from "vue"
export type Locale = "zh" | "en"
/** 检测浏览器语言偏好 */
function detectLocale(): Locale {
const saved = localStorage.getItem("webfont-locale") as Locale | null
if (saved === "zh" || saved === "en") return saved
const lang = navigator.language.toLowerCase()
return lang.startsWith("zh") ? "zh" : "en"
}
/** 当前语言 */
export const locale = ref<Locale>(detectLocale())
/** 切换语言 */
export function toggleLocale() {
locale.value = locale.value === "zh" ? "en" : "zh"
localStorage.setItem("webfont-locale", locale.value)
}
const messages = {
zh: {
// App.vue
slogan: "如清风似闪电,超级快的字体子集化裁剪",
inputLabel: "输入文本预览效果",
inputPlaceholder: "在此输入文本...",
cssLabel: "CSS 代码",
downloadFont: "下载字体",
copyCss: "复制 CSS",
copied: "已复制",
copyFailed: "复制失败",
principle: "原理:",
principleText: "服务端根据 text 参数裁剪字体,只返回所需字符的子集。相同 URL 的请求会被浏览器自动缓存。",
basicUsage: "基础用法:",
basicUsageText: "将 CSS 复制到你的页面,修改 text 参数中的文字即可:",
jsSdk: "JS SDK推荐",
jsSdkText: "增量加载字体片段,按需请求,不会出现全量字体闪烁。",
downloadSdk: "下载 SDK",
sdkModes: "还支持",
observeFont: "MutationObserver 事件驱动)和",
loadText: "手动传文本两种方式多种方式可同时使用SDK 内部自动按字体去重增量加载。",
thanks: "感谢",
thanksText: "收录本项目",
buyCoffee: "觉得好用?",
buyCoffeeAction: "请作者喝杯咖啡",
buyCoffeeSuffix: ",支持持续开发",
viewSkill: "查看 AI Chinese Font Skill →",
sponsor: "赞助支持",
agentSkillDemo: "Agent Skill Demo",
// FontSelector.vue
selectFont: "选择字体",
pleaseSelect: "-- 请选择 --",
outputFormat: "输出格式",
woff2Label: "WOFF2 体积更小",
ttfLabel: "TTF 速度更快",
woff2Desc: "约压缩 50%,适合生产",
ttfDesc: "无编码开销,适合开发",
// UploadSection.vue
uploadTip: "支持 .ttf 和 .otf 格式,建议上传 .ttf 字体文件以获得最佳兼容性",
uploadFont: "上传字体",
guestUpload: "游客上传",
guestUploadDesc: "临时文件,最多保留 10 个,总大小限制 200MB超出后自动删除最早上传的",
adminUpload: "管理员上传",
adminUploadDesc: "永久保存,需要 API Key 认证",
selectFile: "选择文件",
noFile: "未选择文件",
upload: "上传",
uploadSuccess: "上传成功",
uploadFailed: "上传失败",
// StatsPanel.vue
serverStatus: "服务状态",
uptime: "运行",
requests: "请求",
times: "次",
subset: "裁剪",
chars: "文字",
charUnit: "字",
cacheHit: "缓存命中",
// TypographyDemo.vue
demoSlogan: "字体不同,体验天壤之别",
viewSkillLink: "查看 Skill →",
back: "← 返回",
beforeLabel: "Before: 默认字体",
afterLabel: "After: AI 使用 Skill 后可调用特殊字体",
},
en: {
// App.vue
slogan: "Lightning-fast Chinese font subsetting",
inputLabel: "Preview with your text",
inputPlaceholder: "Type text here...",
cssLabel: "CSS Code",
downloadFont: "Download",
copyCss: "Copy CSS",
copied: "Copied!",
copyFailed: "Failed",
principle: "How it works: ",
principleText: "The server subsets fonts based on the text parameter, returning only the glyphs needed. Identical URLs are cached by the browser.",
basicUsage: "Basic usage: ",
basicUsageText: "Copy the CSS to your page and modify the text parameter:",
jsSdk: "JS SDK (Recommended): ",
jsSdkText: "Incremental font loading, on-demand requests, no full-font flicker.",
downloadSdk: "Download SDK",
sdkModes: "Also supports ",
observeFont: " (MutationObserver-driven) and ",
loadText: " (manual text). Multiple modes can be used simultaneously with automatic deduplication.",
thanks: "Thanks to ",
thanksText: " for featuring this project",
buyCoffee: "Find it useful? ",
buyCoffeeAction: "Buy the author a coffee",
buyCoffeeSuffix: " to support development",
viewSkill: "View AI Chinese Font Skill →",
sponsor: "Sponsor",
agentSkillDemo: "Agent Skill Demo",
// FontSelector.vue
selectFont: "Select font",
pleaseSelect: "-- Select --",
outputFormat: "Format",
woff2Label: "WOFF2 Smaller",
ttfLabel: "TTF Faster",
woff2Desc: "~50% smaller, for production",
ttfDesc: "No encoding overhead, for dev",
// UploadSection.vue
uploadTip: "Supports .ttf and .otf. .ttf recommended for best compatibility",
uploadFont: "Upload Font",
guestUpload: "Guest Upload",
guestUploadDesc: "Temporary files, max 10 files, 200MB total. Oldest deleted when full.",
adminUpload: "Admin Upload",
adminUploadDesc: "Permanent storage, requires API Key",
selectFile: "Choose file",
noFile: "No file selected",
upload: "Upload",
uploadSuccess: "Upload successful",
uploadFailed: "Upload failed",
// StatsPanel.vue
serverStatus: "Server Status",
uptime: "Uptime",
requests: "Requests",
times: "",
subset: "Subset",
chars: "Chars",
charUnit: "chars",
cacheHit: "Cache Hit",
// TypographyDemo.vue
demoSlogan: "Same content, different fonts, completely different feel",
viewSkillLink: "View Skill →",
back: "← Back",
beforeLabel: "Before: Default Font",
afterLabel: "After: AI with Skill can use custom fonts",
},
} as const
export type MessageKey = keyof typeof messages.zh
/** 翻译函数 */
export function t(key: MessageKey): string {
return messages[locale.value][key] ?? key
}

View File

@ -1,4 +1,15 @@
import { createApp } from "vue";
import App from "./App.vue";
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import serveStatic from 'serve-static';
import { Response } from 'express';
import { logger } from './middleware/logger.middleware';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors();
app.use(logger);
await app.listen(3000);
}
bootstrap();
createApp(App).mount("#root");

View File

@ -0,0 +1,26 @@
import { Response, Request } from 'express';
const NS_PER_SEC = 1e9;
export async function logger(req: Request, res: Response, next) {
const time = process.hrtime();
next();
res.once('finish', () => {
const diff = process.hrtime(time);
console.log(
`[${req.headers['x-forwarded-for'] ||
req.connection.remoteAddress ||
req.socket.remoteAddress ||
req.connection.remoteAddress}][${(diff[0] * NS_PER_SEC + diff[1]) /
1000000}]`,
decodeURI_catch(req.url),
);
});
}
function decodeURI_catch(url: string) {
try {
return decodeURI(url);
} catch (error) {
return url;
}
}

9
src/req.decorator.ts Normal file
View File

@ -0,0 +1,9 @@
import { createParamDecorator } from '@nestjs/common';
import { Request } from 'express';
export const req_par = createParamDecorator((data: string, req:Request) => {
if('host_url'===data){
return `//${req.headers.host}/`
}
return req
});

1
src/vite-env.d.ts vendored
View File

@ -1 +0,0 @@
/// <reference types="vite/client" />

140
static/App.svelte Normal file
View File

@ -0,0 +1,140 @@
<script>
import { writable } from 'svelte/store';
import { get_font, get_font_list,server,post_fontmin } from './req';
/** 可用的字体列表 {id:number,name:string:selected:undefined | boolen,css:undefined|string}*/
$: font_list = [];
get_font_list().then(r => {
font_list = r.map(ttf => ({ name: ttf.replace(/\.ttf$/, '') }));
});
/** 选择的文字 */
let text = '在此输入需要提取的文字\n在右侧选择字体\n然后点击下方的生成字体按钮';
/** 请求方式 */
let request_method="post"
/** 用于测试动态生成接口 */
let generate_fonts_dynamically=`<style>
@font-face {
font-family: "test";
src:
url("${server}generate_fonts_dynamically.ttf?temp=true&font=优设标题黑&text=优设标题黑(直接改这里和前面的字体名看效果)") format("truetype");
font-style: normal;
font-weight: normal;
}
</style>`
$: selected_font = font_list.filter(font => font.selected);
function generate_font() {
if('post'===request_method){
/** 使用 post 请求,单请求方式 */
post_fontmin(
selected_font.map(f=>({
font:f.name, text
}))
).then(res=>{
selected_font.forEach(font=>{
let r=res.find(o=>o.font===font.name).css
font_processing(font,r)
})
})
}
if('get'===request_method){
/** 使用 get 请求,多请求方式 */
selected_font.forEach(font => {
get_font(font.name, text)
.then(r => {
font_processing(font,r)
})
});
}
function font_processing(font,r) {
r=r.replace(/\/\/.*?\//g,server)
const family = r.match(/font-family: "(.*)"/)[1];
font.css = r;
font.family = family;
font.zip=server+r.match(/(asset\/font\/\d+\/)/)[0]+'asset.zip'
/** 因为要触发其他更新则必须对这个变量重新赋值 */
font_list = font_list;
}
}
function copy(str) {
var input = document.getElementById("copy_box");
input.value=str
input.focus();
input.setSelectionRange(0, -1); // 全选
document.execCommand("copy")
alert(`复制成功\n${str}`)
}
</script>
{#each font_list as font, i}
{@html "<style>"+font.css+'.'+font.name+"{font-family:"+font.family+"}</style>"}
{/each}
<h1 class="text-lg text-center mb-3 font-bold">web font 字体裁剪工具</h1>
<textarea id="copy_box" class="w-0 h-0 fixed -m-24" />
<div class="flex justify-evenly">
<textarea
bind:value={text}
class="border flex-1 m-1"
placeholder="在此输入需要提取的文字 在右侧选择字体 然后点击下方的生成字体按钮"
cols="40"
rows="3" />
<div class="flex-1 m-1 flex flex-wrap">
{#each font_list as font, i}
<div
on:click={e => (font.selected = !font.selected)}
class="c-label {font.selected ? 'c-label-selected' : ''}
{font.name}">
{font.name}
</div>
{/each}
</div>
</div>
<div class="flex">
<div on:click={generate_font} class="bg-red-200 text-red-600 rounded-md px-2 hover:bg-red-400 hover:text-white duration-75 flex items-center shadow-md">
生成字体
</div>
<div class="flex border ml-2 items-end">
<div
on:click={e => request_method="post"}
class="c-label {request_method==="post" ? 'c-label-selected' : ''}">
使用 post 请求
</div>
<div
on:click={e => request_method="get"}
class="c-label {request_method==="get" ? 'c-label-selected' : ''}">
使用 get 请求
</div>
<div class="text-sm">* 具体区别请打开控制台查看请求</div>
</div>
</div>
{#each selected_font as font, i}
<div class={font.name}>
<div style="font-size:2rem">{text}</div>
<div class="flex justify-end items-center text-xs">
{#if font.css}
<a class="text-blue-400 underline" href="/{font.zip}">下载压缩资源</a>
<div class="c-label mx-1 text-xs" on:click={copy(font.css)}>复制css</div>
{/if}
<div>{font.name}</div>
</div>
</div>
{/each}
<h2 class="text-lg text-center my-3 font-bold"> 动态生成字体generate_fonts_dynamically 接口)</h2>
<p class="ml-1">使用如下的方式引入,则可以直接使用</p>
<textarea
bind:value={generate_fonts_dynamically}
class="border flex-1 m-1 w-full text-lg"
placeholder="在此输入需要提取的文字"
rows="13"
style="font-family:test;" />
{@html generate_fonts_dynamically}

7
static/app.css Normal file
View File

@ -0,0 +1,7 @@
.c-label {
@apply border m-1 rounded-md px-1 items-center h-6 text-sm;
}
.c-label-selected {
@apply bg-red-600 text-white;
}

3
static/index.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

19
static/index.html Normal file
View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>web font</title>
<link rel="stylesheet" href="./index.css">
</head>
<body class="p-4">
<div class="c-app"></div>
<!-- This script tag points to the source of the JS file we want to load and bundle -->
<script src="./index.js"></script>
</body>
</html>

6
static/index.js Normal file
View File

@ -0,0 +1,6 @@
// import '@babel/polyfill';
import App from './App.svelte';
import "./app.css";
new App({
target: document.querySelector('.c-app'),
});

49
static/req.ts Normal file
View File

@ -0,0 +1,49 @@
export const server = '//' + location.host + location.pathname;
/** get 方式压缩字体 */
export function get_font(font: string, text: string) {
return new Promise((rs, re) => {
var xhr = new XMLHttpRequest();
xhr.addEventListener('readystatechange', function() {
if (this.readyState === 4) {
rs(this.responseText);
}
});
xhr.open(
'GET',
`${server}fontmin?font=${encodeURIComponent(
font,
)}&text=${encodeURIComponent(text)}`,
);
xhr.onerror = re;
xhr.send();
});
}
/** post 方式压缩字体 */
export function post_fontmin(par:{font:string,text:string}[]) {
return new Promise((rs, re) => {
var data = JSON.stringify(par);
var xhr = new XMLHttpRequest();
xhr.addEventListener('readystatechange', function() {
if (this.readyState === 4) {
rs(JSON.parse(this.responseText) );
}
});
xhr.open('POST', `${server}fontmin`);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onerror = re;
xhr.send(data);
});
}
export function get_font_list(font: string, text: string) {
return new Promise((rs, re) => {
var xhr = new XMLHttpRequest();
xhr.addEventListener('readystatechange', function() {
if (this.readyState === 4) {
rs(JSON.parse(this.responseText));
}
});
xhr.open('GET', `${server}font_list`);
xhr.onerror = re;
xhr.send();
});
}

3
static/svelte.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
declare module '*.svelte' {
export default any;
}

6
tailwind.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
theme: {
},
variants: {},
plugins: [],
};

23
task.md
View File

@ -1,23 +0,0 @@
/loop 持续优化字体子集化性能和提高ssim评分可以大胆放开手脚的去做但是优化完一定要通过`pnpx tsx ./基准测试.test.ts`。中途不要切换到其他模式,比如计划模式也不要询问我,你直接做就行了,请你持续的去优化,不要去询问我,不要去中断,好吧
把基准测试结果文档保存在本地 benchmark_results/ 这样我方便查看。你的文档中应该在每个重大节点更新基准测试结果benchmark_results/OPTIMIZATION_LOG.md这样我能方便看到你使用了哪些优化方法得到了什么样的优化效果。
不要修改基准测试中的full生成方案如果基准测试结果不对一定是你的修改有问题而非浏览器渲染等其他问题
=== 字体裁剪基准测试 ===
8个汉字: avg=23.6ms min=18.4ms max=37.2ms 输出=16,508 bytes ssim=1.0000
拉丁+数字: avg=16.4ms min=13.7ms max=18.2ms 输出=1,272 bytes ssim=1.0000
千字文前段: avg=59.4ms min=47.3ms max=76.5ms 输出=161,344 bytes ssim=1.0000
=== 一晚上优化后的 字体裁剪基准测试 ===
8个汉字: avg=7.7ms min=3.8ms max=20.7ms 输出=16,572 bytes ssim=1.0000
拉丁+数字: avg=3.2ms min=1.6ms max=6.6ms 输出=1,272 bytes ssim=1.0000
千字文前段: avg=11.7ms min=6.8ms max=21.7ms 输出=161,816 bytes ssim=1.0000
## 其他方向
woff2 格式是不是更优越可以新增这种格式吗然后ttf的也还支持但是默认使用这个
就是有一个纯前端的优化咱们提供的js SDK好像是通过定时器扫描的吧这当然是一种方式也是最省心的一种方式但是咱们是不是还可以考虑另外一种方式就是通过配置来启用定时扫描还是由用户自己的事件来触发甚至由用户直接传递文本这样的话对于首页上的demo来说可能会有更高的及时性响应

24
test/app.e2e-spec.ts Normal file
View File

@ -0,0 +1,24 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

9
test/jest-e2e.json Normal file
View File

@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@ -1,31 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"types": [],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue"
]
}

4
tsconfig.build.json Normal file
View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View File

@ -1,15 +1,16 @@
{
"files": [],
"compilerOptions": {},
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.scripts.json"
}
]
}
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "es2017",
"sourceMap": true,
"outDir": "./dist",
"esModuleInterop": true,
"baseUrl": "./",
"incremental": true
},
"exclude": ["node_modules", "dist"]
}

View File

@ -1,26 +0,0 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": [
"ES2023","DOM"
],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"vite.config.ts",
"backend/*.ts",
"backend/**/*.ts",
]
}

View File

@ -1,11 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["node"],
"esModuleInterop": true,
"strict": true
},
"include": ["scripts"]
}

View File

@ -1,29 +0,0 @@
import { defineConfig } from "tsdown";
const shared = {
format: ["cjs"],
clean: true,
sourcemap: true,
outDir: "dist_backend",
outputOptions: {
/** 禁用代码拆分确保单文件输出tsdown 默认会拆分大依赖为 chunk */
codeSplitting: false,
},
deps: {
/** 所有依赖都打进 bundleLLRT scratch 镜像无 node_modules */
alwaysBundle: [/.*/],
},
};
export default [
defineConfig({
...shared,
entry: ["backend/app.ts"],
}),
defineConfig({
...shared,
/** 第二个配置不 clean避免清掉第一个的输出 */
clean: false,
entry: ["基准测试_llrt.ts"],
}),
];

18
tslint.json Normal file
View File

@ -0,0 +1,18 @@
{
"defaultSeverity": "error",
"extends": ["tslint:recommended"],
"jsRules": {
"no-unused-expression": true
},
"rules": {
"quotemark": [true, "single"],
"member-access": [false],
"ordered-imports": [false],
"max-line-length": [true, 150],
"member-ordering": [false],
"interface-name": [false],
"arrow-parens": false,
"object-literal-sort-keys": false
},
"rulesDirectory": []
}

View File

@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2014 ecomfe
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,200 +0,0 @@
# fonteditor-core
**FontEditor core functions**
[![NPM version][npm-image]][npm-url]
[![Downloads][downloads-image]][npm-url]
## Feature
Read and write sfnt font like ttf, woff, woff2, eot, svg, otf.
- sfnt parse
- read, write, transform fonts
- ttf (read and write)
- woff (read and write)
- woff2 (read and write)
- eot (read and write)
- svg (read and write)
- otf (only read and convert to ttf)
- ttf glyph adjust
- svg to glyph
- ESM compatibility for modern bundlers (Webpack, Rollup, Vite, Next.js, etc.)
- TypeScript support with type definitions
## Usage
```javascript
// read font file
import {createFont} from 'fonteditor-core';
import fs from 'fs';
const buffer = fs.readFileSync('font.ttf');
// read font data, support format:
// - for ttf, otf, woff, woff2, support ArrayBuffer, Buffer
// - for svg, support string or Document(parsed svg)
const font = createFont(buffer, {
// support ttf, woff, woff2, eot, otf, svg
type: 'ttf',
// only read `a`, `b` glyphs
subset: [65, 66],
// read font hinting tables, default false
hinting: true,
// read font kerning tables, default false
kerning: true,
// transform ttf compound glyph to simple
compound2simple: true,
// inflate function for woff
inflate: undefined,
// for svg path
combinePath: false,
});
const fontObject = font.get();
console.log(Object.keys(fontObject));
/* => [ 'version',
'numTables',
'searchRenge',
'entrySelector',
'rengeShift',
'head',
'maxp',
'glyf',
'cmap',
'name',
'hhea',
'post',
'OS/2',
'fpgm',
'cvt',
'prep'
]
*/
// write font file
const buffer = font.write({
// support ttf, woff, woff2, eot, svg
type: 'woff',
// save font hinting tables, default false
hinting: false,
// save font kerning tables, default false
kerning: false,
// write glyf data when simple glyph has no contours, default false
writeZeroContoursGlyfData: false,
// deflate function for woff, eg. pako.deflate
deflate: undefined,
// for user to overwrite head.xMin, head.xMax, head.yMin, head.yMax, hhea etc.
support: {head: {}, hhea: {}}
});
fs.writeFileSync('font.woff', buffer);
// to base64 str
font.toBase64({
// support ttf, woff, woff2, eot, svg
type: 'ttf'
});
// optimize glyphs
font.optimize()
// compound2simple
font.compound2simple()
// sort glyphs
font.sort()
// find glyphs
const result = font.find({
unicode: [65]
});
const result = font.find({
filter: function (glyf) {
return glyf.name === 'icon'
}
});
// merge another font object
font.merge(font1, {
scale: 1
});
```
### Modern ES Module Usage
This library supports both CommonJS and ES Modules. For detailed information on using with modern bundlers, please see [ESM_USAGE.md](./ESM_USAGE.md).
```javascript
// ESM import
import fonteditorCore, { createFont, woff2 } from 'fonteditor-core';
createFont(buffer, options);
```
### woff2
**Notice:** woff2 use wasm build of google woff2, before read and write `woff2`, we should first call `woff2.init()`.
```javascript
import {createFont, woff2} from 'fonteditor-core';
// in nodejs
woff2.init().then(() => {
// read woff2
const font = createFont(buffer, {
type: 'woff2'
});
// write woff2
const buffer = font.write({type: 'woff2'});
});
// in browser
woff2.init('/assets/woff2.wasm').then(() => {
// read woff2
const font = createFont();
// write woff2
const arrayBuffer = font.write({type: 'woff2'});
});
```
## Demo
```
npm run dev
```
## build
```
npm run build
```
## test
```
npm run test
```
## support
Node.js:>= 12.0
Browser: Chrome, Safari
## Related
- [fonteditor](https://github.com/ecomfe/fonteditor)
- [fontmin](https://github.com/ecomfe/fontmin)
- [fonteditor online](https://kekee000.github.io/fonteditor/index.html)
## License
MIT © Fonteditor
[downloads-image]: http://img.shields.io/npm/dm/fonteditor-core.svg
[npm-url]: https://npmjs.org/package/fonteditor-core
[npm-image]: http://img.shields.io/npm/v/fonteditor-core.svg
[travis-url]: https://travis-ci.org/kekee000/fonteditor-core
[travis-image]: http://img.shields.io/travis/kekee000/fonteditor-core.svg

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
/**
* @file DOM解析器兼容node端和浏览器端
* @author mengke01(kekee000@gmail.com)
*/
/* eslint-disable no-undef */
var _default = exports.default = typeof window !== 'undefined' && window.DOMParser ? window.DOMParser : require('@xmldom/xmldom').DOMParser;

View File

@ -1,92 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); }
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, _toPropertyKey(descriptor.key), descriptor); } }
function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == _typeof(i) ? i : i + ""; }
function _toPrimitive(t, r) { if ("object" != _typeof(t) || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != _typeof(i)) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); }
/**
* @file 用于国际化的字符串管理类
* @author mengke01(kekee000@gmail.com)
*/
function appendLanguage(store, languageList) {
languageList.forEach(function (item) {
var language = item[0];
store[language] = Object.assign(store[language] || {}, item[1]);
});
return store;
}
/**
* 管理国际化字符根据lang切换语言版本
*
* @class I18n
* @param {Array} languageList 当前支持的语言列表
* @param {string=} defaultLanguage 默认语言
* languageList = [
* 'en-us', // 语言名称
* langObject // 语言字符串列表
* ]
*/
var I18n = exports.default = /*#__PURE__*/function () {
function I18n(languageList, defaultLanguage) {
_classCallCheck(this, I18n);
this.store = appendLanguage({}, languageList);
this.setLanguage(defaultLanguage || typeof navigator !== 'undefined' && navigator.language && navigator.language.toLowerCase() || 'en-us');
}
/**
* 设置语言
*
* @param {string} language 语言
* @return {this}
*/
return _createClass(I18n, [{
key: "setLanguage",
value: function setLanguage(language) {
if (!this.store[language]) {
language = 'en-us';
}
this.lang = this.store[this.language = language];
return this;
}
/**
* 添加一个语言字符串
*
* @param {string} language 语言
* @param {Object} langObject 语言对象
* @return {this}
*/
}, {
key: "addLanguage",
value: function addLanguage(language, langObject) {
appendLanguage(this.store, [[language, langObject]]);
return this;
}
/**
* 获取当前语言字符串
*
* @param {string} path 语言路径
* @return {string} 语言字符串
*/
}, {
key: "get",
value: function get(path) {
var ref = path.split('.');
var refObject = this.lang;
var level;
while (refObject != null && (level = ref.shift())) {
refObject = refObject[level];
}
return refObject != null ? refObject : '';
}
}]);
}();

View File

@ -1,80 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = ajaxFile;
exports.loadFile = loadFile;
/**
* @file ajax获取文本数据
* @author mengke01(kekee000@gmail.com)
*/
/**
* ajax获取数据
*
* @param {Object} options 参数选项
* @param {string=} options.type 类型
* @param {string=} options.method method
* @param {Function=} options.onSuccess 成功回调
* @param {Function=} options.onError 失败回调
* @param {Object=} options.params 参数集合
*/
function ajaxFile(options) {
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
var status = xhr.status;
if (status >= 200 && status < 300 || status === 304) {
if (options.onSuccess) {
if (options.type === 'binary') {
var buffer = xhr.responseBlob || xhr.response;
options.onSuccess(buffer);
} else if (options.type === 'xml') {
options.onSuccess(xhr.responseXML);
} else if (options.type === 'json') {
options.onSuccess(JSON.parse(xhr.responseText));
} else {
options.onSuccess(xhr.responseText);
}
}
} else if (options.onError) {
options.onError(xhr, xhr.status);
}
}
};
var method = (options.method || 'GET').toUpperCase();
var params = null;
if (options.params) {
var str = [];
Object.keys(options.params).forEach(function (key) {
str.push(key + '=' + encodeURIComponent(options.params[key]));
});
str = str.join('&');
if (method === 'GET') {
options.url += (options.url.indexOf('?') === -1 ? '?' : '&') + str;
} else {
params = str;
}
}
xhr.open(method, options.url, true);
if (options.type === 'binary') {
xhr.responseType = 'arraybuffer';
}
xhr.send(params);
}
function loadFile(url) {
var type = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'binary';
return new Promise(function (resolve, reject) {
ajaxFile({
type: type,
url: url,
onSuccess: function onSuccess(buffer) {
resolve(buffer);
},
onError: function onError(e) {
reject(e);
}
});
});
}

Some files were not shown because too many files have changed in this diff Show More