feat: AI Typography Skill 系统 + 中英双语 i18n + Before/After Demo

- 新增 skills/chinese-web-typography.md 作为提供给 AI agent 的排版智能文件
- 重写 README.md(中文默认),新增 README.en.md(英文版),互相引用
- 新增 TypographyDemo.vue 前后对比组件,展示中文字体效果差异
- 新增 src/i18n.ts 轻量国际化方案,所有组件文案支持中英切换
- 后端 /api/fonts 返回 temporary 字段标识临时字体
- 升级至 v1.8.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
崮生(子虚) 2026-05-28 22:41:47 +08:00
parent 106eb8ce7b
commit b4a7a820eb
12 changed files with 1558 additions and 1025 deletions

216
README.en.md Normal file
View File

@ -0,0 +1,216 @@
# WebFont — Runtime Font Delivery + AI Typography 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 Typography Skill 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.
### Typography Presets
Pre-defined font style configs for common scenarios:
| Preset | Style | Heading | Body | Use Cases |
|--------|-------|---------|------|-----------|
| zen | Oriental / Zen | Noto Serif CJK SC | LXGW WenKai | Tea, culture, meditation |
| luxury | Luxury / High-end | Noto Serif CJK SC | Noto Serif CJK SC | Brand sites, fashion |
| editorial | Editorial / Content | Noto Serif CJK SC | Noto Sans CJK SC | News, blogs, magazines |
| modern-tech | Modern Tech | Alibaba PuHuiTi | Noto Sans CJK SC | SaaS, dev tools |
| cyberpunk | Cyberpunk | ZCOOL KuaiLe | Noto Sans CJK SC | Games, street culture |
| minimal | Minimal | Noto Sans CJK SC | Noto Sans CJK SC | Portfolios, design |
| warm | Warm / Humanistic | LXGW WenKai | LXGW WenKai | Education, community |
| startup | Startup Energy | Noto Sans CJK SC Bold | Noto Sans CJK SC | Startups, events |
Each preset includes font pairing, type scale, line height, spacing, fallback chain, runtime strategy, and ready-to-use CSS.
### AI Typography Skill
Inject Chinese typography intelligence 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 typography 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 preset → 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 │
└──────────────────────────┬───────────────────────────────────┘
┌────────────┴────────────┐
│ Typography Presets │ ← Font style configs
│ + AI Skill Prompt │ ← Chinese typography intelligence
└────────────┬────────────┘
┌────────────┴────────────┐
│ 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 Typography Skill Prompts
├── examples/ # Before/After demo pages
└── vendor/ # fonteditor-core
```
## Roadmap
### Current (MVP)
- [x] Font subsetting API
- [x] Runtime Font Delivery SDK (incremental loading, zero flicker)
- [x] Docker deployment (LLRT, ~30MB image)
- [x] Typography Presets
- [x] AI Typography Skill Prompt
### Next
- [ ] MCP Tool integration
- [ ] Vite Plugin for build-time font optimization
- [ ] CLI tool for batch font operations
### Future
- [ ] AI Website Builder integration
- [ ] Runtime Glyph Streaming
- [ ] Edge Cache / CDN Strategy
- [ ] Variable Font support
## 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)

223
README.md
View File

@ -1,99 +1,198 @@
# web font 字体裁剪工具
# WebFont — 中文字体按需裁剪 + AI 字体技能
之前的版本请查看 master 分支,为了能使用 llrt ,我进行了重写,之后只维护此分支
> 按需裁剪中文字体6 个字 ≈ 6KB。让任何网页、海报、H5 都能用上高级中文字体。
![](./doc/启动内存占用.png)
[English](README.en.md)
上面的内存占用是空载状态下,在执行字体裁剪时会将字体加载到内存中,所以会占用更多的内存,不过 llrt 也具有 gc 功能,在内存不够用时会自动释放。
虽然 llrt 内存占用低但它运行速度慢不到node的1/2。有运行速度要求的建议使用node/bun运行
**中文字体包太大**(思源黑体 16MB+),没法像英文字体那样直接 `@font-face` 引入。
## 起因
本项目在服务端按需子集化字体——传什么文字,就只返回那些字符的字体子集。一张海报用了 20 个字?返回的字体文件就是 20KB而不是 16MB。
ui 需要展现一些特定的字体但直接引入字体包又过大于是想到了裁剪字体一开始想的使用「字蛛」但他是针对静态网站的而且实际他会多出许多英文的估计是直接将源码中存在的文字都算进去了。后来又找到阿里的「webfont」 但他的字体有限,项目又不开源,所以自己写了这个
```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>
```
## 在线尝试
## 谁在用
- [web font 在线站点](https://webfont.shenzilong.cn/)
**任何需要中文 Web Font 的场景:**
## 目的与功能
- 海报 / H5 活动页 — 几个字裁剪后只有几 KB
- 品牌官网 — 高级字体不再受限于体积
- 小程序 / PWA — 流量敏感,按需加载
- 静态博客 / CMS — 文字内容确定CSS 直出即可
- AI 生成网页 — 让 AI 也能输出有设计感的中文字体方案
1.裁剪字体包使其仅包含选中的字体,其体积自然十分之小
2.另外可以生成 css 直接复制可用,部署在公网便可永久访问
3.支持字体文件上传(临时上传和管理员上传两种模式)
4.支持下载裁剪后的字体文件
5.字体名称支持模糊匹配(精确 > 前缀 > 包含)
## 核心能力
> [在线体验 Typography Skill Demo](https://webfont.shenzilong.cn/?demo) — 同一内容,字体不同,体验天壤之别
## 安装与使用
### Runtime Font Delivery
### 使用 node / llrt 等运行时
服务端按需子集化 + 客户端增量加载。
拉取项目,并将字体文件放到项目内的 font 目录下,然后运行:
```
6 个字 → 服务端裁剪 → 返回约 6KB 子集字体(而非 16MB 完整字体)
```
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 Typography Skill
给 AIClaude、Cursor、Copilot 等注入中文排版智能。AI 读取 Skill Prompt 后可以:
- 自动选择匹配场景的中文字体
- 正确处理中英混排
- 建立字体层级Typography 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");
}
```
### 自部署
```bash
# Node.js / LLRT
pnpm install && pnpm build && pnpm build_backend
node ./dist_backend/app.cjs
llrt ./dist_backend/app.cjs
```
Docker~30MB 极简镜像):
### 使用 docker 安装
此镜像使用 llrt 运行时
https://hub.docker.com/repository/docker/llej0/web-font 很小的包体积 ![alt text](doc/image.png)
docker compose.yml
```yml
version: '3'
```yaml
services:
app:
webfont:
image: docker.io/llej0/web-font:latest
ports:
- "8087:8087"
volumes:
- ./data:/home/font # 挂载本机字体目录
- ./fonts:/home/font
environment:
- ENABLE_TEMP_UPLOAD=true # 开启临时上传(默认 false
- TEMP_MAX_FILES=10 # 临时上传最大文件数(默认 10
- TEMP_MAX_TOTAL_SIZE=209715200 # 临时上传目录总体积上限,单位字节(默认 209715200即 200MB
- ADMIN_API_KEY=你的管理员密钥 # 设置后开启管理员上传,不设置则不可用
- SUBSET_CACHE_MAX_SIZE=10485760 # 字体裁剪结果内存缓存容量上限,单位字节(默认 10MB
deploy:
resources:
limits:
memory: 900M # 设置内存限制为900MB根据实际需求来设置
restart: on-failure # 设置重启策略为 on-failure
- ENABLE_TEMP_UPLOAD=true
- ADMIN_API_KEY=your-secret-key
- SUBSET_CACHE_MAX_SIZE=10485760
```
其中 font 目录替换成你的字体文件存放目录
## 提供的服务
### API 接口
## API
| 接口 | 说明 |
|------|------|
| `GET /api?font=字体名&text=文字` | 裁剪字体,字体名支持模糊匹配 |
| `GET /api/fonts` | 列出所有可用字体 |
| `GET /api/config` | 获取公开配置(是否开启上传等) |
| `POST /api/upload?mode=temp` | 临时上传字体文件(需开启 `ENABLE_TEMP_UPLOAD` |
| `POST /api/upload?mode=admin` | 管理员上传字体文件(需 `Authorization: Bearer <API_KEY>` |
| `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 |
### 上传功能
字体名支持模糊匹配:精确 > 前缀 > 包含。
- **临时上传**:设置环境变量 `ENABLE_TEMP_UPLOAD=true` 启用,最多保留 10 个字体文件(`TEMP_MAX_FILES`),总大小限制 200MB`TEMP_MAX_TOTAL_SIZE`超出后自动删除最早上传的FIFO
- **管理员上传**:设置环境变量 `ADMIN_API_KEY=你的密钥` 启用,上传的字体永久保存,需要通过 API Key 认证
- 支持的字体格式:`.ttf` `.otf` `.woff` `.woff2`
## 架构
```
┌──────────────────────────────────────────────────────────────┐
│ 使用场景 │
│ 海报/H5 · 品牌官网 · 博客 · 小程序 · AI 生成页面 │
└──────────────────────────┬───────────────────────────────────┘
┌────────────┴────────────┐
│ Typography Presets │ ← 字体风格配置
│ + AI 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 Typography Skill Prompts
├── examples/ # Before/After 对比演示
└── vendor/ # fonteditor-core
```
## 路线图
### 当前MVP
- [x] 字体子集化 API
- [x] Runtime Font Delivery SDK增量加载零闪烁
- [x] Docker 部署LLRT~30MB 镜像)
- [x] AI Typography Skill Prompt
### 下一阶段
- [ ] MCP Tool 集成
- [ ] Vite Plugin 构建时字体优化
- [ ] CLI 批量字体操作工具
### 未来
- [ ] AI Website Builder 集成
- [ ] Runtime Glyph Streaming
- [ ] Edge Cache / CDN 策略
- [ ] Variable Font 支持
## 鸣谢
[kekee000/fonteditor-core](https://github.com/kekee000/fonteditor-core)
[字体天下](http://www.fonts.net.cn/commercial-free-32767/fonts-zh-1.html)
- [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)
## License
MIT © [崮生](https://shenzilong.cn/关于/mit.html)
MIT © [崮生](https://shenzilong.cn)

View File

@ -4,14 +4,14 @@ import { fontDirs } from "../config";
/** GET /api/fonts — 列出所有可用字体 */
export async function handleListFonts(req: Request, res: Response) {
const allFonts: Array<{ name: string; dir: string }> = [];
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, dir });
allFonts.push({ name: entry.name, temporary: dir === "font/temp" });
}
}
} catch {}

