Compare commits
No commits in common. "v20260613" and "master" have entirely different histories.
52
.gitignore
vendored
@ -1,37 +1,39 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
# OS
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
llrt
|
||||
font/*
|
||||
tjs
|
||||
app
|
||||
*.tar
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
dist_backend
|
||||
.pilot
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
verify_font_baseline
|
||||
benchmark_results
|
||||
.claude
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
.cache
|
||||
dist
|
||||
asset/font
|
||||
asset/dynamically
|
||||
4
.prettierrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"deno.enable": false
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
pnpm dev 可以同时启动前后端,并且都会修改代码后自动重载
|
||||
- pnpm release 构建并发布当前项目的docker镜像
|
||||
## 浏览器测试(vite-plugin-pilot)
|
||||
已安装。`npx pilot run '代码'` 执行 JS(返回结果+日志+快照)、`npx pilot page` 页面状态
|
||||
`npx pilot help` 查看pilot所有功能
|
||||
常用:`__pilot_clickByText("文本")` 点击、`__pilot_typeByPlaceholder("提示文字", "值")` 输入、`__pilot_waitFor("文本")` 等待、`__pilot_findByText("文本")` 查找。snapshot 中 `#N` 是元素索引。
|
||||
多 tab 时用 `npx pilot status` 查看实例列表,`npx pilot run '代码' instance:前缀` 指定目标实例(支持 ID 前缀模糊匹配)。
|
||||
@ -1,6 +0,0 @@
|
||||
FROM scratch
|
||||
WORKDIR /home/
|
||||
COPY dist_backend/app.lrt /home/app.lrt
|
||||
COPY llrt /home/llrt
|
||||
COPY dist/ /home/dist/
|
||||
CMD ["/home/llrt", "/home/app.lrt"]
|
||||
179
README.en.md
@ -1,179 +0,0 @@
|
||||
# WebFont — Runtime Font Delivery + AI Chinese Font Skill
|
||||
|
||||
> Subset Chinese fonts on demand — 6 characters ≈ 6KB. Use premium Chinese fonts on any web page.
|
||||
|
||||
[中文](README.md)
|
||||
|
||||
**Chinese fonts are huge** (Source Han Sans: 16MB+). You can't just `@font-face` them like English fonts.
|
||||
|
||||
This project subsets fonts on the server side — pass in the text you need, get back only those glyphs. A poster with 20 characters? The font file is 20KB, not 16MB.
|
||||
|
||||
```html
|
||||
<!-- One CSS block to use any Chinese font -->
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "MyFont";
|
||||
src: url("https://webfont.shenzilong.cn/api?font=令东齐伋复刻体&text=静心茶舍&outType=woff2") format("woff2");
|
||||
}
|
||||
.title { font-family: "MyFont", serif; }
|
||||
</style>
|
||||
<h1 class="title">静心茶舍</h1>
|
||||
```
|
||||
|
||||
## Who Is This For
|
||||
|
||||
**Any project that needs Chinese Web Fonts:**
|
||||
|
||||
- Posters / H5 pages — a few characters subset to a few KB
|
||||
- Brand websites — premium fonts no longer limited by file size
|
||||
- Mini apps / PWA — bandwidth-sensitive, load on demand
|
||||
- Static blogs / CMS — content is known, CSS-only output
|
||||
- AI-generated pages — let AI output well-designed Chinese font solutions
|
||||
|
||||
## Features
|
||||
|
||||
> [Live Font Demo](https://webfont.shenzilong.cn/) — same content, different fonts, completely different feel
|
||||
|
||||
### Runtime Font Delivery
|
||||
|
||||
Server-side subsetting + client-side incremental loading.
|
||||
|
||||
```
|
||||
6 characters → server subsets font → returns ~6KB (not 16MB)
|
||||
```
|
||||
|
||||
JS SDK with three loading modes:
|
||||
|
||||
```html
|
||||
<script src="https://webfont.shenzilong.cn/webfont-sdk.js"></script>
|
||||
<script>
|
||||
// Recommended: MutationObserver-driven, auto-loads new characters on DOM change
|
||||
WebFont.observeFont({ fontName: "令东齐伋复刻体.ttf", selector: ".content", family: "MySerif" });
|
||||
|
||||
// Polling mode
|
||||
WebFont.loadFont({ fontName: "令东齐伋复刻体.ttf", selector: ".title", family: "MySerif" });
|
||||
|
||||
// Manual text mode
|
||||
var loader = WebFont.loadText({ fontName: "令东齐伋复刻体.ttf", text: "你好世界", family: "MySerif" });
|
||||
loader.update("追加文字");
|
||||
</script>
|
||||
```
|
||||
|
||||
All modes share the same character set per font — zero duplicate requests, zero flicker.
|
||||
|
||||
### AI Chinese Font Skill
|
||||
|
||||
Inject Chinese font application capability into AI (Claude, Cursor, Copilot, etc.). With the Skill Prompt, AI can:
|
||||
|
||||
- Auto-select fonts matching the scene
|
||||
- Handle Chinese-English mixed typesetting
|
||||
- Build proper font hierarchy
|
||||
- Generate fallback chains and runtime loading strategies
|
||||
|
||||
```
|
||||
Prompt: "Build a zen-style tea brand website"
|
||||
|
||||
Without Skill: font-family: sans-serif → system default → looks generic
|
||||
|
||||
With Skill: zen style → serif heading + kai body → auto subset → visual quality leap
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### API Only
|
||||
|
||||
No SDK needed. One CSS block to use any Chinese font. Server returns only the character subset you need.
|
||||
|
||||
```css
|
||||
@font-face {
|
||||
font-family: "MyFont";
|
||||
src: url("https://webfont.shenzilong.cn/api?font=令东齐伋复刻体&text=你的文字&outType=woff2") format("woff2");
|
||||
}
|
||||
```
|
||||
|
||||
### Self-hosted
|
||||
|
||||
```bash
|
||||
# Node.js / LLRT
|
||||
pnpm install && pnpm build && pnpm build_backend
|
||||
node ./dist_backend/app.cjs
|
||||
```
|
||||
|
||||
Docker (~30MB image):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
webfont:
|
||||
image: docker.io/llej0/web-font:latest
|
||||
ports:
|
||||
- "8087:8087"
|
||||
volumes:
|
||||
- ./fonts:/home/font
|
||||
environment:
|
||||
- ENABLE_TEMP_UPLOAD=true
|
||||
- ADMIN_API_KEY=your-secret-key
|
||||
- SUBSET_CACHE_MAX_SIZE=10485760
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `GET /api?font={name}&text={chars}&outType={woff2\|ttf}` | Subset font to specified characters |
|
||||
| `GET /api/fonts` | List available fonts |
|
||||
| `GET /api/config` | Get server configuration |
|
||||
| `POST /api/upload?mode=temp` | Temporary font upload (auto-cleanup) |
|
||||
| `POST /api/upload?mode=admin` | Permanent font upload (requires API key) |
|
||||
|
||||
Font name supports fuzzy matching: exact → prefix → contains.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Use Cases │
|
||||
│ Posters/H5 · Brand Sites · Blogs · Mini Apps · AI │
|
||||
└──────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌────────────┴────────────┐
|
||||
│ AI Chinese Font Skill │ ← Chinese font application
|
||||
│ + AI Skill Prompt │ ← Chinese font application
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
┌────────────┴────────────┐
|
||||
│ Runtime Font Delivery │ ← On-demand subsetting + incremental loading
|
||||
│ SDK + API │
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
┌────────────┴────────────┐
|
||||
│ Subset Engine │ ← fonteditor-core
|
||||
│ parse → subset → │ Font parsing, subsetting, format conversion
|
||||
│ optimize → serialize │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
web-font/
|
||||
├── backend/ # Server: HTTP API + font subsetting engine
|
||||
│ ├── routes/ # API route handlers
|
||||
│ ├── font_util/ # Font subsetting core
|
||||
│ └── server/ # HTTP server (Node.js + LLRT dual runtime)
|
||||
├── src/ # Frontend: Vue 3 SPA
|
||||
├── public/
|
||||
│ └── webfont-sdk.js # Runtime Font Delivery SDK
|
||||
├── skills/ # AI Chinese Font Skill
|
||||
├── examples/ # Before/After demo pages
|
||||
└── vendor/ # fonteditor-core
|
||||
```
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- [kekee000/fonteditor-core](https://github.com/kekee000/fonteditor-core) — Font parsing, subsetting, and format conversion
|
||||
- [字体天下](http://www.fonts.net.cn/commercial-free-32767/fonts-zh-1.html) — Free commercial-use Chinese fonts
|
||||
- Featured in [阮一峰科技爱好者周刊第 100 期](https://www.ruuyifeng.com/blog/2020/03/weekly-issue-100.html)
|
||||
|
||||
## License
|
||||
|
||||
MIT © [崮生](https://shenzilong.cn)
|
||||
224
README.md
@ -1,179 +1,105 @@
|
||||
# WebFont — 中文字体按需裁剪 + AI 字体技能
|
||||
# web font 字体裁剪工具
|
||||
|
||||
> 按需裁剪中文字体,6 个字 ≈ 6KB。让任何网页、海报、H5 都能用上高级中文字体。
|
||||
## 起因
|
||||
|
||||
[English](README.en.md)
|
||||
ui 需要展现一些特定的字体,但直接引入字体包又过大,于是想到了裁剪字体,一开始想的使用「字蛛」但他是针对静态网站的,而且实际他会多出许多英文的,估计是直接将源码中存在的文字都算进去了。
|
||||
后来又找到阿里的「webfont」 但他的字体有限,项目又不开源,所以自己写了这个
|
||||
|
||||
**中文字体包太大**(思源黑体 16MB+),没法像英文字体那样直接 `@font-face` 引入。
|
||||
## 尝试
|
||||
|
||||
本项目在服务端按需子集化字体——传什么文字,就只返回那些字符的字体子集。一张海报用了 20 个字?返回的字体文件就是 20KB,而不是 16MB。
|
||||
- [web font 在线站点](https://webfont.shenzilong.cn/)
|
||||
|
||||
```html
|
||||
<!-- 一段 CSS 即可使用任意中文字体 -->
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "MyFont";
|
||||
src: url("https://webfont.shenzilong.cn/api?font=令东齐伋复刻体&text=静心茶舍&outType=woff2") format("woff2");
|
||||
}
|
||||
.title { font-family: "MyFont", serif; }
|
||||
</style>
|
||||
<h1 class="title">静心茶舍</h1>
|
||||
```
|
||||
- 基于 [malagu](https://github.com/cellbang/malagu) 搭建的 [web font **serverless** 版在线尝试地址](http://webfontserverless.shenzilong.cn/) , [serverless代码分支](https://github.com/2234839/web-font/tree/serverless)
|
||||
|
||||
## 谁在用
|
||||
请注意,由于这个服务器比较差,所以访问可能比较慢,且因为服务器空间问题我会不定时的清空生成的资源,所以请不要使用这个站点生成的在线资源,如有需要应当自行布设
|
||||
|
||||
**任何需要中文 Web Font 的场景:**
|
||||
## 目的与功能
|
||||
|
||||
- 海报 / H5 活动页 — 几个字裁剪后只有几 KB
|
||||
- 品牌官网 — 高级字体不再受限于体积
|
||||
- 小程序 / PWA — 流量敏感,按需加载
|
||||
- 静态博客 / CMS — 文字内容确定,CSS 直出即可
|
||||
- AI 生成网页 — 让 AI 也能输出有设计感的中文字体方案
|
||||
1.裁剪字体包使其仅包含选中的字体
|
||||
|
||||
## 核心能力
|
||||
例如 如下图生成的字体包仅包含 「天地无极乾坤借法」
|
||||

|
||||
|
||||
> [在线体验中文字体效果对比](https://webfont.shenzilong.cn/?demo) — 同一内容,字体不同,体验天壤之别
|
||||
<video src="./doc_img/功能演示.mkv" controls="controls" width:100% height:auto></video>
|
||||
|
||||
### Runtime Font Delivery
|
||||
其体积自然十分之小
|
||||
|
||||
服务端按需子集化 + 客户端增量加载。
|
||||

|
||||
|
||||
```
|
||||
6 个字 → 服务端裁剪 → 返回约 6KB 子集字体(而非 16MB 完整字体)
|
||||
```
|
||||
2.另外可以生成 css 直接复制可用,部署在公网便可永久访问
|
||||
|
||||
JS SDK 三种加载模式:
|
||||
|
||||
```html
|
||||
<script src="https://webfont.shenzilong.cn/webfont-sdk.js"></script>
|
||||
<script>
|
||||
// 推荐:MutationObserver 事件驱动,DOM 变化自动加载新字符
|
||||
WebFont.observeFont({ fontName: "令东齐伋复刻体.ttf", selector: ".content", family: "MySerif" });
|
||||
|
||||
// 轮询模式
|
||||
WebFont.loadFont({ fontName: "令东齐伋复刻体.ttf", selector: ".title", family: "MySerif" });
|
||||
|
||||
// 手动传入文本
|
||||
var loader = WebFont.loadText({ fontName: "令东齐伋复刻体.ttf", text: "你好世界", family: "MySerif" });
|
||||
loader.update("追加文字");
|
||||
</script>
|
||||
```
|
||||
|
||||
同一字体下所有模式共享字符集,零重复请求,零闪烁。
|
||||
|
||||
### AI Chinese Font Skill
|
||||
|
||||
给 AI(Claude、Cursor、Copilot 等)注入中文字体应用能力。AI 读取 Skill Prompt 后可以:
|
||||
|
||||
- 自动选择匹配场景的中文字体
|
||||
- 正确处理中英混排
|
||||
- 建立字体层级(Font Hierarchy)
|
||||
- 生成 fallback 链和 Runtime 加载策略
|
||||
|
||||
```
|
||||
Prompt: "生成一个东方禅意风格的茶品牌官网"
|
||||
|
||||
无 Skill: font-family: sans-serif → 系统黑体 → 廉价感
|
||||
|
||||
有 Skill: zen 风格 → 宋体标题 + 楷体正文 → 自动子集化 → 质感显著提升
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 直接调用 API
|
||||
|
||||
无需 SDK,一段 CSS 即可。服务端只返回你需要的字符子集。
|
||||
例如
|
||||
|
||||
```css
|
||||
@font-face {
|
||||
font-family: "MyFont";
|
||||
src: url("https://webfont.shenzilong.cn/api?font=令东齐伋复刻体&text=你的文字&outType=woff2") format("woff2");
|
||||
font-family: "QIJIC";
|
||||
src: url("http://127.0.0.1:3000/asset/font/1584680576469/令东齐伋复刻体.eot"); /* IE9 */
|
||||
src: url("http://127.0.0.1:3000/asset/font/1584680576469/令东齐伋复刻体.eot?#iefix") format("embedded-opentype"), /* IE6-IE8 */
|
||||
url("http://127.0.0.1:3000/asset/font/1584680576469/令东齐伋复刻体.woff") format("woff"), /* chrome, firefox */
|
||||
url("http://127.0.0.1:3000/asset/font/1584680576469/令东齐伋复刻体.ttf") format("truetype"), /* chrome, firefox, opera, Safari, Android, iOS 4.2+ */
|
||||
url("http://127.0.0.1:3000/asset/font/1584680576469/令东齐伋复刻体.svg#QIJIC") format("svg"); /* iOS 4.1- */
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
}
|
||||
```
|
||||
|
||||
### 自部署
|
||||
3.将 ttf 的字体包放置在 ./asset/font_src/ 目录下自然可以检测到新的可用字体,无需重启服务
|
||||
|
||||

|
||||
|
||||
4.提供 zip 的整体下载方案
|
||||
|
||||

|
||||
|
||||
## 提供的服务
|
||||
|
||||
### 查询可用字体列表
|
||||
|
||||

|
||||
|
||||
### 生成压缩字体包
|
||||
|
||||

|
||||
|
||||
如图可见每个返回的字体资源,访问即可下载。另外在访问该目录下的 asset.zip 可以直接下载全部的文件,生成的资源目录结构见下图
|
||||
|
||||

|
||||
|
||||
注意,此接口是还支持 post 方式访问的,这样可以一次请求多个类型的字体文件,而且不会如同 get 方法那样有长度限制
|
||||
|
||||
|
||||

|
||||
|
||||
### 动态生成字体
|
||||
|
||||

|
||||
|
||||
#### 请注意
|
||||
|
||||
只支持生成 .ttf .eot .woff .svg 这几种格式
|
||||
|
||||
## 写项目时遇到的问题
|
||||
|
||||
1. 使用 svelte https://github.com/DeMoorJasper/parcel-plugin-svelte 通过这个插件使用 parcel 然后报 new 的错 需要限制 编译的版本,在package.json browserslist 字段限制一下版本就好
|
||||
|
||||
2. parcel 对 post purgecss 支持好像有问题,需要修改 postcss.config.js 文件他才能正确的删除样式
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
# Node.js / LLRT
|
||||
pnpm install && pnpm build && pnpm build_backend
|
||||
node ./dist_backend/app.cjs
|
||||
npm i
|
||||
npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
Docker(~30MB 极简镜像):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
webfont:
|
||||
image: docker.io/llej0/web-font:latest
|
||||
ports:
|
||||
- "8087:8087"
|
||||
volumes:
|
||||
- ./fonts:/home/font
|
||||
environment:
|
||||
- ENABLE_TEMP_UPLOAD=true
|
||||
- ADMIN_API_KEY=your-secret-key
|
||||
- SUBSET_CACHE_MAX_SIZE=10485760
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
| 接口 | 说明 |
|
||||
|------|------|
|
||||
| `GET /api?font={name}&text={chars}&outType={woff2\|ttf}` | 裁剪字体,只返回指定字符的子集 |
|
||||
| `GET /api/fonts` | 列出可用字体 |
|
||||
| `GET /api/config` | 获取服务配置 |
|
||||
| `POST /api/upload?mode=temp` | 临时上传(自动清理) |
|
||||
| `POST /api/upload?mode=admin` | 永久上传(需 API Key) |
|
||||
|
||||
字体名支持模糊匹配:精确 > 前缀 > 包含。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 使用场景 │
|
||||
│ 海报/H5 · 品牌官网 · 博客 · 小程序 · AI 生成页面 │
|
||||
└──────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌────────────┴────────────┐
|
||||
│ AI Chinese Font Skill │ ← 中文字体应用能力
|
||||
│ + Skill Prompt │ ← 字体裁剪 + 加载策略
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
┌────────────┴────────────┐
|
||||
│ Runtime Font Delivery │ ← 按需子集化 + 增量加载
|
||||
│ SDK + API │
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
┌────────────┴────────────┐
|
||||
│ Subset Engine │ ← fonteditor-core
|
||||
│ parse → subset → │ 字体解析、子集化、格式转换
|
||||
│ optimize → serialize │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
web-font/
|
||||
├── backend/ # 服务端:HTTP API + 字体裁剪引擎
|
||||
│ ├── routes/ # API 路由
|
||||
│ ├── font_util/ # 字体裁剪核心
|
||||
│ └── server/ # HTTP 服务器(兼容 Node.js + LLRT)
|
||||
├── src/ # 前端:Vue 3 单页应用
|
||||
├── public/
|
||||
│ └── webfont-sdk.js # Runtime Font Delivery SDK
|
||||
├── skills/ # AI Chinese Font Skill
|
||||
├── examples/ # Before/After 对比演示
|
||||
└── vendor/ # fonteditor-core
|
||||
```
|
||||
默认的访问地址是 http://127.0.0.1:3000
|
||||
|
||||
## 鸣谢
|
||||
|
||||
- [kekee000/fonteditor-core](https://github.com/kekee000/fonteditor-core) — 字体解析、子集化、格式转换
|
||||
- [字体天下](http://www.fonts.net.cn/commercial-free-32767/fonts-zh-1.html) — 免费商用中文字体
|
||||
- 入选[阮一峰科技爱好者周刊第 100 期](https://www.ruanyifeng.com/blog/2020/03/weekly-issue-100.html)
|
||||
[字体天下](http://www.fonts.net.cn/commercial-free-32767/fonts-zh-1.html)
|
||||
|
||||
[fontmin](https://github.com/ecomfe/fontmin)
|
||||
|
||||
## License
|
||||
|
||||
MIT © [崮生](https://shenzilong.cn)
|
||||
MIT © [崮生](https://shenzilong.cn/关于/mit.html)
|
||||
|
||||
BIN
asset/font_src/Alibaba-PuHuiTi-Heavy.ttf
Normal file
BIN
asset/font_src/令东齐伋复刻体.ttf
Normal file
BIN
asset/font_src/优设标题黑.ttf
Normal file
BIN
asset/font_src/问藏书房.ttf
Normal file
166
backend/app.ts
@ -1,166 +0,0 @@
|
||||
import { mimeTypes } from "./server/mime_type";
|
||||
import type { cMiddleware } from "./server/req_res";
|
||||
import { SimpleHttpServer } from "./server/server";
|
||||
import { path_join, readFile, stat, readdir, mkdir } from "./interface";
|
||||
import { parseUrl, jsonResponse, stats } from "./shared";
|
||||
import { enableTempUpload, adminApiKey } from "./config";
|
||||
import { handleListFonts } from "./routes/fonts";
|
||||
import { handleGetConfig } from "./routes/config";
|
||||
import { handleStats } from "./routes/stats";
|
||||
import { handleUpload } from "./routes/upload";
|
||||
import { handleFontSubset } from "./routes/subset";
|
||||
import "./server/node";
|
||||
import "./server/llrt";
|
||||
|
||||
const ROOT_DIR = "dist";
|
||||
|
||||
/** 启动时确保必要目录存在 */
|
||||
async function ensureDirectories() {
|
||||
for (const dir of ["font/temp", "font/admin"]) {
|
||||
try {
|
||||
await stat(dir);
|
||||
} catch {
|
||||
await mkdir(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const logMiddleware: cMiddleware = async (req, res, next) => {
|
||||
stats.totalRequests++;
|
||||
const t1 = Date.now();
|
||||
const r = await next(req, res);
|
||||
const t2 = Date.now();
|
||||
const url = parseUrl(req);
|
||||
console.log(`[${t2 - t1}ms] ${req.method} ${url.pathname}`);
|
||||
return r;
|
||||
};
|
||||
|
||||
const staticFileMiddleware: cMiddleware = async function (req, res, next) {
|
||||
let newRes: Response;
|
||||
if (req.method === "GET") {
|
||||
const url = parseUrl(req);
|
||||
const filePath = path_join(ROOT_DIR, url.pathname === "/" ? "index.html" : url.pathname);
|
||||
/** 防止路径穿越:规范化后必须仍在 dist 目录内 */
|
||||
if (!filePath.startsWith(ROOT_DIR + "/") && filePath !== ROOT_DIR) {
|
||||
newRes = new Response("403 Forbidden", {
|
||||
status: 403,
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
return next(req, newRes);
|
||||
}
|
||||
try {
|
||||
const fileStat = await stat(filePath);
|
||||
|
||||
if (fileStat.isFile()) {
|
||||
const fileContent = await readFile(filePath);
|
||||
const extname = filePath.split(".").pop() ?? "";
|
||||
newRes = new Response(fileContent, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": mimeTypes[extname] || "application/octet-stream",
|
||||
"Content-Length": `${fileStat.size}`,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
newRes = new Response("404 Not Found", {
|
||||
status: 404,
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("[err]", err);
|
||||
newRes = new Response("500 Internal Server Error", {
|
||||
status: 500,
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
newRes = new Response("Method Not Allowed", {
|
||||
status: 405,
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
},
|
||||
});
|
||||
}
|
||||
return next(req, newRes);
|
||||
};
|
||||
const corsMiddleware: cMiddleware = async (req, res, next) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return {
|
||||
req,
|
||||
res: new Response("", {
|
||||
status: 204,
|
||||
headers: {
|
||||
"Content-Length": "0",
|
||||
},
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
const newRes = await next(req, res);
|
||||
newRes.res.headers.append("Access-Control-Allow-Origin", "*");
|
||||
newRes.res.headers.append("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
||||
newRes.res.headers.append("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
return newRes;
|
||||
}
|
||||
};
|
||||
|
||||
/** 统一的 API 路由中间件 */
|
||||
const fontApiMiddleware: cMiddleware = async (req, res, next) => {
|
||||
const url = parseUrl(req);
|
||||
if (!url.pathname.startsWith("/api")) return next(req, res);
|
||||
|
||||
if (url.pathname === "/api/fonts" && req.method === "GET") {
|
||||
return handleListFonts(req, res);
|
||||
}
|
||||
if (url.pathname === "/api/config" && req.method === "GET") {
|
||||
return handleGetConfig(req, res);
|
||||
}
|
||||
if (url.pathname === "/api/stats" && req.method === "GET") {
|
||||
return handleStats(req, res);
|
||||
}
|
||||
if (url.pathname === "/api/upload" && req.method === "POST") {
|
||||
return handleUpload(req, res);
|
||||
}
|
||||
if (url.pathname === "/api" && req.method === "GET") {
|
||||
return handleFontSubset(req, res);
|
||||
}
|
||||
|
||||
return next(req, res);
|
||||
};
|
||||
|
||||
/** 上传文件大小限制 50MB */
|
||||
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024;
|
||||
|
||||
const uploadSizeMiddleware: cMiddleware = async (req, res, next) => {
|
||||
if (req.method === "POST" && parseUrl(req).pathname === "/api/upload") {
|
||||
const contentLength = parseInt(req.headers.get("Content-Length") ?? "0", 10);
|
||||
if (contentLength > MAX_UPLOAD_SIZE) {
|
||||
return {
|
||||
req,
|
||||
res: jsonResponse({ success: false, error: "文件过大,最大 50MB" }, 413),
|
||||
};
|
||||
}
|
||||
}
|
||||
return next(req, res);
|
||||
};
|
||||
|
||||
async function main() {
|
||||
await ensureDirectories();
|
||||
|
||||
const server = new SimpleHttpServer({ port: 8087 });
|
||||
server.use(
|
||||
logMiddleware,
|
||||
corsMiddleware,
|
||||
uploadSizeMiddleware,
|
||||
fontApiMiddleware,
|
||||
staticFileMiddleware,
|
||||
);
|
||||
console.log("[config] temp upload:", enableTempUpload);
|
||||
console.log("[config] admin upload:", !!adminApiKey);
|
||||
}
|
||||
|
||||
main();
|
||||
@ -1,22 +0,0 @@
|
||||
/**
|
||||
* 从环境变量读取服务配置,启动时一次性加载
|
||||
*/
|
||||
const env = globalThis.process?.env ?? {};
|
||||
|
||||
/** 临时上传开关 */
|
||||
export const enableTempUpload = env.ENABLE_TEMP_UPLOAD === "true";
|
||||
|
||||
/** 管理员 API Key,为空则管理员上传不可用 */
|
||||
export const adminApiKey: string = env.ADMIN_API_KEY ?? "";
|
||||
|
||||
/** 临时上传目录最大文件数 */
|
||||
export const tempMaxFiles = parseInt(env.TEMP_MAX_FILES ?? "10", 10) || 10;
|
||||
|
||||
/** 临时上传目录总体积上限(字节),默认 200MB */
|
||||
export const tempMaxTotalSize = parseInt(env.TEMP_MAX_TOTAL_SIZE ?? `${200 * 1024 * 1024}`, 10) || 200 * 1024 * 1024;
|
||||
|
||||
/** 字体裁剪结果内存缓存容量上限(字节),默认 10MB */
|
||||
export const subsetCacheMaxSize = parseInt(env.SUBSET_CACHE_MAX_SIZE ?? `${10 * 1024 * 1024}`, 10) || 10 * 1024 * 1024;
|
||||
|
||||
/** 字体搜索目录(按优先级排序:admin > 普通 > 临时) */
|
||||
export const fontDirs = ["font/admin", "font", "font/temp"] as const;
|
||||
@ -1,77 +0,0 @@
|
||||
import { Font } from "../../vendor/fonteditor-core/lib/ttf/font.js";
|
||||
import type { FontEditor } from "../../vendor/fonteditor-core/lib/ttf/font.js";
|
||||
|
||||
/** 优化291: TextEncoder 模块级单例 */
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* 字体裁剪的所有可配置步骤
|
||||
* 每个步骤独立导出,方便组合使用和单独测试
|
||||
*/
|
||||
|
||||
/** 从字符串提取 Unicode 码点数组 */
|
||||
export const textToCodePoints = (text: string) => {
|
||||
const result: number[] = [];
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const cp = text.codePointAt(i) as number;
|
||||
result.push(cp);
|
||||
if (cp > 0xFFFF) i++; /** 跳过代理对的低半部分 */
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析字体并执行 subset(最耗时的步骤)
|
||||
*/
|
||||
export const createSubsetFont = (
|
||||
fontBuffer: ArrayBuffer,
|
||||
codePoints: number[],
|
||||
sourceType: FontEditor.FontType,
|
||||
) =>
|
||||
Font.create(fontBuffer, {
|
||||
type: sourceType,
|
||||
subset: codePoints,
|
||||
});
|
||||
|
||||
/**
|
||||
* 优化字体(去冗余表、清理无用字形)
|
||||
* subset 模式下 TTFReader.resolveGlyf 已完成 compound2simple,跳过
|
||||
* optimizettf 已设置 _unicodeSorted=true,sortGlyf 会直接返回
|
||||
*/
|
||||
export const optimizeFont = (font: ReturnType<typeof Font.create>) => {
|
||||
const optimized = font.optimize();
|
||||
return optimized;
|
||||
};
|
||||
|
||||
/** 序列化为指定格式的二进制数据 */
|
||||
/** 优化291: 移除 async,消除不必要的微任务调度 */
|
||||
export const writeFont = (
|
||||
font: ReturnType<ReturnType<typeof Font.create>["optimize"]>,
|
||||
outType: FontEditor.FontType,
|
||||
): Uint8Array => {
|
||||
const result = font.write({ type: outType });
|
||||
if (typeof result === "string") {
|
||||
return textEncoder.encode(result);
|
||||
}
|
||||
/** 优化278: Buffer 是 Uint8Array 子类,直接返回避免多余拷贝 */
|
||||
if (result instanceof Uint8Array) {
|
||||
return result;
|
||||
}
|
||||
return new Uint8Array(result);
|
||||
};
|
||||
|
||||
/**
|
||||
* 完整的字体裁剪流程(当前生产实现)
|
||||
* 解析 -> subset -> 优化 -> 序列化
|
||||
* 优化293: 移除 async,函数体内无 await,消除不必要的 Promise 包装和微任务调度
|
||||
*/
|
||||
export const fontSubset = (
|
||||
fontBuffer: ArrayBuffer,
|
||||
subString: string,
|
||||
option: { sourceType: FontEditor.FontType; outType: FontEditor.FontType },
|
||||
): Uint8Array => {
|
||||
const codePoints = textToCodePoints(subString);
|
||||
const font = createSubsetFont(fontBuffer, codePoints, option.sourceType);
|
||||
const optimized = optimizeFont(font);
|
||||
return writeFont(optimized, option.outType);
|
||||
};
|
||||
@ -1,67 +0,0 @@
|
||||
export let stat: (path: string) => Promise<{
|
||||
isFile: () => boolean;
|
||||
size: number;
|
||||
}>;
|
||||
|
||||
export let readFile: (path: string) => Promise<Uint8Array>;
|
||||
|
||||
export let writeFile: (path: string, data: Uint8Array) => Promise<void>;
|
||||
|
||||
export let readdir: (path: string) => Promise<{
|
||||
isFile: () => boolean;
|
||||
name: string;
|
||||
}[]>;
|
||||
|
||||
export let mkdir: (path: string) => Promise<void>;
|
||||
|
||||
export let unlink: (path: string) => Promise<void>;
|
||||
|
||||
export const implInterface = (options: {
|
||||
stat: typeof stat;
|
||||
readFile: typeof readFile;
|
||||
writeFile: typeof writeFile;
|
||||
readdir: typeof readdir;
|
||||
mkdir: typeof mkdir;
|
||||
unlink: typeof unlink;
|
||||
}) => {
|
||||
stat = options.stat;
|
||||
readFile = options.readFile;
|
||||
writeFile = options.writeFile;
|
||||
readdir = options.readdir;
|
||||
mkdir = options.mkdir;
|
||||
unlink = options.unlink;
|
||||
};
|
||||
|
||||
export function path_join(...paths: string[]) {
|
||||
const sep = "/";
|
||||
|
||||
function trimSlashes(p: string) {
|
||||
return p.replace(/\/+$/, "").replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
/** 将路径按 / 分割并解析 . 和 .. 段 */
|
||||
function normalizeSegments(segments: string[]) {
|
||||
const resolved: string[] = [];
|
||||
for (const seg of segments) {
|
||||
if (seg === "..") {
|
||||
resolved.pop();
|
||||
} else if (seg !== "." && seg !== "") {
|
||||
resolved.push(seg);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const isAbsolute = paths[0] && paths[0].startsWith(sep);
|
||||
const segments = paths
|
||||
.map((path) => trimSlashes(path))
|
||||
.join(sep)
|
||||
.split(sep);
|
||||
|
||||
const resolved = normalizeSegments(segments);
|
||||
|
||||
if (!resolved.length) return isAbsolute ? sep : ".";
|
||||
|
||||
const result = resolved.join(sep);
|
||||
return isAbsolute ? sep + result : result;
|
||||
}
|
||||
@ -1,80 +0,0 @@
|
||||
/**
|
||||
* 基于 Map 插入顺序的通用 LRU 缓存
|
||||
* 支持两种淘汰策略:按条目数、按字节容量
|
||||
*/
|
||||
export class LruCache<V> {
|
||||
private cache = new Map<string, V>();
|
||||
|
||||
/** 计算条目字节大小(仅按容量淘汰时需要) */
|
||||
private readonly sizeFn?: (value: V) => number;
|
||||
|
||||
/** 最大条目数(按条目淘汰时使用) */
|
||||
private readonly maxSize?: number;
|
||||
|
||||
/** 最大字节容量(按容量淘汰时使用) */
|
||||
private readonly maxBytes?: number;
|
||||
|
||||
/** 当前已用字节 */
|
||||
private usedBytes = 0;
|
||||
|
||||
/** 当前缓存条目数 */
|
||||
get size() {
|
||||
return this.cache.size;
|
||||
}
|
||||
|
||||
constructor(options: { maxSize: number } | { maxBytes: number; sizeFn: (value: V) => number }) {
|
||||
if ("maxSize" in options) {
|
||||
this.maxSize = options.maxSize;
|
||||
} else {
|
||||
this.maxBytes = options.maxBytes;
|
||||
this.sizeFn = options.sizeFn;
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取缓存值,命中时移到末尾(LRU) */
|
||||
get(key: string): V | undefined {
|
||||
const value = this.cache.get(key);
|
||||
if (value === undefined) return undefined;
|
||||
this.cache.delete(key);
|
||||
this.cache.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 写入缓存,超限时自动淘汰最久未使用的条目 */
|
||||
set(key: string, value: V): void {
|
||||
/** 如果 key 已存在,先移除旧值(更新大小) */
|
||||
const existing = this.cache.get(key);
|
||||
if (existing !== undefined) {
|
||||
this.cache.delete(key);
|
||||
if (this.sizeFn) {
|
||||
this.usedBytes -= this.sizeFn(existing);
|
||||
}
|
||||
}
|
||||
|
||||
this.cache.set(key, value);
|
||||
if (this.sizeFn) {
|
||||
this.usedBytes += this.sizeFn(value);
|
||||
this.evictByBytes();
|
||||
} else if (this.maxSize !== undefined) {
|
||||
this.evictByCount();
|
||||
}
|
||||
}
|
||||
|
||||
/** 按条目数淘汰 */
|
||||
private evictByCount() {
|
||||
while (this.cache.size > this.maxSize!) {
|
||||
const oldest = this.cache.keys().next().value!;
|
||||
this.cache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
/** 按字节容量淘汰 */
|
||||
private evictByBytes() {
|
||||
while (this.usedBytes > this.maxBytes! && this.cache.size > 0) {
|
||||
const oldest = this.cache.keys().next().value!;
|
||||
const entry = this.cache.get(oldest)!;
|
||||
this.usedBytes -= this.sizeFn!(entry);
|
||||
this.cache.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,98 +0,0 @@
|
||||
/**
|
||||
* 轻量 multipart/form-data 解析器,不依赖外部库
|
||||
*/
|
||||
export interface MultipartFile {
|
||||
/** 表单字段名 */
|
||||
name: string;
|
||||
/** 原始文件名 */
|
||||
filename: string;
|
||||
/** 文件二进制数据 */
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
export interface MultipartParseResult {
|
||||
files: MultipartFile[];
|
||||
}
|
||||
|
||||
export function parseMultipart(contentType: string, body: ArrayBuffer): MultipartParseResult {
|
||||
if (!body || body.byteLength === 0) {
|
||||
return { files: [] };
|
||||
}
|
||||
|
||||
const boundaryMatch = contentType.match(/boundary=([^\s;]+)/);
|
||||
if (!boundaryMatch) throw new Error("No boundary found");
|
||||
const boundary = boundaryMatch[1].replace(/^"(.*)"$/, "$1");
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
const bodyBytes = new Uint8Array(body);
|
||||
const delimiter = encoder.encode("\r\n--" + boundary);
|
||||
|
||||
/** 在字节数组中查找子串位置 */
|
||||
function findBytes(haystack: Uint8Array, needle: Uint8Array, offset: number): number {
|
||||
for (let i = offset; i <= haystack.length - needle.length; i++) {
|
||||
let match = true;
|
||||
for (let j = 0; j < needle.length; j++) {
|
||||
if (haystack[i + j] !== needle[j]) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
const files: MultipartFile[] = [];
|
||||
|
||||
// 跳过起始边界 "--boundary\r\n"
|
||||
const startBoundary = encoder.encode("--" + boundary + "\r\n");
|
||||
let pos = 0;
|
||||
const sbPos = findBytes(bodyBytes, startBoundary, pos);
|
||||
if (sbPos === -1) throw new Error("Invalid multipart: no start boundary");
|
||||
pos = sbPos + startBoundary.length;
|
||||
|
||||
while (pos < bodyBytes.length) {
|
||||
// 查找 headers 和 body 的分界 "\r\n\r\n"
|
||||
const headerEnd = findBytes(bodyBytes, encoder.encode("\r\n\r\n"), pos);
|
||||
if (headerEnd === -1) break;
|
||||
|
||||
const headerText = decoder.decode(bodyBytes.slice(pos, headerEnd));
|
||||
const bodyStart = headerEnd + 4;
|
||||
|
||||
// 查找下一个 boundary
|
||||
const nextBoundary = findBytes(bodyBytes, delimiter, bodyStart);
|
||||
if (nextBoundary === -1) break;
|
||||
|
||||
// part body 去掉末尾的 "\r\n"
|
||||
const partBody = bodyBytes.slice(bodyStart, nextBoundary);
|
||||
const actualBody = partBody.length >= 2 && partBody[partBody.length - 1] === 10 && partBody[partBody.length - 2] === 13
|
||||
? partBody.slice(0, partBody.length - 2)
|
||||
: partBody;
|
||||
|
||||
// 从 Content-Disposition 中解析字段名和文件名
|
||||
const nameMatch = headerText.match(/name="([^"]*)"/);
|
||||
const filenameMatch = headerText.match(/filename="([^"]*)"/);
|
||||
|
||||
if (nameMatch) {
|
||||
files.push({
|
||||
name: nameMatch[1],
|
||||
filename: filenameMatch?.[1] ?? "",
|
||||
data: actualBody,
|
||||
});
|
||||
}
|
||||
|
||||
pos = nextBoundary + delimiter.length;
|
||||
|
||||
// 检查 boundary 后面是否紧跟 "--"(结束标记)
|
||||
if (pos + 2 <= bodyBytes.length && bodyBytes[pos] === 45 && bodyBytes[pos + 1] === 45) {
|
||||
break;
|
||||
}
|
||||
// 跳过 boundary 后的 "\r\n"
|
||||
if (pos < bodyBytes.length && bodyBytes[pos] === 13 && bodyBytes[pos + 1] === 10) {
|
||||
pos += 2;
|
||||
}
|
||||
}
|
||||
|
||||
return { files };
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
import { jsonResponse } from "../shared";
|
||||
import { enableTempUpload, adminApiKey } from "../config";
|
||||
|
||||
/** GET /api/config — 返回公开配置 */
|
||||
export async function handleGetConfig(req: Request, res: Response) {
|
||||
return {
|
||||
req,
|
||||
res: jsonResponse({
|
||||
enableTempUpload,
|
||||
adminUploadEnabled: !!adminApiKey,
|
||||
supportedOutTypes: ["woff2", "ttf"],
|
||||
}),
|
||||
};
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
import { jsonResponse, parseUrl } from "../shared";
|
||||
import { readdir, stat } from "../interface";
|
||||
import { fontDirs } from "../config";
|
||||
|
||||
/** GET /api/fonts — 列出所有可用字体 */
|
||||
export async function handleListFonts(req: Request, res: Response) {
|
||||
const allFonts: Array<{ name: string; temporary: boolean }> = [];
|
||||
|
||||
for (const dir of fontDirs) {
|
||||
try {
|
||||
const entries = await readdir(dir);
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && /\.(ttf|otf|woff|woff2)$/i.test(entry.name)) {
|
||||
allFonts.push({ name: entry.name, temporary: dir === "font/temp" });
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return { req, res: jsonResponse(allFonts) };
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
import { jsonResponse, stats, subsetCache, fontBufferCache } from "../shared";
|
||||
|
||||
/** GET /api/stats — 返回运行时统计 */
|
||||
export async function handleStats(req: Request, res: Response) {
|
||||
return {
|
||||
req,
|
||||
res: jsonResponse({
|
||||
uptime: Math.floor((Date.now() - stats.startTime) / 1000),
|
||||
totalRequests: stats.totalRequests,
|
||||
subsetRequests: stats.subsetRequests,
|
||||
subsetCacheHits: stats.subsetCacheHits,
|
||||
totalChars: stats.totalChars,
|
||||
subsetCacheEntries: subsetCache.size,
|
||||
fontBufferCacheEntries: fontBufferCache.size,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@ -1,86 +0,0 @@
|
||||
import { fontSubset } from "../font_util/font";
|
||||
import type { FontEditor } from "../../vendor/fonteditor-core/lib/ttf/font.js";
|
||||
import { parseUrl, jsonResponse, stats, subsetCache, findFontPath, readFontBuffer } from "../shared";
|
||||
|
||||
/** GET /api?font=...&text=... — 字体裁剪 */
|
||||
export async function handleFontSubset(req: Request, res: Response) {
|
||||
const url = parseUrl(req);
|
||||
const params = new URLSearchParams(url.search);
|
||||
const font = params.get("font") || "";
|
||||
const text = params.get("text") || "";
|
||||
if (text.length === 0) {
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
const fontPath = await findFontPath(font);
|
||||
if (!fontPath) {
|
||||
return {
|
||||
req,
|
||||
res: new Response(`Font not found: ${font}`, {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 默认 ttf(兼容性最好) */
|
||||
const outTypeParam = params.get("outType") || "";
|
||||
const outType = (outTypeParam === "woff2" || outTypeParam === "ttf") ? outTypeParam : "ttf";
|
||||
|
||||
/** 查询裁剪结果缓存 */
|
||||
const cacheKey = `${fontPath}:${outType}:${text}`;
|
||||
stats.subsetRequests++;
|
||||
stats.totalChars += text.length;
|
||||
const cached = subsetCache.get(cacheKey);
|
||||
if (cached) {
|
||||
stats.subsetCacheHits++;
|
||||
const contentTypes: Record<string, string> = { ttf: "font/ttf", woff2: "font/woff2" };
|
||||
return {
|
||||
req,
|
||||
res: new Response(cached, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentTypes[outType] || "font/ttf",
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
"X-Cache": "HIT",
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const fontType = fontPath.split(".").pop() as FontEditor.FontType;
|
||||
let oldFontBuffer: ArrayBuffer;
|
||||
try {
|
||||
oldFontBuffer = await readFontBuffer(fontPath);
|
||||
} catch {
|
||||
return {
|
||||
req,
|
||||
res: new Response(`Font read error: ${font}`, {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const newFont = await fontSubset(oldFontBuffer, text, {
|
||||
outType: outType,
|
||||
sourceType: fontType,
|
||||
});
|
||||
|
||||
/** 写入裁剪结果缓存 */
|
||||
subsetCache.set(cacheKey, newFont as ArrayBuffer);
|
||||
|
||||
const contentTypes: Record<string, string> = { ttf: "font/ttf", woff2: "font/woff2" };
|
||||
|
||||
return {
|
||||
req,
|
||||
res: new Response(newFont, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentTypes[outType] || "font/ttf",
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
"X-Cache": "MISS",
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
import { jsonResponse, parseUrl } from "../shared";
|
||||
import { parseMultipart } from "../multipart";
|
||||
import { handleTempUpload, handleAdminUpload } from "../upload";
|
||||
|
||||
/** POST /api/upload?mode=temp|admin — 上传字体 */
|
||||
export async function handleUpload(req: Request, res: Response) {
|
||||
const url = parseUrl(req);
|
||||
const mode = url.searchParams.get("mode") ?? "temp";
|
||||
|
||||
const contentType = req.headers.get("Content-Type") ?? "";
|
||||
console.log("[upload] mode:", mode, "contentType:", contentType);
|
||||
|
||||
const body = (req as Request & { _bodyBuffer?: ArrayBuffer })._bodyBuffer;
|
||||
if (!body || body.byteLength === 0) {
|
||||
return { req, res: jsonResponse({ success: false, error: "请求体为空" }, 400) };
|
||||
}
|
||||
console.log("[upload] body size:", body.byteLength);
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseMultipart(contentType, body);
|
||||
console.log("[upload] parsed files:", parsed.files.length);
|
||||
} catch (err) {
|
||||
console.log("[upload] parse error:", err);
|
||||
return { req, res: jsonResponse({ success: false, error: "无效的 multipart 数据" }, 400) };
|
||||
}
|
||||
|
||||
if (!parsed.files || parsed.files.length === 0) {
|
||||
return { req, res: jsonResponse({ success: false, error: "未提供文件" }, 400) };
|
||||
}
|
||||
|
||||
const file = parsed.files[0];
|
||||
console.log("[upload] file:", file.name, "filename:", file.filename, "data size:", file.data.length);
|
||||
|
||||
let result;
|
||||
if (mode === "admin") {
|
||||
const authHeader = req.headers.get("Authorization") ?? "";
|
||||
const apiKey = authHeader.replace("Bearer ", "");
|
||||
result = await handleAdminUpload({ data: file.data, filename: file.filename }, apiKey);
|
||||
console.log("[upload] admin result:", result);
|
||||
return { req, res: jsonResponse(result, result.success ? 200 : 403) };
|
||||
}
|
||||
|
||||
result = await handleTempUpload({ data: file.data, filename: file.filename });
|
||||
console.log("[upload] temp result:", result);
|
||||
return { req, res: jsonResponse(result, result.success ? 200 : 400) };
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
import "web-streams-polyfill/polyfill";
|
||||
@ -1,16 +0,0 @@
|
||||
// MIME 类型映射
|
||||
export const mimeTypes: Record<string, string> = {
|
||||
"html": "text/html",
|
||||
"css": "text/css",
|
||||
"js": "application/javascript; charset=utf-8",
|
||||
"json": "application/json",
|
||||
"png": "image/png",
|
||||
"jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg",
|
||||
"gif": "image/gif",
|
||||
"svg": "image/svg+xml",
|
||||
"woff": "font/woff",
|
||||
"woff2": "font/woff2",
|
||||
"ttf": "font/ttf",
|
||||
"otf": "font/otf",
|
||||
};
|
||||
@ -1,27 +0,0 @@
|
||||
import { implInterface } from "../interface";
|
||||
import { stat, readFile, writeFile, readdir, mkdir, unlink } from "fs/promises";
|
||||
implInterface({
|
||||
async stat(path) {
|
||||
const r = await stat(path);
|
||||
return r;
|
||||
},
|
||||
readFile(path) {
|
||||
return readFile(path);
|
||||
},
|
||||
writeFile(path, data) {
|
||||
return writeFile(path, data);
|
||||
},
|
||||
async readdir(path) {
|
||||
const entries = await readdir(path, { withFileTypes: true });
|
||||
return entries.map((entry) => ({
|
||||
isFile: () => entry.isFile(),
|
||||
name: entry.name,
|
||||
}));
|
||||
},
|
||||
async mkdir(path) {
|
||||
await mkdir(path, { recursive: true });
|
||||
},
|
||||
unlink(path) {
|
||||
return unlink(path);
|
||||
},
|
||||
});
|
||||
@ -1,10 +0,0 @@
|
||||
// 请求和响应模型
|
||||
export type cRequest = Request;
|
||||
|
||||
export type cResponse = Response;
|
||||
export type cNext = (
|
||||
req: cRequest,
|
||||
res: cResponse,
|
||||
) => { req: cRequest; res: cResponse } | Promise<{ req: cRequest; res: cResponse }>;
|
||||
// 中间件函数类型
|
||||
export type cMiddleware = (req: cRequest, res: cResponse, next: cNext) => ReturnType<cNext>;
|
||||
@ -1,299 +0,0 @@
|
||||
import { cMiddleware, cRequest, cResponse, type cNext } from "./req_res";
|
||||
import { createTcpServer } from "./tcp_server";
|
||||
// 配置
|
||||
// 路由器类
|
||||
export class cRouter {
|
||||
private middleware: cMiddleware[] = [];
|
||||
|
||||
use(middleware: cMiddleware) {
|
||||
this.middleware.push(middleware);
|
||||
return this;
|
||||
}
|
||||
|
||||
async handle(req: cRequest, res: cResponse) {
|
||||
let index = -1;
|
||||
const next = async (req: cRequest, res: cResponse) => {
|
||||
index += 1;
|
||||
// console.log(`开始执行第 ${index} ${this.middleware[index]?.name} 中间件`);
|
||||
const r = (await this.middleware[index]?.(req, res, next)) ?? { req, res };
|
||||
// console.log(`执行完毕第 ${index} 中间件`);
|
||||
|
||||
return r;
|
||||
};
|
||||
return next(req, res);
|
||||
}
|
||||
}
|
||||
// 实现一个简化的 HTTP 服务器
|
||||
export class SimpleHttpServer {
|
||||
private router: cRouter = new cRouter();
|
||||
|
||||
constructor(options: { port: number; hostname?: string }) {
|
||||
const release_name = globalThis?.process?.release?.name;
|
||||
console.log("[release.name]", release_name);
|
||||
if (release_name === "llrt" || release_name === "node") {
|
||||
const server = createTcpServer((socket) => {
|
||||
connectionHandle(socket, (req, res) => this.router.handle(req, res));
|
||||
});
|
||||
server.listen(options.port, options.hostname, () => {
|
||||
console.log(`Server is listening on port ${options.port}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
use(...middlewares: cMiddleware[]) {
|
||||
middlewares.forEach((middleware) => this.router.use(middleware));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
/** 合并多个 Uint8Array 为单个 ArrayBuffer */
|
||||
function mergeChunks(chunks: Uint8Array[], totalLength: number): ArrayBuffer {
|
||||
const merged = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return merged.buffer.slice(merged.byteOffset, merged.byteOffset + merged.byteLength);
|
||||
}
|
||||
|
||||
// 请求头终止符
|
||||
const target = encoder.encode("\r\n\r\n");
|
||||
async function connectionHandle(
|
||||
connection: {
|
||||
readable: ReadableStream<Uint8Array>;
|
||||
writable: WritableStream<Uint8Array>;
|
||||
close: () => void;
|
||||
},
|
||||
handle: cNext,
|
||||
) {
|
||||
try {
|
||||
const { header, body } = await createStreamAfterTarget(connection.readable, target);
|
||||
if (!header) {
|
||||
return;
|
||||
}
|
||||
const httpHeaderText = decoder.decode(header);
|
||||
const httpHeader = parseHttpRequest(httpHeaderText);
|
||||
const hasBody = httpHeader.method !== "GET" && httpHeader.method !== "HEAD";
|
||||
/** 大小写不敏感查找 header */
|
||||
const getHeader = (name: string) => {
|
||||
const lower = name.toLowerCase();
|
||||
for (const key of Object.keys(httpHeader.headers)) {
|
||||
if (key.toLowerCase() === lower) return httpHeader.headers[key];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
/** 读取请求体 */
|
||||
let bodyArrayBuffer: ArrayBuffer | undefined;
|
||||
if (hasBody && body) {
|
||||
const contentLength = parseInt(getHeader("Content-Length") ?? "0", 10);
|
||||
if (contentLength > 0) {
|
||||
/** 根据 Content-Length 读取指定长度的 body */
|
||||
const chunks: Uint8Array[] = [];
|
||||
let received = 0;
|
||||
for await (const chunk of body) {
|
||||
chunks.push(chunk);
|
||||
received += chunk.length;
|
||||
if (received >= contentLength) break;
|
||||
}
|
||||
body.cancel?.();
|
||||
bodyArrayBuffer = mergeChunks(chunks, received);
|
||||
} else if (getHeader("Transfer-Encoding") === "chunked") {
|
||||
/** 解码 chunked transfer encoding */
|
||||
const chunks: Uint8Array[] = [];
|
||||
let totalLength = 0;
|
||||
const chunkBuf: number[] = [];
|
||||
let state: "size" | "data" | "crlf_after_data" = "size";
|
||||
let chunkSize = 0;
|
||||
let dataRead = 0;
|
||||
let goto_done = false;
|
||||
for await (const rawChunk of body) {
|
||||
for (const byte of rawChunk) {
|
||||
switch (state) {
|
||||
case "size": {
|
||||
if (byte === 13) continue; // \r
|
||||
if (byte === 10) {
|
||||
// \n — size 行结束
|
||||
const sizeStr = new TextDecoder().decode(new Uint8Array(chunkBuf)).trim();
|
||||
chunkSize = parseInt(sizeStr, 16);
|
||||
chunkBuf.length = 0;
|
||||
if (chunkSize === 0) {
|
||||
state = "crlf_after_data"; // 最后一个空行
|
||||
} else {
|
||||
state = "data";
|
||||
dataRead = 0;
|
||||
}
|
||||
} else {
|
||||
chunkBuf.push(byte);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "data": {
|
||||
chunkBuf.push(byte);
|
||||
dataRead++;
|
||||
if (dataRead >= chunkSize) {
|
||||
const data = new Uint8Array(chunkBuf);
|
||||
chunks.push(data);
|
||||
totalLength += data.length;
|
||||
chunkBuf.length = 0;
|
||||
state = "crlf_after_data";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "crlf_after_data": {
|
||||
// 跳过 trailing \r\n
|
||||
if (byte === 10) {
|
||||
if (chunkSize === 0) {
|
||||
// 结束标记后的 \n
|
||||
state = "size";
|
||||
goto_done = true;
|
||||
} else {
|
||||
state = "size";
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (goto_done) break;
|
||||
}
|
||||
if (goto_done) break;
|
||||
}
|
||||
if (totalLength > 0) {
|
||||
bodyArrayBuffer = mergeChunks(chunks, totalLength);
|
||||
}
|
||||
body.cancel?.();
|
||||
} else {
|
||||
/** 无 Content-Length 且非 chunked,暂不处理 */
|
||||
}
|
||||
}
|
||||
const rawReq = new Request("http://" + (getHeader("Host") ?? "localhost") + httpHeader.url, {
|
||||
method: httpHeader.method,
|
||||
headers: httpHeader.headers,
|
||||
});
|
||||
/** 将 body 数据挂到 request 对象上,供中间件直接读取 */
|
||||
(rawReq as Request & { _bodyBuffer?: ArrayBuffer })._bodyBuffer = bodyArrayBuffer;
|
||||
const rawRes = new Response();
|
||||
|
||||
const { req, res } = await handle(rawReq, rawRes);
|
||||
const resWriter = connection.writable.getWriter();
|
||||
let headerText: string[] = [];
|
||||
res.headers.forEach((value, key) => {
|
||||
headerText.push(`${key}: ${value}`);
|
||||
});
|
||||
const resHeaertText = `HTTP/1.1 ${res.status} OK\r\n${headerText.join("\r\n")}\r\n\r\n`;
|
||||
await resWriter.write(encoder.encode(resHeaertText));
|
||||
if (res.body) {
|
||||
/** node 运行时 */
|
||||
resWriter.releaseLock();
|
||||
await res.body?.pipeTo(connection.writable);
|
||||
} else {
|
||||
/** llrt 运行时 */
|
||||
const buffer = new Uint8Array(await (await res.blob()).arrayBuffer());
|
||||
await resWriter.write(buffer);
|
||||
}
|
||||
if (!resWriter.closed) {
|
||||
await resWriter.close();
|
||||
}
|
||||
connection.close();
|
||||
} catch (err) {
|
||||
console.log("[connectionHandle error]", err);
|
||||
try { connection.close(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
function parseHttpRequest(requestText: string) {
|
||||
const lines = requestText.trim().split("\n");
|
||||
if (lines.length === 0) {
|
||||
throw new Error("Invalid HTTP request");
|
||||
}
|
||||
|
||||
// 解析请求行
|
||||
const [method, url, httpVersion] = lines[0].split(" ");
|
||||
|
||||
// 解析头部
|
||||
const headers: Record<string, string> = {};
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line === "" || line === "\r") break; // 空行表示头部结束
|
||||
|
||||
const colonIndex = line.indexOf(":");
|
||||
if (colonIndex === -1) continue;
|
||||
const key = line.slice(0, colonIndex).trim();
|
||||
const value = line.slice(colonIndex + 1).trim();
|
||||
headers[key] = value;
|
||||
}
|
||||
|
||||
return {
|
||||
method,
|
||||
url,
|
||||
httpVersion,
|
||||
headers,
|
||||
};
|
||||
}
|
||||
function createStreamAfterTarget(
|
||||
originalStream: ReadableStream<Uint8Array>,
|
||||
target: Uint8Array,
|
||||
): Promise<{ header: Uint8Array | null; body: ReadableStream<Uint8Array> }> {
|
||||
const reader = originalStream.getReader();
|
||||
let buffer = new Uint8Array();
|
||||
|
||||
function containsTarget(buf: Uint8Array, tgt: Uint8Array): number {
|
||||
for (let i = 0; i <= buf.length - tgt.length; i++) {
|
||||
if (buf.slice(i, i + tgt.length).every((value, index) => value === tgt[index])) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
function pump() {
|
||||
reader.read().then(({ done, value }) => {
|
||||
if (done) {
|
||||
resolve({ header: null, body: new ReadableStream<Uint8Array>() });
|
||||
return;
|
||||
}
|
||||
const newBuffer = new Uint8Array(buffer.length + value.length);
|
||||
newBuffer.set(buffer);
|
||||
newBuffer.set(value, buffer.length);
|
||||
buffer = newBuffer;
|
||||
|
||||
const targetIndex = containsTarget(buffer, target);
|
||||
if (targetIndex === -1) {
|
||||
pump();
|
||||
return;
|
||||
}
|
||||
const start = targetIndex + target.length;
|
||||
const header = buffer.slice(0, start);
|
||||
const remainingData = buffer.slice(start);
|
||||
|
||||
/** body stream:先写入剩余数据,然后在后台继续从 originalStream 读取 */
|
||||
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
controller = c;
|
||||
if (remainingData.length > 0) {
|
||||
controller.enqueue(remainingData);
|
||||
}
|
||||
/** 后台继续读取 originalStream 并转发到 body stream */
|
||||
(async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) { controller.close(); return; }
|
||||
controller.enqueue(value);
|
||||
}
|
||||
} catch (err) {
|
||||
/** body stream 被消费方 cancel 时预期会抛错 */
|
||||
console.log("[createStreamAfterTarget] body stream closed:", err);
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
resolve({ header, body });
|
||||
});
|
||||
}
|
||||
pump();
|
||||
});
|
||||
}
|
||||
@ -1,56 +0,0 @@
|
||||
import { createServer } from "net";
|
||||
|
||||
// 创建 TCP 服务器
|
||||
export function createTcpServer(
|
||||
onSocket: (socket: {
|
||||
readable: ReadableStream<Uint8Array>;
|
||||
writable: WritableStream<Uint8Array>;
|
||||
close: () => void;
|
||||
}) => void,
|
||||
) {
|
||||
const server = createServer((socket) => {
|
||||
const readable = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
socket.on("data", (chunk) => {
|
||||
controller.enqueue(new Uint8Array(chunk));
|
||||
});
|
||||
socket.on("error", (err) => {
|
||||
controller.error(err);
|
||||
});
|
||||
socket.on("close", () => {
|
||||
controller.close();
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
socket.destroy();
|
||||
},
|
||||
});
|
||||
|
||||
// 创建 WritableStream
|
||||
const writable = new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.write(chunk, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
close() {
|
||||
socket.end();
|
||||
},
|
||||
abort(reason) {
|
||||
socket.destroy(reason);
|
||||
},
|
||||
});
|
||||
|
||||
// 实现 close 方法
|
||||
function close() {
|
||||
socket.end();
|
||||
socket.destroy();
|
||||
}
|
||||
|
||||
onSocket({ readable, writable, close });
|
||||
});
|
||||
return server;
|
||||
}
|
||||
@ -1,86 +0,0 @@
|
||||
import { fontDirs, subsetCacheMaxSize } from "./config";
|
||||
import { LruCache } from "./lru_cache";
|
||||
import { path_join, readFile, stat, readdir } from "./interface";
|
||||
|
||||
/** 解析请求 URL(req.url 只有路径,需要补全协议和主机才能用 URL API) */
|
||||
export function parseUrl(req: Request): URL {
|
||||
return new URL(req.url, "http://localhost");
|
||||
}
|
||||
|
||||
/** JSON 响应工具 */
|
||||
export function jsonResponse(data: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
/** 运行时统计 */
|
||||
export const stats = {
|
||||
/** 服务启动时间戳 */
|
||||
startTime: Date.now(),
|
||||
/** 总请求数 */
|
||||
totalRequests: 0,
|
||||
/** 字体裁剪请求次数(含缓存命中) */
|
||||
subsetRequests: 0,
|
||||
/** 字体裁剪缓存命中次数 */
|
||||
subsetCacheHits: 0,
|
||||
/** 累计裁剪文字字符数 */
|
||||
totalChars: 0,
|
||||
};
|
||||
|
||||
/** 字体文件 LRU 缓存,最多保留 3 个最近使用的字体 buffer(按条目数淘汰) */
|
||||
export const fontBufferCache = new LruCache<ArrayBuffer>({ maxSize: 3 });
|
||||
|
||||
/** 字体裁剪结果 LRU 缓存(按字节容量淘汰) */
|
||||
export const subsetCache = new LruCache<ArrayBuffer>({ maxBytes: subsetCacheMaxSize, sizeFn: (v) => v.byteLength });
|
||||
|
||||
/**
|
||||
* 在所有字体目录中查找字体文件
|
||||
* 匹配优先级:精确匹配 > 前缀匹配 > 包含匹配
|
||||
*/
|
||||
export async function findFontPath(filename: string): Promise<string | null> {
|
||||
for (const dir of fontDirs) {
|
||||
const filePath = path_join(dir, filename);
|
||||
try {
|
||||
const s = await stat(filePath);
|
||||
if (s.isFile()) return filePath;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const allFonts: Array<{ basename: string; path: string }> = [];
|
||||
for (const dir of fontDirs) {
|
||||
try {
|
||||
const entries = await readdir(dir);
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && /\.(ttf|otf|woff|woff2)$/i.test(entry.name)) {
|
||||
allFonts.push({
|
||||
basename: entry.name.replace(/\.[^.]+$/, ""),
|
||||
path: path_join(dir, entry.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const query = filename.replace(/\.[^.]+$/, "").toLowerCase();
|
||||
|
||||
for (const f of allFonts) {
|
||||
if (f.basename.toLowerCase().startsWith(query)) return f.path;
|
||||
}
|
||||
|
||||
for (const f of allFonts) {
|
||||
if (f.basename.toLowerCase().includes(query)) return f.path;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 从缓存或磁盘读取字体 buffer */
|
||||
export async function readFontBuffer(fontPath: string): Promise<ArrayBuffer> {
|
||||
const cached = fontBufferCache.get(fontPath);
|
||||
if (cached) return cached;
|
||||
const buffer = new Uint8Array(await readFile(fontPath)).buffer;
|
||||
fontBufferCache.set(fontPath, buffer);
|
||||
return buffer;
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
// console.log("[global.tjs.engine]", global.tjs.engine.gc.enabled);
|
||||
// global.tjs.engine.gc.threshold = 100;
|
||||
// console.log("[global.tjs.engine]", global.tjs.engine.gc.threshold);
|
||||
|
||||
function runGCTests() {
|
||||
let objects = [];
|
||||
for (let i = 0; i < 100000; i++) {
|
||||
objects[i] = { index: i, data: new Array(1000).fill(i) };
|
||||
}
|
||||
objects = null; // Dereference the objects to make them eligible for garbage collection
|
||||
}
|
||||
|
||||
runGCTests();
|
||||
setInterval(() => {
|
||||
if (global.tjs) {
|
||||
console.log("[global.tjs.engine]", global.tjs.engine.gc.run());
|
||||
}
|
||||
}, 3000);
|
||||
setTimeout(() => {}, 1000000);
|
||||
// 168064
|
||||
@ -1,128 +0,0 @@
|
||||
import { writeFile, unlink, readdir, stat, mkdir, path_join } from "./interface";
|
||||
import { enableTempUpload, adminApiKey, tempMaxFiles, tempMaxTotalSize } from "./config";
|
||||
|
||||
/** 允许的字体文件扩展名 */
|
||||
const ALLOWED_EXTENSIONS = [".ttf", ".otf", ".woff", ".woff2"];
|
||||
|
||||
function isAllowedFontFile(filename: string): boolean {
|
||||
const lower = filename.toLowerCase();
|
||||
return ALLOWED_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
||||
}
|
||||
|
||||
/** 清理文件名,移除路径分隔符、危险字符和路径穿越成分 */
|
||||
function sanitizeFilename(filename: string) {
|
||||
let name = filename.replace(/[/\\]/g, "").replace(/[\x00-\x1f]/g, "");
|
||||
/** 移除所有 .. 防止路径穿越 */
|
||||
name = name.replace(/\.\./g, "");
|
||||
/** 取基础文件名,防止以 . 开头的隐藏文件 */
|
||||
name = name.replace(/^\./, "");
|
||||
if (!name) return "unnamed.ttf";
|
||||
return name;
|
||||
}
|
||||
|
||||
/** 确保目录存在,不存在则创建 */
|
||||
async function ensureDir(dir: string) {
|
||||
try {
|
||||
await stat(dir);
|
||||
} catch {
|
||||
await mkdir(dir);
|
||||
}
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 temp 目录中所有字体文件及其大小
|
||||
* 返回按名称排序的列表(文件系统 readdir 顺序近似 FIFO)
|
||||
*/
|
||||
async function getTempFontFiles(): Promise<Array<{ name: string; size: number }>> {
|
||||
const entries = await readdir("font/temp");
|
||||
const files: Array<{ name: string; size: number }> = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && isAllowedFontFile(entry.name)) {
|
||||
const s = await stat(path_join("font/temp", entry.name));
|
||||
files.push({ name: entry.name, size: s.size });
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按需清理 temp 目录,使文件数量和总体积满足限制
|
||||
* @param incomingSize 即将上传的文件大小,用于判断总体积是否超限
|
||||
*/
|
||||
async function evictIfNeeded(incomingSize: number) {
|
||||
const files = await getTempFontFiles();
|
||||
|
||||
/** 数量超限时删除最早的文件 */
|
||||
while (files.length >= tempMaxFiles) {
|
||||
const oldest = files.shift();
|
||||
if (!oldest) break;
|
||||
try { await unlink(path_join("font/temp", oldest.name)); } catch { /* 删除失败不影响上传 */ }
|
||||
}
|
||||
|
||||
/** 总体积超限时继续删除,直到腾出足够空间 */
|
||||
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
|
||||
const needed = totalSize + incomingSize - tempMaxTotalSize;
|
||||
if (needed > 0) {
|
||||
let freed = 0;
|
||||
for (const file of files) {
|
||||
if (freed >= needed) break;
|
||||
try { await unlink(path_join("font/temp", file.name)); } catch { /* 删除失败不影响上传 */ }
|
||||
freed += file.size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleTempUpload(fileData: { data: Uint8Array; filename: string }): Promise<UploadResult> {
|
||||
if (!enableTempUpload) {
|
||||
return { success: false, error: "临时上传功能未启用" };
|
||||
}
|
||||
|
||||
if (!isAllowedFontFile(fileData.filename)) {
|
||||
return { success: false, error: "不支持的字体文件格式,仅支持 ttf/otf/woff/woff2" };
|
||||
}
|
||||
|
||||
await ensureDir("font/temp");
|
||||
|
||||
const filename = sanitizeFilename(fileData.filename);
|
||||
const filePath = path_join("font/temp", filename);
|
||||
|
||||
/** 同名文件直接覆盖(覆盖不算新增),否则检查限制并清理 */
|
||||
try {
|
||||
const existing = await stat(filePath);
|
||||
/** 覆盖时,新文件可能比旧文件大,仍需检查总体积 */
|
||||
await evictIfNeeded(fileData.data.length - existing.size);
|
||||
} catch {
|
||||
await evictIfNeeded(fileData.data.length);
|
||||
}
|
||||
|
||||
await writeFile(filePath, fileData.data);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function handleAdminUpload(
|
||||
fileData: { data: Uint8Array; filename: string },
|
||||
apiKey: string,
|
||||
): Promise<UploadResult> {
|
||||
if (!adminApiKey) {
|
||||
return { success: false, error: "管理员上传功能未启用" };
|
||||
}
|
||||
|
||||
if (apiKey !== adminApiKey) {
|
||||
return { success: false, error: "API Key 无效" };
|
||||
}
|
||||
|
||||
if (!isAllowedFontFile(fileData.filename)) {
|
||||
return { success: false, error: "不支持的字体文件格式,仅支持 ttf/otf/woff/woff2" };
|
||||
}
|
||||
|
||||
await ensureDir("font/admin");
|
||||
|
||||
const filename = sanitizeFilename(fileData.filename);
|
||||
await writeFile(path_join("font/admin", filename), fileData.data);
|
||||
return { success: true };
|
||||
}
|
||||
BIN
doc/image.png
|
Before Width: | Height: | Size: 94 KiB |
BIN
doc/启动内存占用.png
|
Before Width: | Height: | Size: 21 KiB |
BIN
doc_img/api/font_list.jpg
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
doc_img/api/fontmin.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
doc_img/api/fontmin_post.jpg
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
doc_img/api/generate_fonts_dynamically.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
doc_img/下载展示.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
doc_img/体积展示.jpg
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
doc_img/功能演示.mkv
Normal file
BIN
doc_img/生成的资源.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
doc_img/路径展示.jpg
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
doc_img/页面截图.jpg
Normal file
|
After Width: | Height: | Size: 75 KiB |
15
index.html
@ -1,15 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="在线字体裁剪工具 — 服务端按需裁剪字体子集,大小无限制,免费开源。支持自定义裁剪、增量加载 SDK,轻松嵌入任何网站。" />
|
||||
<title>WebFont — 在线字体裁剪 | 按需加载 | 免费开源</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="/webfont-sdk.js"></script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
4
nest-cli.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
14099
package-lock.json
generated
Normal file
98
package.json
@ -1,35 +1,77 @@
|
||||
{
|
||||
"name": "webfont",
|
||||
"private": true,
|
||||
"version": "1.8.0",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "pnpx tsx scripts/dev-all.ts",
|
||||
"dev:frontend": "vite",
|
||||
"dev:backend": "pnpx tsx backend/app.ts",
|
||||
"build": "vite build",
|
||||
"build_backend": "pnpx tsx scripts/build-backend.ts",
|
||||
"docker_build": "docker build -t llej0/web-font:${npm_package_version} -t llej0/web-font:latest .",
|
||||
"docker_push": "docker push llej0/web-font:${npm_package_version} && docker push llej0/web-font:latest",
|
||||
"preview": "vite preview",
|
||||
"release": "pnpm build && pnpm build_backend && pnpm docker_build && pnpm docker_push"
|
||||
"prebuild": "rimraf dist",
|
||||
"build": "nest build && parcel build ./static/index.html",
|
||||
"build:font": "nest build && parcel build ./static/index.html --public-url /font/",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start:web": "parcel ./static/index.html",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "tslint -p tsconfig.json -c tslint.json",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "3.6.0-beta.10",
|
||||
"web-streams-polyfill": "^4.3.0"
|
||||
"@babel/polyfill": "^7.8.7",
|
||||
"@fullhuman/postcss-purgecss": "^2.1.0",
|
||||
"@nestjs/common": "^6.7.2",
|
||||
"@nestjs/core": "^6.7.2",
|
||||
"@nestjs/platform-express": "^6.7.2",
|
||||
"@nestjs/serve-static": "^2.1.0",
|
||||
"font-spider": "^1.3.5",
|
||||
"fontmin": "^0.9.8",
|
||||
"parcel-bundler": "^1.12.4",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.0",
|
||||
"rxjs": "^6.5.3",
|
||||
"svelte": "^3.20.1",
|
||||
"tailwindcss": "^1.2.0",
|
||||
"zip-a-folder": "0.0.12",
|
||||
"zip-folder": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"@xmldom/xmldom": "^0.9.10",
|
||||
"jsdom": "^29.1.1",
|
||||
"pngjs": "^7.0.0",
|
||||
"puppeteer": "^25.1.0",
|
||||
"tsdown": "^0.22.1",
|
||||
"typescript": "^6.0.3",
|
||||
"undici": "^8.3.0",
|
||||
"vite": "^8.0.14",
|
||||
"vite-plugin-pilot": "^1.0.31",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
}
|
||||
"@nestjs/cli": "^6.9.0",
|
||||
"@nestjs/schematics": "^6.7.0",
|
||||
"@nestjs/testing": "^6.7.1",
|
||||
"@types/express": "^4.17.1",
|
||||
"@types/jest": "^24.0.18",
|
||||
"@types/node": "^12.7.5",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"cssnano": "^4.1.10",
|
||||
"jest": "^24.9.0",
|
||||
"parcel-plugin-svelte": "^4.0.6",
|
||||
"prettier": "^1.18.2",
|
||||
"supertest": "^4.0.2",
|
||||
"ts-jest": "^24.1.0",
|
||||
"ts-loader": "^6.1.1",
|
||||
"ts-node": "^8.4.1",
|
||||
"tsconfig-paths": "^3.9.0",
|
||||
"tslint": "^5.20.0",
|
||||
"typescript": "^3.6.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
},
|
||||
"browserslist": "last 10 chrome versions"
|
||||
}
|
||||
|
||||
2536
pnpm-lock.yaml
generated
@ -1 +0,0 @@
|
||||
approveBuilds: puppeteer
|
||||
23
postcss.config.js
Normal file
@ -0,0 +1,23 @@
|
||||
const tailwindcss = require('tailwindcss');
|
||||
|
||||
const purgecss = require('@fullhuman/postcss-purgecss')({
|
||||
// Specify the paths to all of the template files in your project
|
||||
content: [
|
||||
'./static/**/*.svelte',
|
||||
'./static/**/*.js',
|
||||
'./static/**/*.ts',
|
||||
'./static/*.svelte',
|
||||
'./static/*.js',
|
||||
'./static/*.ts',
|
||||
`111 `
|
||||
],
|
||||
|
||||
// Include any special characters you're using in this regular expression
|
||||
defaultExtractor: content => content.match(/[\w-/:]+(?<!:)/g) || [],
|
||||
});
|
||||
module.exports = {
|
||||
plugins: [
|
||||
tailwindcss,
|
||||
...(process.env.NODE_ENV === 'development' ? [] : [purgecss]),
|
||||
],
|
||||
};
|
||||
|
Before Width: | Height: | Size: 20 KiB |
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@ -1,393 +0,0 @@
|
||||
/**
|
||||
* WebFont SDK — 按需增量加载字体片段,无闪烁
|
||||
*
|
||||
* 架构:核心增量引擎 + 多种触发方式
|
||||
* - 核心:FontLoader 按 fontKey 管理已加载字符集,只生成增量 CSS
|
||||
* - 触发器:loadFont(轮询)、observeFont(DOM 事件)、loadText(手动传文本)
|
||||
* - 同一 fontKey 下所有触发器共享字符集,绝不会重复请求
|
||||
*
|
||||
* 用法:
|
||||
* // 轮询模式
|
||||
* WebFont.loadFont({ fontName, selector, family, interval });
|
||||
*
|
||||
* // 事件驱动模式
|
||||
* var obs = WebFont.observeFont({ fontName, selector, family });
|
||||
* obs.dispose();
|
||||
*
|
||||
* // 直接传文本模式
|
||||
* var loader = WebFont.loadText({ fontName, text: "你好世界", family });
|
||||
* loader.update("追加文字");
|
||||
* loader.dispose();
|
||||
*
|
||||
* // 清理全部
|
||||
* WebFont.disposeAll();
|
||||
*/
|
||||
var WebFont = (function () {
|
||||
/* ============================================================
|
||||
* 核心增量引擎 — 按 fontKey 管理已加载字符集,生成增量 CSS
|
||||
* ============================================================ */
|
||||
|
||||
/** @type {Object.<string, { loadedChars: Object.<string,boolean>, injectedStyles: Element[], applied: boolean, fontName: string, family: string, baseUrl: string }>} */
|
||||
var loaders = {};
|
||||
|
||||
/**
|
||||
* 生成 fontKey,同一字体+family 归入同一组
|
||||
*/
|
||||
function fontKey(fontName, family) {
|
||||
return fontName + "|" + family;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建对应 fontKey 的加载器
|
||||
*/
|
||||
function getLoader(fontName, baseUrl, family, outType) {
|
||||
var key = fontKey(fontName, family);
|
||||
if (!loaders[key]) {
|
||||
loaders[key] = {
|
||||
loadedChars: {},
|
||||
injectedStyles: [],
|
||||
applied: false,
|
||||
fontName: fontName,
|
||||
family: family,
|
||||
baseUrl: baseUrl,
|
||||
outType: outType || "woff2"
|
||||
};
|
||||
}
|
||||
return loaders[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* 差量加载新字符,生成 unicode-range CSS 并注入
|
||||
* @param {Object} loader - getLoader 返回的加载器对象
|
||||
* @param {string[]} newChars - 待加载的新字符数组
|
||||
*/
|
||||
function loadChars(loader, newChars) {
|
||||
if (newChars.length === 0) return;
|
||||
|
||||
var fontName = loader.fontName;
|
||||
var family = loader.family;
|
||||
var baseUrl = loader.baseUrl;
|
||||
var loadedChars = loader.loadedChars;
|
||||
|
||||
var text = newChars.join("");
|
||||
var outType = loader.outType || "woff2";
|
||||
var url = baseUrl + "/api?font=" + encodeURIComponent(fontName) + "&text=" + encodeURIComponent(text) + "&outType=" + outType;
|
||||
var formatStr = outType === "woff2" ? "woff2" : "truetype";
|
||||
var unicodeRanges = newChars
|
||||
.map(function (c) { return "U+" + c.codePointAt(0).toString(16).padStart(4, "0"); })
|
||||
.join(", ");
|
||||
|
||||
var style = document.createElement("style");
|
||||
style.textContent =
|
||||
'@font-face {\n' +
|
||||
' font-family: "' + family + '";\n' +
|
||||
' src: url("' + url + '") format("' + formatStr + '");\n' +
|
||||
' unicode-range: ' + unicodeRanges + ';\n' +
|
||||
'}\n';
|
||||
document.head.appendChild(style);
|
||||
loader.injectedStyles.push(style);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从字符集中过滤出未加载的新字符,标记为已加载,并生成 CSS
|
||||
* @param {Object} loader - getLoader 返回的加载器对象
|
||||
* @param {Object.<string,boolean>} charSet - 待检查的字符集
|
||||
* @returns {boolean} 是否有新字符被加载
|
||||
*/
|
||||
function processChars(loader, charSet) {
|
||||
var loadedChars = loader.loadedChars;
|
||||
var newChars = [];
|
||||
for (var c in charSet) {
|
||||
if (!loadedChars[c]) {
|
||||
loadedChars[c] = true;
|
||||
newChars.push(c);
|
||||
}
|
||||
}
|
||||
loadChars(loader, newChars);
|
||||
return newChars.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从字符串中过滤出未加载的新字符,标记为已加载,并生成 CSS
|
||||
* @param {Object} loader - getLoader 返回的加载器对象
|
||||
* @param {string} text - 待检查的文本
|
||||
* @returns {boolean} 是否有新字符被加载
|
||||
*/
|
||||
function processText(loader, text) {
|
||||
var loadedChars = loader.loadedChars;
|
||||
var newChars = [];
|
||||
for (var i = 0; i < text.length; i++) {
|
||||
var c = text[i];
|
||||
if (!loadedChars[c]) {
|
||||
loadedChars[c] = true;
|
||||
newChars.push(c);
|
||||
}
|
||||
}
|
||||
loadChars(loader, newChars);
|
||||
return newChars.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁加载器及其所有注入的样式
|
||||
*/
|
||||
function destroyLoader(key) {
|
||||
var loader = loaders[key];
|
||||
if (!loader) return;
|
||||
for (var i = 0; i < loader.injectedStyles.length; i++) {
|
||||
loader.injectedStyles[i].remove();
|
||||
}
|
||||
delete loaders[key];
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 辅助函数
|
||||
* ============================================================ */
|
||||
|
||||
/**
|
||||
* 获取元素的文本内容
|
||||
*/
|
||||
function getText(el) {
|
||||
var tag = el.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") {
|
||||
/** 同时收集 value 和 placeholder,确保占位文本的字体也被加载 */
|
||||
var val = el.value || "";
|
||||
var ph = el.placeholder || "";
|
||||
return val + ph;
|
||||
}
|
||||
return el.textContent || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集选择器匹配元素中的所有字符
|
||||
*/
|
||||
function collectChars(selector) {
|
||||
var charSet = {};
|
||||
var elements = document.querySelectorAll(selector);
|
||||
for (var i = 0; i < elements.length; i++) {
|
||||
var text = getText(elements[i]);
|
||||
for (var j = 0; j < text.length; j++) {
|
||||
charSet[text[j]] = true;
|
||||
}
|
||||
}
|
||||
return charSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用字体到元素
|
||||
*/
|
||||
function applyFamily(selector, family) {
|
||||
var elements = document.querySelectorAll(selector);
|
||||
for (var i = 0; i < elements.length; i++) {
|
||||
elements[i].style.fontFamily = '"' + family + '", sans-serif';
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 任务管理 — 各触发器的清理
|
||||
* ============================================================ */
|
||||
|
||||
/** 按 selector 索引的 loadFont 任务 */
|
||||
var pollTasks = {};
|
||||
|
||||
/** 按选择器索引的 observeFont 任务 */
|
||||
var observeTasks = {};
|
||||
|
||||
/* ============================================================
|
||||
* 1. loadFont — 定时器轮询模式
|
||||
* ============================================================ */
|
||||
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {string} options.fontName
|
||||
* @param {string} options.selector
|
||||
* @param {string} [options.baseUrl]
|
||||
* @param {string} [options.family]
|
||||
* @param {number} [options.interval=1000] - 轮询间隔(ms)
|
||||
*/
|
||||
function loadFont(options) {
|
||||
var selector = options.selector;
|
||||
var fontName = options.fontName;
|
||||
var baseUrl = options.baseUrl || location.origin;
|
||||
var family = options.family || fontName.replace(/\.[^.]+$/, "");
|
||||
var interval = options.interval || 1000;
|
||||
|
||||
/* 清理同一选择器的旧任务 */
|
||||
if (pollTasks[selector]) {
|
||||
clearInterval(pollTasks[selector].timer);
|
||||
}
|
||||
|
||||
var outType = options.outType || "woff2";
|
||||
var loader = getLoader(fontName, baseUrl, family, outType);
|
||||
var applied = false;
|
||||
|
||||
function tick() {
|
||||
var current = collectChars(selector);
|
||||
if (processChars(loader, current) && !applied) {
|
||||
applied = true;
|
||||
applyFamily(selector, family);
|
||||
}
|
||||
}
|
||||
|
||||
tick();
|
||||
var timer = setInterval(tick, interval);
|
||||
pollTasks[selector] = { timer: timer };
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 2. observeFont — MutationObserver 事件驱动模式
|
||||
* ============================================================ */
|
||||
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {string} options.fontName
|
||||
* @param {string} options.selector
|
||||
* @param {string} [options.baseUrl]
|
||||
* @param {string} [options.family]
|
||||
* @param {number} [options.debounceMs=50] - 防抖间隔(ms)
|
||||
* @returns {{ dispose: function }}
|
||||
*/
|
||||
function observeFont(options) {
|
||||
var selector = options.selector;
|
||||
var fontName = options.fontName;
|
||||
var baseUrl = options.baseUrl || location.origin;
|
||||
var family = options.family || fontName.replace(/\.[^.]+$/, "");
|
||||
var debounceMs = options.debounceMs || 50;
|
||||
|
||||
/* 清理同一选择器的旧任务 */
|
||||
if (observeTasks[selector]) {
|
||||
observeTasks[selector].dispose();
|
||||
}
|
||||
|
||||
var outType = options.outType || "woff2";
|
||||
var loader = getLoader(fontName, baseUrl, family, outType);
|
||||
var applied = false;
|
||||
var debounceTimer = null;
|
||||
|
||||
function doLoad() {
|
||||
var current = collectChars(selector);
|
||||
if (processChars(loader, current) && !applied) {
|
||||
applied = true;
|
||||
applyFamily(selector, family);
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedLoad() {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(doLoad, debounceMs);
|
||||
}
|
||||
|
||||
var observer = new MutationObserver(function (mutations) {
|
||||
for (var i = 0; i < mutations.length; i++) {
|
||||
if (mutations[i].type === "childList" || mutations[i].type === "characterData") {
|
||||
debouncedLoad();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var inputHandler = function () { debouncedLoad(); };
|
||||
|
||||
var elements = document.querySelectorAll(selector);
|
||||
observer.observe(document.body || document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
for (var i = 0; i < elements.length; i++) {
|
||||
var el = elements[i];
|
||||
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
|
||||
el.addEventListener("input", inputHandler);
|
||||
}
|
||||
}
|
||||
|
||||
doLoad();
|
||||
|
||||
var disposed = false;
|
||||
|
||||
var task = {
|
||||
dispose: function () {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
observer.disconnect();
|
||||
for (var j = 0; j < elements.length; j++) {
|
||||
var el2 = elements[j];
|
||||
if (el2.tagName === "INPUT" || el2.tagName === "TEXTAREA") {
|
||||
el2.removeEventListener("input", inputHandler);
|
||||
}
|
||||
}
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
delete observeTasks[selector];
|
||||
}
|
||||
};
|
||||
|
||||
observeTasks[selector] = task;
|
||||
return task;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 3. loadText — 直接传文本模式
|
||||
* ============================================================ */
|
||||
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {string} options.fontName
|
||||
* @param {string} options.text
|
||||
* @param {string} [options.baseUrl]
|
||||
* @param {string} [options.family]
|
||||
* @returns {{ update: function(string): void, dispose: function(): void }}
|
||||
*/
|
||||
function loadText(options) {
|
||||
var fontName = options.fontName;
|
||||
var baseUrl = options.baseUrl || location.origin;
|
||||
var family = options.family || fontName.replace(/\.[^.]+$/, "");
|
||||
|
||||
var outType = options.outType || "woff2";
|
||||
var loader = getLoader(fontName, baseUrl, family, outType);
|
||||
|
||||
processText(loader, options.text);
|
||||
|
||||
var disposed = false;
|
||||
|
||||
return {
|
||||
update: function (text) {
|
||||
if (disposed) return;
|
||||
processText(loader, text);
|
||||
},
|
||||
dispose: function () {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
/** 移除该 loader 注入的所有 @font-face 样式,避免同名 family 的 CSS 优先级冲突 */
|
||||
destroyLoader(fontKey(fontName, family));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 公共 API
|
||||
* ============================================================ */
|
||||
|
||||
/**
|
||||
* 清理所有任务和加载器(页面卸载时调用)
|
||||
*/
|
||||
function disposeAll() {
|
||||
for (var sel in pollTasks) {
|
||||
clearInterval(pollTasks[sel].timer);
|
||||
}
|
||||
for (var oid in observeTasks) {
|
||||
observeTasks[oid].dispose();
|
||||
}
|
||||
pollTasks = {};
|
||||
observeTasks = {};
|
||||
|
||||
for (var key in loaders) {
|
||||
destroyLoader(key);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loadFont: loadFont,
|
||||
observeFont: observeFont,
|
||||
loadText: loadText,
|
||||
disposeAll: disposeAll
|
||||
};
|
||||
})();
|
||||
@ -1,129 +0,0 @@
|
||||
/**
|
||||
* 构建后端脚本
|
||||
* 1. 检测并下载 LLRT 二进制文件
|
||||
* 2. 运行 tsup 编译
|
||||
* 3. 使用 LLRT compile 生成 .lrt 文件
|
||||
*/
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, writeFile, rm } from "node:fs/promises";
|
||||
import { arch, platform } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { EnvHttpProxyAgent, setGlobalDispatcher } from "undici";
|
||||
|
||||
/** 自动读取 http_proxy/https_proxy 环境变量配置全局代理 */
|
||||
const proxyUrl = process.env.https_proxy || process.env.HTTPS_PROXY
|
||||
|| process.env.http_proxy || process.env.HTTP_PROXY;
|
||||
if (proxyUrl) {
|
||||
setGlobalDispatcher(new EnvHttpProxyAgent());
|
||||
console.log(`Using proxy: ${proxyUrl}`);
|
||||
}
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT_DIR = join(__dirname, "..");
|
||||
const LLRT_BIN = join(ROOT_DIR, "llrt");
|
||||
|
||||
/** 映射 node arch 到 LLRT arch */
|
||||
function getLlrtArch() {
|
||||
const a = arch();
|
||||
if (a === "x64") return "x64";
|
||||
if (a === "arm64") return "arm64";
|
||||
throw new Error(`Unsupported architecture: ${a}`);
|
||||
}
|
||||
|
||||
/** 映射 node platform 到 LLRT platform */
|
||||
function getLlrtPlatform() {
|
||||
const p = platform();
|
||||
if (p === "linux") return "linux";
|
||||
if (p === "darwin") return "darwin";
|
||||
if (p === "win32") return "windows";
|
||||
throw new Error(`Unsupported platform: ${p}`);
|
||||
}
|
||||
|
||||
/** 确保 llrt 二进制文件存在,不存在则下载 */
|
||||
async function ensureLlrt() {
|
||||
if (existsSync(LLRT_BIN)) {
|
||||
console.log("LLRT binary found, skipping download.");
|
||||
return;
|
||||
}
|
||||
|
||||
const llrtArch = getLlrtArch();
|
||||
const llrtPlatform = getLlrtPlatform();
|
||||
|
||||
console.log("Fetching latest LLRT release version...");
|
||||
const res = await fetch("https://api.github.com/repos/awslabs/llrt/releases/latest");
|
||||
/** 响应数据 */
|
||||
const data = await res.json() as { tag_name: string };
|
||||
const version = data.tag_name;
|
||||
|
||||
if (!version) {
|
||||
throw new Error("Failed to fetch latest LLRT version from GitHub");
|
||||
}
|
||||
|
||||
console.log(`Latest LLRT version: ${version}`);
|
||||
|
||||
const downloadUrl = `https://github.com/awslabs/llrt/releases/download/${version}/llrt-${llrtPlatform}-${llrtArch}-no-sdk.zip`;
|
||||
console.log(`Downloading from ${downloadUrl} ...`);
|
||||
|
||||
const zipRes = await fetch(downloadUrl);
|
||||
if (!zipRes.ok) {
|
||||
throw new Error(`Failed to download LLRT: ${zipRes.status} ${zipRes.statusText}`);
|
||||
}
|
||||
|
||||
/** 下载 zip 到临时文件 */
|
||||
const tmpDir = join(ROOT_DIR, ".tmp-llrt");
|
||||
await mkdir(tmpDir, { recursive: true });
|
||||
const zipPath = join(tmpDir, "llrt.zip");
|
||||
const arrayBuffer = await zipRes.arrayBuffer();
|
||||
await writeFile(zipPath, Buffer.from(arrayBuffer));
|
||||
|
||||
/** 使用 unzip 命令解压(Node 内置没有 zip 解压) */
|
||||
const binaryName = platform() === "win32" ? "llrt.exe" : "llrt";
|
||||
execSync(`unzip -o -j "${zipPath}" "${binaryName}" -d "${ROOT_DIR}"`, {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
/** linux/mac 需要可执行权限 */
|
||||
if (platform() !== "win32") {
|
||||
execSync(`chmod +x "${LLRT_BIN}"`);
|
||||
}
|
||||
|
||||
await rm(tmpDir, { recursive: true });
|
||||
|
||||
console.log(`LLRT ${version} installed successfully.`);
|
||||
}
|
||||
|
||||
/** 运行 tsdown 编译 */
|
||||
function runTsdown() {
|
||||
console.log("\n--- Running tsdown build ---");
|
||||
execSync("pnpm tsdown", { stdio: "inherit", cwd: ROOT_DIR });
|
||||
}
|
||||
|
||||
/** woff2 已使用纯 JS 实现(vendor/fonteditor-core/woff2/index.js),无需复制 wasm */
|
||||
|
||||
/** 使用 LLRT compile 生成 .lrt 文件 */
|
||||
function runLlrtCompile() {
|
||||
console.log("\n--- Running LLRT compile ---");
|
||||
execSync(`${LLRT_BIN} compile ./dist_backend/app.cjs ./dist_backend/app.lrt`, {
|
||||
stdio: "inherit",
|
||||
cwd: ROOT_DIR,
|
||||
});
|
||||
execSync(`${LLRT_BIN} compile "./dist_backend/基准测试_llrt.cjs" ./dist_backend/llrt_bench.lrt`, {
|
||||
stdio: "inherit",
|
||||
cwd: ROOT_DIR,
|
||||
});
|
||||
console.log("\nBackend build completed successfully!");
|
||||
}
|
||||
|
||||
/** 主流程 */
|
||||
async function main() {
|
||||
await ensureLlrt();
|
||||
runTsdown();
|
||||
runLlrtCompile();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -1,42 +0,0 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { Font } from "../vendor/fonteditor-core/lib/ttf/font.js";
|
||||
|
||||
const raw = await readFile("font/temp/YiShanBeiZhuanTi.ttf");
|
||||
const buf = new Uint8Array(raw).buffer;
|
||||
const text = "你好世界";
|
||||
const subset = [...text].map(c => c.codePointAt(0));
|
||||
|
||||
const font = Font.create(buf, { type: "ttf", subset });
|
||||
const optimized = font.optimize().sort();
|
||||
const result = optimized.write({ type: "ttf" });
|
||||
const data = new Uint8Array(result);
|
||||
|
||||
console.log("=== TTF Header ===");
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
console.log("sfVersion:", "0x" + view.getUint32(0, false).toString(16));
|
||||
console.log("numTables:", view.getUint16(4, false));
|
||||
|
||||
const tables: Array<{ tag: string; offset: number; length: number }> = [];
|
||||
for (let i = 0; i < view.getUint16(4, false); i++) {
|
||||
const offset = 12 + i * 16;
|
||||
const tag = String.fromCharCode(view.getUint8(offset), view.getUint8(offset + 1), view.getUint8(offset + 2), view.getUint8(offset + 3));
|
||||
const toffset = view.getUint32(offset + 8, false);
|
||||
const tlength = view.getUint32(offset + 12, false);
|
||||
tables.push({ tag, offset: toffset, length: tlength });
|
||||
console.log(" ", tag, "offset=" + toffset, "length=" + tlength);
|
||||
}
|
||||
|
||||
const headEntry = tables.find(t => t.tag === "head");
|
||||
if (headEntry) {
|
||||
const magic = view.getUint32(headEntry.offset + 12, false);
|
||||
console.log("\nhead magicNumber:", "0x" + magic.toString(16), magic === 0x5F0F3CF5 ? "OK" : "INVALID!");
|
||||
}
|
||||
|
||||
console.log("\nfile size:", data.length);
|
||||
console.log("last table end:", Math.max(...tables.map(t => t.offset + t.length)));
|
||||
|
||||
/** 和原始字体对比 */
|
||||
const origView = new DataView(buf);
|
||||
console.log("\n=== 原始字体 ===");
|
||||
console.log("sfVersion:", "0x" + origView.getUint32(0, false).toString(16));
|
||||
console.log("numTables:", origView.getUint16(4, false));
|
||||
@ -1,56 +0,0 @@
|
||||
/**
|
||||
* 同时启动前端 (vite) 和后端 (tsx backend/app.ts) 的开发服务器
|
||||
* Ctrl+C 会同时终止两个进程
|
||||
*/
|
||||
import { execSync, spawn } from "node:child_process";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT_DIR = join(__dirname, "..");
|
||||
|
||||
/** 子进程列表,用于退出时统一清理 */
|
||||
const children: ReturnType<typeof spawn>[] = [];
|
||||
|
||||
/** 清理所有子进程 */
|
||||
function cleanup() {
|
||||
for (const child of children) {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
}
|
||||
|
||||
/** 杀掉占用指定端口的进程 */
|
||||
function killPort(port: number) {
|
||||
try {
|
||||
execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null`, { stdio: "ignore" });
|
||||
} catch {
|
||||
/** 端口没有被占用,忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
cleanup();
|
||||
process.exit(0);
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
cleanup();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
killPort(8087);
|
||||
console.log("Starting frontend and backend dev servers...\n");
|
||||
|
||||
const backend = spawn("pnpx", ["tsx", "watch", "backend/app.ts"], {
|
||||
cwd: ROOT_DIR,
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
env: { ...process.env, ENABLE_TEMP_UPLOAD: "true", ADMIN_API_KEY: "dev-key" },
|
||||
});
|
||||
children.push(backend);
|
||||
|
||||
const frontend = spawn("pnpm", ["dev:frontend"], {
|
||||
cwd: ROOT_DIR,
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
});
|
||||
children.push(frontend);
|
||||
@ -1,71 +0,0 @@
|
||||
/**
|
||||
* 字体裁剪验证 — 裁剪多种字体并保存为文件
|
||||
* 运行: pnpm tsx scripts/test_font_valid.ts
|
||||
*/
|
||||
import { Font } from "../vendor/fonteditor-core/lib/ttf/font.js";
|
||||
import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
|
||||
|
||||
const OUTPUT_DIR = "benchmark_results/font_test";
|
||||
await mkdir(OUTPUT_DIR, { recursive: true });
|
||||
|
||||
/** 在所有字体目录中查找字体 */
|
||||
import { stat } from "node:fs/promises";
|
||||
const fontDirs = ["font/admin", "font", "font/temp"];
|
||||
async function findFonts(): Promise<Array<{ name: string; path: string }>> {
|
||||
const all: Array<{ name: string; path: string }> = [];
|
||||
for (const dir of fontDirs) {
|
||||
try {
|
||||
const entries = await readdir(dir);
|
||||
for (const entry of entries) {
|
||||
const name = typeof entry === "string" ? entry : entry.name;
|
||||
if (/\.(ttf|otf|woff|woff2)$/i.test(name)) {
|
||||
const fullPath = `${dir}/${name}`;
|
||||
try {
|
||||
const s = await stat(fullPath);
|
||||
if (s.isFile()) {
|
||||
all.push({ name, path: fullPath });
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
const fonts = await findFonts();
|
||||
const testText = "你好世界";
|
||||
const codePoints = [...testText].map(c => c.codePointAt(0)!);
|
||||
|
||||
console.log("\n=== 字体裁剪验证 ===\n");
|
||||
console.log(`测试文本: "${testText}"\n`);
|
||||
|
||||
for (const f of fonts) {
|
||||
try {
|
||||
const raw = await readFile(f.path);
|
||||
const buf = new Uint8Array(raw).buffer;
|
||||
|
||||
const font = Font.create(buf, { type: "ttf", subset: codePoints });
|
||||
const optimized = font.optimize().sort();
|
||||
const result = optimized.write({ type: "ttf" });
|
||||
|
||||
const data = typeof result === "string"
|
||||
? new TextEncoder().encode(result)
|
||||
: new Uint8Array(result);
|
||||
|
||||
const outPath = `${OUTPUT_DIR}/${f.name.replace(/\.[^.]+$/, "")}_subset.ttf`;
|
||||
await writeFile(outPath, data);
|
||||
|
||||
/** 检查 TTF 文件头 */
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
const sfVersion = view.getUint32(0, false);
|
||||
const numTables = view.getUint16(4, false);
|
||||
|
||||
console.log(` ${f.name}: ${data.length.toLocaleString()} bytes, sfVersion=0x${sfVersion.toString(16)}, numTables=${numTables}`);
|
||||
} catch (e: any) {
|
||||
console.log(` ${f.name}: ERROR - ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n输出目录: ${OUTPUT_DIR}/`);
|
||||
console.log("请在 Windows 字体查看器中打开验证");
|
||||
@ -1,88 +0,0 @@
|
||||
---
|
||||
name: chinese-web-font
|
||||
description: 中文字体按需裁剪与加载能力,可让 AI 在生成中文网页时引入特殊中文字体,按字符子集化加载,极大减少流量。
|
||||
---
|
||||
|
||||
# Chinese Web Font Skill
|
||||
|
||||
## WebFont API
|
||||
|
||||
按需裁剪中文字体,只返回页面实际使用的字符。6 个字 ≈ 6KB。
|
||||
|
||||
### CSS 直出
|
||||
|
||||
```html
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "MyFont";
|
||||
src: url("https://webfont.shenzilong.cn/api?font=令东齐伋复刻体&text=静心茶舍&outType=woff2") format("woff2");
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
`text` 参数写什么字,就只返回那些字的子集。
|
||||
|
||||
### 查看可用字体
|
||||
|
||||
```
|
||||
GET https://webfont.shenzilong.cn/api/fonts
|
||||
```
|
||||
|
||||
返回当前服务器上所有可用字体列表。响应中 `temporary: true` 表示临时字体,随时可能被清理,不要使用。使用前先调用此接口确认字体是否存在。
|
||||
|
||||
### 其他接口
|
||||
|
||||
```
|
||||
GET https://webfont.shenzilong.cn/api?font={name}&text={chars}&outType=woff2 — 裁剪字体
|
||||
GET https://webfont.shenzilong.cn/api/fonts — 列出可用字体
|
||||
GET https://webfont.shenzilong.cn/api/config — 服务配置
|
||||
POST https://webfont.shenzilong.cn/api/upload?mode=temp — 临时上传字体
|
||||
```
|
||||
|
||||
字体名支持模糊匹配(精确 > 前缀 > 包含)。`outType` 支持 `woff2` 和 `ttf`。
|
||||
|
||||
### SDK 增量加载
|
||||
|
||||
```html
|
||||
<script src="https://webfont.shenzilong.cn/webfont-sdk.js"></script>
|
||||
<script>
|
||||
WebFont.observeFont({ fontName: "令东齐伋复刻体.ttf", selector: ".content", family: "MyFont" });
|
||||
</script>
|
||||
```
|
||||
|
||||
三种模式:`observeFont`(推荐,DOM 事件驱动)、`loadFont`(轮询)、`loadText`(手动文本)。
|
||||
|
||||
SDK 支持文字动态变化:当 DOM 内容改变时,`observeFont` 会自动检测新字符并增量加载,无需手动更新。适合 AI 生成内容、动态渲染等场景。
|
||||
|
||||
## 关键规则
|
||||
|
||||
- **行高**:中文正文 1.8,标题 1.3。中文笔画密集,比英文需要更大行距
|
||||
- **字重**:中文 400/700 区分明显,中间字重(300/500)表现弱,不要依赖
|
||||
- **中英混排**:中文与英文/数字之间加空格
|
||||
- **字号级差**:标题间至少 4px 差距,否则层级模糊
|
||||
- **首行缩进**:`text-indent: 2em` 是中文正文的传统
|
||||
|
||||
## 可用字体
|
||||
|
||||
如果可以发请求,先调用 `GET https://webfont.shenzilong.cn/api/fonts` 查看最新可用字体列表。响应中 `temporary: true` 的字体随时可能被清理,可仅作演示,并提示用户。
|
||||
|
||||
如果不能发请求,公共服务永久字体有:
|
||||
|
||||
- `令东齐伋复刻体.ttf` — 宋体风格,古典优雅,适合传统文化、禅意
|
||||
- `问藏书房.ttf` — 书法风格,适合文艺、文化场景
|
||||
|
||||
## Fallback 链
|
||||
|
||||
```css
|
||||
/* 衬线 */ "CustomFont", "Noto Serif CJK SC", "STSong", "SimSun", serif;
|
||||
/* 无衬线 */ "CustomFont", "Noto Sans CJK SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
/* 楷体 */ "CustomFont", "LXGW WenKai", "STKaiti", "KaiTi", cursive;
|
||||
```
|
||||
|
||||
## 风格速查
|
||||
|
||||
**zen(禅意)**:衬线标题 + 楷体正文,line-height 2.0,大量留白,暖色 #faf9f6
|
||||
**modern-tech(科技)**:无衬线粗标题,letter-spacing -0.02em,强对比
|
||||
**luxury(奢侈)**:衬线细字重(300),超大 letter-spacing 0.15em,极简
|
||||
**editorial(编辑)**:衬线标题 + 无衬线正文,首行缩进,行长 60~80 字符
|
||||
**warm(温暖)**:楷体为主,line-height 2.0,暖色系
|
||||
235
src/App.vue
@ -1,235 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from "vue";
|
||||
import { fetchFonts, fetchConfig } from "./api";
|
||||
import type { FontInfo, ServerConfig } from "./api";
|
||||
import { t, toggleLocale, locale } from "./i18n";
|
||||
|
||||
const isDev = import.meta.env.DEV;
|
||||
const origin = location.origin;
|
||||
import UploadSection from "./UploadSection.vue";
|
||||
import StatsPanel from "./StatsPanel.vue";
|
||||
import SelectorRow from "./FontSelector.vue";
|
||||
import FontDebugPreview from "./FontDebugPreview.vue";
|
||||
import TypographyDemo from "./TypographyDemo.vue";
|
||||
|
||||
/** 是否展示 Typography Demo */
|
||||
const showDemo = ref(location.search.includes("demo"));
|
||||
|
||||
const text = ref("天地无极,乾坤借法");
|
||||
const fonts = ref<FontInfo[]>([]);
|
||||
const selectedFont = ref("");
|
||||
const outType = ref<"woff2" | "ttf">("ttf");
|
||||
const serverConfig = ref<ServerConfig>({
|
||||
enableTempUpload: false,
|
||||
adminUploadEnabled: false,
|
||||
supportedOutTypes: ["woff2", "ttf"],
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const [fontList, config] = await Promise.all([
|
||||
fetchFonts().catch(() => [] as FontInfo[]),
|
||||
fetchConfig().catch((): ServerConfig => ({ enableTempUpload: false, adminUploadEnabled: false })),
|
||||
]);
|
||||
fonts.value = fontList;
|
||||
serverConfig.value = config;
|
||||
|
||||
if (!config.supportedOutTypes?.includes(outType.value)) {
|
||||
outType.value = config.supportedOutTypes?.[0] || "ttf";
|
||||
}
|
||||
|
||||
if (fontList.length > 0) {
|
||||
const usableFonts = fontList.filter((f) => /\.(ttf)$/i.test(f.name));
|
||||
const randomFont = usableFonts[Math.floor(Math.random() * usableFonts.length)];
|
||||
const sloganText = t("slogan");
|
||||
(globalThis as any).WebFont?.loadText({
|
||||
fontName: randomFont.name,
|
||||
text: sloganText,
|
||||
family: "SloganFont",
|
||||
});
|
||||
const sloganEl = document.getElementById("slogan");
|
||||
if (sloganEl) {
|
||||
sloganEl.style.fontFamily = '"SloganFont", sans-serif';
|
||||
sloganEl.title = randomFont.name;
|
||||
}
|
||||
selectedFont.value = fontList[0].name;
|
||||
}
|
||||
});
|
||||
|
||||
const cssStyle = computed(() => {
|
||||
const font = selectedFont.value;
|
||||
const ot = outType.value;
|
||||
if (!font) return "";
|
||||
const formatStr = ot === "woff2" ? "woff2" : "truetype";
|
||||
return `@font-face {
|
||||
font-family: "CustomFont";
|
||||
src: url("${origin}/api?font=${font}&text=${encodeURIComponent(text.value)}&outType=${ot}") format("${formatStr}");
|
||||
}
|
||||
.custom-font {
|
||||
color: red;
|
||||
font-family: "CustomFont";
|
||||
}`;
|
||||
});
|
||||
|
||||
let textLoader: { update: (text: string) => void; dispose: () => void } | null = null;
|
||||
|
||||
function onTextChange(value: string) {
|
||||
text.value = value;
|
||||
textLoader?.update(value);
|
||||
}
|
||||
|
||||
const textareaRows = computed(() => {
|
||||
const lines = text.value.split("\n").length;
|
||||
return Math.max(2, Math.min(lines, 10));
|
||||
});
|
||||
|
||||
let lastLoadKey = "";
|
||||
|
||||
function reloadFont(font: string, ot: "woff2" | "ttf") {
|
||||
const key = `${font}|${ot}`;
|
||||
if (!font || key === lastLoadKey) return;
|
||||
lastLoadKey = key;
|
||||
if (textLoader) textLoader.dispose();
|
||||
textLoader = (globalThis as any).WebFont?.loadText({
|
||||
fontName: font,
|
||||
text: text.value,
|
||||
family: "CustomFont",
|
||||
outType: ot,
|
||||
}) ?? null;
|
||||
const el = document.getElementById("webfont-preview");
|
||||
if (el) el.style.fontFamily = '"CustomFont", sans-serif';
|
||||
}
|
||||
|
||||
function onFontChange(font: string) {
|
||||
selectedFont.value = font;
|
||||
}
|
||||
|
||||
watch([selectedFont, outType], ([font, ot]) => {
|
||||
reloadFont(font, ot as "woff2" | "ttf");
|
||||
});
|
||||
|
||||
async function refreshFonts() {
|
||||
const fontList = await fetchFonts();
|
||||
fonts.value = fontList;
|
||||
if (fontList.length > 0 && !selectedFont.value) {
|
||||
onFontChange(fontList[0].name);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TypographyDemo v-if="showDemo" :onBack="() => showDemo = false" />
|
||||
<div v-else style="max-width: 720px; margin: 0 auto; padding: 48px 24px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #1a1a1a; line-height: 1.6">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between">
|
||||
<h1 style="font-size: 22px; font-weight: 600; margin: 0 0 4px 0">Web Font</h1>
|
||||
<div style="display: flex; gap: 10px; align-items: center">
|
||||
<button @click="toggleLocale" style="font-size: 12px; border: 1px solid #d9d9d9; border-radius: 6px; padding: 4px 10px; cursor: pointer; background: #fff; color: #333; min-width: 42px">
|
||||
{{ locale === 'zh' ? 'EN' : '中' }}
|
||||
</button>
|
||||
<a href="#" @click.prevent="showDemo = true" style="font-size: 13px; color: #8b7355; text-decoration: none; border: 1px solid #8b7355; border-radius: 6px; padding: 4px 12px; display: inline-flex; align-items: center; gap: 4px">
|
||||
{{ t('agentSkillDemo') }}
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/2234839/web-font"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style="display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: #888; text-decoration: none; border: 1px solid #d9d9d9; border-radius: 6px; padding: 4px 10px"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
|
||||
Star on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<p id="slogan" style="font-size: 24px; color: #888; margin: 0 0 36px 0">{{ t('slogan') }}</p>
|
||||
|
||||
<section style="margin-bottom: 28px">
|
||||
<SelectorRow
|
||||
:fonts="fonts"
|
||||
:selectedFont="selectedFont"
|
||||
:onFontChange="onFontChange"
|
||||
:supportedOutTypes="serverConfig.supportedOutTypes || ['woff2', 'ttf']"
|
||||
:outType="outType"
|
||||
:onOutTypeChange="(v: 'woff2' | 'ttf') => outType = v"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section style="margin-bottom: 28px">
|
||||
<label style="display: block; font-size: 13px; color: #555; margin-bottom: 6px">{{ t('inputLabel') }}</label>
|
||||
<textarea
|
||||
id="webfont-preview"
|
||||
:rows="textareaRows"
|
||||
:value="text"
|
||||
@input="onTextChange(($event.target as HTMLTextAreaElement).value)"
|
||||
:placeholder="t('inputPlaceholder')"
|
||||
style="width: 100%; padding: 8px 12px; font-size: 32px; border: 1px solid #d9d9d9; border-radius: 6px; resize: none; box-sizing: border-box; outline: none; color: #e74c3c; line-height: 1.4"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section v-if="selectedFont" style="margin-bottom: 28px">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px">
|
||||
<label style="display: block; font-size: 13px; color: #555; margin: 0">{{ t('cssLabel') }}</label>
|
||||
<div style="display: flex; gap: 6px">
|
||||
<button
|
||||
style="padding: 3px 12px; font-size: 12px; border: 1px solid #d9d9d9; border-radius: 6px; cursor: pointer; background: #fff; color: #333"
|
||||
@click="() => {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api?font=${selectedFont}&text=${encodeURIComponent(text)}&outType=${outType}`;
|
||||
a.download = selectedFont.replace(/\.[^.]+$/, '') + `_subset.${outType}`;
|
||||
a.click();
|
||||
}"
|
||||
>
|
||||
{{ t('downloadFont') }}
|
||||
</button>
|
||||
<button
|
||||
style="padding: 3px 12px; font-size: 12px; border: 1px solid #d9d9d9; border-radius: 6px; cursor: pointer; background: #fff; color: #333"
|
||||
@click="async (e: MouseEvent) => {
|
||||
const btn = e.currentTarget as HTMLButtonElement;
|
||||
try {
|
||||
await navigator.clipboard.writeText(cssStyle);
|
||||
btn.textContent = t('copied');
|
||||
setTimeout(() => { btn.textContent = t('copyCss'); }, 1500);
|
||||
} catch {
|
||||
btn.textContent = t('copyFailed');
|
||||
setTimeout(() => { btn.textContent = t('copyCss'); }, 1500);
|
||||
}
|
||||
}"
|
||||
>
|
||||
{{ t('copyCss') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre style="background: #f7f7f8; padding: 16px; border-radius: 6px; font-size: 13px; font-family: 'SF Mono', Menlo, Consolas, monospace; overflow: auto; white-space: pre-wrap; word-break: break-all; line-height: 1.5; margin: 0">{{ cssStyle }}</pre>
|
||||
</section>
|
||||
|
||||
<FontDebugPreview v-if="isDev" />
|
||||
|
||||
<UploadSection :config="serverConfig" :onUploaded="refreshFonts" />
|
||||
|
||||
<StatsPanel />
|
||||
|
||||
<section style="margin-bottom: 28px; font-size: 12px; color: #aaa; line-height: 1.8">
|
||||
<p><b>{{ t('principle') }}</b>{{ t('principleText') }}</p>
|
||||
<p><b>{{ t('basicUsage') }}</b>{{ t('basicUsageText') }}</p>
|
||||
<pre style="background: #f7f7f8; padding: 16px; border-radius: 6px; font-size: 13px; font-family: 'SF Mono', Menlo, Consolas, monospace; overflow: auto; white-space: pre-wrap; word-break: break-all; line-height: 1.5; margin-top: 4px">{{ `<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>{{ 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>
|
||||
<p style="margin-top: 8px">{{ t('sdkModes') }}<code>WebFont.observeFont()</code>{{ t('observeFont') }}<code>WebFont.loadText()</code>{{ t('loadText') }}</p>
|
||||
</section>
|
||||
|
||||
<footer style="margin-top: 48px; padding-top: 16px; border-top: 1px solid #eee; font-size: 12px; color: #999; text-align: center">
|
||||
<p>{{ t('thanks') }}<a href="https://www.ruanyifeng.com/blog/2020/03/weekly-issue-100.html" target="_blank" rel="noopener noreferrer" style="color: #999">阮一峰科技爱好者周刊(第 100 期)</a> {{ t('thanksText') }}</p>
|
||||
<p style="margin-top: 8px">{{ t('buyCoffee') }}<a href="https://shenzilong.cn/%E5%85%B3%E4%BA%8E/%E8%B5%9E%E5%8A%A9.html#" target="_blank" rel="noopener noreferrer" style="color: #e6a700; text-decoration: underline">{{ t('buyCoffeeAction') }}</a>{{ t('buyCoffeeSuffix') }}</p>
|
||||
<p style="margin-top: 12px"><a href="https://github.com/2234839/web-font/blob/new/skills/chinese-web-font.md" target="_blank" style="color: #8b7355; text-decoration: underline">{{ t('viewSkill') }}</a></p>
|
||||
</footer>
|
||||
|
||||
<a
|
||||
href="https://shenzilong.cn/%E5%85%B3%E4%BA%8E/%E8%B5%9E%E5%8A%A9.html#"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style="position: fixed; right: 0; top: 50%; transform: translateY(-50%); background: #e6a700; color: #fff; padding: 12px 6px; font-size: 12px; writing-mode: vertical-rl; text-decoration: none; border-radius: 6px 0 0 6px; box-shadow: -2px 0 8px rgba(0,0,0,0.1); z-index: 999; transition: padding 0.2s"
|
||||
onmouseover="this.style.paddingRight='10px'"
|
||||
onmouseout="this.style.paddingRight='6px'"
|
||||
>
|
||||
{{ t('sponsor') }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,62 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import type { FontInfo } from "./api";
|
||||
|
||||
const PREVIEW_TEXT = `天地无极乾坤借法:"":"" 0123456789 ABCDEF`;
|
||||
const fonts = ref<FontInfo[]>([]);
|
||||
const loaders = new Map<string, { update: (text: string) => void; dispose: () => void }>();
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await fetch("/api/fonts");
|
||||
const fontList: FontInfo[] = await res.json();
|
||||
const usableFonts = fontList.filter((f) => /\.(ttf|otf)$/i.test(f.name));
|
||||
fonts.value = usableFonts;
|
||||
|
||||
for (const font of usableFonts) {
|
||||
const base = font.name.replace(/\.[^.]+$/, "");
|
||||
for (const ot of ["woff2", "ttf"] as const) {
|
||||
const family = `DevPreview_${base}_${ot}`;
|
||||
const loader = (globalThis as any).WebFont?.loadText({
|
||||
fontName: font.name,
|
||||
text: PREVIEW_TEXT,
|
||||
family,
|
||||
outType: ot,
|
||||
});
|
||||
if (loader) loaders.set(`${font.name}|${ot}`, loader);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
for (const loader of loaders.values()) loader.dispose();
|
||||
loaders.clear();
|
||||
});
|
||||
|
||||
function fontFamily(font: FontInfo, ot: string) {
|
||||
const base = font.name.replace(/\.[^.]+$/, "");
|
||||
return `"DevPreview_${base}_${ot}", "楷体", KaiTi, STKaiti, serif`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section style="margin-bottom: 28px; padding: 16px; border: 2px dashed #e6a700; border-radius: 8px; background: #fffdf5">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 12px">
|
||||
<span style="font-size: 13px; font-weight: 600; color: #e6a700">DEV 字体调试预览</span>
|
||||
<span style="font-size: 11px; color: #aaa">所有字体的 woff2 / ttf 渲染效果</span>
|
||||
</div>
|
||||
<div v-for="font in fonts" :key="font.name" style="margin-bottom: 12px; padding: 8px 12px; background: #fff; border: 1px solid #e8e8e8; border-radius: 6px">
|
||||
<div style="font-size: 11px; color: #999; margin-bottom: 6px; display: flex; justify-content: space-between">
|
||||
<span style="font-weight: 500; color: #555">{{ font.name }}</span>
|
||||
<span style="color: #bbb">{{ font.dir }}</span>
|
||||
</div>
|
||||
<div v-for="ot in (['woff2', 'ttf'] as const)" :key="ot" style="margin-bottom: 4px; display: flex; align-items: baseline; gap: 8px">
|
||||
<span style="font-size: 11px; color: #bbb; min-width: 40px; flex: none">{{ ot }}</span>
|
||||
<div
|
||||
:style="{ fontSize: '22px', lineHeight: '1.5', color: '#1a1a1a', minHeight: '36px', fontFamily: fontFamily(font, ot) }"
|
||||
>
|
||||
{{ PREVIEW_TEXT }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@ -1,50 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { FontInfo } from "./api";
|
||||
import { t } from "./i18n";
|
||||
|
||||
defineProps<{
|
||||
fonts: FontInfo[];
|
||||
selectedFont: string;
|
||||
onFontChange: (font: string) => void;
|
||||
supportedOutTypes: ("woff2" | "ttf")[];
|
||||
outType: "woff2" | "ttf";
|
||||
onOutTypeChange: (v: "woff2" | "ttf") => void;
|
||||
}>();
|
||||
|
||||
const outTypeLabels: Record<string, () => string> = {
|
||||
woff2: () => t("woff2Label"),
|
||||
ttf: () => t("ttfLabel"),
|
||||
};
|
||||
|
||||
const outTypeDescs: Record<string, () => string> = {
|
||||
woff2: () => t("woff2Desc"),
|
||||
ttf: () => t("ttfDesc"),
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="display: flex; gap: 12px">
|
||||
<div style="flex: 1">
|
||||
<label style="display: block; font-size: 13px; color: #555; margin-bottom: 6px">{{ t('selectFont') }}</label>
|
||||
<select
|
||||
:value="selectedFont"
|
||||
@change="onFontChange(($event.target).value)"
|
||||
style="width: 100%; padding: 8px 12px; font-size: 14px; border: 1px solid #d9d9d9; border-radius: 6px; outline: none; box-sizing: border-box; cursor: pointer; appearance: none; background-image: url("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="">{{ t('pleaseSelect') }}</option>
|
||||
<option v-for="f in fonts" :key="f.name" :value="f.name">{{ f.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="width: 160px">
|
||||
<label style="display: block; font-size: 13px; color: #555; margin-bottom: 6px">{{ t('outputFormat') }}</label>
|
||||
<select
|
||||
:value="outType"
|
||||
@change="onOutTypeChange(($event.target).value)"
|
||||
style="width: 100%; padding: 8px 12px; font-size: 14px; border: 1px solid #d9d9d9; border-radius: 6px; outline: none; box-sizing: border-box; cursor: pointer; appearance: none; background-image: url("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="ot in supportedOutTypes" :key="ot" :value="ot">{{ outTypeLabels[ot]() }}</option>
|
||||
</select>
|
||||
<p style="font-size: 11px; color: #bbb; margin-top: 4px">{{ outTypeDescs[outType]() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,78 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import { fetchStats, type ServerStats } from "./api";
|
||||
import { t, locale } from "./i18n";
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
if (locale.value === "en") {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
if (h < 24) return `${h}h ${m}m ${s}s`;
|
||||
const d = Math.floor(h / 24);
|
||||
return `${d}d ${h % 24}h`;
|
||||
}
|
||||
if (seconds < 60) return `${seconds}秒`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}分${seconds % 60}秒`;
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
if (h < 24) return `${h}时${m}分${s}秒`;
|
||||
const d = Math.floor(h / 24);
|
||||
return `${d}天${h % 24}时${m}分`;
|
||||
}
|
||||
|
||||
const data = ref<ServerStats | null>(null);
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function load() {
|
||||
const s = await fetchStats().catch(() => null);
|
||||
if (s) data.value = s;
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (timer) return;
|
||||
load();
|
||||
timer = setInterval(load, 10_000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState === "visible") {
|
||||
startPolling();
|
||||
} else {
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
startPolling();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling();
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="data" style="margin-top: 24px; margin-bottom: 28px; padding: 12px 16px; background: #f0f0f0; border-radius: 8px">
|
||||
<div style="font-size: 13px; font-weight: 600; color: #333; margin-bottom: 4px">{{ t('serverStatus') }}</div>
|
||||
<div style="display: flex; gap: 20px; flex-wrap: wrap; font-size: 13px; color: #555; line-height: 2">
|
||||
<span><b style="color: #333">{{ t('uptime') }}</b> {{ formatUptime(data.uptime) }}</span>
|
||||
<span><b style="color: #333">{{ t('requests') }}</b> {{ data.totalRequests }} {{ t('times') }}</span>
|
||||
<span><b style="color: #333">{{ t('subset') }}</b> {{ data.subsetRequests }} {{ t('times') }}</span>
|
||||
<span><b style="color: #333">{{ t('chars') }}</b> {{ data.totalChars }} {{ t('charUnit') }}</span>
|
||||
<span><b style="color: #333">{{ t('cacheHit') }}</b> {{ data.subsetRequests > 0 ? ((data.subsetCacheHits / data.subsetRequests) * 100).toFixed(1) : '0.0' }}%</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@ -1,149 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/** Before/After Typography Demo — 同一内容,只有字体不同 */
|
||||
|
||||
import { onMounted } from "vue"
|
||||
import { t } from "./i18n"
|
||||
|
||||
const props = defineProps<{ onBack: () => void }>()
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
WebFont?: {
|
||||
observeFont: (options: {
|
||||
fontName: string
|
||||
selector: string
|
||||
family: string
|
||||
outType?: string
|
||||
}) => { dispose: () => void }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!window.WebFont) return
|
||||
|
||||
window.WebFont.observeFont({
|
||||
fontName: "令东齐伋复刻体.ttf",
|
||||
selector: ".demo-after .zh-font",
|
||||
family: "ZenSerif",
|
||||
outType: "woff2",
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="min-height: 100vh">
|
||||
<!-- 顶部导航 -->
|
||||
<div style="position: sticky; top: 0; z-index: 100; background: rgba(255,255,255,0.95); backdrop-filter: blur(8px); border-bottom: 1px solid #eee; padding: 12px 24px; display: flex; justify-content: space-between; align-items: center">
|
||||
<button @click="props.onBack" style="padding: 6px 16px; border: 1px solid #ddd; border-radius: 6px; background: #fff; cursor: pointer; font-size: 14px">
|
||||
{{ t('back') }}
|
||||
</button>
|
||||
<span style="font-size: 13px; color: #888">{{ t('demoSlogan') }} · <a href="https://github.com/2234839/web-font/blob/new/skills/chinese-web-font.md" target="_blank" style="color: #8b7355; text-decoration: none">{{ t('viewSkillLink') }}</a></span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; min-height: calc(100vh - 49px)">
|
||||
|
||||
<!-- ===== Before ===== -->
|
||||
<div style="flex: 1; overflow-y: auto; border-right: 2px solid #eee; background: #fff">
|
||||
<div style="display: inline-block; padding: 4px 12px; background: #f5f5f5; color: #999; font-size: 12px; border-radius: 4px; margin: 16px; font-family: -apple-system, sans-serif">
|
||||
{{ t('beforeLabel') }}
|
||||
</div>
|
||||
|
||||
<!-- Hero -->
|
||||
<div style="position: relative; padding: 120px 32px 80px; text-align: center; overflow: hidden">
|
||||
<div style="font-family: sans-serif; font-size: 200px; font-weight: 700; color: rgba(0,0,0,0.04); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); white-space: nowrap; pointer-events: none; line-height: 1">
|
||||
静心
|
||||
</div>
|
||||
<h1 style="font-family: sans-serif; font-size: 48px; font-weight: 600; color: #2c2c2c; margin: 0; line-height: 1.3">
|
||||
静心茶舍
|
||||
</h1>
|
||||
<p style="font-family: sans-serif; font-size: 18px; color: #888; margin: 24px 0 0; letter-spacing: 0.1em">
|
||||
以茶为媒 · 静心观自在
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<div style="padding: 0 48px 80px">
|
||||
<h2 style="font-family: sans-serif; font-size: 24px; font-weight: 600; margin: 48px 0 20px; color: #3a3a3a">
|
||||
一叶知秋
|
||||
</h2>
|
||||
<p style="font-family: sans-serif; font-size: 16px; line-height: 1.8; color: #4a4a4a; text-indent: 2em">
|
||||
山间晨露未晞,茶人已入林深处。指尖轻捻,择其嫩芽一二,置于竹篮之中。此乃一年之始,亦是一叶与万物的初遇。
|
||||
</p>
|
||||
|
||||
<h2 style="font-family: sans-serif; font-size: 24px; font-weight: 600; margin: 48px 0 20px; color: #3a3a3a">
|
||||
茶之六味
|
||||
</h2>
|
||||
<p style="font-family: sans-serif; font-size: 16px; line-height: 1.8; color: #4a4a4a; text-indent: 2em">
|
||||
甘、苦、涩、鲜、酸、咸——茶之六味,恰似人生六境。
|
||||
</p>
|
||||
|
||||
<!-- 引用 -->
|
||||
<div style="margin: 48px 0; padding: 32px; background: #f7f7f5; border-radius: 8px; text-align: center">
|
||||
<p style="font-family: sans-serif; font-size: 20px; color: #666; line-height: 1.8; margin: 0">
|
||||
茶不过一仰一俯之间,<br>然天地之大,尽在一盏之中。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<div style="text-align: center; margin-top: 64px">
|
||||
<span style="display: inline-block; padding: 14px 48px; border: 1px solid #8b7355; color: #8b7355; font-size: 15px; letter-spacing: 0.1em; border-radius: 4px">
|
||||
预约品茗
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== After ===== -->
|
||||
<div class="demo-after" style="flex: 1; overflow-y: auto; background: #fff">
|
||||
<div style="display: inline-block; padding: 4px 12px; background: #8b7355; color: #fff; font-size: 12px; border-radius: 4px; margin: 16px; font-family: -apple-system, sans-serif">
|
||||
{{ t('afterLabel') }}
|
||||
</div>
|
||||
|
||||
<!-- Hero -->
|
||||
<div style="position: relative; padding: 120px 32px 80px; text-align: center; overflow: hidden">
|
||||
<div class="zh-font" style="font-family: 'ZenSerif', 'Noto Serif CJK SC', 'STSong', serif; font-size: 200px; font-weight: 600; color: rgba(139,115,85,0.08); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); white-space: nowrap; pointer-events: none; line-height: 1; letter-spacing: 0.05em">
|
||||
静心
|
||||
</div>
|
||||
<h1 class="zh-font" style="font-family: 'ZenSerif', 'Noto Serif CJK SC', 'STSong', serif; font-size: 48px; font-weight: 600; color: #2c2c2c; margin: 0; line-height: 1.3; letter-spacing: 0.08em">
|
||||
静心茶舍
|
||||
</h1>
|
||||
<p class="zh-font" style="font-family: 'ZenSerif', 'Noto Serif CJK SC', 'STSong', serif; font-size: 18px; color: #888; margin: 24px 0 0; letter-spacing: 0.12em">
|
||||
以茶为媒 · 静心观自在
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<div style="padding: 0 48px 80px">
|
||||
<h2 class="zh-font" style="font-family: 'ZenSerif', 'Noto Serif CJK SC', 'STSong', serif; font-size: 24px; font-weight: 600; margin: 48px 0 20px; color: #3a3a3a; letter-spacing: 0.06em">
|
||||
一叶知秋
|
||||
</h2>
|
||||
<p style="font-family: sans-serif; font-size: 16px; line-height: 1.8; color: #4a4a4a; text-indent: 2em">
|
||||
山间晨露未晞,茶人已入林深处。指尖轻捻,择其嫩芽一二,置于竹篮之中。此乃一年之始,亦是一叶与万物的初遇。
|
||||
</p>
|
||||
|
||||
<h2 class="zh-font" style="font-family: 'ZenSerif', 'Noto Serif CJK SC', 'STSong', serif; font-size: 24px; font-weight: 600; margin: 48px 0 20px; color: #3a3a3a; letter-spacing: 0.06em">
|
||||
茶之六味
|
||||
</h2>
|
||||
<p style="font-family: sans-serif; font-size: 16px; line-height: 1.8; color: #4a4a4a; text-indent: 2em">
|
||||
甘、苦、涩、鲜、酸、咸——茶之六味,恰似人生六境。
|
||||
</p>
|
||||
|
||||
<!-- 引用 -->
|
||||
<div style="margin: 48px 0; padding: 32px; background: rgba(196,181,160,0.1); border-radius: 8px; text-align: center">
|
||||
<p class="zh-font" style="font-family: 'ZenSerif', 'Noto Serif CJK SC', 'STSong', serif; font-size: 20px; color: #666; line-height: 1.8; margin: 0; letter-spacing: 0.05em">
|
||||
茶不过一仰一俯之间,<br>然天地之大,尽在一盏之中。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<div style="text-align: center; margin-top: 64px">
|
||||
<span class="zh-font" style="display: inline-block; padding: 14px 48px; border: 1px solid #8b7355; color: #8b7355; font-size: 15px; letter-spacing: 0.15em; border-radius: 4px; font-family: 'ZenSerif', serif">
|
||||
预约品茗
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,117 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { uploadFont, type UploadResult, type ServerConfig } from "./api";
|
||||
import { t } from "./i18n";
|
||||
|
||||
const ACCEPT = ".ttf,.otf,.woff,.woff2";
|
||||
|
||||
const props = defineProps<{
|
||||
config: ServerConfig;
|
||||
onUploaded: () => void;
|
||||
}>();
|
||||
|
||||
function useUpload(onSuccess: () => void) {
|
||||
const file = ref<File | null>(null);
|
||||
const apiKey = ref("");
|
||||
const uploading = ref(false);
|
||||
const msg = ref<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
function showMsg(ok: boolean, text: string) {
|
||||
msg.value = { ok, text };
|
||||
setTimeout(() => { msg.value = null; }, 3000);
|
||||
}
|
||||
|
||||
async function upload(mode: "temp" | "admin", key?: string) {
|
||||
const f = file.value;
|
||||
if (!f) return;
|
||||
uploading.value = true;
|
||||
const result: UploadResult = await uploadFont(f, mode, key);
|
||||
uploading.value = false;
|
||||
if (result.success) {
|
||||
showMsg(true, t("uploadSuccess"));
|
||||
file.value = null;
|
||||
onSuccess();
|
||||
} else {
|
||||
showMsg(false, result.error ?? t("uploadFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
return { file, apiKey, uploading, msg, upload };
|
||||
}
|
||||
|
||||
const temp = useUpload(() => props.onUploaded());
|
||||
const admin = useUpload(() => props.onUploaded());
|
||||
const canUpload = computed(() => props.config.enableTempUpload || props.config.adminUploadEnabled);
|
||||
|
||||
function onFileSelect(e: Event, target: ReturnType<typeof useUpload>) {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
if (f) target.file.value = f;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="canUpload" style="margin-bottom: 28px">
|
||||
<label style="display: block; font-size: 14px; font-weight: 500; margin-bottom: 12px">{{ t('uploadFont') }}</label>
|
||||
<div style="font-size: 12px; color: #e6a700; margin-bottom: 12px">{{ t('uploadTip') }}</div>
|
||||
|
||||
<div
|
||||
v-if="temp.msg.value"
|
||||
:style="{
|
||||
padding: '8px 12px',
|
||||
marginBottom: '12px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '13px',
|
||||
background: temp.msg.value.ok ? '#f0faf0' : '#fef2f2',
|
||||
color: temp.msg.value.ok ? '#166534' : '#b91c1c',
|
||||
border: `1px solid ${temp.msg.value.ok ? '#bbf7d0' : '#fecaca'}`,
|
||||
}"
|
||||
>
|
||||
{{ temp.msg.value.text }}
|
||||
</div>
|
||||
|
||||
<div v-if="config.enableTempUpload" style="padding: 16px; border: 1px solid #e8e8e8; border-radius: 8px; margin-bottom: 16px">
|
||||
<div style="font-size: 14px; font-weight: 500; margin-bottom: 4px">{{ t('guestUpload') }}</div>
|
||||
<div style="font-size: 12px; color: #999; margin-bottom: 12px">{{ t('guestUploadDesc') }}</div>
|
||||
<div style="display: flex; gap: 8px; align-items: center">
|
||||
<label style="padding: 6px 20px; font-size: 14px; border: 1px solid #d9d9d9; border-radius: 6px; cursor: pointer; background: #fff; color: #333; display: inline-flex; align-items: center">
|
||||
{{ t('selectFile') }}
|
||||
<input type="file" :accept="ACCEPT" style="display: none" @change="onFileSelect($event, temp)" />
|
||||
</label>
|
||||
<span style="font-size: 13px; color: #666">{{ temp.file.value?.name ?? t('noFile') }}</span>
|
||||
<button
|
||||
:disabled="!temp.file.value || temp.uploading.value"
|
||||
:style="{ padding: '6px 20px', fontSize: '14px', border: '1px solid #d9d9d9', borderRadius: '6px', cursor: temp.file.value && !temp.uploading.value ? 'pointer' : 'not-allowed', background: '#fff', color: '#333', opacity: temp.file.value && !temp.uploading.value ? 1 : 0.5 }"
|
||||
@click="temp.upload('temp')"
|
||||
>
|
||||
{{ temp.uploading.value ? '...' : t('upload') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="config.adminUploadEnabled" style="padding: 16px; border: 1px solid #e8e8e8; border-radius: 8px; margin-bottom: 16px">
|
||||
<div style="font-size: 14px; font-weight: 500; margin-bottom: 4px">{{ t('adminUpload') }}</div>
|
||||
<div style="font-size: 12px; color: #999; margin-bottom: 12px">{{ t('adminUploadDesc') }}</div>
|
||||
<input
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
v-model="admin.apiKey.value"
|
||||
placeholder="API Key"
|
||||
style="padding: 6px 12px; font-size: 14px; border: 1px solid #d9d9d9; border-radius: 6px; outline: none; box-sizing: border-box; width: 100%; margin-bottom: 10px"
|
||||
/>
|
||||
<div style="display: flex; gap: 8px; align-items: center">
|
||||
<label style="padding: 6px 20px; font-size: 14px; border: 1px solid #d9d9d9; border-radius: 6px; cursor: pointer; background: #fff; color: #333; display: inline-flex; align-items: center">
|
||||
{{ t('selectFile') }}
|
||||
<input type="file" :accept="ACCEPT" style="display: none" @change="onFileSelect($event, admin)" />
|
||||
</label>
|
||||
<span style="font-size: 13px; color: #666">{{ admin.file.value?.name ?? t('noFile') }}</span>
|
||||
<button
|
||||
:disabled="!admin.file.value || !admin.apiKey.value || admin.uploading.value"
|
||||
:style="{ padding: '6px 20px', fontSize: '14px', border: '1px solid #d9d9d9', borderRadius: '6px', cursor: admin.file.value && admin.apiKey.value && !admin.uploading.value ? 'pointer' : 'not-allowed', background: '#fff', color: '#333', opacity: admin.file.value && admin.apiKey.value && !admin.uploading.value ? 1 : 0.5 }"
|
||||
@click="admin.upload('admin', admin.apiKey.value)"
|
||||
>
|
||||
{{ admin.uploading.value ? '...' : t('upload') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
61
src/api.ts
@ -1,61 +0,0 @@
|
||||
export interface FontInfo {
|
||||
name: string;
|
||||
dir: string;
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
enableTempUpload: boolean;
|
||||
adminUploadEnabled: boolean;
|
||||
supportedOutTypes: ("woff2" | "ttf")[];
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ServerStats {
|
||||
uptime: number;
|
||||
totalRequests: number;
|
||||
subsetRequests: number;
|
||||
subsetCacheHits: number;
|
||||
totalChars: number;
|
||||
subsetCacheEntries: number;
|
||||
fontBufferCacheEntries: number;
|
||||
}
|
||||
|
||||
export async function fetchFonts(): Promise<FontInfo[]> {
|
||||
const res = await fetch("/api/fonts");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchConfig(): Promise<ServerConfig> {
|
||||
const res = await fetch("/api/config");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function uploadFont(
|
||||
file: File,
|
||||
mode: "temp" | "admin",
|
||||
apiKey?: string,
|
||||
): Promise<UploadResult> {
|
||||
const formData = new FormData();
|
||||
formData.append("font", file);
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (apiKey) {
|
||||
headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/upload?mode=${mode}`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers,
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchStats(): Promise<ServerStats> {
|
||||
const res = await fetch("/api/stats");
|
||||
return res.json();
|
||||
}
|
||||
22
src/app.controller.spec.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
||||
85
src/app.controller.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Query,
|
||||
Response,
|
||||
Res,
|
||||
Req,
|
||||
Post,
|
||||
Body,
|
||||
} from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
import { join } from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
import { Request } from 'express';
|
||||
import { Stream } from 'stream';
|
||||
import { req_par } from './req.decorator';
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
/** 压缩字体 */
|
||||
@Get('fontmin')
|
||||
font_min(@Query('text') text, @Query('font') font,@req_par('host_url') host_url:string) {
|
||||
|
||||
return this.appService.font_min(text, font,host_url);
|
||||
}
|
||||
@Post('fontmin')
|
||||
font_min_post(@Body() body: { text: string; font: string }[],@req_par('host_url') host_url:string) {
|
||||
const res = body.map(par => {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.appService
|
||||
.font_min(par.text, par.font,host_url)
|
||||
.then(r => {
|
||||
resolve({
|
||||
font: par.font,
|
||||
css:r,
|
||||
status: 'success',
|
||||
});
|
||||
})
|
||||
.catch(e => {
|
||||
resolve({
|
||||
font: par.font,
|
||||
status: 'failure',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
return Promise.all(res);
|
||||
}
|
||||
|
||||
/** 返回字体列表 */
|
||||
@Get('font_list')
|
||||
font_list() {
|
||||
return this.appService.font_list();
|
||||
}
|
||||
|
||||
/** 压缩字体 */
|
||||
@Get('generate_fonts_dynamically*')
|
||||
async generate_fonts_dynamically(
|
||||
@Req() req: Request,
|
||||
@Res() res,
|
||||
@Query('text') text: string,
|
||||
@Query('font') font: string,
|
||||
@Query('temp') temp: string,
|
||||
) {
|
||||
const type = req.url.match(/\.(.*)\?/)[1];
|
||||
res.set({
|
||||
'Content-Type': `font/${type}`,
|
||||
});
|
||||
if (!text) return ' ';
|
||||
const file = await this.appService.generate_fonts_dynamically(
|
||||
text,
|
||||
font,
|
||||
temp,
|
||||
type,
|
||||
);
|
||||
|
||||
const bufferStream = new Stream.PassThrough();
|
||||
bufferStream.end(file);
|
||||
bufferStream.pipe(res);
|
||||
}
|
||||
}
|
||||
|
||||
function promise_execute_all<T>(params: Promise<T>[]) {}
|
||||
21
src/app.module.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import { join } from 'path';
|
||||
console.log( join(__dirname, '..'));
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ServeStaticModule.forRoot({
|
||||
rootPath: join(__dirname, '..'),
|
||||
}),
|
||||
ServeStaticModule.forRoot({
|
||||
rootPath: join(__dirname, '../../asset'),
|
||||
serveRoot: '/asset',
|
||||
}),
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
142
src/app.service.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import Fontmin from 'fontmin';
|
||||
const { zip } = require('zip-a-folder');
|
||||
import { join } from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { config } from './config';
|
||||
import { promises as fs } from 'fs';
|
||||
import { req_par } from './req.decorator';
|
||||
|
||||
const font_src="./asset/font_src/"
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
font_list() {
|
||||
const font_dir = join(__dirname, `../../${font_src}`);
|
||||
return fs.readdir(font_dir);
|
||||
}
|
||||
async font_min(text: string, font: string,server_url:string) {
|
||||
/** 因为 text 为 空或者是空格之类的 会导致 fontmin 运算很久 */
|
||||
text+='●'
|
||||
const srcPath = `${font_src}${font}.ttf`; // 字体源文件
|
||||
const outPath = `asset/font/${Date.now()}/`;
|
||||
const destPath = `./${outPath}`; // 输出路径
|
||||
// 初始化
|
||||
const fontmin = new Fontmin()
|
||||
.src(srcPath) // 输入配置
|
||||
.use(
|
||||
Fontmin.glyph({
|
||||
text, // 所需文字
|
||||
}),
|
||||
)
|
||||
.use(Fontmin.ttf2eot()) // eot 转换插件
|
||||
.use(Fontmin.ttf2woff()) // woff 转换插件
|
||||
.use(Fontmin.ttf2svg()) // svg 转换插件
|
||||
.use(Fontmin.css({ fontPath: `${server_url}${outPath}` })) // css 生成插件
|
||||
.dest(destPath); // 输出配置
|
||||
|
||||
// 执行
|
||||
return new Promise((resolve, reject) => {
|
||||
fontmin.run(function(err, files, stream) {
|
||||
if (err) {
|
||||
// 异常捕捉
|
||||
reject(err);
|
||||
} else {
|
||||
const css = files
|
||||
.filter(f =>
|
||||
(f.history[f.history.length - 1] as string).endsWith('.css'),
|
||||
)
|
||||
.map(f => f._contents.toString())[0];
|
||||
zip(
|
||||
join(__dirname, '../../', destPath),
|
||||
join(__dirname, '../../', destPath, 'asset.zip'),
|
||||
);
|
||||
// resolve({code:0,fil:files.map(f=>f._contents.toString())}); // 成功
|
||||
// resolve({code:0,files}); // 成功
|
||||
resolve(css); // 成功
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async generate_fonts_dynamically(
|
||||
text: string,
|
||||
font: string,
|
||||
temp: string,
|
||||
type: string,
|
||||
) {
|
||||
text+='●'
|
||||
const hash = crypto.createHash('md5');
|
||||
hash.update(`${type}${font}${text}`);
|
||||
const hash_str = hash.digest('hex');
|
||||
const srcPath = `${font_src}${font}.ttf`; // 字体源文件
|
||||
const outPath = `asset/dynamically/${hash_str}`;
|
||||
const destPath = `./${outPath}`; // 输出路径
|
||||
|
||||
const full_path = join(__dirname, '../../', destPath, `${font}.${type}`);
|
||||
/** 需要持久化 */
|
||||
if (temp !== 'true') {
|
||||
try {
|
||||
return await fs.readFile(full_path);
|
||||
} catch (error) {
|
||||
console.log(`开始生成 ${full_path}`,error);
|
||||
}
|
||||
}
|
||||
// 初始化
|
||||
const fontmin = new Fontmin()
|
||||
.src(srcPath) // 输入配置
|
||||
.use(
|
||||
Fontmin.glyph({
|
||||
text, // 所需文字
|
||||
}),
|
||||
);
|
||||
|
||||
if ('eot' === type) {
|
||||
fontmin.use(Fontmin.ttf2eot()); // eot 转换插件
|
||||
}
|
||||
if ('woff' === type) {
|
||||
fontmin.use(Fontmin.ttf2woff()); // eot 转换插件
|
||||
}
|
||||
if ('svg' === type) {
|
||||
fontmin.use(Fontmin.ttf2svg()); // eot 转换插件
|
||||
}
|
||||
/** 缓存数据 */
|
||||
if (temp !== 'true') {
|
||||
fontmin.dest(destPath)
|
||||
}
|
||||
|
||||
|
||||
// 执行
|
||||
return new Promise((resolve, reject) => {
|
||||
fontmin.run(async function(err, files, stream) {
|
||||
if (err) {
|
||||
// 异常捕捉
|
||||
reject(err);
|
||||
} else {
|
||||
const buffer = files.filter(f =>
|
||||
/** 筛选需要的类型 */
|
||||
(f.history[f.history.length - 1] as string).endsWith(type),
|
||||
)[0]._contents;
|
||||
resolve(buffer); // 成功
|
||||
/** 存个日志 */
|
||||
if (temp !== 'true') {
|
||||
const content_path = join(
|
||||
__dirname,
|
||||
'../../',
|
||||
destPath,
|
||||
`content.txt`,
|
||||
);
|
||||
try {
|
||||
await fs.appendFile(
|
||||
content_path,
|
||||
`type:${type}\nfont:${font}\ntext:${text}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`写 ${content_path} 失败`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 166 155.3"><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" fill="#76b3e1"/><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="27.5" y1="3" x2="152" y2="63.5"><stop offset=".1" stop-color="#76b3e1"/><stop offset=".3" stop-color="#dcf2fd"/><stop offset="1" stop-color="#76b3e1"/></linearGradient><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" opacity=".3" fill="url(#a)"/><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" fill="#518ac8"/><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="95.8" y1="32.6" x2="74" y2="105.2"><stop offset="0" stop-color="#76b3e1"/><stop offset=".5" stop-color="#4377bb"/><stop offset="1" stop-color="#1f3b77"/></linearGradient><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" opacity=".3" fill="url(#b)"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="18.4" y1="64.2" x2="144.3" y2="149.8"><stop offset="0" stop-color="#315aa9"/><stop offset=".5" stop-color="#518ac8"/><stop offset="1" stop-color="#315aa9"/></linearGradient><path d="M134 80a45 45 0 00-48-15L24 85 4 120l112 19 20-36c4-7 3-15-2-23z" fill="url(#c)"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="75.2" y1="74.5" x2="24.4" y2="260.8"><stop offset="0" stop-color="#4377bb"/><stop offset=".5" stop-color="#1a336b"/><stop offset="1" stop-color="#1a336b"/></linearGradient><path d="M114 115a45 45 0 00-48-15L4 120s53 40 94 30l3-1c17-5 23-21 13-34z" fill="url(#d)"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
3
src/config.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const config={
|
||||
web_font_path:"//127.0.0.1:3000/"
|
||||
}
|
||||
166
src/i18n.ts
@ -1,166 +0,0 @@
|
||||
import { ref, computed } from "vue"
|
||||
|
||||
export type Locale = "zh" | "en"
|
||||
|
||||
/** 检测浏览器语言偏好 */
|
||||
function detectLocale(): Locale {
|
||||
const saved = localStorage.getItem("webfont-locale") as Locale | null
|
||||
if (saved === "zh" || saved === "en") return saved
|
||||
const lang = navigator.language.toLowerCase()
|
||||
return lang.startsWith("zh") ? "zh" : "en"
|
||||
}
|
||||
|
||||
/** 当前语言 */
|
||||
export const locale = ref<Locale>(detectLocale())
|
||||
|
||||
/** 切换语言 */
|
||||
export function toggleLocale() {
|
||||
locale.value = locale.value === "zh" ? "en" : "zh"
|
||||
localStorage.setItem("webfont-locale", locale.value)
|
||||
}
|
||||
|
||||
const messages = {
|
||||
zh: {
|
||||
// App.vue
|
||||
slogan: "如清风似闪电,超级快的字体子集化裁剪",
|
||||
inputLabel: "输入文本预览效果",
|
||||
inputPlaceholder: "在此输入文本...",
|
||||
cssLabel: "CSS 代码",
|
||||
downloadFont: "下载字体",
|
||||
copyCss: "复制 CSS",
|
||||
copied: "已复制",
|
||||
copyFailed: "复制失败",
|
||||
principle: "原理:",
|
||||
principleText: "服务端根据 text 参数裁剪字体,只返回所需字符的子集。相同 URL 的请求会被浏览器自动缓存。",
|
||||
basicUsage: "基础用法:",
|
||||
basicUsageText: "将 CSS 复制到你的页面,修改 text 参数中的文字即可:",
|
||||
jsSdk: "JS SDK(推荐):",
|
||||
jsSdkText: "增量加载字体片段,按需请求,不会出现全量字体闪烁。",
|
||||
downloadSdk: "下载 SDK",
|
||||
sdkModes: "还支持",
|
||||
observeFont: "(MutationObserver 事件驱动)和",
|
||||
loadText: "(手动传文本)两种方式,多种方式可同时使用,SDK 内部自动按字体去重增量加载。",
|
||||
thanks: "感谢",
|
||||
thanksText: "收录本项目",
|
||||
buyCoffee: "觉得好用?",
|
||||
buyCoffeeAction: "请作者喝杯咖啡",
|
||||
buyCoffeeSuffix: ",支持持续开发",
|
||||
viewSkill: "查看 AI Chinese Font Skill →",
|
||||
sponsor: "赞助支持",
|
||||
agentSkillDemo: "Agent Skill Demo",
|
||||
|
||||
// FontSelector.vue
|
||||
selectFont: "选择字体",
|
||||
pleaseSelect: "-- 请选择 --",
|
||||
outputFormat: "输出格式",
|
||||
woff2Label: "WOFF2 体积更小",
|
||||
ttfLabel: "TTF 速度更快",
|
||||
woff2Desc: "约压缩 50%,适合生产",
|
||||
ttfDesc: "无编码开销,适合开发",
|
||||
|
||||
// UploadSection.vue
|
||||
uploadTip: "支持 .ttf 和 .otf 格式,建议上传 .ttf 字体文件以获得最佳兼容性",
|
||||
uploadFont: "上传字体",
|
||||
guestUpload: "游客上传",
|
||||
guestUploadDesc: "临时文件,最多保留 10 个,总大小限制 200MB,超出后自动删除最早上传的",
|
||||
adminUpload: "管理员上传",
|
||||
adminUploadDesc: "永久保存,需要 API Key 认证",
|
||||
selectFile: "选择文件",
|
||||
noFile: "未选择文件",
|
||||
upload: "上传",
|
||||
uploadSuccess: "上传成功",
|
||||
uploadFailed: "上传失败",
|
||||
|
||||
// StatsPanel.vue
|
||||
serverStatus: "服务状态",
|
||||
uptime: "运行",
|
||||
requests: "请求",
|
||||
times: "次",
|
||||
subset: "裁剪",
|
||||
chars: "文字",
|
||||
charUnit: "字",
|
||||
cacheHit: "缓存命中",
|
||||
|
||||
// TypographyDemo.vue
|
||||
demoSlogan: "字体不同,体验天壤之别",
|
||||
viewSkillLink: "查看 Skill →",
|
||||
back: "← 返回",
|
||||
beforeLabel: "Before: 默认字体",
|
||||
afterLabel: "After: AI 使用 Skill 后可调用特殊字体",
|
||||
},
|
||||
en: {
|
||||
// App.vue
|
||||
slogan: "Lightning-fast Chinese font subsetting",
|
||||
inputLabel: "Preview with your text",
|
||||
inputPlaceholder: "Type text here...",
|
||||
cssLabel: "CSS Code",
|
||||
downloadFont: "Download",
|
||||
copyCss: "Copy CSS",
|
||||
copied: "Copied!",
|
||||
copyFailed: "Failed",
|
||||
principle: "How it works: ",
|
||||
principleText: "The server subsets fonts based on the text parameter, returning only the glyphs needed. Identical URLs are cached by the browser.",
|
||||
basicUsage: "Basic usage: ",
|
||||
basicUsageText: "Copy the CSS to your page and modify the text parameter:",
|
||||
jsSdk: "JS SDK (Recommended): ",
|
||||
jsSdkText: "Incremental font loading, on-demand requests, no full-font flicker.",
|
||||
downloadSdk: "Download SDK",
|
||||
sdkModes: "Also supports ",
|
||||
observeFont: " (MutationObserver-driven) and ",
|
||||
loadText: " (manual text). Multiple modes can be used simultaneously with automatic deduplication.",
|
||||
thanks: "Thanks to ",
|
||||
thanksText: " for featuring this project",
|
||||
buyCoffee: "Find it useful? ",
|
||||
buyCoffeeAction: "Buy the author a coffee",
|
||||
buyCoffeeSuffix: " to support development",
|
||||
viewSkill: "View AI Chinese Font Skill →",
|
||||
sponsor: "Sponsor",
|
||||
agentSkillDemo: "Agent Skill Demo",
|
||||
|
||||
// FontSelector.vue
|
||||
selectFont: "Select font",
|
||||
pleaseSelect: "-- Select --",
|
||||
outputFormat: "Format",
|
||||
woff2Label: "WOFF2 Smaller",
|
||||
ttfLabel: "TTF Faster",
|
||||
woff2Desc: "~50% smaller, for production",
|
||||
ttfDesc: "No encoding overhead, for dev",
|
||||
|
||||
// UploadSection.vue
|
||||
uploadTip: "Supports .ttf and .otf. .ttf recommended for best compatibility",
|
||||
uploadFont: "Upload Font",
|
||||
guestUpload: "Guest Upload",
|
||||
guestUploadDesc: "Temporary files, max 10 files, 200MB total. Oldest deleted when full.",
|
||||
adminUpload: "Admin Upload",
|
||||
adminUploadDesc: "Permanent storage, requires API Key",
|
||||
selectFile: "Choose file",
|
||||
noFile: "No file selected",
|
||||
upload: "Upload",
|
||||
uploadSuccess: "Upload successful",
|
||||
uploadFailed: "Upload failed",
|
||||
|
||||
// StatsPanel.vue
|
||||
serverStatus: "Server Status",
|
||||
uptime: "Uptime",
|
||||
requests: "Requests",
|
||||
times: "",
|
||||
subset: "Subset",
|
||||
chars: "Chars",
|
||||
charUnit: "chars",
|
||||
cacheHit: "Cache Hit",
|
||||
|
||||
// TypographyDemo.vue
|
||||
demoSlogan: "Same content, different fonts, completely different feel",
|
||||
viewSkillLink: "View Skill →",
|
||||
back: "← Back",
|
||||
beforeLabel: "Before: Default Font",
|
||||
afterLabel: "After: AI with Skill can use custom fonts",
|
||||
},
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages.zh
|
||||
|
||||
/** 翻译函数 */
|
||||
export function t(key: MessageKey): string {
|
||||
return messages[locale.value][key] ?? key
|
||||
}
|
||||
17
src/main.ts
@ -1,4 +1,15 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import serveStatic from 'serve-static';
|
||||
import { Response } from 'express';
|
||||
import { logger } from './middleware/logger.middleware';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.enableCors();
|
||||
app.use(logger);
|
||||
|
||||
await app.listen(3000);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
createApp(App).mount("#root");
|
||||
|
||||
26
src/middleware/logger.middleware.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { Response, Request } from 'express';
|
||||
|
||||
const NS_PER_SEC = 1e9;
|
||||
export async function logger(req: Request, res: Response, next) {
|
||||
const time = process.hrtime();
|
||||
next();
|
||||
res.once('finish', () => {
|
||||
const diff = process.hrtime(time);
|
||||
console.log(
|
||||
`[${req.headers['x-forwarded-for'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.socket.remoteAddress ||
|
||||
req.connection.remoteAddress}][${(diff[0] * NS_PER_SEC + diff[1]) /
|
||||
1000000}]`,
|
||||
decodeURI_catch(req.url),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function decodeURI_catch(url: string) {
|
||||
try {
|
||||
return decodeURI(url);
|
||||
} catch (error) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
9
src/req.decorator.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { createParamDecorator } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
|
||||
export const req_par = createParamDecorator((data: string, req:Request) => {
|
||||
if('host_url'===data){
|
||||
return `//${req.headers.host}/`
|
||||
}
|
||||
return req
|
||||
});
|
||||
1
src/vite-env.d.ts
vendored
@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
140
static/App.svelte
Normal file
@ -0,0 +1,140 @@
|
||||
<script>
|
||||
import { writable } from 'svelte/store';
|
||||
import { get_font, get_font_list,server,post_fontmin } from './req';
|
||||
/** 可用的字体列表 {id:number,name:string:selected:undefined | boolen,css:undefined|string}*/
|
||||
$: font_list = [];
|
||||
get_font_list().then(r => {
|
||||
font_list = r.map(ttf => ({ name: ttf.replace(/\.ttf$/, '') }));
|
||||
});
|
||||
/** 选择的文字 */
|
||||
let text = '在此输入需要提取的文字\n在右侧选择字体\n然后点击下方的生成字体按钮';
|
||||
/** 请求方式 */
|
||||
let request_method="post"
|
||||
/** 用于测试动态生成接口 */
|
||||
let generate_fonts_dynamically=`<style>
|
||||
@font-face {
|
||||
font-family: "test";
|
||||
src:
|
||||
url("${server}generate_fonts_dynamically.ttf?temp=true&font=优设标题黑&text=优设标题黑(直接改这里和前面的字体名看效果)") format("truetype");
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
}
|
||||
</style>`
|
||||
$: selected_font = font_list.filter(font => font.selected);
|
||||
function generate_font() {
|
||||
if('post'===request_method){
|
||||
/** 使用 post 请求,单请求方式 */
|
||||
post_fontmin(
|
||||
selected_font.map(f=>({
|
||||
font:f.name, text
|
||||
}))
|
||||
).then(res=>{
|
||||
selected_font.forEach(font=>{
|
||||
let r=res.find(o=>o.font===font.name).css
|
||||
font_processing(font,r)
|
||||
})
|
||||
})
|
||||
}
|
||||
if('get'===request_method){
|
||||
/** 使用 get 请求,多请求方式 */
|
||||
selected_font.forEach(font => {
|
||||
get_font(font.name, text)
|
||||
.then(r => {
|
||||
font_processing(font,r)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
function font_processing(font,r) {
|
||||
r=r.replace(/\/\/.*?\//g,server)
|
||||
|
||||
const family = r.match(/font-family: "(.*)"/)[1];
|
||||
font.css = r;
|
||||
font.family = family;
|
||||
font.zip=server+r.match(/(asset\/font\/\d+\/)/)[0]+'asset.zip'
|
||||
/** 因为要触发其他更新则必须对这个变量重新赋值 */
|
||||
font_list = font_list;
|
||||
}
|
||||
}
|
||||
function copy(str) {
|
||||
var input = document.getElementById("copy_box");
|
||||
input.value=str
|
||||
input.focus();
|
||||
input.setSelectionRange(0, -1); // 全选
|
||||
document.execCommand("copy")
|
||||
alert(`复制成功\n${str}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each font_list as font, i}
|
||||
{@html "<style>"+font.css+'.'+font.name+"{font-family:"+font.family+"}</style>"}
|
||||
{/each}
|
||||
<h1 class="text-lg text-center mb-3 font-bold">web font 字体裁剪工具</h1>
|
||||
<textarea id="copy_box" class="w-0 h-0 fixed -m-24" />
|
||||
<div class="flex justify-evenly">
|
||||
<textarea
|
||||
bind:value={text}
|
||||
class="border flex-1 m-1"
|
||||
placeholder="在此输入需要提取的文字 在右侧选择字体 然后点击下方的生成字体按钮"
|
||||
cols="40"
|
||||
rows="3" />
|
||||
<div class="flex-1 m-1 flex flex-wrap">
|
||||
{#each font_list as font, i}
|
||||
<div
|
||||
on:click={e => (font.selected = !font.selected)}
|
||||
class="c-label {font.selected ? 'c-label-selected' : ''}
|
||||
{font.name}">
|
||||
{font.name}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<div on:click={generate_font} class="bg-red-200 text-red-600 rounded-md px-2 hover:bg-red-400 hover:text-white duration-75 flex items-center shadow-md">
|
||||
生成字体
|
||||
</div>
|
||||
|
||||
<div class="flex border ml-2 items-end">
|
||||
<div
|
||||
on:click={e => request_method="post"}
|
||||
class="c-label {request_method==="post" ? 'c-label-selected' : ''}">
|
||||
使用 post 请求
|
||||
</div>
|
||||
<div
|
||||
on:click={e => request_method="get"}
|
||||
class="c-label {request_method==="get" ? 'c-label-selected' : ''}">
|
||||
使用 get 请求
|
||||
</div>
|
||||
<div class="text-sm">* 具体区别请打开控制台查看请求</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{#each selected_font as font, i}
|
||||
<div class={font.name}>
|
||||
<div style="font-size:2rem">{text}</div>
|
||||
<div class="flex justify-end items-center text-xs">
|
||||
{#if font.css}
|
||||
<a class="text-blue-400 underline" href="/{font.zip}">下载压缩资源</a>
|
||||
<div class="c-label mx-1 text-xs" on:click={copy(font.css)}>复制css</div>
|
||||
{/if}
|
||||
|
||||
<div>{font.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
|
||||
<h2 class="text-lg text-center my-3 font-bold"> 动态生成字体(generate_fonts_dynamically 接口)</h2>
|
||||
<p class="ml-1">使用如下的方式引入,则可以直接使用</p>
|
||||
<textarea
|
||||
bind:value={generate_fonts_dynamically}
|
||||
class="border flex-1 m-1 w-full text-lg"
|
||||
placeholder="在此输入需要提取的文字"
|
||||
rows="13"
|
||||
style="font-family:test;" />
|
||||
{@html generate_fonts_dynamically}
|
||||
|
||||
|
||||
7
static/app.css
Normal file
@ -0,0 +1,7 @@
|
||||
.c-label {
|
||||
@apply border m-1 rounded-md px-1 items-center h-6 text-sm;
|
||||
}
|
||||
|
||||
.c-label-selected {
|
||||
@apply bg-red-600 text-white;
|
||||
}
|
||||
3
static/index.css
Normal file
@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
19
static/index.html
Normal file
@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>web font</title>
|
||||
<link rel="stylesheet" href="./index.css">
|
||||
</head>
|
||||
|
||||
<body class="p-4">
|
||||
|
||||
<div class="c-app"></div>
|
||||
|
||||
<!-- This script tag points to the source of the JS file we want to load and bundle -->
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
6
static/index.js
Normal file
@ -0,0 +1,6 @@
|
||||
// import '@babel/polyfill';
|
||||
import App from './App.svelte';
|
||||
import "./app.css";
|
||||
new App({
|
||||
target: document.querySelector('.c-app'),
|
||||
});
|
||||
49
static/req.ts
Normal file
@ -0,0 +1,49 @@
|
||||
export const server = '//' + location.host + location.pathname;
|
||||
/** get 方式压缩字体 */
|
||||
export function get_font(font: string, text: string) {
|
||||
return new Promise((rs, re) => {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.addEventListener('readystatechange', function() {
|
||||
if (this.readyState === 4) {
|
||||
rs(this.responseText);
|
||||
}
|
||||
});
|
||||
xhr.open(
|
||||
'GET',
|
||||
`${server}fontmin?font=${encodeURIComponent(
|
||||
font,
|
||||
)}&text=${encodeURIComponent(text)}`,
|
||||
);
|
||||
xhr.onerror = re;
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
/** post 方式压缩字体 */
|
||||
export function post_fontmin(par:{font:string,text:string}[]) {
|
||||
return new Promise((rs, re) => {
|
||||
var data = JSON.stringify(par);
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.addEventListener('readystatechange', function() {
|
||||
if (this.readyState === 4) {
|
||||
rs(JSON.parse(this.responseText) );
|
||||
}
|
||||
});
|
||||
xhr.open('POST', `${server}fontmin`);
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.onerror = re;
|
||||
xhr.send(data);
|
||||
});
|
||||
}
|
||||
export function get_font_list(font: string, text: string) {
|
||||
return new Promise((rs, re) => {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.addEventListener('readystatechange', function() {
|
||||
if (this.readyState === 4) {
|
||||
rs(JSON.parse(this.responseText));
|
||||
}
|
||||
});
|
||||
xhr.open('GET', `${server}font_list`);
|
||||
xhr.onerror = re;
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
3
static/svelte.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
declare module '*.svelte' {
|
||||
export default any;
|
||||
}
|
||||
6
tailwind.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
theme: {
|
||||
},
|
||||
variants: {},
|
||||
plugins: [],
|
||||
};
|
||||
23
task.md
@ -1,23 +0,0 @@
|
||||
/loop 持续优化字体子集化性能和提高ssim评分,可以大胆放开手脚的去做,但是优化完一定要通过`pnpx tsx ./基准测试.test.ts`。中途不要切换到其他模式,比如计划模式也不要询问我,你直接做就行了,请你持续的去优化,不要去询问我,不要去中断,好吧
|
||||
|
||||
把基准测试结果文档保存在本地 benchmark_results/ ,这样我方便查看。你的文档中应该在每个重大节点更新基准测试结果(benchmark_results/OPTIMIZATION_LOG.md),这样我能方便看到你使用了哪些优化方法,得到了什么样的优化效果。
|
||||
不要修改基准测试中的full生成方案,如果基准测试结果不对一定是你的修改有问题,而非浏览器渲染等其他问题
|
||||
|
||||
=== 字体裁剪基准测试 ===
|
||||
|
||||
8个汉字: avg=23.6ms min=18.4ms max=37.2ms 输出=16,508 bytes ssim=1.0000
|
||||
拉丁+数字: avg=16.4ms min=13.7ms max=18.2ms 输出=1,272 bytes ssim=1.0000
|
||||
千字文前段: avg=59.4ms min=47.3ms max=76.5ms 输出=161,344 bytes ssim=1.0000
|
||||
|
||||
|
||||
=== 一晚上优化后的 字体裁剪基准测试 ===
|
||||
|
||||
8个汉字: avg=7.7ms min=3.8ms max=20.7ms 输出=16,572 bytes ssim=1.0000
|
||||
拉丁+数字: avg=3.2ms min=1.6ms max=6.6ms 输出=1,272 bytes ssim=1.0000
|
||||
千字文前段: avg=11.7ms min=6.8ms max=21.7ms 输出=161,816 bytes ssim=1.0000
|
||||
|
||||
## 其他方向
|
||||
|
||||
woff2 格式是不是更优越,可以新增这种格式吗,然后ttf的也还支持,但是默认使用这个
|
||||
|
||||
就是有一个纯前端的优化,咱们提供的js SDK好像是通过定时器扫描的吧,这当然是一种方式,也是最省心的一种方式,但是咱们是不是还可以考虑另外一种方式,就是通过配置来启用定时扫描还是由用户自己的事件来触发,甚至由用户直接传递文本,这样的话对于首页上的demo来说,可能会有更高的及时性响应
|
||||
24
test/app.e2e-spec.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as request from 'supertest';
|
||||
import { AppModule } from './../src/app.module';
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
});
|
||||
});
|
||||
9
test/jest-e2e.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"types": [],
|
||||
"skipLibCheck": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue"
|
||||
]
|
||||
}
|
||||
4
tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@ -1,15 +1,16 @@
|
||||
{
|
||||
"files": [],
|
||||
"compilerOptions": {},
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.scripts.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"target": "es2017",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"esModuleInterop": true,
|
||||
"baseUrl": "./",
|
||||
"incremental": true
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
@ -1,26 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": [
|
||||
"ES2023","DOM"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts",
|
||||
"backend/*.ts",
|
||||
"backend/**/*.ts",
|
||||
]
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["node"],
|
||||
"esModuleInterop": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["scripts"]
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
import { defineConfig } from "tsdown";
|
||||
|
||||
const shared = {
|
||||
format: ["cjs"],
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
outDir: "dist_backend",
|
||||
outputOptions: {
|
||||
/** 禁用代码拆分,确保单文件输出(tsdown 默认会拆分大依赖为 chunk) */
|
||||
codeSplitting: false,
|
||||
},
|
||||
deps: {
|
||||
/** 所有依赖都打进 bundle(LLRT scratch 镜像无 node_modules) */
|
||||
alwaysBundle: [/.*/],
|
||||
},
|
||||
};
|
||||
|
||||
export default [
|
||||
defineConfig({
|
||||
...shared,
|
||||
entry: ["backend/app.ts"],
|
||||
}),
|
||||
defineConfig({
|
||||
...shared,
|
||||
/** 第二个配置不 clean,避免清掉第一个的输出 */
|
||||
clean: false,
|
||||
entry: ["基准测试_llrt.ts"],
|
||||
}),
|
||||
];
|
||||
18
tslint.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"defaultSeverity": "error",
|
||||
"extends": ["tslint:recommended"],
|
||||
"jsRules": {
|
||||
"no-unused-expression": true
|
||||
},
|
||||
"rules": {
|
||||
"quotemark": [true, "single"],
|
||||
"member-access": [false],
|
||||
"ordered-imports": [false],
|
||||
"max-line-length": [true, 150],
|
||||
"member-ordering": [false],
|
||||
"interface-name": [false],
|
||||
"arrow-parens": false,
|
||||
"object-literal-sort-keys": false
|
||||
},
|
||||
"rulesDirectory": []
|
||||
}
|
||||
21
vendor/fonteditor-core/LICENSE
vendored
@ -1,21 +0,0 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014 ecomfe
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
200
vendor/fonteditor-core/README.md
vendored
@ -1,200 +0,0 @@
|
||||
# fonteditor-core
|
||||
|
||||
**FontEditor core functions**
|
||||
|
||||
[![NPM version][npm-image]][npm-url]
|
||||
[![Downloads][downloads-image]][npm-url]
|
||||
|
||||
## Feature
|
||||
|
||||
Read and write sfnt font like ttf, woff, woff2, eot, svg, otf.
|
||||
|
||||
- sfnt parse
|
||||
- read, write, transform fonts
|
||||
- ttf (read and write)
|
||||
- woff (read and write)
|
||||
- woff2 (read and write)
|
||||
- eot (read and write)
|
||||
- svg (read and write)
|
||||
- otf (only read and convert to ttf)
|
||||
- ttf glyph adjust
|
||||
- svg to glyph
|
||||
- ESM compatibility for modern bundlers (Webpack, Rollup, Vite, Next.js, etc.)
|
||||
- TypeScript support with type definitions
|
||||
|
||||
## Usage
|
||||
|
||||
```javascript
|
||||
// read font file
|
||||
import {createFont} from 'fonteditor-core';
|
||||
import fs from 'fs';
|
||||
|
||||
const buffer = fs.readFileSync('font.ttf');
|
||||
// read font data, support format:
|
||||
// - for ttf, otf, woff, woff2, support ArrayBuffer, Buffer
|
||||
// - for svg, support string or Document(parsed svg)
|
||||
const font = createFont(buffer, {
|
||||
// support ttf, woff, woff2, eot, otf, svg
|
||||
type: 'ttf',
|
||||
// only read `a`, `b` glyphs
|
||||
subset: [65, 66],
|
||||
// read font hinting tables, default false
|
||||
hinting: true,
|
||||
// read font kerning tables, default false
|
||||
kerning: true,
|
||||
// transform ttf compound glyph to simple
|
||||
compound2simple: true,
|
||||
// inflate function for woff
|
||||
inflate: undefined,
|
||||
// for svg path
|
||||
combinePath: false,
|
||||
});
|
||||
const fontObject = font.get();
|
||||
console.log(Object.keys(fontObject));
|
||||
|
||||
/* => [ 'version',
|
||||
'numTables',
|
||||
'searchRenge',
|
||||
'entrySelector',
|
||||
'rengeShift',
|
||||
'head',
|
||||
'maxp',
|
||||
'glyf',
|
||||
'cmap',
|
||||
'name',
|
||||
'hhea',
|
||||
'post',
|
||||
'OS/2',
|
||||
'fpgm',
|
||||
'cvt',
|
||||
'prep'
|
||||
]
|
||||
*/
|
||||
|
||||
// write font file
|
||||
const buffer = font.write({
|
||||
// support ttf, woff, woff2, eot, svg
|
||||
type: 'woff',
|
||||
// save font hinting tables, default false
|
||||
hinting: false,
|
||||
// save font kerning tables, default false
|
||||
kerning: false,
|
||||
// write glyf data when simple glyph has no contours, default false
|
||||
writeZeroContoursGlyfData: false,
|
||||
// deflate function for woff, eg. pako.deflate
|
||||
deflate: undefined,
|
||||
// for user to overwrite head.xMin, head.xMax, head.yMin, head.yMax, hhea etc.
|
||||
support: {head: {}, hhea: {}}
|
||||
});
|
||||
fs.writeFileSync('font.woff', buffer);
|
||||
|
||||
// to base64 str
|
||||
font.toBase64({
|
||||
// support ttf, woff, woff2, eot, svg
|
||||
type: 'ttf'
|
||||
});
|
||||
|
||||
// optimize glyphs
|
||||
font.optimize()
|
||||
|
||||
// compound2simple
|
||||
font.compound2simple()
|
||||
|
||||
// sort glyphs
|
||||
font.sort()
|
||||
|
||||
// find glyphs
|
||||
const result = font.find({
|
||||
unicode: [65]
|
||||
});
|
||||
|
||||
const result = font.find({
|
||||
filter: function (glyf) {
|
||||
return glyf.name === 'icon'
|
||||
}
|
||||
});
|
||||
|
||||
// merge another font object
|
||||
font.merge(font1, {
|
||||
scale: 1
|
||||
});
|
||||
```
|
||||
|
||||
### Modern ES Module Usage
|
||||
|
||||
This library supports both CommonJS and ES Modules. For detailed information on using with modern bundlers, please see [ESM_USAGE.md](./ESM_USAGE.md).
|
||||
|
||||
```javascript
|
||||
// ESM import
|
||||
import fonteditorCore, { createFont, woff2 } from 'fonteditor-core';
|
||||
|
||||
createFont(buffer, options);
|
||||
```
|
||||
|
||||
### woff2
|
||||
|
||||
**Notice:** woff2 use wasm build of google woff2, before read and write `woff2`, we should first call `woff2.init()`.
|
||||
|
||||
```javascript
|
||||
import {createFont, woff2} from 'fonteditor-core';
|
||||
|
||||
// in nodejs
|
||||
woff2.init().then(() => {
|
||||
// read woff2
|
||||
const font = createFont(buffer, {
|
||||
type: 'woff2'
|
||||
});
|
||||
// write woff2
|
||||
const buffer = font.write({type: 'woff2'});
|
||||
});
|
||||
|
||||
// in browser
|
||||
woff2.init('/assets/woff2.wasm').then(() => {
|
||||
// read woff2
|
||||
const font = createFont();
|
||||
// write woff2
|
||||
const arrayBuffer = font.write({type: 'woff2'});
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
## Demo
|
||||
|
||||
```
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## build
|
||||
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
|
||||
## test
|
||||
|
||||
```
|
||||
npm run test
|
||||
```
|
||||
|
||||
## support
|
||||
|
||||
Node.js:>= 12.0
|
||||
|
||||
Browser: Chrome, Safari
|
||||
|
||||
## Related
|
||||
|
||||
- [fonteditor](https://github.com/ecomfe/fonteditor)
|
||||
- [fontmin](https://github.com/ecomfe/fontmin)
|
||||
- [fonteditor online](https://kekee000.github.io/fonteditor/index.html)
|
||||
|
||||
## License
|
||||
|
||||
MIT © Fonteditor
|
||||
|
||||
[downloads-image]: http://img.shields.io/npm/dm/fonteditor-core.svg
|
||||
[npm-url]: https://npmjs.org/package/fonteditor-core
|
||||
[npm-image]: http://img.shields.io/npm/v/fonteditor-core.svg
|
||||
|
||||
[travis-url]: https://travis-ci.org/kekee000/fonteditor-core
|
||||
[travis-image]: http://img.shields.io/travis/kekee000/fonteditor-core.svg
|
||||
1249
vendor/fonteditor-core/index.d.ts
vendored
12
vendor/fonteditor-core/lib/common/DOMParser.js
vendored
@ -1,12 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
Object.defineProperty(exports, "__esModule", {
|
||||
value: true
|
||||
});
|
||||
exports.default = void 0;
|
||||
/**
|
||||
* @file DOM解析器,兼容node端和浏览器端
|
||||
* @author mengke01(kekee000@gmail.com)
|
||||
*/
|
||||
/* eslint-disable no-undef */
|
||||
var _default = exports.default = typeof window !== 'undefined' && window.DOMParser ? window.DOMParser : require('@xmldom/xmldom').DOMParser;
|
||||
92
vendor/fonteditor-core/lib/common/I18n.js
vendored
@ -1,92 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
Object.defineProperty(exports, "__esModule", {
|
||||
value: true
|
||||
});
|
||||
exports.default = void 0;
|
||||
function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); }
|
||||
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
|
||||
function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, _toPropertyKey(descriptor.key), descriptor); } }
|
||||
function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
|
||||
function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == _typeof(i) ? i : i + ""; }
|
||||
function _toPrimitive(t, r) { if ("object" != _typeof(t) || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != _typeof(i)) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); }
|
||||
/**
|
||||
* @file 用于国际化的字符串管理类
|
||||
* @author mengke01(kekee000@gmail.com)
|
||||
*/
|
||||
|
||||
function appendLanguage(store, languageList) {
|
||||
languageList.forEach(function (item) {
|
||||
var language = item[0];
|
||||
store[language] = Object.assign(store[language] || {}, item[1]);
|
||||
});
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理国际化字符,根据lang切换语言版本
|
||||
*
|
||||
* @class I18n
|
||||
* @param {Array} languageList 当前支持的语言列表
|
||||
* @param {string=} defaultLanguage 默认语言
|
||||
* languageList = [
|
||||
* 'en-us', // 语言名称
|
||||
* langObject // 语言字符串列表
|
||||
* ]
|
||||
*/
|
||||
var I18n = exports.default = /*#__PURE__*/function () {
|
||||
function I18n(languageList, defaultLanguage) {
|
||||
_classCallCheck(this, I18n);
|
||||
this.store = appendLanguage({}, languageList);
|
||||
this.setLanguage(defaultLanguage || typeof navigator !== 'undefined' && navigator.language && navigator.language.toLowerCase() || 'en-us');
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置语言
|
||||
*
|
||||
* @param {string} language 语言
|
||||
* @return {this}
|
||||
*/
|
||||
return _createClass(I18n, [{
|
||||
key: "setLanguage",
|
||||
value: function setLanguage(language) {
|
||||
if (!this.store[language]) {
|
||||
language = 'en-us';
|
||||
}
|
||||
this.lang = this.store[this.language = language];
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加一个语言字符串
|
||||
*
|
||||
* @param {string} language 语言
|
||||
* @param {Object} langObject 语言对象
|
||||
* @return {this}
|
||||
*/
|
||||
}, {
|
||||
key: "addLanguage",
|
||||
value: function addLanguage(language, langObject) {
|
||||
appendLanguage(this.store, [[language, langObject]]);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前语言字符串
|
||||
*
|
||||
* @param {string} path 语言路径
|
||||
* @return {string} 语言字符串
|
||||
*/
|
||||
}, {
|
||||
key: "get",
|
||||
value: function get(path) {
|
||||
var ref = path.split('.');
|
||||
var refObject = this.lang;
|
||||
var level;
|
||||
while (refObject != null && (level = ref.shift())) {
|
||||
refObject = refObject[level];
|
||||
}
|
||||
return refObject != null ? refObject : '';
|
||||
}
|
||||
}]);
|
||||
}();
|
||||
80
vendor/fonteditor-core/lib/common/ajaxFile.js
vendored
@ -1,80 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
Object.defineProperty(exports, "__esModule", {
|
||||
value: true
|
||||
});
|
||||
exports.default = ajaxFile;
|
||||
exports.loadFile = loadFile;
|
||||
/**
|
||||
* @file ajax获取文本数据
|
||||
* @author mengke01(kekee000@gmail.com)
|
||||
*/
|
||||
|
||||
/**
|
||||
* ajax获取数据
|
||||
*
|
||||
* @param {Object} options 参数选项
|
||||
* @param {string=} options.type 类型
|
||||
* @param {string=} options.method method
|
||||
* @param {Function=} options.onSuccess 成功回调
|
||||
* @param {Function=} options.onError 失败回调
|
||||
* @param {Object=} options.params 参数集合
|
||||
*/
|
||||
function ajaxFile(options) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.onreadystatechange = function () {
|
||||
if (xhr.readyState === 4) {
|
||||
var status = xhr.status;
|
||||
if (status >= 200 && status < 300 || status === 304) {
|
||||
if (options.onSuccess) {
|
||||
if (options.type === 'binary') {
|
||||
var buffer = xhr.responseBlob || xhr.response;
|
||||
options.onSuccess(buffer);
|
||||
} else if (options.type === 'xml') {
|
||||
options.onSuccess(xhr.responseXML);
|
||||
} else if (options.type === 'json') {
|
||||
options.onSuccess(JSON.parse(xhr.responseText));
|
||||
} else {
|
||||
options.onSuccess(xhr.responseText);
|
||||
}
|
||||
}
|
||||
} else if (options.onError) {
|
||||
options.onError(xhr, xhr.status);
|
||||
}
|
||||
}
|
||||
};
|
||||
var method = (options.method || 'GET').toUpperCase();
|
||||
var params = null;
|
||||
if (options.params) {
|
||||
var str = [];
|
||||
Object.keys(options.params).forEach(function (key) {
|
||||
str.push(key + '=' + encodeURIComponent(options.params[key]));
|
||||
});
|
||||
str = str.join('&');
|
||||
if (method === 'GET') {
|
||||
options.url += (options.url.indexOf('?') === -1 ? '?' : '&') + str;
|
||||
} else {
|
||||
params = str;
|
||||
}
|
||||
}
|
||||
xhr.open(method, options.url, true);
|
||||
if (options.type === 'binary') {
|
||||
xhr.responseType = 'arraybuffer';
|
||||
}
|
||||
xhr.send(params);
|
||||
}
|
||||
function loadFile(url) {
|
||||
var type = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'binary';
|
||||
return new Promise(function (resolve, reject) {
|
||||
ajaxFile({
|
||||
type: type,
|
||||
url: url,
|
||||
onSuccess: function onSuccess(buffer) {
|
||||
resolve(buffer);
|
||||
},
|
||||
onError: function onError(e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||