mirror of
https://github.com/2234839/web-font.git
synced 2026-06-04 00:08:11 +08:00
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:
parent
106eb8ce7b
commit
b4a7a820eb
216
README.en.md
Normal file
216
README.en.md
Normal 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
223
README.md
@ -1,99 +1,198 @@
|
|||||||
# web font 字体裁剪工具
|
# WebFont — 中文字体按需裁剪 + AI 字体技能
|
||||||
|
|
||||||
之前的版本请查看 master 分支,为了能使用 llrt ,我进行了重写,之后只维护此分支
|
> 按需裁剪中文字体,6 个字 ≈ 6KB。让任何网页、海报、H5 都能用上高级中文字体。
|
||||||
|
|
||||||

|
[English](README.en.md)
|
||||||
|
|
||||||
上面的内存占用是空载状态下,在执行字体裁剪时会将字体加载到内存中,所以会占用更多的内存,不过 llrt 也具有 gc 功能,在内存不够用时会自动释放。
|
**中文字体包太大**(思源黑体 16MB+),没法像英文字体那样直接 `@font-face` 引入。
|
||||||
虽然 llrt 内存占用低,但它运行速度慢,不到node的1/2。有运行速度要求的建议使用node/bun运行
|
|
||||||
|
|
||||||
## 起因
|
本项目在服务端按需子集化字体——传什么文字,就只返回那些字符的字体子集。一张海报用了 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
|
||||||
|
|
||||||
|
给 AI(Claude、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
|
pnpm install && pnpm build && pnpm build_backend
|
||||||
|
|
||||||
node ./dist_backend/app.cjs
|
node ./dist_backend/app.cjs
|
||||||
llrt ./dist_backend/app.cjs
|
```
|
||||||
|
|
||||||
|
Docker(~30MB 极简镜像):
|
||||||
|
|
||||||
### 使用 docker 安装
|
```yaml
|
||||||
|
|
||||||
此镜像使用 llrt 运行时
|
|
||||||
|
|
||||||
https://hub.docker.com/repository/docker/llej0/web-font 很小的包体积 
|
|
||||||
|
|
||||||
docker compose.yml
|
|
||||||
|
|
||||||
```yml
|
|
||||||
version: '3'
|
|
||||||
services:
|
services:
|
||||||
app:
|
webfont:
|
||||||
image: docker.io/llej0/web-font:latest
|
image: docker.io/llej0/web-font:latest
|
||||||
ports:
|
ports:
|
||||||
- "8087:8087"
|
- "8087:8087"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/home/font # 挂载本机字体目录
|
- ./fonts:/home/font
|
||||||
environment:
|
environment:
|
||||||
- ENABLE_TEMP_UPLOAD=true # 开启临时上传(默认 false)
|
- ENABLE_TEMP_UPLOAD=true
|
||||||
- TEMP_MAX_FILES=10 # 临时上传最大文件数(默认 10)
|
- ADMIN_API_KEY=your-secret-key
|
||||||
- TEMP_MAX_TOTAL_SIZE=209715200 # 临时上传目录总体积上限,单位字节(默认 209715200,即 200MB)
|
- SUBSET_CACHE_MAX_SIZE=10485760
|
||||||
- ADMIN_API_KEY=你的管理员密钥 # 设置后开启管理员上传,不设置则不可用
|
|
||||||
- SUBSET_CACHE_MAX_SIZE=10485760 # 字体裁剪结果内存缓存容量上限,单位字节(默认 10MB)
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 900M # 设置内存限制为900MB,根据实际需求来设置
|
|
||||||
restart: on-failure # 设置重启策略为 on-failure
|
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
其中 font 目录替换成你的字体文件存放目录
|
## API
|
||||||
|
|
||||||
## 提供的服务
|
|
||||||
|
|
||||||
### API 接口
|
|
||||||
|
|
||||||
| 接口 | 说明 |
|
| 接口 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `GET /api?font=字体名&text=文字` | 裁剪字体,字体名支持模糊匹配 |
|
| `GET /api?font={name}&text={chars}&outType={woff2\|ttf}` | 裁剪字体,只返回指定字符的子集 |
|
||||||
| `GET /api/fonts` | 列出所有可用字体 |
|
| `GET /api/fonts` | 列出可用字体 |
|
||||||
| `GET /api/config` | 获取公开配置(是否开启上传等) |
|
| `GET /api/config` | 获取服务配置 |
|
||||||
| `POST /api/upload?mode=temp` | 临时上传字体文件(需开启 `ENABLE_TEMP_UPLOAD`) |
|
| `POST /api/upload?mode=temp` | 临时上传(自动清理) |
|
||||||
| `POST /api/upload?mode=admin` | 管理员上传字体文件(需 `Authorization: Bearer <API_KEY>`) |
|
| `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)
|
- [kekee000/fonteditor-core](https://github.com/kekee000/fonteditor-core) — 字体解析、子集化、格式转换
|
||||||
|
- [字体天下](http://www.fonts.net.cn/commercial-free-32767/fonts-zh-1.html) — 免费商用中文字体
|
||||||
[字体天下](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
|
## License
|
||||||
|
|
||||||
MIT © [崮生](https://shenzilong.cn/关于/mit.html)
|
MIT © [崮生](https://shenzilong.cn)
|
||||||
|
|||||||
@ -4,14 +4,14 @@ import { fontDirs } from "../config";
|
|||||||
|
|
||||||
/** GET /api/fonts — 列出所有可用字体 */
|
/** GET /api/fonts — 列出所有可用字体 */
|
||||||
export async function handleListFonts(req: Request, res: Response) {
|
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) {
|
for (const dir of fontDirs) {
|
||||||
try {
|
try {
|
||||||
const entries = await readdir(dir);
|
const entries = await readdir(dir);
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.isFile() && /\.(ttf|otf|woff|woff2)$/i.test(entry.name)) {
|
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 {}
|
} catch {}
|
||||||
|
|||||||
20
package.json
20
package.json
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "webfont",
|
"name": "webfont",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpx tsx scripts/dev-all.ts",
|
"dev": "pnpx tsx scripts/dev-all.ts",
|
||||||
@ -16,20 +16,20 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "3.6.0-beta.10",
|
"vue": "3.6.0-beta.10",
|
||||||
"web-streams-polyfill": "^4.2.0"
|
"web-streams-polyfill": "^4.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.9.1",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
"@xmldom/xmldom": "^0.9.10",
|
"@xmldom/xmldom": "^0.9.10",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"pngjs": "^7.0.0",
|
"pngjs": "^7.0.0",
|
||||||
"puppeteer": "^24.42.0",
|
"puppeteer": "^25.1.0",
|
||||||
"tsdown": "^0.21.10",
|
"tsdown": "^0.22.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"undici": "^8.1.0",
|
"undici": "^8.3.0",
|
||||||
"vite": "^8.0.10",
|
"vite": "^8.0.14",
|
||||||
"vite-plugin-pilot": "^1.0.24",
|
"vite-plugin-pilot": "^1.0.31",
|
||||||
"vitest": "^4.1.5"
|
"vitest": "^4.1.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1557
pnpm-lock.yaml
generated
1557
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
88
skills/chinese-web-typography.md
Normal file
88
skills/chinese-web-typography.md
Normal 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,暖色系
|
||||||
58
src/App.vue
58
src/App.vue
@ -2,6 +2,7 @@
|
|||||||
import { ref, computed, watch, onMounted } from "vue";
|
import { ref, computed, watch, onMounted } from "vue";
|
||||||
import { fetchFonts, fetchConfig } from "./api";
|
import { fetchFonts, fetchConfig } from "./api";
|
||||||
import type { FontInfo, ServerConfig } from "./api";
|
import type { FontInfo, ServerConfig } from "./api";
|
||||||
|
import { t, toggleLocale, locale } from "./i18n";
|
||||||
|
|
||||||
const isDev = import.meta.env.DEV;
|
const isDev = import.meta.env.DEV;
|
||||||
const origin = location.origin;
|
const origin = location.origin;
|
||||||
@ -9,8 +10,10 @@ import UploadSection from "./UploadSection.vue";
|
|||||||
import StatsPanel from "./StatsPanel.vue";
|
import StatsPanel from "./StatsPanel.vue";
|
||||||
import SelectorRow from "./FontSelector.vue";
|
import SelectorRow from "./FontSelector.vue";
|
||||||
import FontDebugPreview from "./FontDebugPreview.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 text = ref("天地无极,乾坤借法");
|
||||||
const fonts = ref<FontInfo[]>([]);
|
const fonts = ref<FontInfo[]>([]);
|
||||||
@ -37,9 +40,10 @@ onMounted(async () => {
|
|||||||
if (fontList.length > 0) {
|
if (fontList.length > 0) {
|
||||||
const usableFonts = fontList.filter((f) => /\.(ttf)$/i.test(f.name));
|
const usableFonts = fontList.filter((f) => /\.(ttf)$/i.test(f.name));
|
||||||
const randomFont = usableFonts[Math.floor(Math.random() * usableFonts.length)];
|
const randomFont = usableFonts[Math.floor(Math.random() * usableFonts.length)];
|
||||||
|
const sloganText = t("slogan");
|
||||||
(globalThis as any).WebFont?.loadText({
|
(globalThis as any).WebFont?.loadText({
|
||||||
fontName: randomFont.name,
|
fontName: randomFont.name,
|
||||||
text: SLOGAN,
|
text: sloganText,
|
||||||
family: "SloganFont",
|
family: "SloganFont",
|
||||||
});
|
});
|
||||||
const sloganEl = document.getElementById("slogan");
|
const sloganEl = document.getElementById("slogan");
|
||||||
@ -113,9 +117,17 @@ async function refreshFonts() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<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>
|
<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
|
<a
|
||||||
href="https://github.com/2234839/web-font"
|
href="https://github.com/2234839/web-font"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@ -126,7 +138,8 @@ async function refreshFonts() {
|
|||||||
Star on GitHub
|
Star on GitHub
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p id="slogan" style="font-size: 24px; color: #888; margin: 0 0 36px 0">{{ SLOGAN }}</p>
|
</div>
|
||||||
|
<p id="slogan" style="font-size: 24px; color: #888; margin: 0 0 36px 0">{{ t('slogan') }}</p>
|
||||||
|
|
||||||
<section style="margin-bottom: 28px">
|
<section style="margin-bottom: 28px">
|
||||||
<SelectorRow
|
<SelectorRow
|
||||||
@ -140,20 +153,20 @@ async function refreshFonts() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section style="margin-bottom: 28px">
|
<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
|
<textarea
|
||||||
id="webfont-preview"
|
id="webfont-preview"
|
||||||
:rows="textareaRows"
|
:rows="textareaRows"
|
||||||
:value="text"
|
:value="text"
|
||||||
@input="onTextChange(($event.target as HTMLTextAreaElement).value)"
|
@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"
|
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>
|
||||||
|
|
||||||
<section v-if="selectedFont" style="margin-bottom: 28px">
|
<section v-if="selectedFont" style="margin-bottom: 28px">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px">
|
<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">
|
<div style="display: flex; gap: 6px">
|
||||||
<button
|
<button
|
||||||
style="padding: 3px 12px; font-size: 12px; border: 1px solid #d9d9d9; border-radius: 6px; cursor: pointer; background: #fff; color: #333"
|
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();
|
a.click();
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
下载字体
|
{{ t('downloadFont') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
style="padding: 3px 12px; font-size: 12px; border: 1px solid #d9d9d9; border-radius: 6px; cursor: pointer; background: #fff; color: #333"
|
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;
|
const btn = e.currentTarget as HTMLButtonElement;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(cssStyle);
|
await navigator.clipboard.writeText(cssStyle);
|
||||||
btn.textContent = '已复制';
|
btn.textContent = t('copied');
|
||||||
setTimeout(() => { btn.textContent = '复制 CSS'; }, 1500);
|
setTimeout(() => { btn.textContent = t('copyCss'); }, 1500);
|
||||||
} catch {
|
} catch {
|
||||||
btn.textContent = '复制失败';
|
btn.textContent = t('copyFailed');
|
||||||
setTimeout(() => { btn.textContent = '复制 CSS'; }, 1500);
|
setTimeout(() => { btn.textContent = t('copyCss'); }, 1500);
|
||||||
}
|
}
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
复制 CSS
|
{{ t('copyCss') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -194,17 +207,18 @@ async function refreshFonts() {
|
|||||||
<StatsPanel />
|
<StatsPanel />
|
||||||
|
|
||||||
<section style="margin-bottom: 28px; font-size: 12px; color: #aaa; line-height: 1.8">
|
<section style="margin-bottom: 28px; font-size: 12px; color: #aaa; line-height: 1.8">
|
||||||
<p><b>原理:</b>服务端根据 text 参数裁剪字体,只返回所需字符的子集。相同 URL 的请求会被浏览器自动缓存。</p>
|
<p><b>{{ t('principle') }}</b>{{ t('principleText') }}</p>
|
||||||
<p><b>基础用法:</b>将 CSS 复制到你的页面,修改 text 参数中的文字即可:</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">{{ `<style>\n@font-face {\n font-family: "MyFont";\n src: url("${origin}/api?font=字体名&text=你的文字") format("woff2");\n}\n.title { font-family: "MyFont"; }\n</style>\n<h1 class="title">你的文字</h1>` }}</pre>
|
<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">{{ `<style>\n@font-face {\n font-family: \"MyFont\";\n src: url(\"${origin}/api?font=字体名&text=你的文字\") format(\"woff2\");\n}\n.title { font-family: \"MyFont\"; }\n</style>\n<h1 class=\"title\">你的文字</h1>` }}</pre>
|
||||||
<p style="margin-top: 12px"><b>JS SDK(推荐):</b>增量加载字体片段,按需请求,不会出现全量字体闪烁。<a href="/webfont-sdk.js" download="webfont-sdk.js">下载 SDK</a></p>
|
<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">{{ `<script src="${origin}/webfont-sdk.js"></script>\n<script>\n WebFont.loadFont({\n fontName: "字体文件名.ttf",\n selector: ".my-element",\n family: "MyFont",\n interval: 1000,\n });\n</script>` }}</pre>
|
<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">{{ `<script src=\"${origin}/webfont-sdk.js\"></script>\n<script>\n WebFont.loadFont({\n fontName: \"字体文件名.ttf\",\n selector: \".my-element\",\n family: \"MyFont\",\n interval: 1000,\n });\n</script>` }}</pre>
|
||||||
<p style="margin-top: 8px">还支持 <code>WebFont.observeFont()</code>(MutationObserver 事件驱动)和 <code>WebFont.loadText()</code>(手动传文本)两种方式,多种方式可同时使用,SDK 内部自动按字体去重增量加载。</p>
|
<p style="margin-top: 8px">{{ t('sdkModes') }}<code>WebFont.observeFont()</code>{{ t('observeFont') }}<code>WebFont.loadText()</code>{{ t('loadText') }}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer style="margin-top: 48px; padding-top: 16px; border-top: 1px solid #eee; font-size: 12px; color: #999; text-align: center">
|
<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>{{ 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">觉得好用?<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 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>
|
</footer>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
@ -215,7 +229,7 @@ async function refreshFonts() {
|
|||||||
onmouseover="this.style.paddingRight='10px'"
|
onmouseover="this.style.paddingRight='10px'"
|
||||||
onmouseout="this.style.paddingRight='6px'"
|
onmouseout="this.style.paddingRight='6px'"
|
||||||
>
|
>
|
||||||
赞助支持
|
{{ t('sponsor') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,15 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { FontInfo } from "./api";
|
import type { FontInfo } from "./api";
|
||||||
|
import { t } from "./i18n";
|
||||||
const outTypeLabels = {
|
|
||||||
woff2: "WOFF2 体积更小",
|
|
||||||
ttf: "TTF 速度更快",
|
|
||||||
};
|
|
||||||
|
|
||||||
const outTypeDescs = {
|
|
||||||
woff2: "约压缩 50%,适合生产",
|
|
||||||
ttf: "无编码开销,适合开发",
|
|
||||||
};
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
fonts: FontInfo[];
|
fonts: FontInfo[];
|
||||||
@ -19,31 +10,41 @@ defineProps<{
|
|||||||
outType: "woff2" | "ttf";
|
outType: "woff2" | "ttf";
|
||||||
onOutTypeChange: (v: "woff2" | "ttf") => void;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div style="display: flex; gap: 12px">
|
<div style="display: flex; gap: 12px">
|
||||||
<div style="flex: 1">
|
<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
|
<select
|
||||||
:value="selectedFont"
|
:value="selectedFont"
|
||||||
@change="onFontChange(($event.target).value)"
|
@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("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"); background-repeat: no-repeat; background-position: right 10px center; padding-right: 28px"
|
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("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"); 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>
|
<option v-for="f in fonts" :key="f.name" :value="f.name">{{ f.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div style="width: 160px">
|
<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
|
<select
|
||||||
:value="outType"
|
:value="outType"
|
||||||
@change="onOutTypeChange(($event.target).value)"
|
@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("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"); background-repeat: no-repeat; background-position: right 10px center; padding-right: 28px"
|
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("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"); 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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,8 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from "vue";
|
import { ref, onMounted, onUnmounted } from "vue";
|
||||||
import { fetchStats, type ServerStats } from "./api";
|
import { fetchStats, type ServerStats } from "./api";
|
||||||
|
import { t, locale } from "./i18n";
|
||||||
|
|
||||||
function formatUptime(seconds: number): string {
|
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 < 60) return `${seconds}秒`;
|
||||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}分${seconds % 60}秒`;
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}分${seconds % 60}秒`;
|
||||||
const h = Math.floor(seconds / 3600);
|
const h = Math.floor(seconds / 3600);
|
||||||
@ -55,13 +66,13 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section v-if="data" style="margin-top: 24px; margin-bottom: 28px; padding: 12px 16px; background: #f0f0f0; border-radius: 8px">
|
<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">
|
<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">{{ t('uptime') }}</b> {{ formatUptime(data.uptime) }}</span>
|
||||||
<span><b style="color: #333">请求</b> {{ data.totalRequests }} 次</span>
|
<span><b style="color: #333">{{ t('requests') }}</b> {{ data.totalRequests }} {{ t('times') }}</span>
|
||||||
<span><b style="color: #333">裁剪</b> {{ data.subsetRequests }} 次</span>
|
<span><b style="color: #333">{{ t('subset') }}</b> {{ data.subsetRequests }} {{ t('times') }}</span>
|
||||||
<span><b style="color: #333">文字</b> {{ data.totalChars }} 字</span>
|
<span><b style="color: #333">{{ t('chars') }}</b> {{ data.totalChars }} {{ t('charUnit') }}</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('cacheHit') }}</b> {{ data.subsetRequests > 0 ? ((data.subsetCacheHits / data.subsetRequests) * 100).toFixed(1) : '0.0' }}%</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
149
src/TypographyDemo.vue
Normal file
149
src/TypographyDemo.vue
Normal 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>
|
||||||
@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import { uploadFont, type UploadResult, type ServerConfig } from "./api";
|
import { uploadFont, type UploadResult, type ServerConfig } from "./api";
|
||||||
|
import { t } from "./i18n";
|
||||||
|
|
||||||
const ACCEPT = ".ttf,.otf,.woff,.woff2";
|
const ACCEPT = ".ttf,.otf,.woff,.woff2";
|
||||||
const UPLOAD_TIP = "支持 .ttf 和 .otf 格式,建议上传 .ttf 字体文件以获得最佳兼容性";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
config: ServerConfig;
|
config: ServerConfig;
|
||||||
@ -28,11 +28,11 @@ function useUpload(onSuccess: () => void) {
|
|||||||
const result: UploadResult = await uploadFont(f, mode, key);
|
const result: UploadResult = await uploadFont(f, mode, key);
|
||||||
uploading.value = false;
|
uploading.value = false;
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showMsg(true, "上传成功");
|
showMsg(true, t("uploadSuccess"));
|
||||||
file.value = null;
|
file.value = null;
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} else {
|
} else {
|
||||||
showMsg(false, result.error ?? "上传失败");
|
showMsg(false, result.error ?? t("uploadFailed"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,8 +51,8 @@ function onFileSelect(e: Event, target: ReturnType<typeof useUpload>) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section v-if="canUpload" style="margin-bottom: 28px">
|
<section v-if="canUpload" style="margin-bottom: 28px">
|
||||||
<label style="display: block; font-size: 14px; font-weight: 500; margin-bottom: 12px">上传字体</label>
|
<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">{{ UPLOAD_TIP }}</div>
|
<div style="font-size: 12px; color: #e6a700; margin-bottom: 12px">{{ t('uploadTip') }}</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="temp.msg.value"
|
v-if="temp.msg.value"
|
||||||
@ -70,27 +70,27 @@ function onFileSelect(e: Event, target: ReturnType<typeof useUpload>) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="config.enableTempUpload" style="padding: 16px; border: 1px solid #e8e8e8; border-radius: 8px; margin-bottom: 16px">
|
<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: 14px; font-weight: 500; margin-bottom: 4px">{{ t('guestUpload') }}</div>
|
||||||
<div style="font-size: 12px; color: #999; margin-bottom: 12px">临时文件,最多保留 10 个,总大小限制 200MB,超出后自动删除最早上传的</div>
|
<div style="font-size: 12px; color: #999; margin-bottom: 12px">{{ t('guestUploadDesc') }}</div>
|
||||||
<div style="display: flex; gap: 8px; align-items: center">
|
<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">
|
<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)" />
|
<input type="file" :accept="ACCEPT" style="display: none" @change="onFileSelect($event, temp)" />
|
||||||
</label>
|
</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
|
<button
|
||||||
:disabled="!temp.file.value || temp.uploading.value"
|
: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 }"
|
: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')"
|
@click="temp.upload('temp')"
|
||||||
>
|
>
|
||||||
{{ temp.uploading.value ? '...' : '上传' }}
|
{{ temp.uploading.value ? '...' : t('upload') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="config.adminUploadEnabled" style="padding: 16px; border: 1px solid #e8e8e8; border-radius: 8px; margin-bottom: 16px">
|
<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: 14px; font-weight: 500; margin-bottom: 4px">{{ t('adminUpload') }}</div>
|
||||||
<div style="font-size: 12px; color: #999; margin-bottom: 12px">永久保存,需要 API Key 认证</div>
|
<div style="font-size: 12px; color: #999; margin-bottom: 12px">{{ t('adminUploadDesc') }}</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
@ -100,16 +100,16 @@ function onFileSelect(e: Event, target: ReturnType<typeof useUpload>) {
|
|||||||
/>
|
/>
|
||||||
<div style="display: flex; gap: 8px; align-items: center">
|
<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">
|
<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)" />
|
<input type="file" :accept="ACCEPT" style="display: none" @change="onFileSelect($event, admin)" />
|
||||||
</label>
|
</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
|
<button
|
||||||
:disabled="!admin.file.value || !admin.apiKey.value || admin.uploading.value"
|
: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 }"
|
: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)"
|
@click="admin.upload('admin', admin.apiKey.value)"
|
||||||
>
|
>
|
||||||
{{ admin.uploading.value ? '...' : '上传' }}
|
{{ admin.uploading.value ? '...' : t('upload') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
166
src/i18n.ts
Normal file
166
src/i18n.ts
Normal 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
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user