View File

@ -1,7 +1,7 @@
{
"name": "webfont",
"private": true,
"version": "1.7.0",
"version": "1.8.0",
"type": "module",
"scripts": {
"dev": "pnpx tsx scripts/dev-all.ts",
@ -16,20 +16,20 @@
},
"dependencies": {
"vue": "3.6.0-beta.10",
"web-streams-polyfill": "^4.2.0"
"web-streams-polyfill": "^4.3.0"
},
"devDependencies": {
"@types/node": "^25.6.0",
"@vitejs/plugin-vue": "^6.0.6",
"@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": "^24.42.0",
"tsdown": "^0.21.10",
"puppeteer": "^25.1.0",
"tsdown": "^0.22.1",
"typescript": "^6.0.3",
"undici": "^8.1.0",
"vite": "^8.0.10",
"vite-plugin-pilot": "^1.0.24",
"vitest": "^4.1.5"
"undici": "^8.3.0",
"vite": "^8.0.14",
"vite-plugin-pilot": "^1.0.31",
"vitest": "^4.1.7"
}
}

1557
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,88 @@
---
name: chinese-web-typography
description: 设计精美网页时,可利用此技能引入中文字体、中文排版规范、字体裁剪 API 等元素,提升中文页面的排版品质。
---
# Chinese Web Typography 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

@ -2,6 +2,7 @@
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;
@ -9,8 +10,10 @@ 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";
const SLOGAN = "如清风似闪电,超级快的字体子集化裁剪";
/** 是否展示 Typography Demo */
const showDemo = ref(location.search.includes("demo"));
const text = ref("天地无极,乾坤借法");
const fonts = ref<FontInfo[]>([]);
@ -37,9 +40,10 @@ onMounted(async () => {
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: SLOGAN,
text: sloganText,
family: "SloganFont",
});
const sloganEl = document.getElementById("slogan");
@ -113,20 +117,29 @@ async function refreshFonts() {
</script>
<template>
<div style="max-width: 720px; margin: 0 auto; padding: 48px 24px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #1a1a1a; line-height: 1.6">
<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>
<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 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">{{ SLOGAN }}</p>
<p id="slogan" style="font-size: 24px; color: #888; margin: 0 0 36px 0">{{ t('slogan') }}</p>
<section style="margin-bottom: 28px">
<SelectorRow
@ -140,20 +153,20 @@ async function refreshFonts() {
</section>
<section style="margin-bottom: 28px">
<label style="display: block; font-size: 13px; color: #555; margin-bottom: 6px">输入文本预览效果</label>
<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="在此输入文本..."
: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">CSS 代码</label>
<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"
@ -164,7 +177,7 @@ async function refreshFonts() {
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"
@ -172,15 +185,15 @@ async function refreshFonts() {
const btn = e.currentTarget as HTMLButtonElement;
try {
await navigator.clipboard.writeText(cssStyle);
btn.textContent = '已复制';
setTimeout(() => { btn.textContent = '复制 CSS'; }, 1500);
btn.textContent = t('copied');
setTimeout(() => { btn.textContent = t('copyCss'); }, 1500);
} catch {
btn.textContent = '复制失败';
setTimeout(() => { btn.textContent = '复制 CSS'; }, 1500);
btn.textContent = t('copyFailed');
setTimeout(() => { btn.textContent = t('copyCss'); }, 1500);
}
}"
>
复制 CSS
{{ t('copyCss') }}
</button>
</div>
</div>
@ -194,17 +207,18 @@ async function refreshFonts() {
<StatsPanel />
<section style="margin-bottom: 28px; font-size: 12px; color: #aaa; line-height: 1.8">
<p><b>原理</b>服务端根据 text 参数裁剪字体只返回所需字符的子集相同 URL 的请求会被浏览器自动缓存</p>
<p><b>基础用法</b> CSS 复制到你的页面修改 text 参数中的文字即可</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>JS SDK推荐</b>增量加载字体片段按需请求不会出现全量字体闪烁<a href="/webfont-sdk.js" download="webfont-sdk.js">下载 SDK</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">还支持 <code>WebFont.observeFont()</code>MutationObserver 事件驱动 <code>WebFont.loadText()</code>手动传文本两种方式多种方式可同时使用SDK 内部自动按字体去重增量加载</p>
<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>感谢 <a href="https://www.ruanyifeng.com/blog/2020/03/weekly-issue-100.html" target="_blank" rel="noopener noreferrer" style="color: #999">阮一峰科技爱好者周刊 100 </a> 收录本项目</p>
<p style="margin-top: 8px">觉得好用<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">请作者喝杯咖啡</a>支持持续开发</p>
<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-typography.md" target="_blank" style="color: #8b7355; text-decoration: underline">{{ t('viewSkill') }}</a></p>
</footer>
<a
@ -215,7 +229,7 @@ async function refreshFonts() {
onmouseover="this.style.paddingRight='10px'"
onmouseout="this.style.paddingRight='6px'"
>
赞助支持
{{ t('sponsor') }}
</a>
</div>
</template>

View File

@ -1,15 +1,6 @@
<script setup lang="ts">
import type { FontInfo } from "./api";
const outTypeLabels = {
woff2: "WOFF2 体积更小",
ttf: "TTF 速度更快",
};
const outTypeDescs = {
woff2: "约压缩 50%,适合生产",
ttf: "无编码开销,适合开发",
};
import { t } from "./i18n";
defineProps<{
fonts: FontInfo[];
@ -19,31 +10,41 @@ defineProps<{
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">选择字体</label>
<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="">-- 请选择 --</option>
<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">输出格式</label>
<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="t in supportedOutTypes" :key="t" :value="t">{{ outTypeLabels[t] }}</option>
<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>
<p style="font-size: 11px; color: #bbb; margin-top: 4px">{{ outTypeDescs[outType]() }}</p>
</div>
</div>
</template>

View File

@ -1,8 +1,19 @@
<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);
@ -55,13 +66,13 @@ onUnmounted(() => {
<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">服务状态</div>
<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">运行</b> {{ formatUptime(data.uptime) }}</span>
<span><b style="color: #333">请求</b> {{ data.totalRequests }} </span>
<span><b style="color: #333">裁剪</b> {{ data.subsetRequests }} </span>
<span><b style="color: #333">文字</b> {{ data.totalChars }} </span>
<span><b style="color: #333">缓存命中</b> {{ data.subsetRequests > 0 ? ((data.subsetCacheHits / data.subsetRequests) * 100).toFixed(1) : '0.0' }}%</span>
<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>

149
src/TypographyDemo.vue Normal file
View File

@ -0,0 +1,149 @@
<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-typography.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,9 +1,9 @@
<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 UPLOAD_TIP = "支持 .ttf 和 .otf 格式,建议上传 .ttf 字体文件以获得最佳兼容性";
const props = defineProps<{
config: ServerConfig;
@ -28,11 +28,11 @@ function useUpload(onSuccess: () => void) {
const result: UploadResult = await uploadFont(f, mode, key);
uploading.value = false;
if (result.success) {
showMsg(true, "上传成功");
showMsg(true, t("uploadSuccess"));
file.value = null;
onSuccess();
} else {
showMsg(false, result.error ?? "上传失败");
showMsg(false, result.error ?? t("uploadFailed"));
}
}
@ -51,8 +51,8 @@ function onFileSelect(e: Event, target: ReturnType<typeof useUpload>) {
<template>
<section v-if="canUpload" style="margin-bottom: 28px">
<label style="display: block; font-size: 14px; font-weight: 500; margin-bottom: 12px">上传字体</label>
<div style="font-size: 12px; color: #e6a700; margin-bottom: 12px">{{ UPLOAD_TIP }}</div>
<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"
@ -70,27 +70,27 @@ function onFileSelect(e: Event, target: ReturnType<typeof useUpload>) {
</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">游客上传</div>
<div style="font-size: 12px; color: #999; margin-bottom: 12px">临时文件最多保留 10 总大小限制 200MB超出后自动删除最早上传的</div>
<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 ?? '未选择文件' }}</span>
<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 ? '...' : '上传' }}
{{ 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">管理员上传</div>
<div style="font-size: 12px; color: #999; margin-bottom: 12px">永久保存需要 API Key 认证</div>
<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"
@ -100,16 +100,16 @@ function onFileSelect(e: Event, target: ReturnType<typeof useUpload>) {
/>
<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 ?? '未选择文件' }}</span>
<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 ? '...' : '上传' }}
{{ admin.uploading.value ? '...' : t('upload') }}
</button>
</div>
</div>

166
src/i18n.ts Normal file
View File

@ -0,0 +1,166 @@
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 Typography 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 Typography 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
}