Compare commits

..

84 Commits

Author SHA1 Message Date
douboer
82ab714405 update at 2026-02-14 16:39:10 2026-02-14 16:39:10 +08:00
douboer
b7dac22327 update at 2026-02-14 16:38:52 2026-02-14 16:38:52 +08:00
douboer
1fb3b51beb update at 2026-02-14 16:35:57 2026-02-14 16:35:57 +08:00
douboer
e4e552cca6 update at 2026-02-14 16:32:17 2026-02-14 16:32:17 +08:00
douboer
35e17b4ba2 update at 2026-02-14 16:24:00 2026-02-14 16:24:00 +08:00
douboer
797da6eb76 update at 2026-02-14 16:11:35 2026-02-14 16:11:35 +08:00
douboer
88648748e7 update at 2026-02-14 16:09:24 2026-02-14 16:09:24 +08:00
douboer
46327636b0 update at 2026-02-14 16:06:19 2026-02-14 16:06:19 +08:00
douboer
3fc17e7f45 update at 2026-02-12 09:56:46 2026-02-12 09:56:46 +08:00
douboer
a8f6168433 update at 2026-02-11 19:45:21 2026-02-11 19:45:21 +08:00
douboer
9d8316332b update at 2026-02-11 19:40:43 2026-02-11 19:40:43 +08:00
douboer
e55d0d00c0 update at 2026-02-11 19:39:59 2026-02-11 19:39:59 +08:00
douboer
63f11b0067 update at 2026-02-11 19:35:34 2026-02-11 19:35:34 +08:00
douboer
cc6c9c8a99 update at 2026-02-11 19:31:11 2026-02-11 19:31:11 +08:00
douboer
b0c7ea4cba update at 2026-02-11 19:25:25 2026-02-11 19:25:25 +08:00
douboer
3d5d517439 update at 2026-02-11 19:17:20 2026-02-11 19:17:20 +08:00
douboer
b3add14421 update at 2026-02-11 19:13:31 2026-02-11 19:13:31 +08:00
douboer
74eebc19db update at 2026-02-11 18:37:30 2026-02-11 18:37:30 +08:00
douboer
2a737f2857 update at 2026-02-11 18:33:57 2026-02-11 18:33:57 +08:00
douboer
eb27b93d1e update at 2026-02-11 18:31:17 2026-02-11 18:31:17 +08:00
douboer
3bbd9e3069 update at 2026-02-11 18:27:51 2026-02-11 18:27:51 +08:00
douboer
22685a412b update at 2026-02-11 18:26:06 2026-02-11 18:26:06 +08:00
douboer
91fa46bd0c remove frontend/.gitignore and track frontend dist 2026-02-11 17:32:46 +08:00
douboer@gmail.com
494c9aec0e update at 2026-02-11 17:32:09 2026-02-11 17:32:09 +08:00
douboer@gmail.com
05ccc985c5 update at 2026-02-11 17:30:44 2026-02-11 17:30:44 +08:00
douboer
2329d36260 refresh gitignore 2026-02-11 17:17:24 +08:00
douboer
4c7cbc8ae2 update at 2026-02-11 17:05:23 2026-02-11 17:05:23 +08:00
douboer@gmail.com
831d708838 update at 2026-02-11 17:03:38 2026-02-11 17:03:38 +08:00
douboer
859ec836df update at 2026-02-11 16:55:46 2026-02-11 16:55:46 +08:00
douboer
1076ca1fa0 add frontend dist 2026-02-11 16:54:17 +08:00
douboer
7899b42e5c update at 2026-02-11 16:50:49 2026-02-11 16:50:49 +08:00
douboer
b5f5ade1f3 update at 2026-02-11 16:48:41 2026-02-11 16:48:41 +08:00
douboer
0dbb991522 update at 2026-02-11 12:10:49 2026-02-11 12:10:49 +08:00
douboer
5e4fffbce4 update at 2026-02-11 11:07:30 2026-02-11 11:07:30 +08:00
douboer
a582bf09a8 update at 2026-02-11 11:06:24 2026-02-11 11:06:24 +08:00
douboer
4903ff63c1 update at 2026-02-10 17:57:43 2026-02-10 17:57:43 +08:00
douboer
50e23655e4 update at 2026-02-10 17:56:07 2026-02-10 17:56:07 +08:00
douboer
6fb59d5b8f update at 2026-02-10 17:32:48 2026-02-10 17:32:48 +08:00
douboer
19c47413ec update at 2026-02-10 17:29:44 2026-02-10 17:29:44 +08:00
douboer
95e37e1c20 update at 2026-02-10 17:26:42 2026-02-10 17:26:42 +08:00
douboer
866ec53ebd update at 2026-02-10 17:23:42 2026-02-10 17:23:42 +08:00
douboer
afc2b8447e update at 2026-02-10 17:21:36 2026-02-10 17:21:36 +08:00
douboer
8fd471a8f9 update at 2026-02-10 17:03:44 2026-02-10 17:03:44 +08:00
douboer
58b2d15eae update at 2026-02-10 14:41:16 2026-02-10 14:41:16 +08:00
douboer
0c24d94f4c update at 2026-02-10 14:36:54 2026-02-10 14:36:54 +08:00
douboer
a8c31c1f09 update at 2026-02-10 14:30:30 2026-02-10 14:30:30 +08:00
douboer
b43155dd0f update at 2026-02-10 14:10:20 2026-02-10 14:10:20 +08:00
douboer
b6742cb13a update at 2026-02-10 13:47:16 2026-02-10 13:47:16 +08:00
douboer
917f210dae update at 2026-02-09 16:09:44 2026-02-09 16:09:44 +08:00
douboer
ffb7367d3a update at 2026-02-09 10:22:44 2026-02-09 10:22:44 +08:00
douboer
49c70efed0 update at 2026-02-09 09:48:44 2026-02-09 09:48:44 +08:00
douboer
77a0c7b741 update at 2026-02-08 23:38:19 2026-02-08 23:38:19 +08:00
douboer
f078dd3261 update at 2026-02-08 22:31:25 2026-02-08 22:31:25 +08:00
douboer
0f5a7f0d85 update at 2026-02-08 18:28:39 2026-02-08 18:28:39 +08:00
douboer
e2a46e413a update at 2026-02-08 12:08:50 2026-02-08 12:08:50 +08:00
douboer@gmail.com
9722953746 update at 2026-02-07 17:06:06 2026-02-07 17:06:06 +08:00
douboer@gmail.com
2628b80735 update at 2026-02-07 17:03:19 2026-02-07 17:03:19 +08:00
douboer
5f3c886728 update at 2026-02-07 17:01:41 2026-02-07 17:01:41 +08:00
douboer@gmail.com
7a72cd579f update at 2026-02-07 16:38:22 2026-02-07 16:38:22 +08:00
douboer
5ea54595c6 update at 2026-02-07 16:33:56 2026-02-07 16:33:56 +08:00
douboer@gmail.com
c396b72798 chore: stop tracking generated fonts.json 2026-02-07 16:31:44 +08:00
douboer@gmail.com
e448861eb3 chore: stop tracking generated fonts.json 2026-02-07 16:30:56 +08:00
douboer@gmail.com
27c1577095 update at 2026-02-07 16:27:06 2026-02-07 16:27:06 +08:00
douboer
66da32f2ad update at 2026-02-07 16:26:37 2026-02-07 16:26:37 +08:00
douboer
d2469faae0 update at 2026-02-07 16:15:17 2026-02-07 16:15:17 +08:00
douboer
4291a182f4 update at 2026-02-07 16:09:50 2026-02-07 16:09:50 +08:00
douboer@gmail.com
400813c7fd update at 2026-02-07 16:06:57 2026-02-07 16:06:57 +08:00
douboer
6a2585511f update at 2026-02-07 16:05:38 2026-02-07 16:05:38 +08:00
douboer
91fcd79203 update at 2026-02-07 15:51:08 2026-02-07 15:51:08 +08:00
douboer
0b2595b0e0 update at 2026-02-07 15:41:30 2026-02-07 15:41:30 +08:00
douboer
0f7ade6945 update at 2026-02-07 15:11:16 2026-02-07 15:11:16 +08:00
douboer
d5cfaa4605 update at 2026-02-07 15:05:34 2026-02-07 15:05:34 +08:00
douboer@gmail.com
be510798ff update at 2026-02-07 14:55:56 2026-02-07 14:55:56 +08:00
douboer
6733ce3e9f update at 2026-02-07 14:54:45 2026-02-07 14:54:45 +08:00
douboer@gmail.com
7fcb84aed6 update at 2026-02-07 14:47:53 2026-02-07 14:47:53 +08:00
douboer
2034aec6f7 update at 2026-02-07 14:47:38 2026-02-07 14:47:38 +08:00
douboer
50c20700c0 update at 2026-02-07 14:16:46 2026-02-07 14:16:46 +08:00
douboer
dcaac46f65 update at 2026-02-07 14:10:02 2026-02-07 14:10:02 +08:00
douboer@gmail.com
593027578a update at 2026-02-07 13:57:09 2026-02-07 13:57:09 +08:00
douboer
d77f7446a2 update at 2026-02-07 13:57:01 2026-02-07 13:57:01 +08:00
douboer
951eda9c58 update at 2026-02-07 13:32:31 2026-02-07 13:32:31 +08:00
douboer@gmail.com
b0e89a56e1 update at 2026-02-07 13:06:21 2026-02-07 13:06:21 +08:00
douboer
fb9e6cb1a9 update at 2026-02-07 13:05:11 2026-02-07 13:05:11 +08:00
douboer
12dd29c84b chore: stop tracking frontend vite config 2026-02-07 13:04:46 +08:00
130 changed files with 29563 additions and 1902 deletions

16
.gitignore vendored
View File

@@ -9,8 +9,6 @@ lerna-debug.log*
node_modules node_modules
.npm-cache .npm-cache
dist
dist-ssr
*.local *.local
# Editor directories and files # Editor directories and files
@@ -25,4 +23,16 @@ dist-ssr
*.sw? *.sw?
*.ttf *.ttf
vite.config.ts frontend/vite.config.ts
frontend/dist/fonts.json
frontend/public/fonts.json
miniprogram/assets/fonts.json
# secrets
.env
.env.*
!.env.example
private.*.key
miniprogram/private.*.key
project.private.config.json
__pycache__

5
AI_CONTEXT.md Normal file
View File

@@ -0,0 +1,5 @@
# 项目 AI 协作说明
## 项目目录结构

151
CDN-DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,151 @@
# CDN 图标部署指南
## 📦 部署流程
### 1. 部署图标到服务器
```bash
cd /Users/gavin/font2svg
./scripts/deploy-assets.sh
```
部署脚本会自动:
- 上传所有 SVG 图标到 `fonts.biboer.cn/assets/icons/`
- 上传 PNG logo 到 `fonts.biboer.cn/assets/`
- 设置正确的文件权限
- 验证部署结果
### 2. 验证部署
部署完成后,可以通过浏览器访问以下地址验证:
```
https://fonts.biboer.cn/assets/webicon.png
https://fonts.biboer.cn/assets/icons/export.svg
https://fonts.biboer.cn/assets/icons/font-icon.svg
```
### 3. 测试小程序
在微信开发者工具中:
1. 点击「详情」- 「本地设置」
2. ✅ 勾选「不校验合法域名、web-view业务域名、TLS 版本以及 HTTPS 证书」(开发阶段)
3. 点击「编译」重新加载页面
4. 检查图标是否正常显示
### 4. 配置服务器域名(生产环境)
在微信小程序后台配置:
1. 登录 [微信公众平台](https://mp.weixin.qq.com/)
2. 开发管理 → 开发设置 → 服务器域名
3.`https://fonts.biboer.cn` 添加到:
- **request 合法域名**
- **downloadFile 合法域名**
4. 保存配置,等待生效(可能需要几分钟)
## 🎯 优势对比
### 使用 CDN 方案
**小程序包体积减小** - 图标不占用包大小
**SVG 高清显示** - 网络地址支持 SVG 格式
**便于更新维护** - 更新图标无需重新发布小程序
**统一资源管理** - 字体和图标同一个 CDN
### 本地资源方案
❌ 占用包大小(约 50KB 图标)
❌ 需要使用 PNG 格式SVG 不支持本地)
❌ 更新需要重新发布
✅ 离线可用
## 📝 配置文件说明
### `/miniprogram/config/cdn.js`
统一管理所有 CDN 资源路径:
```javascript
const ICON_PATHS = {
logo: 'https://fonts.biboer.cn/assets/webicon.png',
fontSizeDecrease: 'https://fonts.biboer.cn/assets/icons/font-size-decrease.svg',
// ... 其他图标
}
```
### `/miniprogram/pages/index/index.js`
引入配置并添加到 data
```javascript
const { ICON_PATHS } = require('../../config/cdn')
Page({
data: {
icons: ICON_PATHS,
// ...
}
})
```
### `/miniprogram/pages/index/index.wxml`
使用数据绑定引用图标:
```xml
<image src="{{icons.logo}}" />
<image src="{{icons.export}}" />
```
## 🔄 更新图标流程
1. 修改本地 `assets/icons/` 中的 SVG 文件
2. 运行部署脚本:`./scripts/deploy-assets.sh`
3. 小程序无需重新发布,刷新即可看到新图标
## 🚨 注意事项
1. **HTTPS 要求**:小程序只能加载 HTTPS 资源
2. **域名配置**:生产环境必须在小程序后台配置合法域名
3. **缓存策略**CDN 资源可能有缓存,更新后清除浏览器缓存
4. **文件大小**:建议 SVG 图标控制在 10KB 以内,确保快速加载
5. **兼容性**:保留本地 PNG 作为备用方案(可选)
## 📊 性能对比
| 项目 | 本地 PNG | CDN SVG |
|------|---------|---------|
| 包大小影响 | +50KB | 0KB |
| 首次加载 | 立即显示 | ~100ms网络 |
| 图标清晰度 | 一般 | 高清 |
| 更新成本 | 重新发布 | 无需发布 |
## 🛠️ 故障排查
### 图标不显示
1. 检查网络请求:开发者工具 → Network
2. 验证 CDN 地址:浏览器直接访问
3. 检查域名配置:后台是否添加合法域名
4. 清除缓存:微信开发者工具「清除缓存」
### 部署失败
1. 检查 SSH 连接:`ssh gavin@fonts.biboer.cn`
2. 检查目录权限:服务器上 `/home/gavin/font2svg/assets/`
3. 检查本地文件:`assets/icons/` 是否存在
## 📦 服务器目录结构
```
/home/gavin/font2svg/
├── assets/
│ ├── webicon.png # 小程序 logo
│ └── icons/
│ ├── export.svg
│ ├── export-svg.svg
│ ├── export-png.svg
│ ├── font-icon.svg
│ ├── expand.svg
│ ├── font-size-decrease.svg
│ ├── font-size-increase.svg
│ ├── choose-color.svg
│ ├── selectall.svg
│ ├── unselectall.svg
│ └── checkbox.svg
├── fonts/ # 字体文件
└── fonts.json # 字体列表
```

168
CDN-TEST-GUIDE.md Normal file
View File

@@ -0,0 +1,168 @@
# ✅ CDN 部署完成 - 测试清单
## 📋 已完成项目
**Logo 部署**
- https://fonts.biboer.cn/assets/webicon.png (127KB)
**图标部署** (全部返回 HTTP 200)
- font-icon.svg
- font-size-decrease.svg
- font-size-increase.svg
- choose-color.svg
- checkbox.svg
- expand.svg
- selectall.svg
- unselectall.svg
- export.svg
- export-svg.svg
- export-png.svg
**代码更新**
- `/miniprogram/config/cdn.js` - CDN 配置文件
- `/miniprogram/pages/index/index.js` - 引入 CDN 配置
- `/miniprogram/pages/index/index.wxml` - 使用动态图标路径
## 🧪 测试步骤
### 1. 开发环境测试
在微信开发者工具中:
**Step 1**: 打开项目设置
- 点击右上角「详情」
- 选择「本地设置」标签页
**Step 2**: 关闭域名校验(开发阶段)
- ✅ 勾选「不校验合法域名、web-view业务域名、TLS 版本以及 HTTPS 证书」
**Step 3**: 重新编译
- 点击「编译」按钮重新加载页面
- 或按 `Cmd+B` (Mac) / `Ctrl+B` (Windows)
**Step 4**: 检查显示效果
```
检查项:
□ 顶部 logo 显示正常
□ 字体大小增减图标显示正常
□ 颜色选择器图标显示正常
□ 导出按钮图标3个显示正常
□ 字体树折叠图标显示正常
□ 字体图标显示正常
□ 复选框图标显示正常
□ 收藏图标显示正常
```
**Step 5**: 检查网络请求
- 打开「Network」面板
- 筛选「image」类型
- 确认所有请求指向 `fonts.biboer.cn`
- 确认所有请求返回 200 状态码
### 2. 如果图标不显示
**检查 1**: Console 错误信息
```
打开「Console」面板查看是否有
- 跨域错误 (CORS)
- 网络请求失败
- 其他 JavaScript 错误
```
**检查 2**: 网络请求详情
```
在 Network 面板中点击失败的请求,查看:
- Request URL: 是否正确
- Status Code: 是否为 200
- Response Headers: 是否包含正确的 Content-Type
```
**检查 3**: 代码路径
```javascript
// 在 pages/index/index.js 的 onLoad 中打印
console.log('图标配置:', this.data.icons)
// 应该输出:
// {
// logo: 'https://fonts.biboer.cn/assets/webicon.png',
// fontIcon: 'https://fonts.biboer.cn/assets/icons/font-icon.svg',
// ...
// }
```
### 3. 生产环境配置(发布前必做)
**配置服务器域名白名单**:
1. 登录 [微信公众平台](https://mp.weixin.qq.com/)
2. 进入「开发管理」→「开发设置」→「服务器域名」
3. 在以下两个位置添加 `https://fonts.biboer.cn`:
- **request 合法域名**
- **downloadFile 合法域名**
4. 点击「保存并提交」
5. 等待生效(通常 5-10 分钟)
**验证域名配置**:
- 在微信开发者工具中「取消勾选」域名校验
- 点击「编译」
- 如果仍能正常显示,说明域名配置成功
## 📊 性能对比
### 使用 CDN 后的收益
| 指标 | 之前(本地 PNG | 现在CDN SVG | 改善 |
|------|-----------------|----------------|------|
| 小程序包大小 | ~ 50KB | ~ 0KB | ✅ -50KB |
| 图标清晰度 | PNG 栅格化 | SVG 矢量 | ✅ 高清 |
| 更新维护 | 需重新发布 | 无需发布 | ✅ 便捷 |
| 首次加载速度 | 立即 | ~100ms | ⚠️ 略慢 |
| 离线可用性 | ✅ 可用 | ❌ 需网络 | ⚠️ 牺牲 |
### 资源加载时间 (平均)
- **Logo (PNG, 127KB)**: ~150ms
- **每个图标 (SVG, ~2KB)**: ~50ms
- **总计首次加载**: ~700ms (并行请求)
- **后续加载**: ~0ms (浏览器缓存)
## 🔍 故障排查快速索引
**问题 1**: 所有图标都不显示
```
原因data.icons 未正确初始化
解决:检查 index.js 中是否正确引入 cdn.js
```
**问题 2**: 部分图标显示,部分不显示
```
原因:服务器上文件缺失或路径错误
解决:验证 CDN URL 能否在浏览器中直接访问
```
**问题 3**: 开发环境正常,真机预览不显示
```
原因:未配置服务器域名白名单
解决:在小程序后台添加 fonts.biboer.cn 到合法域名
```
**问题 4**: 图标加载很慢
```
原因CDN 未开启 GZIP 压缩或缓存策略不当
解决:检查 nginx 配置,开启 GZIP 和浏览器缓存
```
## 🎯 下一步行动
1.**立即测试** - 在微信开发者工具中验证显示效果
2. 📱 **真机预览** - 使用手机微信扫码预览
3. 🔧 **配置域名** - 在小程序后台添加合法域名(生产必需)
4. 🚀 **准备发布** - 确认所有功能正常后提交审核
## 📞 需要帮助?
如果遇到问题,请提供以下信息:
- 微信开发者工具 Console 截图
- Network 面板请求详情截图
- 具体报错信息
- 是否在真机还是模拟器测试

242
DETAIL-DESIGN.md Normal file
View File

@@ -0,0 +1,242 @@
# DETAIL-DESIGN
更新时间2026-02-07
## 1. 文档目标
本文定义 `font2svg` 的详细设计,覆盖以下范围:
- Web 前端(`frontend/`)的模块职责、数据结构、处理流程
- Python CLI`font2svg.py` / `pic2svg.py`)的职责边界
- 字体资源组织规范与导出策略
- 性能、错误处理、可维护性约束
## 2. 系统边界
## 2.1 子系统划分
1. `frontend/`:交互式预览与导出(主用户入口)
2. `scripts/generate-font-list.py`:字体清单构建
3. `font2svg.py`:命令行字体文本转 SVG
4. `pic2svg.py`:命令行图片转 SVG
## 2.2 非目标
- 不提供后端 API
- 不做字体版权管理系统
- 不做云端存储与用户账号
## 3. 前端架构设计
## 3.1 技术选型
- 框架Vue 3 + Composition API
- 构建Vite + `vite-plugin-wasm`
- 状态Pinia
- 样式UnoCSS + 手写样式
- 字体解析:`opentype.js`
- 字形 shaping`harfbuzzjs`(已封装,主链路暂未强依赖)
## 3.2 目录与模块映射
- `src/App.vue`:页面总编排、交互入口、导出触发
- `src/stores/fontStore.ts`:字体域状态
- `src/stores/uiStore.ts`UI 域状态与导出选择
- `src/components/FontSelector.vue`:字体搜索与树渲染入口
- `src/components/FontTree.vue`:分类树、收藏、预览勾选
- `src/components/FavoritesList.vue`:收藏列表
- `src/components/SvgPreview.vue`:预览生成调度
- `src/utils/svg-builder.ts`SVG 生成核心
- `src/utils/download.ts`:下载与打包
- `src/utils/font-loader.ts`:字体加载(含进度)
- `src/utils/text-layout.ts`:文本换行标准化
## 3.3 状态模型
## FontInfo
```ts
interface FontInfo {
id: string
name: string
path: string
category: string
isFavorite: boolean
font?: Font
loaded: boolean
progress: number
}
```
## UI 持久化 Key
- `font.favoriteFontIds`
- `font.previewFontIds`
- `font.expandedCategories`
- `ui.fontSize`
- `ui.inputText`
- `ui.textColor`
- `ui.selectedExportItems`
## 4. 关键流程设计
## 4.1 字体清单加载
1. `useFontLoader()``App.vue` 初始化阶段触发。
2. 请求 `/fonts.json`
3. 每条记录映射为 `FontInfo`,加入 `fontStore.fonts`
4. 调用 `updateFontTree()` 生成分组树。
异常策略:请求失败弹窗提示,并记录控制台错误。
## 4.2 字体按需加载
入口:`fontStore.loadFont(fontInfo)`
-`loaded=true` 或无路径则直接返回
- 同字体并发请求通过 `loadingFontTasks` 去重
- 使用 `loadFontWithProgress` 拉取字体并实时写入 `progress`
- 解析成功后写入 `fontInfo.font``loaded=true`
设计意图:避免初始一次性加载全部字体导致内存抖动。
## 4.3 预览生成调度
入口:`SvgPreview.vue`
1. 监听 `previewFonts/inputText/fontSize/fillColor` 变化。
2. 采用 `240ms` 防抖触发重算。
3. 每批最多处理 `20` 个字体,批内并发 `4`
4. 借助 `IntersectionObserver` 懒加载后续批次。
5. 使用 `previewGeometryCache` 缓存几何结果,颜色切换直接替换 token。
核心常量:
- `PREVIEW_DEBOUNCE_MS = 240`
- `PREVIEW_BATCH_SIZE = 20`
- `PREVIEW_CONCURRENCY = 4`
- `PREVIEW_GEOMETRY_CACHE_LIMIT = 600`
取消策略:用 `previewGenerationToken` 判定过期任务,避免旧结果污染。
## 4.4 文本布局
`wrapTextByChars(text, 45)` 策略:
- 统一换行符 `\r\n/\r -> \n`
- 保留手动换行
- 每行按字符数上限切分(默认 45
说明:该策略简单稳定,但不进行词边界或 East Asian 宽度感知换行。
## 4.5 SVG 生成
当前主链路:`generateSvg(options)`
- 基于 `opentype.js``charToGlyph` + path 指令拼装
- 计算 glyph 边界盒,汇总 viewBox 与 width/height
- 输出 `<g transform="translate(... ) scale(1 -1)">` 的坐标翻转结构
高级链路:`generateSvgWithHarfbuzz(options, fontBuffer)`
- 已封装 HarfBuzz shaping 与定位缩放逻辑
- 目前未接到主预览流程
## 4.6 导出流程
入口:`App.vue -> handleExport('svg' | 'png')`
1. 先清理失效导出项(不在当前预览集合中的项)
2. 单项导出:直接下载
3. 多项导出:聚合后 ZIP 下载
4. PNG 导出:先 SVG`canvas` 渲染为 Blob
命名规则:
- SVG`{fontPart}_{textPart}.svg`
- PNG`{fontPart}_{textPart}.png`
- `textPart` 截取前 8 字符
- 非法字符统一替换 `_`
## 5. 字体资源规范
## 5.1 目录约束
字体唯一来源:`frontend/public/fonts/`
- 支持多级目录
- 分类名来自相对目录路径
- 根目录字体分类记为 `未分类`
## 5.2 fonts.json 结构
`scripts/generate-font-list.py` 生成:
```json
{
"id": "分类/字体名",
"name": "字体名",
"filename": "字体文件名.ttf",
"category": "分类",
"path": "/fonts/分类/字体文件名.ttf"
}
```
## 6. Python CLI 设计
## 6.1 font2svg.py
职责:把给定字体与文本转换为 SVG。
- 使用 `uharfbuzz` 做 shaping
- 使用 `fonttools` 读取 glyph 与路径
- 支持单字体或目录批量转换
- 支持字距参数 `--letter-spacing`
## 6.2 pic2svg.py
职责:将二值化后的图片轮廓矢量化。
- OpenCV 预处理灰度与阈值
- 可选圆拟合快速路径
- 默认依赖 potrace 做高保真描边
## 7. 性能设计
- 字体元数据与字体文件解耦(先列表后按需)
- 预览批处理 + 并发上限
- 预览项懒加载
- 几何缓存与颜色 token 替换,减少重复 path 生成
## 8. 错误处理设计
- 用户可恢复错误:`alert` 提示(如空文本、未选导出项)
- 任务级错误:控制台 `console.error/warn`,不中断整个批次
- 资源加载失败:单字体失败不阻断其他字体渲染
## 9. 测试与质量门禁
当前仓库实际情况:
- 有 TypeScript 编译检查(`pnpm -C frontend run build` 中包含 `vue-tsc`
- 暂无标准 `lint` 脚本
- 暂无标准单元测试脚本
建议目标:
1. 增加 `lint`ESLint
2. 增加 `test`Vitest
3. 在 CI 中强制执行 `lint + typecheck + test`
## 10. 已知限制
- 主预览链路尚未默认启用 HarfBuzz shaping
- 自动换行仅按固定字符数,不考虑语义断句
- 浏览器内批量 PNG 导出在极大尺寸时可能触发内存压力
## 11. 后续演进建议
1.`generateSvgWithHarfbuzz` 接入主链路并提供回退机制
2. 为预览与导出建立一致性回归样例
3. 增加导出并发队列与失败重试(有限次数)
4. 统一日志级别,移除生产环境调试日志

2641
PLAN.md

File diff suppressed because it is too large Load Diff

164
README.md
View File

@@ -1,130 +1,104 @@
# font2svg # font2svg
一个基于 Vue 3 + TypeScript 的本地字体预览与导出工具。 本仓库提供三条能力链路:
核心目标:从本地字体库中选择字体,实时生成文本预览,并导出 `SVG``PNG` - Web 应用(`frontend/`):本地字体预览、多字体对比、导出 `SVG/PNG`
- 微信小程序(`miniprogram/`):移动端预览、导出 `SVG/PNG`、文件分享
- 小程序渲染服务(`apiserver/`):服务端渲染 SVG API
- Python CLI根目录脚本图片转 SVG、字体文本转 SVG
## 最新版本
- `v1.0.3`2026-02-10
- 小程序新增“手动路由切换”能力:通过远端 `route-config.json` 在 A/B 服务间切换,无需小程序发版
- 切换策略包含双确认与 `cooldown` 防抖,降低误切换与来回抖动
- B 侧新增 Cloudflare Tunnel 接入路径(`mac-tunnel.biboer.cn`),适配家庭网络场景
## 文档导航
- 项目总览:`README.md`
- 详细设计:`DETAIL-DESIGN.md`
- 使用说明:`USAGE.md`
- 迭代计划:`PLAN.md`
- 前端子项目说明:`frontend/README.md`
- 小程序子项目说明:`miniprogram/README.md`
## 界面快照 ## 界面快照
![snapshot](frontend/src/assets/snapshot.png) ![snapshot](frontend/src/assets/snapshot.png)
## 当前功能 ## 核心特性
- 字体库加载 - 字体树 + 分类折叠 + 搜索 + 收藏
- `frontend/public/fonts.json` 读取字体清单 - 预览勾选与导出勾选分离,支持全选/全不选
- 支持按分类展示字体树 - 文本自动按每行 45 字换行(保留手动换行)
- 支持收藏字体列表 - 预览采用分批加载与并发渲染,降低大字体库卡顿
- 预览控制 - 导出 `SVG``PNG`,多项自动打包 ZIP
- 勾选字体进入预览 - 字体来源统一为 `frontend/public/fonts/`
- 多字体并行预览
- 预览项可单独勾选用于导出
- 文本输入
- 输入框支持手动回车换行
- 自动按每行 45 字换行(保留手动换行)
- 样式控制
- 字号滑块10 ~ 500
- 文字颜色选择
- 导出
- 支持导出 `SVG`
- 支持导出 `PNG`(由 SVG 转换)
- 多项导出自动打包 zip
- 导出文件名规则:`字体名_内容前8字.扩展名`
## 技术栈 ## 目录结构
- `Vue 3` ```text
- `TypeScript` font2svg/
- `Vite` ├── frontend/ # Vue3 + TS Web 应用
- `Pinia` │ ├── public/
- `UnoCSS` │ │ ├── fonts/ # 字体唯一来源目录
- `opentype.js` │ │ └── fonts.json # 字体清单(脚本生成)
- `harfbuzzjs` │ └── src/
- `jszip` ├── miniprogram/ # 微信小程序(原生)
│ ├── pages/ # 输入/预览/字体选择页面
## 目录说明 │ └── assets/fonts.json # 小程序字体清单(脚本同步)
├── apiserver/ # 小程序远端渲染 API
- `frontend/`: 前端应用源码 ├── scripts/
- `frontend/public/fonts/`: 前端静态字体目录(由脚本生成) │ └── generate-font-list.py # 同步生成 Web + 小程序字体清单
- `frontend/public/fonts.json`: 字体清单(由脚本生成 ├── font2svg.py # 字体文本转 SVGPython CLI
- `font/`: 原始字体目录(按分类子目录组织 ├── pic2svg.py # 图片转 SVGPython CLI
- `scripts/generate-font-list.py`: 生成 `fonts.json` └── DETAIL-DESIGN.md # 详细设计
- `scripts/copy-fonts.py`: 复制字体到 `frontend/public/fonts` ```
## 环境要求 ## 环境要求
- Node.js 18+ - Node.js `>=18`
- pnpm 8+ - pnpm `>=8`
- Python 3用于字体准备脚本 - Python `>=3.9`
- 可选potrace`pic2svg.py` 需要
## 快速开始 ## 快速开始Web
### 1. 安装依赖
```bash ```bash
pnpm -C frontend install pnpm -C frontend install
```
### 2. 准备字体
将字体放入如下结构:
```text
font/
手写/
字体A.ttf
黑体/
字体B.otf
```
然后执行:
```bash
pnpm run prepare-fonts pnpm run prepare-fonts
```
该命令会:
1. 扫描 `font/` 生成 `frontend/public/fonts.json`
2. 复制字体到 `frontend/public/fonts/`
### 3. 启动开发环境
```bash
pnpm run dev pnpm run dev
``` ```
默认由 Vite 启动前端开发服务。 默认访问地址:`http://localhost:5174`
## 常用命令 ## 常用命令
```bash ```bash
# 启动开发 # 根目录
pnpm run dev pnpm run dev
# 构建(调用 frontend build
pnpm run build pnpm run build
# 本地预览构建产物
pnpm run preview pnpm run preview
# 重新生成字体清单并复制字体
pnpm run prepare-fonts pnpm run prepare-fonts
pnpm run mp:syntax
pnpm run mp:lint
pnpm run mp:test
# 前端子项目
pnpm -C frontend run dev
pnpm -C frontend run build
pnpm -C frontend run preview
``` ```
## 导出行为说明 ## Python CLI 概览
- 单个选中项:直接下载对应文件(`svg``png` ```bash
- 多个选中项:打包 zip 下载 # 字体转 SVG
- SVG: `font2svg-svg-export.zip` python font2svg.py --font path/to/font.ttf --text "你好"
- PNG: `font2svg-png-export.zip`
- 导出前会自动清理失效选中项,避免导出已不在当前预览列表中的字体
## 文本布局说明 # 图片转 SVG
python pic2svg.py images/your_image.png --outdir output
```
- 输入文本会先做换行标准化(`\r\n` -> `\n` 详细参数请看 `USAGE.md`
- 每行超过 45 字会自动分行
- SVG 高度按字形真实边界计算,不额外添加内置 padding
## 旧脚本说明
仓库仍保留 `pic2svg.py` / `font2svg.py` 等 Python 脚本,用于历史流程或离线转换;当前主交互流程以上述 `frontend/` Web 应用为准。

181
TROUBLESHOOTING-ICONS.md Normal file
View File

@@ -0,0 +1,181 @@
# 🔍 图标显示问题排查指南
## 问题现象
微信开发者工具中看不到图标和 logo
## 📋 排查步骤
### Step 1: 检查开发者工具设置
1. 打开微信开发者工具
2. 点击右上角「详情」按钮
3. 选择「本地设置」标签
4. **必须勾选**「不校验合法域名、web-view业务域名、TLS 版本以及 HTTPS 证书」
### Step 2: 查看 Console 日志
1. 打开「Console」面板底部标签
2. 点击「编译」重新加载页面
3. 查找以下输出:
```
========== 图标配置 ==========
icons: {logo: "https://fonts.biboer.cn/...", ...}
logo URL: https://fonts.biboer.cn/assets/webicon.png
============================
```
**如果看不到这些日志**
- 说明 CDN 配置文件加载失败
- 检查 `/miniprogram/config/cdn.js` 文件是否存在
### Step 3: 检查 Network 请求
1. 打开「Network」面板
2. 筛选器选择「All」或「Img」
3. 重新编译页面
4. 查看是否有对 `fonts.biboer.cn` 的请求
**如果没有请求**
- 说明 WXML 中没有正确绑定数据
- 检查 `{{icons.logo}}` 语法是否正确
**如果有请求但失败**
- 查看 Status Code应为 200
- 查看错误信息(跨域/域名校验等)
### Step 4: 检查图片组件错误
在 WXML 中添加错误处理:
```xml
<image
class="logo"
src="{{icons.logo}}"
mode="aspectFit"
binderror="onImageError"
bindload="onImageLoad"
/>
```
在 JS 中添加:
```javascript
onImageError(e) {
console.error('图片加载失败:', e.detail)
},
onImageLoad(e) {
console.log('图片加载成功:', e.currentTarget.dataset)
}
```
## 🔧 快速修复方案
### 方案 A: 使用本地 PNG (临时方案)
如果 CDN 图标始终无法显示,先恢复使用本地 PNG
```javascript
// pages/index/index.js 的 data 中
icons: {
logo: '/assets/icons/webicon.png',
fontIcon: '/assets/icons/font-icon.png',
export: '/assets/icons/export.png',
exportSvg: '/assets/icons/export-svg.png',
exportPng: '/assets/icons/export-png.png',
fontSizeDecrease: '/assets/icons/font-size-decrease.png',
fontSizeIncrease: '/assets/icons/font-size-increase.png',
chooseColor: '/assets/icons/choose-color.png',
expand: '/assets/icons/expand.png',
selectAll: '/assets/icons/selectall.png',
unselectAll: '/assets/icons/unselectall.png',
checkbox: '/assets/icons/checkbox.png'
}
```
删除 CDN 配置的引入:
```javascript
// 注释掉或删除这行
// const { ICON_PATHS } = require('../../config/cdn')
```
### 方案 B: 检查 CDN 配置加载
`pages/index/index.js` 第一行添加:
```javascript
console.log('开始加载页面模块...')
try {
const cdnConfig = require('../../config/cdn')
console.log('CDN 配置加载成功:', cdnConfig)
} catch (error) {
console.error('CDN 配置加载失败:', error)
}
```
## 🎯 常见错误及解决
### 错误 1: require is not defined
```
原因:路径错误或文件不存在
解决:确认 config/cdn.js 文件存在且路径正确
```
### 错误 2: Cannot read property 'logo' of undefined
```
原因ICON_PATHS 没有正确导出或导入
解决:检查 cdn.js 的 module.exports 和引入语句
```
### 错误 3: net::ERR_CERT_AUTHORITY_INVALID
```
原因HTTPS 证书问题
解决:勾选「不校验合法域名...」选项
```
### 错误 4: 图标显示为裂图/叉号
```
原因:
1. URL 错误404
2. 跨域问题
3. 图片格式不支持
解决:
1. 在浏览器中直接访问 URL 验证
2. 检查服务器 CORS 配置
3. 确认小程序支持该格式PNG/SVG
```
## 📱 测试命令
在终端测试 CDN 是否可访问:
```bash
# 测试 logo
curl -I https://fonts.biboer.cn/assets/webicon.png
# 测试图标
curl -I https://fonts.biboer.cn/assets/icons/export.svg
# 应该返回 HTTP/2 200
```
## 🚀 最终检查清单
在提交前确认:
- [ ] 微信开发者工具已勾选「不校验合法域名」
- [ ] Console 中输出了图标配置信息
- [ ] Network 中看到了对 fonts.biboer.cn 的请求
- [ ] 所有请求返回 200 状态码
- [ ] 图标在页面上正常显示
- [ ] 手机真机预览也能看到图标
## 💡 下一步
如果以上步骤都检查完毕仍无法显示,请提供:
1. Console 面板的完整输出
2. Network 面板的请求列表截图
3. 是否有任何报错信息

211
USAGE.md
View File

@@ -1,44 +1,120 @@
# 使用示例与说明 # 使用说明
## 🚀 快速开始 本文覆盖两类使用方式:
### 1. 安装依赖 - Web 应用(推荐):`frontend/` 图形界面
- 微信小程序:`miniprogram/` 移动端图形界面
- Python CLI`font2svg.py` / `pic2svg.py`
## 1. Web 应用
### 1.1 安装依赖
```bash
pnpm -C frontend install
```
### 1.2 准备字体
将字体放入:`frontend/public/fonts/`,支持多级目录,例如:
```text
frontend/public/fonts/
书法/
字体A.ttf
黑体/
字体B.otf
```
执行清单生成:
```bash
pnpm run prepare-fonts
```
该命令会重建:`frontend/public/fonts.json`
### 1.3 启动与构建
```bash
pnpm run dev
pnpm run build
pnpm run preview
```
默认开发端口:`5174`
### 1.4 页面操作
1. 左侧字体区搜索/勾选字体进入预览。
2. 可点击收藏按钮加入“已收藏字体”。
3. 顶部设置字号、颜色、输入内容。
4. 右侧预览区点击条目可切换导出选择。
5. 点击导出 `SVG``PNG`
导出规则:
- 单个条目:直接下载
- 多个条目:自动打包 ZIP
- 文件名:`字体名_文本前8字符.扩展名`
## 2. 微信小程序
### 2.1 字体清单准备
```bash
pnpm run prepare-fonts
```
该命令会同时更新:
- `frontend/public/fonts.json`
- `miniprogram/assets/fonts.json`
### 2.2 启动方式
1. 打开微信开发者工具。
2. 导入目录:`miniprogram/`
3. 在小程序后台配置合法域名(`request``downloadFile` 均需配置 `https://fonts.biboer.cn`)。
4. 在服务器启动渲染 API
```bash
python3 apiserver/server.py --host 0.0.0.0 --port 9300 --static-root /home/gavin/font2svg
```
5. Nginx 配置 `/api/` 反代到 `http://127.0.0.1:9300`
6. 编译并预览。
### 2.3 页面操作
1. 首页输入文本,选择字体,调整字号/颜色/字间距。
2. 预览区实时显示 SVG 结果。
3. 点击“导出 SVG 并分享”可生成文件并转发到聊天。
4. 点击“导出 PNG 到相册”可保存图片到系统相册。
### 2.4 小程序检查命令
```bash
pnpm run mp:syntax
pnpm run mp:lint
pnpm run mp:test
```
## 3. Python CLI
### 2.1 安装依赖
```bash ```bash
pip install -r requirements.txt pip install -r requirements.txt
``` ```
### 2. 安装 potrace(必需) `pic2svg.py` 还需要 `potrace`
```bash ```bash
brew install potrace brew install potrace
``` ```
### 2.1 字体转SVG依赖 ### 2.2 字体转 SVGfont2svg.py
```bash
pip install fonttools uharfbuzz
```
### 3. 转换单个文件
```bash
python pic2svg.py images/your_image.png
```
### 3.1 指定输出目录
```bash
python pic2svg.py images/your_image.png --outdir output/
```
### 4. 批量转换
```bash
python pic2svg.py --indir images --outdir output
```
### 5. 字体转SVG
```bash ```bash
python font2svg.py --font path/to/font.ttf --text "Hello" python font2svg.py --font path/to/font.ttf --text "Hello"
@@ -47,56 +123,53 @@ python font2svg.py --font path/to/font.ttf --text "Hello" --letter-spacing 20
python font2svg.py --fontdir font --text "星程紫微" --outdir svg python font2svg.py --fontdir font --text "星程紫微" --outdir svg
``` ```
说明:单字体输出文件名根据 `--text` 自动生成;使用 `--fontdir` 时会加上字体名作为前缀。 参数:
## ⚙️ 参数说明 - `--font`单个字体文件ttf/otf
- `--fontdir`:字体目录(批量处理)
- `--text`:必填,渲染文字
- `--outdir`:输出目录,不传则输出到当前目录
- `--letter-spacing`:字距(字体单位)
- `--threshold`固定阈值0-255默认使用 Otsu 自动阈值。 ### 2.3 图片转 SVGpic2svg.py
- `--indir`:输入目录(批量转换)。
- `--outdir`:输出目录(自动创建,使用输入文件名.svg
- `--turdsize`:抑制噪点面积阈值,越小保留细节越多。
- `--opttolerance`:曲线优化容差,越大文件越小但可能失真。
- `--unit`:坐标量化单位,`1` 表示不量化。
- `--optimize-curves`:开启曲线优化(更小但可能略失真)。
- `--circle-fit`:圆拟合误差阈值(相对半径),>0 启用圆替代。
- `--font`字体文件路径ttf/otf
- `--fontdir`字体目录遍历ttf/otf
- `--text`:文字内容。
- `--letter-spacing`:字距(字体单位),默认 0。
## 🧩 常用配置示例
```bash ```bash
# 保真优先(默认参数) python pic2svg.py images/your_image.png
python pic2svg.py images/your_image.png --turdsize 0 --opttolerance 0 --unit 1 python pic2svg.py images/your_image.png --outdir output/
# 文件更小(可能略失真)
python pic2svg.py images/your_image.png --optimize-curves --opttolerance 0.2
# 需要固定阈值时
python pic2svg.py images/your_image.png --threshold 128
# 圆拟合简化(仅当轮廓接近圆时生效)
python pic2svg.py images/your_image.png --circle-fit 0.02
# 批量转换
python pic2svg.py --indir images --outdir output python pic2svg.py --indir images --outdir output
``` ```
## 🐛 常见问题 常用参数:
**Q: 细节丢失或断裂?** - `--threshold`固定阈值0-255默认 Otsu
A: 降低 `--turdsize`,关闭 `--optimize-curves`,必要时指定 `--threshold` - `--turdsize`:噪点抑制阈值
- `--opttolerance`:曲线优化容差
- `--unit`:坐标量化单位
- `--optimize-curves`:启用曲线优化
- `--circle-fit`:圆拟合误差阈值
**Q: SVG 太大?** ## 4. 常见问题
A: 开启 `--optimize-curves`,或适当增大 `--opttolerance`
**Q: 能否处理彩色图?** ### Q1前端看不到字体
A: 当前流程会转为灰度并二值化,只保留黑色区域。
**Q: 圆拟合过于粗糙?** 按顺序检查:
A: 减小 `--circle-fit` 或关闭圆拟合。
## 📄 License 1. 字体是否放在 `frontend/public/fonts/`
2. 是否执行了 `pnpm run prepare-fonts`
3. `frontend/public/fonts.json` 是否包含对应条目
MIT License - 自由使用和修改 ### Q2导出 PNG 失败?
通常是浏览器内存或 SVG 内容异常导致,建议:
1. 降低字号后重试
2. 先导出 SVG 验证生成是否正常
3. 分批导出而不是一次全选过多字体
### Q3`pic2svg.py` 提示找不到 potrace
安装后确认:
```bash
which potrace
```

Binary file not shown.

180
apiserver/README.md Normal file
View File

@@ -0,0 +1,180 @@
# apiserver
`apiserver/` 提供微信小程序用的远端渲染接口:
- 小程序只上传参数(字体 ID、文字、字号、颜色等
- 服务端读取字体清单 + 字体目录,生成 SVG/PNG 后返回
## 1. 启动前准备(必须)
在仓库根目录执行:
```bash
cd /path/to/font2svg
# 1) 创建并激活虚拟环境launchd 默认使用这个解释器)
python3 -m venv .venv
source .venv/bin/activate
# 2) 安装 Python 依赖
python -m pip install -U pip
python -m pip install -r requirements.txt
# 3) 若需要 PNG 接口,再安装 Node 依赖
npm install
```
快速验证:
```bash
/path/to/font2svg/.venv/bin/python -V
/path/to/font2svg/.venv/bin/python -c "import fontTools, uharfbuzz; print('ok')"
```
## 2. 启动前台调试生产见以下5. systemd、 6. launchd macOS。)
在仓库根目录执行:
```bash
/path/to/font2svg/.venv/bin/python apiserver/server.py \
--host 0.0.0.0 \
--port 9300 \
--static-root /path/to/font2svg
```
其中 `--static-root` 目录必须包含:
- `fonts/`(统一字体目录)
- `miniprogram/assets/fonts.json`(小程序清单)
如果不传 `--manifest`,默认优先读取 `<static-root>/miniprogram/assets/fonts.json`,不存在时回退到 `<static-root>/fonts.json`
## 3. API
### GET `/healthz`
返回服务健康状态和已加载字体数量。
### POST `/api/render-svg`
请求示例:
```json
{
"fontId": "0001",
"text": "星程字体转换",
"fontSize": 120,
"fillColor": "#000000",
"letterSpacing": 0,
"maxCharsPerLine": 45
}
```
### POST `/api/render-png`
请求体与 `/api/render-svg` 相同,成功时直接返回 `image/png` 二进制。
小程序应使用 `wx.request({ responseType: 'arraybuffer' })` 接收。
成功响应:
```json
{
"ok": true,
"data": {
"fontId": "0001",
"fontName": "AlimamaDaoLiTi",
"width": 956.2,
"height": 144.3,
"svg": "<?xml ...>"
}
}
```
## 4. Nginx 反向代理
`fonts.biboer.cn` 的 server 块中增加:
```nginx
location /api/ {
proxy_pass http://127.0.0.1:9300;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
```
然后执行:
```bash
sudo nginx -t && sudo systemctl reload nginx
```
## 5. systemdLinux可选
仓库内提供示例:`apiserver/font2svg-api.service.example`,可复制到:
```bash
sudo cp apiserver/font2svg-api.service.example /etc/systemd/system/font2svg-api.service
sudo systemctl daemon-reload
sudo systemctl enable --now font2svg-api
sudo systemctl status font2svg-api
```
## 6. launchdmacOS可选
仓库内提供示例:`apiserver/font2svg-api.launchd.plist.example`
1. 复制并按本机路径修改(重点改 Python 路径和项目路径):
```bash
cp apiserver/font2svg-api.launchd.plist.example ~/Library/LaunchAgents/cn.biboer.font2svg-api.plist
```
2. 校验与权限修正:
```bash
plutil -lint ~/Library/LaunchAgents/cn.biboer.font2svg-api.plist
chown $(id -un):staff ~/Library/LaunchAgents/cn.biboer.font2svg-api.plist
chmod 644 ~/Library/LaunchAgents/cn.biboer.font2svg-api.plist
```
3. 加载并启动(不要使用 `sudo`
```bash
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/cn.biboer.font2svg-api.plist 2>/dev/null || true
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/cn.biboer.font2svg-api.plist
launchctl enable gui/$(id -u)/cn.biboer.font2svg-api
launchctl kickstart -k gui/$(id -u)/cn.biboer.font2svg-api
```
4. 查看状态与日志:
```bash
launchctl print gui/$(id -u)/cn.biboer.font2svg-api
tail -f /tmp/font2svg-api.log
```
5. 停止并卸载:
```bash
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/cn.biboer.font2svg-api.plist
```
常见错误排查:
- `Bootstrap failed: 5: Input/output error`
- 通常是 `plist` 路径/权限问题,或使用了 `sudo launchctl`
- `Missing executable detected`
- `plist``ProgramArguments` 第一个路径不可执行(常见是 `.venv` 未创建)。
- 先执行“启动前准备(必须)”创建 `.venv`,再重载 launchd。
## 7. 约束
- 字体解析完全基于字体清单(默认 `miniprogram/assets/fonts.json`),字体文件统一从 `<static-root>/fonts/` 读取,`fontId` 必须存在。
- 服务端启用 CORS允许小程序访问。
- 不依赖 Flask/FastAPI使用 Python 标准库 HTTP 服务。
- `/api/render-png` 依赖 `node + sharp`(使用 `apiserver/svg_to_png.js` 转换)。

1
apiserver/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Font2SVG API server package."""

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>cn.biboer.font2svg-api</string>
<key>ProgramArguments</key>
<array>
<string>/Users/gavin/font2svg/.venv/bin/python</string>
<string>/Users/gavin/font2svg/apiserver/server.py</string>
<string>--host</string>
<string>127.0.0.1</string>
<string>--port</string>
<string>9300</string>
<string>--static-root</string>
<string>/Users/gavin/font2svg</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/gavin/font2svg</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/font2svg-api.log</string>
<key>StandardErrorPath</key>
<string>/tmp/font2svg-api.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PYTHONUNBUFFERED</key>
<string>1</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,14 @@
[Unit]
Description=Font2SVG API Server
After=network.target
[Service]
Type=simple
User=gavin
WorkingDirectory=/home/gavin/font2svg
ExecStart=/home/gavin/font2svg/.venv/bin/python /home/gavin/font2svg/apiserver/server.py --host 127.0.0.1 --port 9300 --static-root /home/gavin/font2svg
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target

55
apiserver/png_renderer.py Normal file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""服务端 PNG 渲染:通过 sharp 将 SVG 转为 PNG。"""
import os
import subprocess
def _script_path():
return os.path.join(os.path.dirname(__file__), "svg_to_png.js")
def render_png_from_svg(svg_text, width, height, *, timeout_seconds=20):
if not svg_text or not str(svg_text).strip():
raise ValueError("SVG 内容为空")
script = _script_path()
if not os.path.isfile(script):
raise FileNotFoundError(f"未找到 SVG 转 PNG 脚本: {script}")
safe_width = max(1, min(4096, int(round(float(width or 0) or 0))))
safe_height = max(1, min(4096, int(round(float(height or 0) or 0))))
cmd = [
"node",
script,
"--width",
str(safe_width),
"--height",
str(safe_height),
]
try:
completed = subprocess.run(
cmd,
input=str(svg_text).encode("utf-8"),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout_seconds,
check=False,
)
except FileNotFoundError as error:
raise RuntimeError("未找到 node请先安装 Node.js") from error
if completed.returncode != 0:
stderr = completed.stderr.decode("utf-8", errors="replace").strip()
if "Cannot find module 'sharp'" in stderr:
raise RuntimeError("缺少 sharp 依赖,请在项目根目录执行: npm install")
raise RuntimeError(stderr or f"PNG 渲染失败,退出码: {completed.returncode}")
png_bytes = completed.stdout
if not png_bytes:
raise RuntimeError("PNG 渲染失败,返回空内容")
return png_bytes

275
apiserver/renderer.py Normal file
View File

@@ -0,0 +1,275 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""服务端字体渲染核心:输入文本和字体文件,输出 SVG。"""
import os
import re
HB = None
BoundsPen = None
SVGPathPen = None
TTFont = None
MAX_CHARS_PER_LINE = 45
HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
def _ensure_deps():
global HB, BoundsPen, SVGPathPen, TTFont
if HB is not None:
return
try:
import uharfbuzz as hb # type: ignore[import-not-found]
from fontTools.pens.boundsPen import BoundsPen as BP # type: ignore[import-not-found]
from fontTools.pens.svgPathPen import SVGPathPen as SP # type: ignore[import-not-found]
from fontTools.ttLib import TTFont as FT # type: ignore[import-not-found]
except ModuleNotFoundError as error:
raise RuntimeError("缺少依赖,请先安装: fonttools uharfbuzz") from error
HB = hb
BoundsPen = BP
SVGPathPen = SP
TTFont = FT
def _normalize_line_breaks(text):
return str(text or "").replace("\r\n", "\n").replace("\r", "\n")
def wrap_text_by_chars(text, max_chars_per_line=MAX_CHARS_PER_LINE):
if max_chars_per_line <= 0:
return _normalize_line_breaks(text)
normalized = _normalize_line_breaks(text)
lines = normalized.split("\n")
wrapped_lines = []
for line in lines:
chars = list(line)
if not chars:
wrapped_lines.append("")
continue
for i in range(0, len(chars), max_chars_per_line):
wrapped_lines.append("".join(chars[i : i + max_chars_per_line]))
return "\n".join(wrapped_lines)
def _normalize_hex_color(color, fallback="#000000"):
value = str(color or "").strip()
if HEX_COLOR_RE.match(value):
return value
return fallback
def _format_number(value):
text = f"{value:.2f}".rstrip("0").rstrip(".")
return text if text else "0"
def _shape_line(hb_font, line):
buf = HB.Buffer()
buf.add_str(line)
buf.guess_segment_properties()
HB.shape(hb_font, buf)
return buf.glyph_infos, buf.glyph_positions
def _positions_scale(positions, upem):
sample = 0
for pos in positions:
if pos.x_advance:
sample = abs(pos.x_advance)
break
if sample > upem * 4:
return 1 / 64.0
return 1.0
def _compute_bounds(glyph_set, runs):
min_x = None
min_y = None
max_x = None
max_y = None
for glyph_name, x_pos, y_pos in runs:
glyph = glyph_set[glyph_name]
pen = BoundsPen(glyph_set)
glyph.draw(pen)
if pen.bounds is None:
continue
xmin, ymin, xmax, ymax = pen.bounds
xmin += x_pos
xmax += x_pos
ymin += y_pos
ymax += y_pos
min_x = xmin if min_x is None else min(min_x, xmin)
min_y = ymin if min_y is None else min(min_y, ymin)
max_x = xmax if max_x is None else max(max_x, xmax)
max_y = ymax if max_y is None else max(max_y, ymax)
return min_x, min_y, max_x, max_y
def _font_name(ttfont):
name_table = ttfont.get("name")
if not name_table:
return "Unknown"
for name_id in (1, 4):
for record in name_table.names:
if record.nameID != name_id:
continue
try:
value = record.toUnicode().strip()
except Exception:
continue
if value:
return value
return "Unknown"
def _compose_svg(
glyph_set,
runs,
bounds,
*,
fill_color="#000000",
width_override=None,
height_override=None,
):
min_x, min_y, max_x, max_y = bounds
if min_x is None or min_y is None or max_x is None or max_y is None:
raise ValueError("未生成有效字形轮廓。")
width = max_x - min_x
height = max_y - min_y
if width <= 0 or height <= 0:
raise ValueError("计算得到的SVG尺寸无效。")
paths = []
for glyph_name, x_pos, y_pos in runs:
glyph = glyph_set[glyph_name]
pen = SVGPathPen(glyph_set)
glyph.draw(pen)
d = pen.getCommands()
if not d:
continue
transform = f"translate({_format_number(x_pos)} {_format_number(y_pos)})"
paths.append(f' <path d="{d}" transform="{transform}"/>')
if not paths:
raise ValueError("未生成任何路径。")
view_box = f"{_format_number(min_x)} 0 {_format_number(width)} {_format_number(height)}"
group_transform = f"translate(0 {_format_number(max_y)}) scale(1 -1)"
svg_content = (
'<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n'
'<svg xmlns="http://www.w3.org/2000/svg" '
f'width="{_format_number(width_override if width_override is not None else width)}" '
f'height="{_format_number(height_override if height_override is not None else height)}" '
f'viewBox="{view_box}">\n'
f' <g transform="{group_transform}" fill="{_normalize_hex_color(fill_color)}" stroke="none">\n'
f"{chr(10).join(paths)}\n"
" </g>\n"
"</svg>\n"
)
return svg_content, width, height
def render_svg_from_font_file(
font_path,
text,
*,
font_size=120,
fill_color="#000000",
letter_spacing=0.0,
max_chars_per_line=MAX_CHARS_PER_LINE,
):
if not os.path.isfile(font_path):
raise FileNotFoundError(f"字体文件不存在: {font_path}")
raw_text = str(text or "")
if not raw_text.strip():
raise ValueError("文本内容不能为空")
_ensure_deps()
ttfont = TTFont(font_path)
glyph_set = ttfont.getGlyphSet()
upem = ttfont["head"].unitsPerEm
hb_blob = HB.Blob.from_file_path(font_path)
hb_face = HB.Face(hb_blob, 0)
hb_font = HB.Font(hb_face)
hb_font.scale = (upem, upem)
HB.ot_font_set_funcs(hb_font)
normalized_text = wrap_text_by_chars(raw_text, int(max_chars_per_line or 0))
lines = normalized_text.split("\n")
ascender = upem * 0.8
descender = -upem * 0.2
if "hhea" in ttfont:
hhea = ttfont["hhea"]
if hasattr(hhea, "ascent"):
ascender = float(hhea.ascent)
if hasattr(hhea, "descent"):
descender = float(hhea.descent)
line_advance = max(upem * 1.2, ascender - descender)
letter_spacing_raw = float(letter_spacing or 0.0) * upem
runs = []
for line_index, line in enumerate(lines):
if line == "":
continue
infos, positions = _shape_line(hb_font, line)
scale = _positions_scale(positions, upem)
spacing_raw = letter_spacing_raw / scale
x = 0.0
y = 0.0
y_base = -line_index * line_advance
for info, pos in zip(infos, positions):
glyph_name = ttfont.getGlyphName(info.codepoint)
x_pos = (x + pos.x_offset) * scale
y_pos = (y + pos.y_offset) * scale
runs.append((glyph_name, x_pos, y_pos + y_base))
x += float(pos.x_advance) + spacing_raw
y += float(pos.y_advance)
bounds = _compute_bounds(glyph_set, runs)
min_x, min_y, max_x, max_y = bounds
if min_x is None or min_y is None or max_x is None or max_y is None:
raise ValueError("未生成有效字形轮廓。")
scale = float(font_size) / float(upem)
width = (max_x - min_x) * scale
height = (max_y - min_y) * scale
if width <= 0 or height <= 0:
raise ValueError("计算得到的SVG尺寸无效。")
svg_content, _, _ = _compose_svg(
glyph_set,
runs,
bounds,
fill_color=fill_color,
width_override=width,
height_override=height,
)
return {
"svg": svg_content,
"width": width,
"height": height,
"fontName": _font_name(ttfont),
}

403
apiserver/server.py Normal file
View File

@@ -0,0 +1,403 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Font2SVG API 服务。"""
import argparse
import json
import logging
import os
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
try:
from .renderer import MAX_CHARS_PER_LINE, render_svg_from_font_file
except ImportError:
from renderer import MAX_CHARS_PER_LINE, render_svg_from_font_file
try:
from .png_renderer import render_png_from_svg
except ImportError:
from png_renderer import render_png_from_svg
LOGGER = logging.getLogger("font2svg.api")
def _normalize_hex_color(color, fallback="#000000"):
value = str(color or "").strip()
if len(value) == 7 and value.startswith("#"):
hex_part = value[1:]
if all(ch in "0123456789abcdefABCDEF" for ch in hex_part):
return value
return fallback
def _safe_number(value, fallback, min_value=None, max_value=None, cast=float):
try:
parsed = cast(value)
except (TypeError, ValueError):
parsed = fallback
if min_value is not None:
parsed = max(min_value, parsed)
if max_value is not None:
parsed = min(max_value, parsed)
return parsed
def _is_within_root(path, root):
try:
root_abs = os.path.abspath(root)
path_abs = os.path.abspath(path)
return os.path.commonpath([root_abs, path_abs]) == root_abs
except ValueError:
return False
def _normalize_manifest_path(path):
value = str(path or "").strip()
if not value:
return ""
if value.startswith("//"):
parsed = urlparse(f"https:{value}")
return parsed.path
if value.startswith("http://") or value.startswith("https://"):
parsed = urlparse(value)
return parsed.path
return value
def _resolve_font_path(item, static_root):
path = _normalize_manifest_path(item.get("path"))
filename = str(item.get("filename") or "").strip()
category = str(item.get("category") or "").strip()
candidates = []
if path:
cleaned = path.split("?", 1)[0].strip()
candidates.append(os.path.join(static_root, cleaned.lstrip("/")))
if category and filename:
candidates.append(os.path.join(static_root, "fonts", category, filename))
if filename:
candidates.append(os.path.join(static_root, "fonts", filename))
for candidate in candidates:
absolute = os.path.abspath(candidate)
if _is_within_root(absolute, static_root) and os.path.isfile(absolute):
return absolute
return None
class FontCatalog:
def __init__(self, static_root, manifest_path):
self.static_root = os.path.abspath(static_root)
self.manifest_path = os.path.abspath(manifest_path)
self._manifest_mtime = None
self._font_map = {}
self._lock = threading.Lock()
def _reload(self):
if not os.path.isfile(self.manifest_path):
raise FileNotFoundError(f"未找到字体清单: {self.manifest_path}")
mtime = os.path.getmtime(self.manifest_path)
if self._manifest_mtime == mtime and self._font_map:
return
with open(self.manifest_path, "r", encoding="utf-8") as file:
payload = json.load(file)
if not isinstance(payload, list):
raise ValueError("fonts.json 格式无效,必须是数组")
font_map = {}
for item in payload:
if not isinstance(item, dict):
continue
font_id = str(item.get("id") or "").strip()
if not font_id:
continue
font_path = _resolve_font_path(item, self.static_root)
if not font_path:
continue
font_map[font_id] = {
"path": font_path,
"name": str(item.get("name") or "").strip(),
"category": str(item.get("category") or "").strip(),
}
if not font_map:
raise ValueError("未解析到可用字体路径,请检查 fonts.json 与 fonts/ 目录")
self._font_map = font_map
self._manifest_mtime = mtime
LOGGER.info("字体清单已加载: %s 个字体", len(font_map))
def get(self, font_id):
with self._lock:
self._reload()
item = self._font_map.get(font_id)
if not item:
raise KeyError(f"字体不存在: {font_id}")
if not os.path.isfile(item["path"]):
raise FileNotFoundError(f"字体文件不存在: {item['path']}")
return item
def health(self):
with self._lock:
self._reload()
return {
"manifestPath": self.manifest_path,
"fontCount": len(self._font_map),
}
class RenderHandler(BaseHTTPRequestHandler):
catalog = None
def send_response(self, code, message=None):
self._response_status = code
super().send_response(code, message)
def _set_cors_headers(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type,Authorization")
self.send_header("Access-Control-Max-Age", "86400")
def _log_request_timing(self, start_time):
elapsed_ms = (time.perf_counter() - start_time) * 1000
status = getattr(self, "_response_status", "-")
client_ip = self.client_address[0] if self.client_address else "-"
LOGGER.info(
"请求完成 method=%s path=%s status=%s duration_ms=%.2f client=%s",
self.command,
self.path,
status,
elapsed_ms,
client_ip,
)
def _send_json(self, status_code, payload):
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status_code)
self._set_cors_headers()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _send_binary(self, status_code, body, content_type):
self.send_response(status_code)
self._set_cors_headers()
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _parse_render_payload(self):
try:
content_length = int(self.headers.get("Content-Length", "0") or "0")
except ValueError:
raise ValueError("请求长度无效") from None
if content_length <= 0 or content_length > 256 * 1024:
raise ValueError("请求体大小无效")
try:
raw_body = self.rfile.read(content_length)
payload = json.loads(raw_body.decode("utf-8"))
except json.JSONDecodeError:
raise ValueError("请求体不是有效 JSON") from None
if not isinstance(payload, dict):
raise ValueError("请求体格式错误")
font_id = str(payload.get("fontId") or "").strip()
text = str(payload.get("text") or "")
if not font_id:
raise ValueError("缺少 fontId")
if not text.strip():
raise ValueError("文本内容不能为空")
return {
"fontId": font_id,
"text": text,
"fontSize": _safe_number(payload.get("fontSize"), 120.0, 8.0, 1024.0, float),
"letterSpacing": _safe_number(payload.get("letterSpacing"), 0.0, -2.0, 5.0, float),
"maxCharsPerLine": _safe_number(
payload.get("maxCharsPerLine"),
MAX_CHARS_PER_LINE,
1,
300,
int,
),
"fillColor": _normalize_hex_color(payload.get("fillColor"), "#000000"),
}
def _render_svg_core(self, render_params):
font_info = self.catalog.get(render_params["fontId"])
result = render_svg_from_font_file(
font_info["path"],
render_params["text"],
font_size=render_params["fontSize"],
fill_color=render_params["fillColor"],
letter_spacing=render_params["letterSpacing"],
max_chars_per_line=render_params["maxCharsPerLine"],
)
return font_info, result
def do_OPTIONS(self): # noqa: N802
start_time = time.perf_counter()
self._response_status = None
try:
self.send_response(204)
self._set_cors_headers()
self.end_headers()
finally:
self._log_request_timing(start_time)
def do_GET(self): # noqa: N802
start_time = time.perf_counter()
self._response_status = None
try:
parsed = urlparse(self.path)
if parsed.path == "/healthz":
try:
data = self.catalog.health()
self._send_json(200, {"ok": True, "data": data})
except Exception as error:
LOGGER.exception("健康检查失败")
self._send_json(500, {"ok": False, "error": str(error)})
return
self._send_json(404, {"ok": False, "error": "Not Found"})
finally:
self._log_request_timing(start_time)
def do_POST(self): # noqa: N802
start_time = time.perf_counter()
self._response_status = None
try:
parsed = urlparse(self.path)
if parsed.path not in ("/api/render-svg", "/api/render-png"):
self._send_json(404, {"ok": False, "error": "Not Found"})
return
try:
render_params = self._parse_render_payload()
font_info, result = self._render_svg_core(render_params)
except KeyError as error:
self._send_json(404, {"ok": False, "error": str(error)})
return
except FileNotFoundError as error:
self._send_json(404, {"ok": False, "error": str(error)})
return
except ValueError as error:
self._send_json(400, {"ok": False, "error": str(error)})
return
except Exception as error:
LOGGER.exception("渲染失败")
self._send_json(500, {"ok": False, "error": str(error)})
return
if parsed.path == "/api/render-png":
try:
png_bytes = render_png_from_svg(
result["svg"],
result["width"],
result["height"],
)
except Exception as error:
LOGGER.exception("PNG 渲染失败")
self._send_json(500, {"ok": False, "error": str(error)})
return
self._send_binary(200, png_bytes, "image/png")
return
response_data = {
"svg": result["svg"],
"width": result["width"],
"height": result["height"],
"fontName": result.get("fontName") or font_info.get("name") or "Unknown",
"fontId": render_params["fontId"],
}
self._send_json(200, {"ok": True, "data": response_data})
finally:
self._log_request_timing(start_time)
def log_message(self, format_str, *args):
LOGGER.info("%s - %s", self.address_string(), format_str % args)
def build_server(host, port, static_root, manifest_path):
catalog = FontCatalog(static_root=static_root, manifest_path=manifest_path)
RenderHandler.catalog = catalog
return ThreadingHTTPServer((host, port), RenderHandler)
def main():
parser = argparse.ArgumentParser(description="Font2SVG 渲染 API 服务")
parser.add_argument("--host", default=os.getenv("FONT2SVG_API_HOST", "0.0.0.0"))
parser.add_argument("--port", type=int, default=int(os.getenv("FONT2SVG_API_PORT", "9300")))
parser.add_argument(
"--static-root",
default=os.getenv("FONT2SVG_STATIC_ROOT", os.getcwd()),
help="静态资源根目录(包含 fonts/ 与配置清单)",
)
parser.add_argument(
"--manifest",
default=os.getenv("FONT2SVG_MANIFEST_PATH", ""),
help="字体清单路径,默认优先使用 <static-root>/miniprogram/assets/fonts.json其次 <static-root>/fonts.json",
)
args = parser.parse_args()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
static_root = os.path.abspath(args.static_root)
if args.manifest.strip():
manifest_path = args.manifest.strip()
else:
manifest_candidates = [
os.path.join(static_root, "miniprogram", "assets", "fonts.json"),
os.path.join(static_root, "fonts.json"),
]
manifest_path = next((item for item in manifest_candidates if os.path.isfile(item)), manifest_candidates[0])
server = build_server(
host=args.host,
port=args.port,
static_root=static_root,
manifest_path=manifest_path,
)
LOGGER.info("Font2SVG API 服务启动: http://%s:%s", args.host, args.port)
LOGGER.info("静态目录: %s", static_root)
LOGGER.info("字体清单: %s", manifest_path)
try:
server.serve_forever()
except KeyboardInterrupt:
LOGGER.info("收到退出信号,正在停止服务")
finally:
server.server_close()
return 0
if __name__ == "__main__":
raise SystemExit(main())

61
apiserver/svg_to_png.js Normal file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env node
const sharp = require('sharp')
function parseArgs(argv) {
const parsed = {}
for (let i = 2; i < argv.length; i += 1) {
const key = argv[i]
const value = argv[i + 1]
if (key === '--width' && value) {
parsed.width = Number(value)
i += 1
continue
}
if (key === '--height' && value) {
parsed.height = Number(value)
i += 1
}
}
return parsed
}
async function main() {
const args = parseArgs(process.argv)
const chunks = []
process.stdin.on('data', (chunk) => {
chunks.push(chunk)
})
process.stdin.on('end', async () => {
try {
const svgBuffer = Buffer.concat(chunks)
if (!svgBuffer.length) {
throw new Error('empty svg input')
}
const width = Number.isFinite(args.width) && args.width > 0 ? Math.round(args.width) : null
const height = Number.isFinite(args.height) && args.height > 0 ? Math.round(args.height) : null
let pipeline = sharp(svgBuffer, { density: 300 })
if (width || height) {
pipeline = pipeline.resize({
width: width || undefined,
height: height || undefined,
fit: 'contain',
background: { r: 255, g: 255, b: 255, alpha: 1 },
withoutEnlargement: false,
})
}
const pngBuffer = await pipeline.png({ compressionLevel: 9 }).toBuffer()
process.stdout.write(pngBuffer)
} catch (error) {
process.stderr.write(`svg_to_png_error: ${error && error.message ? error.message : String(error)}\n`)
process.exit(1)
}
})
}
main()

59
convert-icons.js Normal file
View File

@@ -0,0 +1,59 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// 检查是否安装了 sharp
try {
require.resolve('sharp');
} catch (e) {
console.log('正在安装 sharp...');
execSync('npm install sharp', { stdio: 'inherit', cwd: __dirname });
}
const sharp = require('sharp');
const iconsDir = path.join(__dirname, 'miniprogram', 'assets', 'icons');
const svgFiles = [
'font-size-decrease',
'font-size-increase',
'choose-color',
'export',
'export-svg',
'export-png',
'font-icon',
'expand',
'selectall',
'unselectall',
'checkbox'
];
async function convertSvgToPng() {
console.log('开始转换 SVG 为 PNG...\n');
for (const name of svgFiles) {
const svgPath = path.join(iconsDir, `${name}.svg`);
const pngPath = path.join(iconsDir, `${name}.png`);
if (!fs.existsSync(svgPath)) {
console.log(`⚠️ ${name}.svg 不存在,跳过`);
continue;
}
try {
await sharp(svgPath, { density: 300 })
.resize(128, 128, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png()
.toFile(pngPath);
console.log(`${name}.svg -> ${name}.png`);
} catch (error) {
console.error(`❌ 转换 ${name}.svg 失败:`, error.message);
}
}
console.log('\n转换完成');
}
convertSvgToPng().catch(console.error);

86
deploy.sh Executable file
View File

@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# -----------------------------------------------------------------------------
# deploy.sh
#
# 用途
# - 一键执行本地推送 + 远端两台服务器拉取代码A/B
# - 默认部署分支main。
#
# 执行流程
# 1) 运行本地推送脚本(默认 ./gitrun.sh
# 2) 服务器 A 执行 git fetch + checkout + pull --ff-only
# 3) 服务器 B 执行 git fetch + checkout + pull --ff-only
#
# 前置条件
# - 本机已配置 SSH 免密登录到 A/B 两台服务器。
# - 远端目录已存在 Git 仓库(默认 ~/font2svg
# - 本地 GITRUN_SCRIPT 可执行。
#
# 可覆盖环境变量
# - GITRUN_SCRIPT 本地推送脚本路径(默认 ./gitrun.sh
# - DEPLOY_BRANCH 部署分支(默认 main
# - REMOTE_WORKDIR 远端项目目录(默认 ~/font2svg
# - SERVER_A 服务器 A默认 gavin@mac.biboer.cn
# - SERVER_A_PORT 服务器 A SSH 端口(默认 22
# - SERVER_B 服务器 B默认 gavin@biboer.cn
# - SERVER_B_PORT 服务器 B SSH 端口(默认 21174
#
# 示例
# - 使用默认配置:
# bash deploy.sh
# - 指定分支:
# DEPLOY_BRANCH=release bash deploy.sh
# - 指定远端目录:
# REMOTE_WORKDIR=~/apps/font2svg bash deploy.sh
#
# 失败策略
# - set -euo pipefail任一步失败立即退出避免部分成功导致状态不一致。
# -----------------------------------------------------------------------------
set -euo pipefail
# 部署配置(可通过环境变量覆盖)
GITRUN_SCRIPT="${GITRUN_SCRIPT:-./gitrun.sh}"
DEPLOY_BRANCH="${DEPLOY_BRANCH:-main}"
REMOTE_WORKDIR="${REMOTE_WORKDIR:-~/font2svg}"
SERVER_A="${SERVER_A:-gavin@mac.biboer.cn}"
SERVER_A_PORT="${SERVER_A_PORT:-22}"
SERVER_B="${SERVER_B:-gavin@biboer.cn}"
SERVER_B_PORT="${SERVER_B_PORT:-21174}"
SSH_OPTS=(
-o BatchMode=yes
-o ConnectTimeout=8
-o StrictHostKeyChecking=accept-new
)
log_info() {
echo "[INFO] $1"
}
run_local_push() {
if [[ ! -x "$GITRUN_SCRIPT" ]]; then
echo "[ERROR] git 推送脚本不可执行: $GITRUN_SCRIPT"
echo "请先执行: chmod +x $GITRUN_SCRIPT"
exit 1
fi
log_info "执行本地推送脚本: $GITRUN_SCRIPT"
"$GITRUN_SCRIPT"
}
run_remote_pull() {
local host="$1"
local port="$2"
log_info "远程拉取: $host:$port ($DEPLOY_BRANCH)"
ssh "${SSH_OPTS[@]}" -p "$port" "$host" \
"cd $REMOTE_WORKDIR && git fetch origin && git checkout $DEPLOY_BRANCH && git pull --ff-only origin $DEPLOY_BRANCH"
}
main() {
run_local_push
run_remote_pull "$SERVER_A" "$SERVER_A_PORT"
run_remote_pull "$SERVER_B" "$SERVER_B_PORT"
log_info "部署完成"
}
main "$@"

161
font2svg.mac.conf Normal file
View File

@@ -0,0 +1,161 @@
# Font2SVG - Mac Nginx 配置mac.biboer.cn / mac-tunnel.biboer.cn
# 用途:
# 1) 为微信小程序提供字体清单、默认配置、路由配置与渲染 API
# 2) 提供 Web 应用静态页面frontend/dist
server {
listen 80;
listen [::]:80;
server_name mac.biboer.cn;
return 301 https://$host:8443$request_uri;
}
# Cloudflare Tunnel 入口(推荐):
# 外部 https://mac-tunnel.biboer.cn(443) -> cloudflared -> 本机 80Nginx
server {
listen 80;
listen [::]:80;
server_name mac-tunnel.biboer.cn;
# 项目根目录(包含 fonts/、fonts.json、miniprogram/assets/*、frontend/dist/*
root /Users/gavin/font2svg;
index index.html;
access_log /opt/homebrew/var/log/nginx/access.log;
error_log /opt/homebrew/var/log/nginx/error.log;
server_tokens off;
# 小程序跨域访问
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET,HEAD,POST,OPTIONS" always;
add_header Access-Control-Allow-Headers "Origin,Range,Accept,Content-Type,Authorization" always;
add_header Access-Control-Expose-Headers "Content-Length,Content-Range" always;
# MIME
types {
text/html html htm shtml;
text/css css;
application/javascript js mjs;
image/svg+xml svg;
image/png png;
image/jpeg jpg jpeg;
image/gif gif;
image/x-icon ico;
application/json json;
font/ttf ttf;
font/otf otf;
font/woff woff;
font/woff2 woff2;
application/vnd.ms-fontobject eot;
}
# SVG 渲染 API独立 Python 服务,监听 127.0.0.1:9300
location ^~ /api/ {
if ($request_method = OPTIONS) {
return 204;
}
proxy_pass http://127.0.0.1:9300;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# 健康检查
location = /healthz {
proxy_pass http://127.0.0.1:9300/healthz;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# -------------------- 小程序静态配置 --------------------
# 字体清单:短缓存,便于更新
location = /fonts.json {
expires 1h;
add_header Cache-Control "public, must-revalidate" always;
# Web 端固定请求 /fonts.json依次回退到 dist/public/root 三处清单
try_files /frontend/dist/fonts.json /frontend/public/fonts.json /fonts.json =404;
}
location = /miniprogram/assets/fonts.json {
expires 1h;
add_header Cache-Control "public, must-revalidate" always;
try_files $uri =404;
}
location = /miniprogram/assets/default.json {
expires 1h;
add_header Cache-Control "public, must-revalidate" always;
try_files $uri =404;
}
location = /miniprogram/assets/route-config.json {
expires 30s;
add_header Cache-Control "public, must-revalidate" always;
try_files $uri =404;
}
# 字体文件:长缓存
location ~* \.(ttf|otf|woff|woff2|eot)$ {
expires 30d;
add_header Cache-Control "public, immutable" always;
try_files $uri =404;
}
# -------------------- Web 静态应用frontend/dist --------------------
# Vite 构建产物目录:/assets/*
location ^~ /assets/ {
expires 30d;
add_header Cache-Control "public, immutable" always;
try_files /frontend/dist$uri =404;
}
# 常见入口文件(精确匹配)
location = /index.html {
try_files /frontend/dist/index.html =404;
}
location = /default.json {
try_files /frontend/dist/default.json =404;
}
location = /favicon.ico {
try_files /frontend/dist/favicon.ico =404;
}
location = /favicon.png {
try_files /frontend/dist/favicon.png =404;
}
location = /favicon.svg {
try_files /frontend/dist/favicon.svg =404;
}
location = /favicon_new.png {
try_files /frontend/dist/favicon_new.png =404;
}
# Web SPA 路由回退(刷新任意前端路径时返回 index.html
# 为 Web 单独指定 root避免 try_files 内部重定向循环导致 500。
location / {
root /Users/gavin/font2svg/frontend/dist;
try_files $uri $uri/ /index.html;
}
# 禁止访问隐藏文件
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}

107
fonts.conf Normal file
View File

@@ -0,0 +1,107 @@
# Font2SVG - Nginx 配置fonts.biboer.cn
# 用途:为微信小程序提供静态字体资源 + 远端 SVG 渲染 API
server {
listen 80;
listen [::]:80;
server_name fonts.biboer.cn;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name fonts.biboer.cn;
# Certbot 证书
ssl_certificate /etc/letsencrypt/live/fonts.biboer.cn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/fonts.biboer.cn/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# 静态资源根目录(需包含 fonts.json 与 fonts/ 目录)
root /home/gavin/font2svg;
index fonts.json;
access_log /var/log/nginx/font2svg_access.log;
error_log /var/log/nginx/font2svg_error.log;
server_tokens off;
# 小程序跨域访问
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET,HEAD,POST,OPTIONS" always;
add_header Access-Control-Allow-Headers "Origin,Range,Accept,Content-Type,Authorization" always;
add_header Access-Control-Expose-Headers "Content-Length,Content-Range" always;
# MIME
types {
application/json json;
font/ttf ttf;
font/otf otf;
font/woff woff;
font/woff2 woff2;
application/vnd.ms-fontobject eot;
}
# SVG 渲染 API独立 Python 服务systemd 监听 127.0.0.1:9300
location ^~ /api/ {
# 预检请求:直接返回 204CORS 头由 server 级 add_header 提供)
if ($request_method = OPTIONS) {
return 204;
}
proxy_pass http://127.0.0.1:9300;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# 健康检查(可选)
location = /healthz {
proxy_pass http://127.0.0.1:9300/healthz;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# fonts.json短缓存便于更新
location = /fonts.json {
expires 1h;
add_header Cache-Control "public, must-revalidate" always;
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET,HEAD,POST,OPTIONS" always;
add_header Access-Control-Allow-Headers "Origin,Range,Accept,Content-Type,Authorization" always;
add_header Access-Control-Expose-Headers "Content-Length,Content-Range" always;
try_files $uri =404;
}
# 字体文件:长缓存
location ~* \.(ttf|otf|woff|woff2|eot)$ {
expires 30d;
add_header Cache-Control "public, immutable" always;
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET,HEAD,POST,OPTIONS" always;
add_header Access-Control-Allow-Headers "Origin,Range,Accept,Content-Type,Authorization" always;
add_header Access-Control-Expose-Headers "Content-Length,Content-Range" always;
try_files $uri =404;
}
# 默认仅提供静态文件
location / {
try_files $uri =404;
}
# 禁止访问隐藏文件
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}

24
frontend/.gitignore vendored
View File

@@ -1,24 +0,0 @@
# 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
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,5 +1,74 @@
# Vue 3 + TypeScript + Vite # frontend
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more. `frontend/``font2svg` Vue 3 + TypeScript 前端应用,负责字体浏览、文本预览与 SVG/PNG 导出。
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup). ## 技术栈
- Vue 3 + `<script setup>`
- TypeScript
- Vite
- Pinia
- UnoCSS
- opentype.js
- harfbuzzjs当前仅在高级接口中可用
## 本地开发
```bash
pnpm install
pnpm run dev
```
默认地址:`http://localhost:5174`
## 构建与预览
```bash
pnpm run build
pnpm run preview
```
`build` 已包含 `vue-tsc -b` 类型检查。
## 目录说明
```text
frontend/
├── public/
│ ├── fonts/ # 字体文件(由根目录统一维护)
│ └── fonts.json # 字体清单(根目录脚本生成)
├── src/
│ ├── components/ # 页面组件
│ ├── composables/ # 组合式逻辑
│ ├── stores/ # Pinia 状态管理
│ ├── utils/ # 字体加载/排版/导出工具
│ ├── types/ # 类型声明
│ ├── App.vue # 主界面
│ └── main.ts # 应用入口
├── vite.config.ts
└── uno.config.ts
```
## 关键实现
- `src/stores/fontStore.ts`
- 字体、收藏、预览、分类展开状态
- localStorage 持久化
- 字体按需加载与进度回传
- `src/components/SvgPreview.vue`
- 预览防抖
- 批量并发生成
- 交叉观察器懒加载
- 预览几何缓存LRU
- `src/utils/svg-builder.ts`
- 字体文本转 SVG
- 自动按 45 字换行
- `src/utils/download.ts`
- SVG/PNG 下载
- ZIP 打包导出
## 注意事项
- 字体目录唯一来源是 `frontend/public/fonts/`
- 更新字体后需要在仓库根目录执行 `pnpm run prepare-fonts`
- 当前仓库尚未配置前端 lint/test 脚本,后续应在计划中补齐

View File

@@ -0,0 +1 @@
import{_ as v}from"./index-JIsg-Hgz.js";function p(t,e,n="text/plain"){const o=new Blob([t],{type:n});s(o,e)}function s(t,e){const n=URL.createObjectURL(t),o=document.createElement("a");o.href=n,o.download=e,document.body.appendChild(o),o.click(),document.body.removeChild(o),URL.revokeObjectURL(n)}function P(t,e){p(t,e,"image/svg+xml")}async function F(t,e="font2svg-export.zip"){const n=(await v(async()=>{const{default:r}=await import("./jszip.min-D7KnG0-e.js").then(i=>i.j);return{default:r}},[])).default,o=new n;for(const r of t)o.file(r.name,r.content);const a=await o.generateAsync({type:"blob"});s(a,e)}function d(t){if(!t)return null;const e=t.match(/-?\d+(\.\d+)?/);if(!e)return null;const n=Number(e[0]);return Number.isFinite(n)?n:null}function x(t){const n=new DOMParser().parseFromString(t,"image/svg+xml").documentElement,o=d(n.getAttribute("width")),a=d(n.getAttribute("height"));if(o&&a)return{width:o,height:a};const r=n.getAttribute("viewBox");if(r){const i=r.trim().split(/[\s,]+/).map(Number);if(i.length===4&&Number.isFinite(i[2])&&Number.isFinite(i[3]))return{width:Math.max(1,i[2]),height:Math.max(1,i[3])}}return{width:1024,height:1024}}async function _(t,e){const n=x(t),o=e?.scale??1,a=Math.max(1,Math.round((e?.width??n.width)*o)),r=Math.max(1,Math.round((e?.height??n.height)*o)),i=document.createElement("canvas");i.width=a,i.height=r;const l=i.getContext("2d");if(!l)throw new Error("无法创建 PNG 画布");e?.backgroundColor?(l.fillStyle=e.backgroundColor,l.fillRect(0,0,a,r)):l.clearRect(0,0,a,r);const f=new Blob([t],{type:"image/svg+xml;charset=utf-8"}),u=URL.createObjectURL(f);try{const c=new Image;await new Promise((w,b)=>{c.onload=()=>w(),c.onerror=()=>b(new Error("SVG 转 PNG 失败")),c.src=u}),l.drawImage(c,0,0,a,r)}finally{URL.revokeObjectURL(u)}const g=await new Promise(c=>{i.toBlob(c,"image/png")});if(!g)throw new Error("PNG 编码失败");return g}async function R(t,e,n){const o=await _(t,n);s(o,e)}function m(t){return t.replace(/[<>:"/\\|?*\x00-\x1F]/g,"_").replace(/\s+/g,"_").substring(0,200)}function h(t,e){const n=m(Array.from(t).slice(0,8).join(""));return`${m(e.substring(0,20))}_${n}`}function B(t,e){return`${h(t,e)}.svg`}function L(t,e){return`${h(t,e)}.png`}export{_ as convertSvgToPngBlob,s as downloadBlob,F as downloadMultipleFiles,R as downloadPngFromSvg,P as downloadSvg,p as downloadText,L as generatePngFilename,B as generateSvgFilename,m as sanitizeFilename};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 170 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 88 KiB

11
frontend/dist/default.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"inputText": "星程字体转换",
"fontSize": 50,
"textColor": "#dc2626",
"selectedFontIds": [
"0001"
],
"favoriteFontIds": [
"0001"
]
}

BIN
frontend/dist/favicon.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

9
frontend/dist/favicon.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 170 KiB

BIN
frontend/dist/favicon_new.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

17
frontend/dist/index.html vendored Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<!-- 统一使用 favicon_new.png 作为站点图标,避免 Safari 继续优先采用 SVG 或 mask 图标 -->
<link rel="icon" type="image/png" href="/favicon_new.png" />
<link rel="shortcut icon" href="/favicon_new.png" />
<link rel="apple-touch-icon" href="/favicon_new.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Font2SVG - 字体转SVG工具</title>
<script type="module" crossorigin src="/assets/index-JIsg-Hgz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BvZNih7U.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -2,7 +2,10 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <!-- 统一使用 favicon_new.png 作为站点图标,避免 Safari 继续优先采用 SVG 或 mask 图标 -->
<link rel="icon" type="image/png" href="/favicon_new.png" />
<link rel="shortcut icon" href="/favicon_new.png" />
<link rel="apple-touch-icon" href="/favicon_new.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Font2SVG - 字体转SVG工具</title> <title>Font2SVG - 字体转SVG工具</title>
</head> </head>

View File

@@ -0,0 +1,11 @@
{
"inputText": "星程字体转换",
"fontSize": 50,
"textColor": "#dc2626",
"selectedFontIds": [
"0001"
],
"favoriteFontIds": [
"0001"
]
}

BIN
frontend/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -1,163 +0,0 @@
[
{
"id": "其他字体/AlimamaDaoLiTi",
"name": "AlimamaDaoLiTi",
"filename": "AlimamaDaoLiTi.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/AlimamaDaoLiTi.ttf"
},
{
"id": "其他字体/Hangeuljaemin4-Regular",
"name": "Hangeuljaemin4-Regular",
"filename": "Hangeuljaemin4-Regular.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/Hangeuljaemin4-Regular.ttf"
},
{
"id": "其他字体/I.顏體",
"name": "I.顏體",
"filename": "I.顏體.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/I.顏體.ttf"
},
{
"id": "其他字体/XCDUANZHUANGSONGTI",
"name": "XCDUANZHUANGSONGTI",
"filename": "XCDUANZHUANGSONGTI.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/XCDUANZHUANGSONGTI.ttf"
},
{
"id": "其他字体/qiji-combo",
"name": "qiji-combo",
"filename": "qiji-combo.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/qiji-combo.ttf"
},
{
"id": "其他字体/临海隶书",
"name": "临海隶书",
"filename": "临海隶书.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/临海隶书.ttf"
},
{
"id": "其他字体/京華老宋体_KingHwa_OldSong",
"name": "京華老宋体_KingHwa_OldSong",
"filename": "京華老宋体_KingHwa_OldSong.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/京華老宋体_KingHwa_OldSong.ttf"
},
{
"id": "其他字体/优设标题黑",
"name": "优设标题黑",
"filename": "优设标题黑.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/优设标题黑.ttf"
},
{
"id": "其他字体/包图小白体",
"name": "包图小白体",
"filename": "包图小白体.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/包图小白体.ttf"
},
{
"id": "其他字体/源界明朝",
"name": "源界明朝",
"filename": "源界明朝.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/源界明朝.ttf"
},
{
"id": "其他字体/演示佛系体",
"name": "演示佛系体",
"filename": "演示佛系体.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/演示佛系体.ttf"
},
{
"id": "其他字体/站酷快乐体",
"name": "站酷快乐体",
"filename": "站酷快乐体.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/站酷快乐体.ttf"
},
{
"id": "其他字体/问藏书房",
"name": "问藏书房",
"filename": "问藏书房.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/问藏书房.ttf"
},
{
"id": "其他字体/霞鹜臻楷",
"name": "霞鹜臻楷",
"filename": "霞鹜臻楷.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/霞鹜臻楷.ttf"
},
{
"id": "庞门正道/庞门正道标题体",
"name": "庞门正道标题体",
"filename": "庞门正道标题体.ttf",
"category": "庞门正道",
"path": "/fonts/庞门正道/庞门正道标题体.ttf"
},
{
"id": "王漢宗/王漢宗勘亭流繁",
"name": "王漢宗勘亭流繁",
"filename": "王漢宗勘亭流繁.ttf",
"category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗勘亭流繁.ttf"
},
{
"id": "王漢宗/王漢宗新潮體",
"name": "王漢宗新潮體",
"filename": "王漢宗新潮體.ttf",
"category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗新潮體.ttf"
},
{
"id": "王漢宗/王漢宗波卡體空陰",
"name": "王漢宗波卡體空陰",
"filename": "王漢宗波卡體空陰.ttf",
"category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗波卡體空陰.ttf"
},
{
"id": "王漢宗/王漢宗細黑體繁",
"name": "王漢宗細黑體繁",
"filename": "王漢宗細黑體繁.ttf",
"category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗細黑體繁.ttf"
},
{
"id": "王漢宗/王漢宗綜藝體雙空陰",
"name": "王漢宗綜藝體雙空陰",
"filename": "王漢宗綜藝體雙空陰.ttf",
"category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗綜藝體雙空陰.ttf"
},
{
"id": "王漢宗/王漢宗超明體繁",
"name": "王漢宗超明體繁",
"filename": "王漢宗超明體繁.ttf",
"category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗超明體繁.ttf"
},
{
"id": "王漢宗/王漢宗酷儷海報",
"name": "王漢宗酷儷海報",
"filename": "王漢宗酷儷海報.ttf",
"category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗酷儷海報.ttf"
},
{
"id": "王漢宗/王漢宗顏楷體繁",
"name": "王漢宗顏楷體繁",
"filename": "王漢宗顏楷體繁.ttf",
"category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗顏楷體繁.ttf"
}
]

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref } from 'vue'
import { useFontLoader } from './composables/useFontLoader' import { useFontLoader } from './composables/useFontLoader'
import { useUiStore } from './stores/uiStore' import { useUiStore } from './stores/uiStore'
import { useFontStore } from './stores/fontStore' import { useFontStore } from './stores/fontStore'
@@ -7,17 +7,47 @@ import { MAX_CHARS_PER_LINE, wrapTextByChars } from './utils/text-layout'
import FontSelector from './components/FontSelector.vue' import FontSelector from './components/FontSelector.vue'
import FavoritesList from './components/FavoritesList.vue' import FavoritesList from './components/FavoritesList.vue'
import SvgPreview from './components/SvgPreview.vue' import SvgPreview from './components/SvgPreview.vue'
import selectAllIcon from './assets/icons/selectall.svg'
import unselectAllIcon from './assets/icons/unselectall.svg'
console.log('App.vue: script setup running...') console.log('App.vue: script setup running...')
const uiStore = useUiStore() const uiStore = useUiStore()
const fontStore = useFontStore() const fontStore = useFontStore()
type SvgPreviewExpose = {
toggleSelectAllPreviewItems: () => void
}
const svgPreviewRef = ref<SvgPreviewExpose | null>(null)
const showWeixinQrModal = ref(false)
const fontSizePercent = computed(() => { const fontSizePercent = computed(() => {
const raw = ((uiStore.fontSize - 10) / (500 - 10)) * 100 const raw = ((uiStore.fontSize - 10) / (500 - 10)) * 100
return Math.max(0, Math.min(100, raw)) return Math.max(0, Math.min(100, raw))
}) })
const isAllPreviewSelected = computed(() => {
const previewIds = fontStore.previewFonts.map(font => font.id)
if (previewIds.length === 0) {
return false
}
const selectedIds = new Set(uiStore.selectedExportItems.map(item => item.fontInfo.id))
return previewIds.every(id => selectedIds.has(id))
})
const isAllFavoriteSelected = computed(() => {
const favoriteIds = fontStore.favoriteFonts.map(font => font.id)
if (favoriteIds.length === 0) {
return false
}
return favoriteIds.every(id => fontStore.previewFontIds.has(id))
})
const favoriteFontCount = computed(() => fontStore.favoriteFonts.length)
// 加载字体列表 // 加载字体列表
try { try {
useFontLoader() useFontLoader()
@@ -49,7 +79,7 @@ async function handleExport(format: ExportFormat) {
} }
try { try {
const { generateSvg } = await import('./utils/svg-builder') const { renderSvgByApi } = await import('./utils/render-api')
const { const {
convertSvgToPngBlob, convertSvgToPngBlob,
downloadSvg, downloadSvg,
@@ -59,22 +89,27 @@ async function handleExport(format: ExportFormat) {
generateSvgFilename, generateSvgFilename,
} = await import('./utils/download') } = await import('./utils/download')
const renderByApi = async (fontId: string) => {
return renderSvgByApi({
fontId,
text: inputText,
fontSize: uiStore.fontSize,
fillColor: uiStore.textColor,
letterSpacing: Number(uiStore.letterSpacing) || 0,
maxCharsPerLine: MAX_CHARS_PER_LINE,
})
}
if (selectedItems.length === 1) { if (selectedItems.length === 1) {
// 单个字体,直接下载 SVG // 单个字体,直接下载 SVG
const item = selectedItems[0] const item = selectedItems[0]
if (!item?.fontInfo.font) { const fontId = item?.fontInfo?.id
alert('选中字体未加载完成,请稍后重试') if (!fontId) {
alert('选中字体信息无效,请重新选择后重试')
return return
} }
const font = item.fontInfo.font const svgResult = await renderByApi(fontId)
const svgResult = await generateSvg({
text: inputText,
font,
fontSize: uiStore.fontSize,
fillColor: uiStore.textColor,
letterSpacing: 0
})
if (format === 'svg') { if (format === 'svg') {
const filename = generateSvgFilename(inputText, svgResult.fontName) const filename = generateSvgFilename(inputText, svgResult.fontName)
@@ -93,19 +128,13 @@ async function handleExport(format: ExportFormat) {
for (const item of selectedItems) { for (const item of selectedItems) {
try { try {
const font = item.fontInfo.font const fontId = item?.fontInfo?.id
if (!font) { if (!fontId) {
console.warn(`字体 ${item.fontInfo.name} 尚未加载,已跳过导出`) console.warn('发现无效字体项,已跳过')
continue continue
} }
const svgResult = await generateSvg({ const svgResult = await renderByApi(fontId)
text: inputText,
font,
fontSize: uiStore.fontSize,
fillColor: uiStore.textColor,
letterSpacing: 0
})
if (format === 'svg') { if (format === 'svg') {
const filename = generateSvgFilename(inputText, svgResult.fontName) const filename = generateSvgFilename(inputText, svgResult.fontName)
@@ -164,25 +193,43 @@ function handleTextInput(event: Event) {
uiStore.setInputText(wrappedText) uiStore.setInputText(wrappedText)
} }
function handleTogglePreviewSelectAll() {
svgPreviewRef.value?.toggleSelectAllPreviewItems()
}
function handleToggleFavoriteSelectAll() {
const favoriteIds = fontStore.favoriteFonts.map(font => font.id)
if (favoriteIds.length === 0) {
return
}
if (isAllFavoriteSelected.value) {
favoriteIds.forEach(id => fontStore.removeFromPreview(id))
return
}
favoriteIds.forEach(id => fontStore.addToPreview(id))
}
console.log('App.vue: script setup completed') console.log('App.vue: script setup completed')
</script> </script>
<template> <template>
<div class="w-screen h-screen box-border p-8px bg-white flex flex-col overflow-hidden"> <div class="app-container w-screen h-screen box-border p-8px bg-white flex flex-col overflow-hidden">
<!-- Frame 7: 顶部工具栏 --> <!-- Frame 7: 顶部工具栏 -->
<div class="flex gap-2 items-center shrink-0 h-24 px-2 py-1"> <div class="header-row flex gap-2 items-center shrink-0 h-24 px-2 py-1">
<!-- webicon - 48x48 --> <!-- webicon - 48x48 -->
<div class="w-12 h-12 rounded-xl overflow-hidden shrink-0"> <div class="logo-box w-12 h-12 rounded-xl overflow-hidden shrink-0">
<img src="./assets/webicon.svg" alt="logo" class="w-full h-full object-cover" /> <img src="./assets/webicon.svg" alt="logo" class="w-full h-full object-cover" />
</div> </div>
<!-- 星程字体转换 - 弹性宽度 --> <!-- 星程字体转换 - 弹性宽度 (移动端隐藏) -->
<div class="shrink-0 max-w-[225px] min-w-[120px]" style="height: 72px;"> <div class="app-title shrink-0 max-w-[225px] min-w-[120px]" style="height: 72px;">
<img src="./assets/icons/星程字体转换.svg" alt="星程SVG文字生成 TEXT to SVG" class="w-full h-full object-contain" /> <img src="./assets/icons/星程字体转换.svg" alt="星程SVG文字生成 TEXT to SVG" class="w-full h-full object-contain" />
</div> </div>
<!-- slider - 增加宽度 --> <!-- slider - 增加宽度 -->
<div class="flex items-center gap-3 px-2 shrink-0 relative" style="width: 280px; height: 32px;"> <div class="slider-box flex items-center gap-3 px-2 shrink-0 relative" style="width: 280px; height: 32px;">
<button <button
@click="handleFontSizeChange(uiStore.fontSize - 10)" @click="handleFontSizeChange(uiStore.fontSize - 10)"
class="w-4 h-4 shrink-0 cursor-pointer hover:opacity-70 transition-opacity flex items-center justify-center p-0 border-0 bg-transparent" class="w-4 h-4 shrink-0 cursor-pointer hover:opacity-70 transition-opacity flex items-center justify-center p-0 border-0 bg-transparent"
@@ -234,8 +281,8 @@ console.log('App.vue: script setup completed')
</label> </label>
</div> </div>
<!-- Frame 14: 输入框 - 弹性宽度 --> <!-- Frame 14: 输入框 - 弹性宽度 (桌面端显示) -->
<div class="flex-1 min-w-[80px] bg-[#f7f8fa] rounded-lg px-2 py-1 h-12"> <div class="input-box-desktop flex-1 min-w-[80px] bg-[#f7f8fa] rounded-lg px-2 py-1 h-12">
<textarea <textarea
:value="uiStore.inputText" :value="uiStore.inputText"
@input="handleTextInput" @input="handleTextInput"
@@ -245,14 +292,14 @@ console.log('App.vue: script setup completed')
</div> </div>
<!-- Export Group --> <!-- Export Group -->
<div class="flex items-center gap-1 shrink-0 border border-[#8552A1] rounded-lg px-1 py-1 bg-[#f7f8fa] shadow-sm"> <div class="export-group flex items-center gap-1 shrink-0 border border-[#8552A1] rounded-lg px-1 py-1 bg-[#f7f8fa] shadow-sm">
<div class="w-[18px] h-[42px] shrink-0 pointer-events-none"> <div class="export-label w-[18px] h-[42px] shrink-0 pointer-events-none">
<img src="./assets/icons/export.svg" alt="导出" class="w-full h-full object-contain" /> <img src="./assets/icons/export.svg" alt="导出" class="w-full h-full object-contain" />
</div> </div>
<button <button
@click="handleExport('svg')" @click="handleExport('svg')"
class="w-12 h-12 shrink-0 cursor-pointer hover:opacity-85 transition-opacity flex items-center justify-center p-0 border-0 bg-transparent" class="export-btn w-12 h-12 shrink-0 cursor-pointer hover:opacity-85 transition-opacity flex items-center justify-center p-0 border-0 bg-transparent"
title="导出 SVG" title="导出 SVG"
> >
<img src="./assets/icons/export-svg.svg" alt="导出SVG" class="w-12 h-12 object-contain" /> <img src="./assets/icons/export-svg.svg" alt="导出SVG" class="w-12 h-12 object-contain" />
@@ -260,29 +307,82 @@ console.log('App.vue: script setup completed')
<button <button
@click="handleExport('png')" @click="handleExport('png')"
class="w-12 h-12 shrink-0 cursor-pointer hover:opacity-85 transition-opacity flex items-center justify-center p-0 border-0 bg-transparent" class="export-btn w-12 h-12 shrink-0 cursor-pointer hover:opacity-85 transition-opacity flex items-center justify-center p-0 border-0 bg-transparent"
title="导出 PNG" title="导出 PNG"
> >
<img src="./assets/icons/export-png.svg" alt="导出PNG" class="w-12 h-12 object-contain" /> <img src="./assets/icons/export-png.svg" alt="导出PNG" class="w-12 h-12 object-contain" />
</button> </button>
<button
@click="showWeixinQrModal = true"
class="weixin-btn w-[54px] h-[42px] shrink-0 cursor-pointer hover:opacity-85 transition-opacity p-0 border-0 bg-transparent"
title="微信小程序"
>
<img src="./assets/icons/weixin.svg" alt="微信小程序二维码" class="w-full h-full object-contain" />
</button>
</div>
</div>
<!-- 移动端输入栏 (仅移动端显示) -->
<div class="input-row-mobile hidden px-2 py-1 shrink-0">
<div class="flex-1 bg-[#f7f8fa] rounded-lg px-2 h-[30px]">
<textarea
:value="uiStore.inputText"
@input="handleTextInput"
placeholder="此处输入内容"
class="w-full h-full bg-transparent border-none outline-none text-sm text-[#4e5969] placeholder-[#4e5969] resize-none leading-[30px] overflow-hidden"
/>
</div>
</div>
<!-- 微信小程序二维码弹窗 -->
<div
v-if="showWeixinQrModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
@click.self="showWeixinQrModal = false"
>
<div class="bg-white rounded-xl p-6 shadow-xl relative max-w-[320px]">
<button
@click="showWeixinQrModal = false"
class="absolute top-2 right-2 w-8 h-8 flex items-center justify-center text-gray-400 hover:text-gray-600 transition-colors border-0 bg-transparent cursor-pointer text-xl"
title="关闭"
>
&times;
</button>
<h3 class="text-center text-gray-800 font-medium mb-4">微信扫码使用小程序</h3>
<img src="./assets/icons/weixin.svg" alt="微信小程序二维码" class="w-64 h-64 object-contain" />
</div> </div>
</div> </div>
<!-- Frame 9: 主内容区 --> <!-- Frame 9: 主内容区 -->
<div class="flex-1 flex gap-2 min-h-0 overflow-hidden px-2"> <div class="main-content flex-1 flex gap-2 min-h-0 overflow-hidden px-2">
<!-- Frame 15: 左侧栏 - 弹性宽度 --> <!-- Frame 15: 左侧栏 - 弹性宽度 (桌面端) -->
<div class="flex flex-col gap-2 shrink-0 overflow-hidden" style="flex-basis: 400px; max-width: 480px; min-width: 320px;"> <div class="sidebar-left flex flex-col gap-2 shrink-0 overflow-hidden" style="flex-basis: 400px; max-width: 480px; min-width: 320px;">
<!-- Frame 5: 字体选择 - 弹性高度 --> <!-- Frame 5: 字体选择 - 弹性高度 -->
<div class="flex-[2] border border-solid border-[#f7e0e0] rounded-lg p-1.5 flex flex-col gap-2 overflow-hidden min-h-0"> <div class="font-selector-box flex-[2] border border-solid border-[#f7e0e0] rounded-lg p-1.5 flex flex-col gap-2 overflow-hidden min-h-0">
<h2 class="text-base text-black shrink-0 leading-none">字体选择</h2>
<div v-overflow-aware class="scrollbar-hover flex-1 overflow-y-auto overflow-x-hidden pr-2"> <div v-overflow-aware class="scrollbar-hover flex-1 overflow-y-auto overflow-x-hidden pr-2">
<FontSelector /> <FontSelector />
</div> </div>
</div> </div>
<!-- Frame 5: 已收藏字体 - 弹性高度 --> <!-- Frame 5: 已收藏字体 - 弹性高度 -->
<div class="border border-solid border-[#f7e0e0] rounded-lg p-1.5 flex flex-col gap-2 flex-1 overflow-hidden min-h-[120px]"> <div class="favorites-box border border-solid border-[#f7e0e0] rounded-lg p-1.5 flex flex-col gap-2 flex-1 overflow-hidden min-h-[120px]">
<h2 class="text-base text-black shrink-0 leading-none">已收藏字体</h2> <div class="flex items-center pr-[9px]">
<h2 class="text-base text-black shrink-0 leading-none flex-1">
已收藏字体{{ favoriteFontCount }}字体
</h2>
<button
@click="handleToggleFavoriteSelectAll"
class="w-4 h-4 shrink-0 p-0 border-0 bg-transparent cursor-pointer hover:opacity-85 transition-opacity"
title="已收藏字体全选/全不选"
>
<img
:src="isAllFavoriteSelected ? unselectAllIcon : selectAllIcon"
alt="已收藏字体全选/全不选"
class="w-full h-full"
/>
</button>
</div>
<div v-overflow-aware class="scrollbar-hover flex-1 overflow-y-auto overflow-x-hidden pr-2"> <div v-overflow-aware class="scrollbar-hover flex-1 overflow-y-auto overflow-x-hidden pr-2">
<FavoritesList /> <FavoritesList />
</div> </div>
@@ -290,16 +390,46 @@ console.log('App.vue: script setup completed')
</div> </div>
<!-- Frame 8: 右侧预览区 - 弹性宽度 --> <!-- Frame 8: 右侧预览区 - 弹性宽度 -->
<div class="flex-1 border border-solid border-[#f7e0e0] rounded-lg p-1.5 flex flex-col gap-2 overflow-hidden min-w-0"> <div class="preview-box flex-1 border border-solid border-[#f7e0e0] rounded-lg p-1.5 flex flex-col gap-2 overflow-hidden min-w-0">
<h2 class="text-base text-black shrink-0 leading-none">效果预览</h2> <div class="flex items-center pr-[9px]">
<h2 class="text-base text-black shrink-0 leading-none flex-1">效果预览</h2>
<button
@click="handleTogglePreviewSelectAll"
class="w-4 h-4 shrink-0 p-0 border-0 bg-transparent cursor-pointer hover:opacity-85 transition-opacity"
title="效果预览全选/全不选"
>
<img
:src="isAllPreviewSelected ? unselectAllIcon : selectAllIcon"
alt="效果预览全选/全不选"
class="w-full h-full"
/>
</button>
</div>
<div v-overflow-aware class="scrollbar-hover flex-1 min-h-0 py-2 overflow-y-auto overflow-x-hidden"> <div v-overflow-aware class="scrollbar-hover flex-1 min-h-0 py-2 overflow-y-auto overflow-x-hidden">
<SvgPreview /> <SvgPreview ref="svgPreviewRef" />
</div>
</div>
<!-- 移动端底部区域: 选择 + 已收藏 并排 -->
<div class="bottom-section-mobile hidden">
<!-- 字体选择 -->
<div class="font-selector-mobile border border-solid border-[#3EE4C3] rounded-lg p-1.5 flex flex-col gap-1 overflow-hidden">
<div v-overflow-aware class="scrollbar-hover flex-1 overflow-y-auto overflow-x-hidden">
<FontSelector />
</div>
</div>
<!-- 已收藏字体 -->
<div class="favorites-mobile border border-solid border-[#3EE4C3] rounded-lg p-1.5 flex flex-col gap-1 overflow-hidden">
<h2 class="text-sm text-black shrink-0 leading-none">已收藏</h2>
<div v-overflow-aware class="scrollbar-hover flex-1 overflow-y-auto overflow-x-hidden">
<FavoritesList />
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- 底部版权 --> <!-- 底部版权 -->
<div class="text-[#86909c] text-xs text-center shrink-0 h-6 pt-4 flex items-center justify-center px-2"> <div class="copyright-footer text-[#86909c] text-xs text-center shrink-0 flex items-center justify-center px-2">
@版权说明所有字体来源互联网分享仅供效果预览不做下载传播如有侵权请告知douboer@gmail.com @版权说明所有字体来源互联网分享仅供效果预览不做下载传播如有侵权请告知douboer@gmail.com
</div> </div>
</div> </div>
@@ -341,4 +471,188 @@ console.log('App.vue: script setup completed')
border-radius: 10px; border-radius: 10px;
background: transparent; background: transparent;
} }
/* 桌面端版权区域 */
.copyright-footer {
height: 24px;
padding-top: 16px;
}
/* 移动端响应式布局 (iOS/Android web) */
@media (max-width: 768px) {
.app-container {
padding: 4px;
overflow: hidden;
}
/* 顶部工具栏 - 压缩 */
.header-row {
height: 48px;
gap: 4px;
padding: 0 4px;
flex-wrap: nowrap;
}
.logo-box {
width: 48px;
height: 48px;
border-radius: 12px;
}
/* 隐藏标题 */
.app-title {
display: none;
}
/* slider 压缩 */
.slider-box {
width: 132px !important;
flex: 1;
min-width: 100px;
gap: 4px;
padding: 0 4px;
height: 24px !important;
}
/* 隐藏桌面端输入框 */
.input-box-desktop {
display: none;
}
/* 显示移动端输入框 */
.input-row-mobile {
display: flex !important;
gap: 6px;
margin-bottom: 4px;
padding-top: 0;
padding-bottom: 0;
}
/* 导出组缩小 */
.export-group {
gap: 2px;
padding: 2px;
}
.export-label {
width: 12px;
height: 28px;
}
.export-btn {
width: 32px;
height: 32px;
}
.export-btn img {
width: 32px;
height: 32px;
}
.weixin-btn {
width: 36px !important;
height: 28px !important;
}
/* 主内容区 - 改为垂直布局 */
.main-content {
flex-direction: column;
gap: 4px;
padding: 0 4px;
overflow: hidden;
}
/* 隐藏桌面端左侧栏 */
.sidebar-left {
display: none;
}
/* 预览区 - 占上半部分 */
.preview-box {
flex: 1;
min-height: 0;
border-color: #3EE4C3;
}
.preview-box h2 {
font-size: 14px;
}
/* 显示移动端底部区域 */
.bottom-section-mobile {
display: flex !important;
flex-direction: row;
gap: 8px;
flex: 1;
min-height: 0;
}
.font-selector-mobile,
.favorites-mobile {
flex: 1;
min-width: 0;
min-height: 0;
}
.font-selector-mobile h2,
.favorites-mobile h2 {
font-size: 14px;
margin-bottom: 4px;
}
/* 移动端字体选择器文字大小统一 */
.font-selector-mobile :deep(.text-\[16px\]) {
font-size: 14px !important;
}
.font-selector-mobile :deep(.text-base) {
font-size: 14px !important;
}
/* 版权信息缩小 */
.copyright-footer {
font-size: 10px;
padding: 4px 0;
height: auto;
flex-shrink: 0;
}
}
/* 更小屏幕适配 (iPhone SE 等) */
@media (max-width: 480px) {
.header-row {
height: 44px;
gap: 2px;
}
.logo-box {
width: 44px;
height: 44px;
}
.slider-box {
width: 100px !important;
min-width: 80px;
}
.export-btn {
width: 28px;
height: 28px;
}
.export-btn img {
width: 28px;
height: 28px;
}
.weixin-btn {
width: 32px !important;
height: 24px !important;
}
/* 手机端底部区域垂直布局 */
.bottom-section-mobile {
flex-direction: column;
}
}
</style> </style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#8552A1" rx="10" transform="matrix(-1 0 0 1 32 0)"/>
<path fill="#FEFDFE" d="m24.845 22.204-5.101-5.105-2.64 2.64 5.105 5.104a1.085 1.085 0 0 0 1.527 0l1.108-1.108a1.09 1.09 0 0 0 0-1.531ZM17.22 18.6l1.382-1.382-1.576-1.576a5.512 5.512 0 0 0-.63-7.032 5.51 5.51 0 0 0-7.785 0c-2.15 2.146-2.146 5.635 0 7.785a5.512 5.512 0 0 0 7.033.63l1.575 1.576Zm-7.541-3.288a3.983 3.983 0 0 1 0-5.635 3.983 3.983 0 0 1 5.635 0 3.983 3.983 0 0 1 0 5.635 3.983 3.983 0 0 1-5.635 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 611 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 20 20">
<path fill="#8552A1" d="M15.608 15.302a.717.717 0 0 1 .218.524.712.712 0 0 1-.218.524.729.729 0 0 1-.525.218H4.326a1.78 1.78 0 0 1-1.303-.542 1.78 1.78 0 0 1-.543-1.303V3.963c0-.207.073-.383.218-.525a.728.728 0 0 1 .525-.218c.203 0 .382.072.524.218a.728.728 0 0 1 .218.524v10.015c0 .303.11.56.327.78.219.217.473.326.776.326h10.015c.206 0 .38.073.525.219Zm-2.97 2.227a.717.717 0 0 1 .218.524.712.712 0 0 1-.218.524.729.729 0 0 1-.524.218H2.098a1.78 1.78 0 0 1-1.303-.542 1.785 1.785 0 0 1-.545-1.306V6.932c0-.206.073-.382.218-.524a.728.728 0 0 1 .524-.219c.203 0 .382.073.525.219a.728.728 0 0 1 .218.524v9.272c0 .304.109.561.327.78.218.217.476.327.779.327h9.273c.203 0 .378.072.524.218ZM18.153.892c.427.43.643.952.643 1.567v9.67c0 .615-.216 1.14-.643 1.566a2.136 2.136 0 0 1-1.567.643H6.914c-.616 0-1.14-.215-1.567-.643a2.129 2.129 0 0 1-.642-1.563V2.459c0-.615.215-1.14.642-1.567A2.136 2.136 0 0 1 6.914.25h9.67c.617 0 1.139.215 1.569.642Zm-.842 2.164c0-.364-.13-.673-.388-.933a1.263 1.263 0 0 0-.934-.388H7.514c-.364 0-.673.13-.934.388a1.27 1.27 0 0 0-.39.933v8.476c0 .364.13.672.387.933.258.26.57.388.934.388h8.475c.364 0 .673-.13.934-.388.26-.257.388-.57.388-.933V3.056h.003Z"/>
<path fill="#8552A1" d="M10.114 9.583 7.966 7.61l-.716.658 2.864 2.633L16.25 5.26l-.716-.659-5.42 4.983Z"/>
<path stroke="#8552A1" stroke-width=".5" d="M15.608 15.302a.717.717 0 0 1 .218.524.712.712 0 0 1-.218.524.729.729 0 0 1-.525.218H4.326a1.78 1.78 0 0 1-1.303-.542 1.78 1.78 0 0 1-.543-1.303V3.963c0-.207.073-.383.218-.525a.728.728 0 0 1 .525-.218c.203 0 .382.072.524.218a.728.728 0 0 1 .218.524v10.015c0 .303.11.56.327.78.219.217.473.326.776.326h10.015c.206 0 .38.073.525.219Zm-2.97 2.227a.717.717 0 0 1 .218.524.712.712 0 0 1-.218.524.729.729 0 0 1-.524.218H2.098a1.78 1.78 0 0 1-1.303-.542 1.785 1.785 0 0 1-.545-1.306V6.932c0-.206.073-.382.218-.524a.728.728 0 0 1 .524-.219c.203 0 .382.073.525.219a.728.728 0 0 1 .218.524v9.272c0 .304.109.561.327.78.218.217.476.327.779.327h9.273c.203 0 .378.072.524.218ZM18.153.892c.427.43.643.952.643 1.567v9.67c0 .615-.216 1.14-.643 1.566a2.136 2.136 0 0 1-1.567.643H6.914c-.616 0-1.14-.215-1.567-.643a2.129 2.129 0 0 1-.642-1.563V2.459c0-.615.215-1.14.642-1.567A2.136 2.136 0 0 1 6.914.25h9.67c.617 0 1.139.215 1.569.642Zm-.842 2.164c0-.364-.13-.673-.388-.933a1.263 1.263 0 0 0-.934-.388H7.514c-.364 0-.673.13-.934.388a1.27 1.27 0 0 0-.39.933v8.476c0 .364.13.672.387.933.258.26.57.388.934.388h8.475c.364 0 .673-.13.934-.388.26-.257.388-.57.388-.933V3.056h.003Z"/>
<path stroke="#8552A1" stroke-width=".5" d="M10.114 9.583 7.966 7.61l-.716.658 2.864 2.633L16.25 5.26l-.716-.659-5.42 4.983Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 20 20">
<path fill="#8552A1" d="M15.608 15.302a.717.717 0 0 1 .218.524.712.712 0 0 1-.218.524.729.729 0 0 1-.525.218H4.326a1.78 1.78 0 0 1-1.303-.542 1.78 1.78 0 0 1-.543-1.303V3.963c0-.207.073-.383.218-.525a.728.728 0 0 1 .525-.218c.203 0 .382.072.524.218a.728.728 0 0 1 .218.524v10.015c0 .303.11.56.327.78.219.217.473.326.776.326h10.015c.206 0 .38.073.525.219Zm-2.97 2.227a.717.717 0 0 1 .218.524.712.712 0 0 1-.218.524.729.729 0 0 1-.524.218H2.098a1.78 1.78 0 0 1-1.303-.542 1.785 1.785 0 0 1-.545-1.306V6.932c0-.206.073-.382.218-.524a.728.728 0 0 1 .524-.219c.203 0 .382.073.525.219a.728.728 0 0 1 .218.524v9.272c0 .304.109.561.327.78.218.217.476.327.779.327h9.273c.203 0 .378.072.524.218ZM18.153.892c.427.43.643.952.643 1.567v9.67c0 .615-.216 1.14-.643 1.566a2.136 2.136 0 0 1-1.567.643H6.914c-.616 0-1.14-.215-1.567-.643a2.129 2.129 0 0 1-.642-1.563V2.459c0-.615.215-1.14.642-1.567A2.136 2.136 0 0 1 6.914.25h9.67c.617 0 1.139.215 1.569.642Zm-.842 2.164c0-.364-.13-.673-.388-.933a1.263 1.263 0 0 0-.934-.388H7.514c-.364 0-.673.13-.934.388a1.27 1.27 0 0 0-.39.933v8.476c0 .364.13.672.387.933.258.26.57.388.934.388h8.475c.364 0 .673-.13.934-.388.26-.257.388-.57.388-.933V3.056h.003Z"/>
<path fill="#8552A1" d="m15.523 4.546-.806-.705-3.194 2.795L8.328 3.84l-.805.705 3.194 2.795-3.194 2.795.805.705 3.195-2.795 3.194 2.795.806-.705-3.195-2.795 3.195-2.795Z"/>
<path stroke="#8552A1" stroke-width=".5" d="M15.608 15.302a.717.717 0 0 1 .218.524.712.712 0 0 1-.218.524.729.729 0 0 1-.525.218H4.326a1.78 1.78 0 0 1-1.303-.542 1.78 1.78 0 0 1-.543-1.303V3.963c0-.207.073-.383.218-.525a.728.728 0 0 1 .525-.218c.203 0 .382.072.524.218a.728.728 0 0 1 .218.524v10.015c0 .303.11.56.327.78.219.217.473.326.776.326h10.015c.206 0 .38.073.525.219Zm-2.97 2.227a.717.717 0 0 1 .218.524.712.712 0 0 1-.218.524.729.729 0 0 1-.524.218H2.098a1.78 1.78 0 0 1-1.303-.542 1.785 1.785 0 0 1-.545-1.306V6.932c0-.206.073-.382.218-.524a.728.728 0 0 1 .524-.219c.203 0 .382.073.525.219a.728.728 0 0 1 .218.524v9.272c0 .304.109.561.327.78.218.217.476.327.779.327h9.273c.203 0 .378.072.524.218ZM18.153.892c.427.43.643.952.643 1.567v9.67c0 .615-.216 1.14-.643 1.566a2.136 2.136 0 0 1-1.567.643H6.914c-.616 0-1.14-.215-1.567-.643a2.129 2.129 0 0 1-.642-1.563V2.459c0-.615.215-1.14.642-1.567A2.136 2.136 0 0 1 6.914.25h9.67c.617 0 1.139.215 1.569.642Zm-.842 2.164c0-.364-.13-.673-.388-.933a1.263 1.263 0 0 0-.934-.388H7.514c-.364 0-.673.13-.934.388a1.27 1.27 0 0 0-.39.933v8.476c0 .364.13.672.387.933.258.26.57.388.934.388h8.475c.364 0 .673-.13.934-.388.26-.257.388-.57.388-.933V3.056h.003Z"/>
<path stroke="#8552A1" stroke-width=".5" d="m15.523 4.546-.806-.705-3.194 2.795L8.328 3.84l-.805.705 3.194 2.795-3.194 2.795.805.705 3.195-2.795 3.194 2.795.806-.705-3.195-2.795 3.195-2.795Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 473 KiB

After

Width:  |  Height:  |  Size: 556 KiB

BIN
frontend/src/assets/xcs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -1,18 +1,92 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref } from 'vue'
import { useFontStore } from '../stores/fontStore' import { useFontStore } from '../stores/fontStore'
import FontTree from './FontTree.vue' import FontTree from './FontTree.vue'
import searchIcon from '../assets/icons/search.svg'
const fontStore = useFontStore() const fontStore = useFontStore()
const searchKeyword = ref('')
const fontTree = computed(() => fontStore.fontTree) const fontTree = computed(() => fontStore.fontTree)
const normalizedSearchKeyword = computed(() => searchKeyword.value.trim().toLowerCase())
const isSelectedOnlyMode = computed(() => {
const keyword = normalizedSearchKeyword.value
return keyword.includes('选中') || keyword.includes('选择') || keyword.includes('已选') || keyword.includes('xuan')
})
const normalizedNameSearchKeyword = computed(() => {
return isSelectedOnlyMode.value ? '' : normalizedSearchKeyword.value
})
const hasSearchKeyword = computed(() => normalizedSearchKeyword.value.length > 0)
function nodeHasMatch(node: (typeof fontTree.value)[number]): boolean {
if (node.type !== 'category') {
return false
}
const fontChildren = (node.children ?? []).filter((child) => {
return child.type === 'font' && !!child.fontInfo
})
const selectedFilteredChildren = isSelectedOnlyMode.value
? fontChildren.filter(child => !!child.fontInfo && fontStore.previewFontIds.has(child.fontInfo.id))
: fontChildren
if (normalizedNameSearchKeyword.value.length === 0) {
return selectedFilteredChildren.length > 0
}
const keyword = normalizedNameSearchKeyword.value
if (node.name.toLowerCase().includes(keyword)) {
return selectedFilteredChildren.length > 0
}
return selectedFilteredChildren.some(child => child.name.toLowerCase().includes(keyword))
}
const hasMatchedFonts = computed(() => {
if (!hasSearchKeyword.value) {
return fontTree.value.length > 0
}
return fontTree.value.some(node => nodeHasMatch(node))
})
</script> </script>
<template> <template>
<div class="space-y-2"> <div class="space-y-2 pb-1">
<div v-if="fontTree.length === 0" class="text-sm text-gray-500 text-center py-8"> <div class="sticky top-0 z-10 bg-white pt-1 pb-1">
暂无字体 <div class="flex items-center gap-3">
<div class="text-[16px] leading-none text-black font-bold shrink-0">
选择字体
</div>
<div class="flex-1 min-w-0">
<div class="h-8 rounded-[10px] bg-[#F3EDF7] pl-2 flex items-center">
<input
v-model="searchKeyword"
type="text"
placeholder="输入搜索字体名称"
aria-label="字体搜索"
class="flex-1 min-w-0 bg-transparent border-none outline-none text-[14px] text-black placeholder-[#a2a0a9]"
/>
<button
type="button"
class="w-8 h-8 shrink-0 p-0 border-0 bg-transparent flex items-center justify-center"
aria-hidden="true"
>
<img :src="searchIcon" alt="" class="w-[24px] h-[24px]" />
</button>
</div>
</div>
</div>
</div> </div>
<FontTree v-else :nodes="fontTree" />
<div v-if="!hasMatchedFonts" class="text-sm text-gray-500 text-center py-8">
{{ hasSearchKeyword ? '未找到匹配字体' : '暂无字体' }}
</div>
<FontTree
v-else
:nodes="fontTree"
:search-keyword="normalizedNameSearchKeyword"
:selected-only="isSelectedOnlyMode"
/>
</div> </div>
</template> </template>

View File

@@ -1,14 +1,59 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import selectAllIcon from '../assets/icons/selectall.svg'
import unselectAllIcon from '../assets/icons/unselectall.svg'
import type { FontTreeNode } from '../types/font' import type { FontTreeNode } from '../types/font'
import { useFontStore } from '../stores/fontStore' import { useFontStore } from '../stores/fontStore'
const props = defineProps<{ const props = defineProps<{
nodes: FontTreeNode[] nodes: FontTreeNode[]
searchKeyword?: string
selectedOnly?: boolean
}>() }>()
const fontStore = useFontStore() const fontStore = useFontStore()
const normalizedSearchKeyword = computed(() => (props.searchKeyword ?? '').trim().toLowerCase())
const isSearchMode = computed(() => normalizedSearchKeyword.value.length > 0)
const isFilterMode = computed(() => isSearchMode.value || props.selectedOnly === true)
type FontLeafNode = FontTreeNode & { fontInfo: NonNullable<FontTreeNode['fontInfo']> }
function getVisibleChildren(node: FontTreeNode): FontLeafNode[] {
if (node.type !== 'category' || !node.children) {
return []
}
const fontChildren = node.children.filter(
(child): child is FontLeafNode => child.type === 'font' && !!child.fontInfo,
)
const selectedFilteredChildren = props.selectedOnly
? fontChildren.filter(child => fontStore.previewFontIds.has(child.fontInfo.id))
: fontChildren
if (!isSearchMode.value) {
return selectedFilteredChildren
}
const keyword = normalizedSearchKeyword.value
if (node.name.toLowerCase().includes(keyword)) {
return selectedFilteredChildren
}
return selectedFilteredChildren.filter(child => child.name.toLowerCase().includes(keyword))
}
function shouldRenderCategory(node: FontTreeNode): boolean {
return node.type === 'category' && getVisibleChildren(node).length > 0
}
function isCategoryExpanded(node: FontTreeNode): boolean {
return isFilterMode.value ? true : !!node.expanded
}
function toggleExpand(node: FontTreeNode) { function toggleExpand(node: FontTreeNode) {
if (isFilterMode.value) {
return
}
const next = !node.expanded const next = !node.expanded
node.expanded = next node.expanded = next
fontStore.setCategoryExpanded(node.name, next) fontStore.setCategoryExpanded(node.name, next)
@@ -35,25 +80,55 @@ function isFavorite(node: FontTreeNode): boolean {
function isInPreview(node: FontTreeNode): boolean { function isInPreview(node: FontTreeNode): boolean {
return node.type === 'font' && node.fontInfo ? fontStore.previewFontIds.has(node.fontInfo.id) : false return node.type === 'font' && node.fontInfo ? fontStore.previewFontIds.has(node.fontInfo.id) : false
} }
function getCategoryFontIds(node: FontTreeNode): string[] {
return getVisibleChildren(node).map(child => child.fontInfo.id)
}
function getCategoryFontCount(node: FontTreeNode): number {
return getVisibleChildren(node).length
}
function isCategoryAllInPreview(node: FontTreeNode): boolean {
const ids = getCategoryFontIds(node)
return ids.length > 0 && ids.every(id => fontStore.previewFontIds.has(id))
}
function handleCategorySelectAll(node: FontTreeNode, event: Event) {
event.stopPropagation()
const ids = getCategoryFontIds(node)
if (ids.length === 0) {
return
}
if (isCategoryAllInPreview(node)) {
ids.forEach(id => fontStore.removeFromPreview(id))
} else {
ids.forEach(id => fontStore.addToPreview(id))
}
}
</script> </script>
<template> <template>
<div class="space-y-0"> <div class="space-y-0">
<div v-for="node in nodes" :key="node.name"> <div v-for="node in nodes" :key="node.name">
<!-- 分类节点 --> <!-- 分类节点 -->
<div v-if="node.type === 'category'" class="relative mb-3"> <div v-if="shouldRenderCategory(node)" class="relative mb-3">
<div class="flex items-center"> <div class="flex items-center gap-2">
<!-- 左侧展开图标 --> <!-- 左侧展开图标 -->
<div class="tree-icon-wrapper"> <div class="tree-icon-wrapper">
<button <button
@click="toggleExpand(node)" @click="toggleExpand(node)"
class="tree-toggle" class="tree-toggle"
:disabled="isFilterMode"
> >
<img <img
v-if="node.expanded" v-if="isCategoryExpanded(node)"
src="../assets/icons/zhedie.svg" src="../assets/icons/zhedie.svg"
alt="收起" alt="收起"
class="w-[15px] h-[15px]" class="w-[15px] h-[15px]"
:class="{ 'opacity-70': isFilterMode }"
/> />
<img <img
v-else v-else
@@ -67,19 +142,35 @@ function isInPreview(node: FontTreeNode): boolean {
<!-- 分类标题 --> <!-- 分类标题 -->
<div <div
@click="toggleExpand(node)" @click="toggleExpand(node)"
class="text-base font-medium text-black cursor-pointer flex-1 ml-2" class="text-base font-medium text-black flex-1 ml-2"
:class="isFilterMode ? 'cursor-default' : 'cursor-pointer'"
> >
{{ node.name }} {{ node.name }}{{ getCategoryFontCount(node) }}字体
</div>
<div class="flex items-center gap-2 shrink-0 mr-[1px]">
<button
@click="handleCategorySelectAll(node, $event)"
class="w-4 h-4 shrink-0 p-0 border-0 bg-transparent cursor-pointer hover:opacity-85 transition-opacity"
title="分类全选/全不选"
>
<img
:src="isCategoryAllInPreview(node) ? unselectAllIcon : selectAllIcon"
alt="分类全选/全不选"
class="w-full h-full"
/>
</button>
<div class="w-[18px] h-[17px] shrink-0" aria-hidden="true"></div>
</div> </div>
</div> </div>
<!-- 竖直连接线 --> <!-- 竖直连接线 -->
<div v-if="node.expanded && node.children" class="tree-vertical-line"></div> <div v-if="isCategoryExpanded(node) && getVisibleChildren(node).length > 0" class="tree-vertical-line"></div>
<!-- 字体列表 --> <!-- 字体列表 -->
<div v-if="node.expanded && node.children" class="flex flex-col gap-3 mt-3"> <div v-if="isCategoryExpanded(node) && getVisibleChildren(node).length > 0" class="flex flex-col gap-3 mt-3">
<div <div
v-for="child in node.children" v-for="child in getVisibleChildren(node)"
:key="child.name" :key="child.name"
class="flex items-center gap-2 border-b border-[#c9cdd4] pb-2 relative" class="flex items-center gap-2 border-b border-[#c9cdd4] pb-2 relative"
> >

View File

@@ -1,98 +1,711 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
import { useFontStore } from '../stores/fontStore' import { useFontStore } from '../stores/fontStore'
import { useUiStore } from '../stores/uiStore' import { useUiStore } from '../stores/uiStore'
import { generateSvg } from '../utils/svg-builder' import { renderSvgByApi } from '../utils/render-api'
import { MAX_CHARS_PER_LINE } from '../utils/text-layout'
import type { PreviewItem as PreviewItemType } from '../types/font' import type { PreviewItem as PreviewItemType } from '../types/font'
import type { SvgGenerateResult, FontInfo } from '../types/font'
const fontStore = useFontStore() const fontStore = useFontStore()
const uiStore = useUiStore() const uiStore = useUiStore()
const previewItems = ref<PreviewItemType[]>([]) interface PreviewApiCacheItem {
svg: string
width: number
height: number
fontName: string
renderFontSize: number
}
interface PreviewRenderItem extends PreviewItemType {
baseSvg: string
baseWidth: number
baseHeight: number
renderFontSize: number
}
const previewItems = ref<PreviewRenderItem[]>([])
const isGenerating = ref(false) const isGenerating = ref(false)
const isBatchGenerating = ref(false)
const activePreviewFonts = ref<FontInfo[]>([])
const processedFontCount = ref(0)
const renderedPreviewCount = ref(0)
const previewTriggerItemEl = ref<HTMLElement | null>(null)
const previewErrorMessage = ref('')
const previewFonts = computed(() => fontStore.previewFonts) const previewFonts = computed(() => fontStore.previewFonts)
const inputText = computed(() => uiStore.inputText) const inputText = computed(() => uiStore.inputText)
const fontSize = computed(() => uiStore.fontSize) const fontSize = computed(() => uiStore.fontSize)
const fillColor = computed(() => uiStore.textColor) const fillColor = computed(() => uiStore.textColor)
watch( const PREVIEW_DEBOUNCE_MS = 240
[previewFonts, inputText, fontSize, fillColor], const PREVIEW_CONCURRENCY = 4
async () => { const PREVIEW_API_CACHE_LIMIT = 600
await generatePreviews() const PREVIEW_BATCH_SIZE = 20
}, const PREVIEW_PREFETCH_OFFSET = 10
{ immediate: true } const PREVIEW_RENDER_FONT_SIZE = 120
)
async function generatePreviews() { let previewGenerateTimer: ReturnType<typeof setTimeout> | null = null
const validPreviewFontIds = new Set(previewFonts.value.map(font => font.id)) let previewGenerationToken = 0
uiStore.retainExportItemsByFontIds(validPreviewFontIds) let hasTriggeredInitialGenerate = false
let previewLazyLoadObserver: IntersectionObserver | null = null
let batchOwnerToken: number | null = null
if (!inputText.value || inputText.value.trim() === '') { const previewApiCache = new Map<string, PreviewApiCacheItem>()
previewItems.value = []
const isAllPreviewSelected = computed(() => {
return previewItems.value.length > 0 && previewItems.value.every(item => item.selected)
})
const hasMorePreviewItems = computed(() => {
return (
renderedPreviewCount.value < previewItems.value.length ||
processedFontCount.value < activePreviewFonts.value.length
)
})
const visiblePreviewItems = computed(() => {
return previewItems.value.slice(0, renderedPreviewCount.value)
})
const hasRenderableInput = computed(() => {
return inputText.value.trim() !== '' && activePreviewFonts.value.length > 0
})
const previewTriggerIndex = computed(() => {
if (!hasMorePreviewItems.value || visiblePreviewItems.value.length <= 0) {
return -1
}
return Math.max(0, visiblePreviewItems.value.length - PREVIEW_PREFETCH_OFFSET)
})
function isStaleGeneration(token: number): boolean {
return token !== previewGenerationToken
}
function scheduleGeneratePreviews(withDebounce = true) {
if (previewGenerateTimer !== null) {
clearTimeout(previewGenerateTimer)
previewGenerateTimer = null
}
if (!withDebounce) {
void regeneratePreviews()
return return
} }
const fonts = previewFonts.value previewGenerateTimer = setTimeout(() => {
if (fonts.length === 0) { previewGenerateTimer = null
void regeneratePreviews()
}, PREVIEW_DEBOUNCE_MS)
}
function findScrollableParent(el: HTMLElement): HTMLElement | null {
let current: HTMLElement | null = el.parentElement
while (current) {
const style = window.getComputedStyle(current)
const overflowY = style.overflowY
if ((overflowY === 'auto' || overflowY === 'scroll') && current.scrollHeight > current.clientHeight) {
return current
}
current = current.parentElement
}
return null
}
function disconnectPreviewLazyLoadObserver() {
if (!previewLazyLoadObserver) {
return
}
previewLazyLoadObserver.disconnect()
previewLazyLoadObserver = null
}
function bindPreviewLazyLoadObserver() {
disconnectPreviewLazyLoadObserver()
if (!hasMorePreviewItems.value || !previewTriggerItemEl.value) {
return
}
const root = findScrollableParent(previewTriggerItemEl.value)
previewLazyLoadObserver = new IntersectionObserver(
(entries) => {
const isVisible = entries.some(entry => entry.isIntersecting)
if (isVisible) {
void handleLoadMoreByScroll()
}
},
{
root,
threshold: 0.01,
},
)
previewLazyLoadObserver.observe(previewTriggerItemEl.value)
}
function setPreviewItemRef(el: unknown, index: number) {
if (index !== previewTriggerIndex.value) {
return
}
previewTriggerItemEl.value = el instanceof HTMLElement ? el : null
}
function normalizeHexColor(input: string, fallback = '#000000'): string {
const value = String(input || '').trim()
if (/^#[0-9a-fA-F]{6}$/.test(value)) {
return value
}
return fallback
}
function clampFontSize(value: number, fallback = PREVIEW_RENDER_FONT_SIZE): number {
if (!Number.isFinite(value)) {
return fallback
}
return Math.max(1, Math.min(2048, Math.round(value)))
}
function formatSvgNumber(value: number): string {
const text = Number(value).toFixed(2)
return text.replace(/\.?0+$/, '')
}
function replaceSvgFillColor(svg: string, color: string): string {
const normalizedColor = normalizeHexColor(color)
if (!svg) return ''
if (/<g\b[^>]*\sfill="[^"]*"/.test(svg)) {
return svg.replace(/(<g\b[^>]*\sfill=")[^"]*(")/, `$1${normalizedColor}$2`)
}
return svg.replace(/<g\b([^>]*)>/, `<g$1 fill="${normalizedColor}">`)
}
function scaleSvgDimensions(svg: string, scale: number): string {
if (!svg || !Number.isFinite(scale) || scale <= 0) {
return svg
}
return svg
.replace(/width="([0-9]+(?:\.[0-9]+)?)"/, (_, width) => {
const scaledWidth = Number(width) * scale
return `width="${formatSvgNumber(scaledWidth)}"`
})
.replace(/height="([0-9]+(?:\.[0-9]+)?)"/, (_, height) => {
const scaledHeight = Number(height) * scale
return `height="${formatSvgNumber(scaledHeight)}"`
})
}
function buildPreviewCacheKey(
fontId: string,
text: string,
letterSpacing: number,
maxCharsPerLine: number,
): string {
const safeSpacing = Number.isFinite(letterSpacing) ? letterSpacing.toFixed(4) : '0.0000'
return [fontId, safeSpacing, String(maxCharsPerLine), text].join('::')
}
function getPreviewApiCacheKey(fontInfo: FontInfo): string {
return buildPreviewCacheKey(
fontInfo.id,
String(inputText.value || ''),
Number(uiStore.letterSpacing) || 0,
MAX_CHARS_PER_LINE,
)
}
function readPreviewFromCache(key: string): PreviewApiCacheItem | null {
const cached = previewApiCache.get(key)
if (!cached) {
return null
}
previewApiCache.delete(key)
previewApiCache.set(key, cached)
return cached
}
function writePreviewToCache(key: string, value: PreviewApiCacheItem) {
if (previewApiCache.has(key)) {
previewApiCache.delete(key)
}
previewApiCache.set(key, value)
while (previewApiCache.size > PREVIEW_API_CACHE_LIMIT) {
const oldestKey = previewApiCache.keys().next().value
if (oldestKey === undefined) {
break
}
previewApiCache.delete(oldestKey)
}
}
function toStyledSvgResult(item: PreviewRenderItem): SvgGenerateResult {
const targetSize = clampFontSize(Number(fontSize.value), PREVIEW_RENDER_FONT_SIZE)
const renderSize = Number(item.renderFontSize) > 0 ? Number(item.renderFontSize) : PREVIEW_RENDER_FONT_SIZE
const scale = targetSize / renderSize
const styledSvg = scaleSvgDimensions(
replaceSvgFillColor(item.baseSvg, fillColor.value),
scale,
)
return {
svg: styledSvg,
width: Number(item.baseWidth) > 0 ? Number(item.baseWidth) * scale : 0,
height: Number(item.baseHeight) > 0 ? Number(item.baseHeight) * scale : 0,
fontName: item.svgResult.fontName || item.fontInfo.name,
}
}
function applyLocalStyleToPreviewItem(item: PreviewRenderItem): PreviewRenderItem {
return {
...item,
svgResult: toStyledSvgResult(item),
}
}
function applyLocalPreviewStyles() {
if (previewItems.value.length === 0) {
return
}
previewItems.value = previewItems.value.map(item => applyLocalStyleToPreviewItem(item))
}
async function getOrRenderPreviewBase(fontInfo: FontInfo): Promise<PreviewApiCacheItem | null> {
const cacheKey = getPreviewApiCacheKey(fontInfo)
const cached = readPreviewFromCache(cacheKey)
if (cached) {
return cached
}
const result = await renderSvgByApi({
fontId: fontInfo.id,
text: inputText.value,
fontSize: PREVIEW_RENDER_FONT_SIZE,
fillColor: '#000000',
letterSpacing: Number(uiStore.letterSpacing) || 0,
maxCharsPerLine: MAX_CHARS_PER_LINE,
})
const base: PreviewApiCacheItem = {
svg: result.svg,
width: result.width,
height: result.height,
fontName: result.fontName || fontInfo.name,
renderFontSize: PREVIEW_RENDER_FONT_SIZE,
}
writePreviewToCache(cacheKey, base)
return base
}
async function generatePreviewBatch(
fonts: FontInfo[],
startIndex: number,
batchSize: number,
generationToken: number,
): Promise<{ items: PreviewRenderItem[]; errors: string[] }> {
const endIndex = Math.min(startIndex + batchSize, fonts.length)
const batchFonts = fonts.slice(startIndex, endIndex)
if (batchFonts.length === 0) {
return { items: [], errors: [] }
}
const selectedFontIdSet = new Set(uiStore.selectedExportItems.map(item => item.fontInfo.id))
const items = new Array<PreviewRenderItem | null>(batchFonts.length).fill(null)
const workerCount = Math.min(PREVIEW_CONCURRENCY, batchFonts.length)
let nextIndex = 0
const errors: string[] = []
const worker = async () => {
while (true) {
if (isStaleGeneration(generationToken)) {
return
}
const localIndex = nextIndex
nextIndex += 1
if (localIndex >= batchFonts.length) {
return
}
const fontInfo = batchFonts[localIndex]
if (!fontInfo) {
continue
}
try {
const base = await getOrRenderPreviewBase(fontInfo)
if (!base || isStaleGeneration(generationToken)) {
continue
}
const item: PreviewRenderItem = {
fontInfo,
selected: selectedFontIdSet.has(fontInfo.id),
baseSvg: base.svg,
baseWidth: base.width,
baseHeight: base.height,
renderFontSize: base.renderFontSize,
svgResult: {
svg: '',
width: 0,
height: 0,
fontName: base.fontName || fontInfo.name,
},
}
items[localIndex] = applyLocalStyleToPreviewItem(item)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`Failed to render preview for ${fontInfo.name}:`, error)
errors.push(`${fontInfo.name}: ${message}`)
}
}
}
await Promise.all(Array.from({ length: workerCount }, () => worker()))
if (isStaleGeneration(generationToken)) {
return { items: [], errors: [] }
}
return {
items: items.filter((item): item is PreviewRenderItem => item !== null),
errors,
}
}
async function loadNextPreviewBatch(generationToken: number) {
if (isStaleGeneration(generationToken)) {
return
}
if (isBatchGenerating.value) {
if (batchOwnerToken === generationToken) {
return
}
// 旧批次仍在收尾时,允许新批次接管
isBatchGenerating.value = false
batchOwnerToken = null
}
const startIndex = processedFontCount.value
if (startIndex >= activePreviewFonts.value.length) {
return
}
isBatchGenerating.value = true
batchOwnerToken = generationToken
try {
const batchItems = await generatePreviewBatch(
activePreviewFonts.value,
startIndex,
PREVIEW_BATCH_SIZE,
generationToken,
)
if (isStaleGeneration(generationToken)) {
return
}
processedFontCount.value = Math.min(
startIndex + PREVIEW_BATCH_SIZE,
activePreviewFonts.value.length,
)
const existingIds = new Set(previewItems.value.map(item => item.fontInfo.id))
const uniqueBatchItems = batchItems.items.filter(item => !existingIds.has(item.fontInfo.id))
previewItems.value = [...previewItems.value, ...uniqueBatchItems]
renderedPreviewCount.value = Math.min(
renderedPreviewCount.value + uniqueBatchItems.length,
previewItems.value.length,
)
if (batchItems.errors.length > 0 && previewItems.value.length === 0) {
previewErrorMessage.value = `预览生成失败:${batchItems.errors[0]}`
} else if (previewItems.value.length > 0) {
previewErrorMessage.value = ''
}
} catch (error) {
console.error('Failed to load preview batch:', error)
previewErrorMessage.value = `预览生成失败:${error instanceof Error ? error.message : String(error)}`
} finally {
if (batchOwnerToken === generationToken) {
isBatchGenerating.value = false
batchOwnerToken = null
}
}
}
async function syncPreviewFontsIncrementally(previousFonts: FontInfo[]) {
const generationToken = ++previewGenerationToken
const nextPreviewFonts = [...previewFonts.value]
const validPreviewFontIds = new Set(nextPreviewFonts.map(font => font.id))
uiStore.retainExportItemsByFontIds(validPreviewFontIds)
activePreviewFonts.value = nextPreviewFonts
previewErrorMessage.value = ''
if (!inputText.value || inputText.value.trim() === '' || nextPreviewFonts.length === 0) {
previewItems.value = [] previewItems.value = []
renderedPreviewCount.value = 0
processedFontCount.value = 0
isGenerating.value = false
isBatchGenerating.value = false
batchOwnerToken = null
return
}
const nextIdSet = new Set(nextPreviewFonts.map(font => font.id))
const previousIdSet = new Set(previousFonts.map(font => font.id))
const addedFonts = nextPreviewFonts.filter(font => !previousIdSet.has(font.id))
previewItems.value = previewItems.value.filter(item => nextIdSet.has(item.fontInfo.id))
renderedPreviewCount.value = Math.min(renderedPreviewCount.value, previewItems.value.length)
processedFontCount.value = Math.min(processedFontCount.value, nextPreviewFonts.length)
if (addedFonts.length > 0) {
if (previewItems.value.length === 0) {
isGenerating.value = true
}
try {
const addedBatch = await generatePreviewBatch(
addedFonts,
0,
addedFonts.length,
generationToken,
)
if (isStaleGeneration(generationToken)) {
return
}
const existingIds = new Set(previewItems.value.map(item => item.fontInfo.id))
const uniqueAddedItems = addedBatch.items.filter(item => !existingIds.has(item.fontInfo.id))
if (uniqueAddedItems.length > 0) {
// 新勾选字体插到顶部,避免全量刷新带来的闪烁。
previewItems.value = [...uniqueAddedItems, ...previewItems.value]
renderedPreviewCount.value = Math.min(
renderedPreviewCount.value + uniqueAddedItems.length,
previewItems.value.length,
)
}
if (addedBatch.errors.length > 0 && previewItems.value.length === 0) {
previewErrorMessage.value = `预览生成失败:${addedBatch.errors[0]}`
}
} catch (error) {
console.error('Failed to incrementally load preview fonts:', error)
previewErrorMessage.value = `预览生成失败:${error instanceof Error ? error.message : String(error)}`
} finally {
if (!isStaleGeneration(generationToken)) {
isGenerating.value = false
}
}
}
if (
!isStaleGeneration(generationToken) &&
previewItems.value.length === 0 &&
processedFontCount.value < activePreviewFonts.value.length &&
!isBatchGenerating.value
) {
isGenerating.value = true
try {
await loadNextPreviewBatch(generationToken)
} finally {
if (!isStaleGeneration(generationToken)) {
isGenerating.value = false
}
}
}
}
async function handleLoadMoreByScroll() {
const generationToken = previewGenerationToken
if (isBatchGenerating.value || isStaleGeneration(generationToken)) {
return
}
if (renderedPreviewCount.value < previewItems.value.length) {
renderedPreviewCount.value = Math.min(
renderedPreviewCount.value + PREVIEW_BATCH_SIZE,
previewItems.value.length,
)
return
}
if (processedFontCount.value < activePreviewFonts.value.length) {
await loadNextPreviewBatch(generationToken)
}
}
async function regeneratePreviews() {
const generationToken = ++previewGenerationToken
const nextPreviewFonts = [...previewFonts.value]
const validPreviewFontIds = new Set(nextPreviewFonts.map(font => font.id))
uiStore.retainExportItemsByFontIds(validPreviewFontIds)
activePreviewFonts.value = nextPreviewFonts
processedFontCount.value = 0
previewItems.value = []
renderedPreviewCount.value = 0
previewErrorMessage.value = ''
if (!inputText.value || inputText.value.trim() === '' || nextPreviewFonts.length === 0) {
isGenerating.value = false
isBatchGenerating.value = false
batchOwnerToken = null
return return
} }
isGenerating.value = true isGenerating.value = true
try { try {
const items: PreviewItemType[] = [] await loadNextPreviewBatch(generationToken)
if (
for (const fontInfo of fonts) { !isStaleGeneration(generationToken) &&
if (!fontInfo.loaded) { previewItems.value.length === 0 &&
await fontStore.loadFont(fontInfo) processedFontCount.value < activePreviewFonts.value.length &&
} !isBatchGenerating.value
) {
if (fontInfo.font) { await loadNextPreviewBatch(generationToken)
try { }
const svgResult = await generateSvg({ if (
text: inputText.value, !isStaleGeneration(generationToken) &&
font: fontInfo.font, previewItems.value.length === 0 &&
fontSize: fontSize.value, nextPreviewFonts.length > 0 &&
fillColor: fillColor.value, inputText.value.trim() !== '' &&
}) !previewErrorMessage.value
) {
items.push({ previewErrorMessage.value = '预览生成失败:服务未返回可用结果'
fontInfo,
svgResult,
selected: uiStore.selectedExportItems.some(item => item.fontInfo.id === fontInfo.id)
})
} catch (error) {
console.error(`Failed to generate SVG for ${fontInfo.name}:`, error)
}
}
} }
previewItems.value = items
} catch (error) { } catch (error) {
console.error('Failed to generate previews:', error) console.error('Failed to regenerate previews:', error)
previewErrorMessage.value = `预览生成失败:${error instanceof Error ? error.message : String(error)}`
} finally { } finally {
isGenerating.value = false if (!isStaleGeneration(generationToken)) {
isGenerating.value = false
}
} }
} }
watch(
previewFonts,
(nextFonts, previousFonts) => {
if (!hasTriggeredInitialGenerate || !previousFonts) {
scheduleGeneratePreviews(false)
hasTriggeredInitialGenerate = true
return
}
const previousIds = new Set(previousFonts.map(font => font.id))
if (
nextFonts.length === previousFonts.length &&
nextFonts.every(font => previousIds.has(font.id))
) {
return
}
void syncPreviewFontsIncrementally(previousFonts)
hasTriggeredInitialGenerate = true
},
{ immediate: true },
)
watch(
[inputText, () => uiStore.letterSpacing],
() => {
scheduleGeneratePreviews(hasTriggeredInitialGenerate)
hasTriggeredInitialGenerate = true
},
)
watch(
[fontSize, fillColor],
() => {
applyLocalPreviewStyles()
},
)
watch(
[previewTriggerIndex, hasMorePreviewItems],
async () => {
previewTriggerItemEl.value = null
await nextTick()
bindPreviewLazyLoadObserver()
},
{ immediate: true },
)
onBeforeUnmount(() => {
if (previewGenerateTimer !== null) {
clearTimeout(previewGenerateTimer)
previewGenerateTimer = null
}
disconnectPreviewLazyLoadObserver()
previewGenerationToken += 1
batchOwnerToken = null
})
function toggleSelectItem(item: PreviewItemType) { function toggleSelectItem(item: PreviewItemType) {
item.selected = !item.selected item.selected = !item.selected
uiStore.toggleExportItem(item) uiStore.toggleExportItem(item)
} }
function toggleSelectAllPreviewItems() {
if (previewItems.value.length === 0) {
return
}
if (isAllPreviewSelected.value) {
uiStore.clearExportSelection()
previewItems.value.forEach(item => {
item.selected = false
})
return
}
previewItems.value.forEach(item => {
item.selected = true
})
uiStore.selectAllExportItems(previewItems.value)
}
defineExpose({
toggleSelectAllPreviewItems,
})
</script> </script>
<template> <template>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div v-if="previewItems.length === 0" class="text-[#86909c] text-center py-20"> <div v-if="previewItems.length === 0" class="text-[#86909c] text-center py-20">
{{ isGenerating ? '生成预览中...' : '请选择字体并输入内容' }} {{
isGenerating
? '生成预览中...'
: (
previewErrorMessage
? previewErrorMessage
: (
inputText.trim() === '' || activePreviewFonts.length === 0
? '请选择字体并输入内容'
: (hasRenderableInput ? '暂无可显示预览' : '请选择字体并输入内容')
)
)
}}
</div> </div>
<div v-else class="flex flex-col gap-2"> <div v-else class="flex flex-col gap-2">
<div <div
v-for="item in previewItems" v-for="(item, index) in visiblePreviewItems"
:key="item.fontInfo.id" :key="item.fontInfo.id"
:ref="(el) => setPreviewItemRef(el, index)"
class="flex flex-col gap-2" class="flex flex-col gap-2"
> >
<div class="flex items-center gap-[8px] border-b border-[#c9cdd4] pb-[8px] pr-[8px]"> <div class="flex items-center gap-[8px] border-b border-[#c9cdd4] pb-[8px] pr-[8px]">
@@ -120,6 +733,10 @@ function toggleSelectItem(item: PreviewItemType) {
<div v-html="item.svgResult.svg" class="svg-preview-container"></div> <div v-html="item.svgResult.svg" class="svg-preview-container"></div>
</div> </div>
</div> </div>
<div v-if="hasMorePreviewItems" class="text-xs text-[#86909c] text-center py-2">
{{ isBatchGenerating ? '加载中...' : `继续下滑加载更多${visiblePreviewItems.length}/${activePreviewFonts.length}` }}
</div>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -13,20 +13,43 @@ interface FontListItem {
export function useFontLoader() { export function useFontLoader() {
const fontStore = useFontStore() const fontStore = useFontStore()
async function fetchFontListWithFallback(): Promise<FontListItem[]> {
const candidates = ['/frontend/public/fonts.json', '/fonts.json']
const errors: string[] = []
for (const url of candidates) {
const requestUrl = `${url}?_ts=${Date.now()}`
try {
console.log(`Fetching ${requestUrl}...`)
const response = await fetch(requestUrl, { cache: 'no-store' })
console.log(`${url} response:`, response.status, response.statusText)
if (!response.ok) {
errors.push(`${url}: HTTP ${response.status}`)
continue
}
const data = await response.json()
if (!Array.isArray(data)) {
errors.push(`${url}: JSON 不是数组`)
continue
}
return data as FontListItem[]
} catch (error) {
errors.push(`${url}: ${error instanceof Error ? error.message : String(error)}`)
}
}
throw new Error(errors.join(' | '))
}
async function loadFontList() { async function loadFontList() {
console.log('Starting to load font list...') console.log('Starting to load font list...')
fontStore.isLoadingFonts = true fontStore.isLoadingFonts = true
try { try {
console.log('Fetching /fonts.json...') const fontList = await fetchFontListWithFallback()
const response = await fetch('/fonts.json')
console.log('Response status:', response.status, response.statusText)
if (!response.ok) {
throw new Error(`Failed to load fonts.json: ${response.statusText}`)
}
const fontList: FontListItem[] = await response.json()
console.log('Loaded font list:', fontList.length, 'fonts') console.log('Loaded font list:', fontList.length, 'fonts')
// 转换为 FontInfo // 转换为 FontInfo
@@ -50,7 +73,7 @@ export function useFontLoader() {
console.log(`Successfully loaded ${fontList.length} fonts`) console.log(`Successfully loaded ${fontList.length} fonts`)
} catch (error) { } catch (error) {
console.error('Failed to load font list:', error) console.error('Failed to load font list:', error)
alert('加载字体列表失败,请刷新页面重试') alert(`加载字体列表失败${error instanceof Error ? error.message : '未知错误'}`)
} finally { } finally {
fontStore.isLoadingFonts = false fontStore.isLoadingFonts = false
console.log('Font loading finished') console.log('Font loading finished')

View File

@@ -31,6 +31,7 @@ export const useFontStore = defineStore('font', () => {
const expandedCategoryNames = ref<Set<string>>(readSet('font.expandedCategories')) const expandedCategoryNames = ref<Set<string>>(readSet('font.expandedCategories'))
const fontTree = ref<FontTreeNode[]>([]) const fontTree = ref<FontTreeNode[]>([])
const isLoadingFonts = ref(false) const isLoadingFonts = ref(false)
const loadingFontTasks = new Map<string, Promise<void>>()
// 计算属性 // 计算属性
const selectedFonts = computed(() => { const selectedFonts = computed(() => {
@@ -151,17 +152,31 @@ export const useFontStore = defineStore('font', () => {
return return
} }
try { const existingTask = loadingFontTasks.get(fontInfo.id)
const font = await loadFontWithProgress(fontInfo.path, (progress) => { if (existingTask) {
fontInfo.progress = progress await existingTask
}) return
fontInfo.font = font
fontInfo.loaded = true
fontInfo.progress = 100
} catch (error) {
console.error(`Failed to load font ${fontInfo.name}:`, error)
throw error
} }
const loadingTask = (async () => {
try {
const font = await loadFontWithProgress(fontInfo.path, (progress) => {
fontInfo.progress = progress
})
fontInfo.font = font
fontInfo.loaded = true
fontInfo.progress = 100
} catch (error) {
console.error(`Failed to load font ${fontInfo.name}:`, error)
throw error
} finally {
loadingFontTasks.delete(fontInfo.id)
}
})()
loadingFontTasks.set(fontInfo.id, loadingTask)
await loadingTask
} }
function buildFontTree(fontList: FontInfo[]): FontTreeNode[] { function buildFontTree(fontList: FontInfo[]): FontTreeNode[] {

View File

@@ -0,0 +1,140 @@
import { MAX_CHARS_PER_LINE } from './text-layout'
const DEFAULT_RENDER_API_URL = '/api/render-svg'
const REQUEST_TIMEOUT_MS = 30000
interface RenderApiResponseData {
svg: string
width: number
height: number
fontName: string
fontId?: string
}
interface RenderApiResponseBody {
ok?: boolean
data?: RenderApiResponseData
error?: string
}
type BrowserWithRenderApiConfig = Window & {
__FONT2SVG_API_URL__?: string
}
export interface RenderSvgPayload {
fontId: string
text: string
fontSize?: number
fillColor?: string
letterSpacing?: number
maxCharsPerLine?: number
}
export interface RenderSvgResult {
svg: string
width: number
height: number
fontName: string
fontId: string
}
function resolveRenderApiUrl(): string {
const envUrl = (import.meta.env.VITE_RENDER_API_URL as string | undefined)?.trim()
if (envUrl) {
return envUrl
}
if (typeof window !== 'undefined') {
const globalUrl = (window as BrowserWithRenderApiConfig).__FONT2SVG_API_URL__
if (typeof globalUrl === 'string' && globalUrl.trim()) {
return globalUrl.trim()
}
}
return DEFAULT_RENDER_API_URL
}
function normalizeRenderResult(data: RenderApiResponseData | undefined): RenderSvgResult {
if (!data || typeof data !== 'object') {
throw new Error('渲染服务返回格式无效')
}
const svg = typeof data.svg === 'string' ? data.svg : ''
if (!svg.trim()) {
throw new Error('渲染服务未返回有效 SVG')
}
return {
svg,
width: Number(data.width) || 0,
height: Number(data.height) || 0,
fontName: data.fontName || 'Unknown',
fontId: data.fontId || '',
}
}
function normalizePayload(payload: RenderSvgPayload) {
const fontId = String(payload.fontId || '').trim()
const text = String(payload.text || '')
if (!fontId) {
throw new Error('缺少字体 ID')
}
if (!text.trim()) {
throw new Error('文本内容不能为空')
}
return {
fontId,
text,
fontSize: Number(payload.fontSize) || 120,
fillColor: payload.fillColor || '#000000',
letterSpacing: Number(payload.letterSpacing) || 0,
maxCharsPerLine: Number(payload.maxCharsPerLine) || MAX_CHARS_PER_LINE,
}
}
export async function renderSvgByApi(payload: RenderSvgPayload): Promise<RenderSvgResult> {
const requestBody = normalizePayload(payload)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
let response: Response
try {
response = await fetch(resolveRenderApiUrl(), {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(requestBody),
signal: controller.signal,
})
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw new Error('渲染服务请求超时')
}
throw new Error(`渲染服务请求失败:${error instanceof Error ? error.message : String(error)}`)
} finally {
clearTimeout(timer)
}
let body: RenderApiResponseBody | null = null
try {
body = (await response.json()) as RenderApiResponseBody
} catch {
body = null
}
if (!response.ok) {
const errMsg = body && typeof body.error === 'string' && body.error.trim()
? body.error
: `渲染服务请求失败HTTP ${response.status}`
throw new Error(errMsg)
}
if (!body || !body.ok) {
throw new Error((body && body.error) || '渲染服务返回错误')
}
return normalizeRenderResult(body.data)
}

View File

@@ -1,26 +0,0 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import UnoCSS from 'unocss/vite'
import wasm from 'vite-plugin-wasm'
import fs from 'fs'
import path from 'path'
import { homedir } from 'os'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), UnoCSS(), wasm()],
optimizeDeps: {
exclude: ['harfbuzzjs']
},
define: {
'process.env': {}
},
server: {
host: '0.0.0.0',
port: 5174,
https: {
key: fs.readFileSync(path.join(homedir(), 'mac.biboer.cn_ecc/mac.biboer.cn.key')),
cert: fs.readFileSync(path.join(homedir(), 'mac.biboer.cn_ecc/fullchain.cer'))
}
}
})

124
miniprogram/ICON_FIX.md Normal file
View File

@@ -0,0 +1,124 @@
# 小程序图标修复清单
## 修复时间
2026年2月9日最后更新
## 问题
所有图标在小程序预览中都无法显示
## 根本原因
1. 使用了相对路径 `../../assets/icons/` 而不是绝对路径
2. 某些图标文件名包含空格(如 `icons_idx _32.svg`),在某些情况下可能导致路径解析问题
## 解决方案
### 1. 图标路径修复
所有图标路径已从相对路径改为绝对路径:
- ❌ 旧: `../../assets/icons/xxx.svg`
- ✅ 新: `/assets/icons/xxx.svg`
### 2. Logo 更新
- ✅ 右上角 logo 已改用 `webicon.png`
- 路径: `/assets/icons/webicon.png`
### 3. 图标映射(最新)
已使用的图标及其用途:
| 图标文件 | 用途 | 位置 |
|---------|------|------|
| `webicon.png` | 应用 Logo | 顶部导航栏左上角 |
| `font-size-decrease.svg` | 减小字号 | 字体大小滑块左侧 |
| `font-size-increase.svg` | 增大字号 | 字体大小滑块右侧 |
| `choose-color.svg` | 颜色选择器 | 顶部导航栏右侧 |
| `font-icon.svg` | 字体图标 | 预览列表、字体树列表项 |
| `checkbox.svg` | 复选框选中状态 | 字体选择复选框(选中时显示) |
| `checkbox-no.svg` | 复选框未选中状态 | 字体选择复选框(未选中时显示) |
| `expand.svg` | 展开/收起 | 字体分类树 |
| `selectall.svg` | 全选图标 | 分类标题右侧(未全选时显示) |
| `unselectall.svg` | 取消全选图标 | 分类标题右侧(已全选时显示) |
| `favorite.svg` | 未收藏 | 收藏按钮(白色心形) |
| `favorite-red.svg` | 已收藏 | 收藏按钮(红色心形) |
| `download.svg` | 下载图标 | 预览项导出按钮区域 |
| `export-svg-s.svg` | SVG 导出 | 预览项 SVG 导出按钮 |
| `export-png-s.svg` | PNG 导出 | 预览项 PNG 导出按钮 |
| `search.svg` | 搜索图标 | 搜索框右侧按钮 |
| `content.svg` | 内容图标 | 输入框左侧 |
### 4. 复选框实现(已更新)
复选框现在始终显示图标,根据状态切换不同图标:
```xml
<view class="font-checkbox" bindtap="onToggleFont" data-font-id="{{item.id}}">
<image class="checkbox-icon-sm" src="{{item.selected ? icons.checkboxChecked : icons.checkbox}}" />
</view>
```
图标配置index.js
```javascript
const LOCAL_ICON_PATHS = {
checkbox: '/assets/icons/checkbox-no.svg', // 未选中
checkboxChecked: '/assets/icons/checkbox.svg', // 选中
// ...
}
```
### 5. 收藏图标实现
收藏按钮根据状态显示不同颜色的心形图标:
```xml
<view class="favorite-btn" bindtap="onToggleFavorite" data-font-id="{{font.id}}">
<image class="favorite-icon" src="{{font.isFavorite ? icons.favoriteRedIcon : icons.favoriteIcon}}" />
</view>
```
## 修改的文件
1. **index.wxml**
- 所有图标路径更新为绝对路径
- Logo 改为 webicon.png
- 复选框结构简化(始终显示图标)
- 收藏图标根据状态切换
- 新增版权说明
2. **index.wxss**
- 统一边框颜色为 `#3EE4C3`
- 优化选择/已收藏区域布局flex fill
- 统一容器 padding 为 8rpx
- 新增版权样式
3. **index.js**
- LOCAL_ICON_PATHS 配置更新
- 搜索框默认展开showSearch: true
- bootstrap() 中修复字体选中状态同步
## 验证清单
请在微信开发者工具中验证以下内容:
- [ ] 顶部 Logo (webicon.png) 正常显示
- [ ] 字体大小增减图标正常显示
- [ ] 颜色选择图标正常显示
- [ ] 预览列表中的字体图标正常显示
- [ ] 预览列表中的导出按钮download、SVG、PNG正常显示
- [ ] 搜索框默认完整展开显示
- [ ] 字体选择列表中的展开/收起图标正常显示
- [ ] 字体选择列表中的复选框正常显示(选中/未选中两种状态)
- [ ] 字体选择列表中的收藏图标正常显示(红心/白心)
- [ ] 分类标题右侧的全选/取消全选图标正常显示
- [ ] 已收藏字体列表中的所有图标正常显示
- [ ] 预览区和选择区边框颜色为 #3EE4C3
- [ ] 底部版权说明正常显示
## 注意事项
1. **路径规范**:小程序中使用绝对路径 `/assets/icons/xxx.svg` 会从项目根目录开始查找
2. **SVG 支持**:微信小程序的 `<image>` 组件支持 SVG 格式
3. **文件名**:避免使用包含空格的文件名,可能在某些情况下导致问题
4. **图标尺寸**:所有图标已按照 Figma 设计稿的尺寸设置 CSS
## 如果图标仍不显示
1. 清除缓存:微信开发者工具 -> 工具 -> 清除缓存
2. 重新编译:点击"编译"按钮
3. 检查控制台:查看是否有资源加载错误
4. 验证文件:确认 `/miniprogram/assets/icons/` 目录下所有图标文件存在

120
miniprogram/README.md Normal file
View File

@@ -0,0 +1,120 @@
# miniprogram
`miniprogram/``font2svg` 的微信小程序版本,面向移动端提供字体预览与导出能力。
## 已实现能力
- 文本输入 + 字体选择(搜索/分类/收藏)
- 远端 API 生成 SVG服务端读取字体并渲染
- SVG 预览
- 导出 SVG 并调用 `wx.shareFileMessage` 分享
- 远端 API 生成 PNG保存到系统相册
## 目录说明
```text
miniprogram/
├── pages/
│ ├── index/ # 首页:输入、预览、导出
│ └── font-picker/ # 字体选择页
├── config/
│ └── server.js # 远端地址/端口/API 路径统一配置
├── utils/
│ ├── core/ # 纯算法模块
│ └── mp/ # 小程序 API 适配层(含 route-manager
├── assets/fonts.json # 字体清单(由脚本生成)
├── assets/default.json # 首次加载默认配置(内容/颜色/字号/默认字体)
├── assets/route-config.json # 手动切换 A/B 服务器配置
├── app.js / app.json / app.wxss
└── project.config.json
```
## 使用步骤
1. 在仓库根目录执行 `pnpm run prepare-fonts`,同步字体清单。
2. 打开微信开发者工具,导入 `miniprogram/` 目录。
3. 在小程序后台配置合法域名(`request` + `downloadFile`)。
4. 部署并启动 `apiserver/`(详见 `apiserver/README.md`)。
5. Nginx 配置 `/api/` 反向代理到渲染服务。
6. 编译运行。
## 服务器配置(换服务器只改一处)
修改 `miniprogram/config/server.js` 中的 `SERVER_CONFIG`
- `protocol`: `https` / `http`
- `host`: 服务器域名
- `port`: 端口(默认 443/80 可留空)
- `apiPrefix`: API 前缀(默认 `/api`
- `fontsManifestPath`: 字体清单路径(默认 `/miniprogram/assets/fonts.json`
- `defaultConfigPath`: 默认配置路径(默认 `/miniprogram/assets/default.json`
- `routeConfigPath`: 路由配置路径(默认 `/miniprogram/assets/route-config.json`
`app.js` 和 API 调用会自动使用该配置生成完整 URL。
## 手动切换 A/B 服务器(无需发版)
远端 `route-config.json`A、B 都部署)示例:
```json
{
"active": "A",
"cooldownMinutes": 10,
"servers": {
"A": { "baseUrl": "https://fonts.biboer.cn" },
"B": { "baseUrl": "https://mac-tunnel.biboer.cn" }
}
}
```
- 冷启动时先读取当前服务器的 `route-config.json`
- 若发现 `active` 指向另一台服务器,会读取目标服务器配置做“双确认”。
- 仅当目标服务器也返回相同 `active`,并且满足 `cooldownMinutes`,才切换。
- 回前台会按 60 秒节流检查一次API/配置请求失败时会触发一次兜底检查。
## 导出说明
- `SVG`:受微信限制,`shareFileMessage` 需由单次点击直接触发,建议逐个字体导出。
- `PNG`:由服务端 `POST /api/render-png` 直接返回二进制,小程序仅负责保存到相册。
## 字体清单格式(由服务端解析)
`assets/fonts.json` 每项字段:
- `id`: 字体唯一 ID
- `name`: 字体显示名
- `category`: 分类
- `path`: 字体地址(支持相对路径或完整 URL
如果 `path` 是相对路径(例如 `/fonts/a.ttf`),服务端会根据静态根目录拼接到实际文件路径。
推荐部署结构:
- 字体目录统一放在服务器根目录:`/fonts/`
- Web 配置文件独立管理:`/fonts.json`(可选 `/default.json`
- 小程序配置文件独立管理:`/miniprogram/assets/fonts.json``/miniprogram/assets/default.json`
## 首次默认配置default.json
- 默认配置文件与 `fonts.json` 同目录:由 `config/server.js` 自动拼接(默认是 `https://fonts.biboer.cn/miniprogram/assets/default.json`
- 小程序会在首次加载时读取该配置(远端失败则回退本地 `miniprogram/assets/default.js`
- 配置只在首次加载生效,后续始终使用用户本地已保存配置(选择、收藏、颜色、字号、内容)
示例:
```json
{
"inputText": "星程字体转换",
"fontSize": 50,
"textColor": "#dc2626",
"selectedFontIds": ["0001"],
"favoriteFontIds": ["0001"]
}
```
## 调试命令(仓库根目录)
```bash
pnpm run mp:syntax
pnpm run mp:lint
pnpm run mp:test
```

334
miniprogram/UPDATE_LOG.md Normal file
View File

@@ -0,0 +1,334 @@
# 小程序 UI 更新日志
## 更新时间
2026年2月9日布局优化与样式统一
## 本次更新内容
### 1. 选择和已收藏区域布局优化
#### 高度填充改造
- **问题**:选择和已收藏区域使用固定高度 `height: 600rpx`,无法自适应屏幕
- **解决**:改为 `flex: 1; min-height: 0`,与预览区域一致的 fill 布局
#### Padding 统一
- **问题**`.bottom-section` 有额外的 `padding: 0 16rpx`,导致选择/已收藏与屏幕边缘距离不一致
- **解决**:移除 `.bottom-section` 的水平 padding由页面容器统一控制
#### 内容溢出修复
- **问题**:设置过大的 `gap: 22rpx` 导致内容溢出容器
- **解决**:移除 gap改用 `margin-bottom` 控制间距;添加 `overflow-y: auto` 确保列表可滚动
### 2. 边框颜色统一
将预览窗口、选择和已收藏的边框颜色统一改为 `#3EE4C3`
```css
.preview-section { border: 1rpx solid #3EE4C3; }
.font-selection, .favorite-selection { border: 1rpx solid #3EE4C3; }
```
### 3. 已收藏标题结构优化
- 新增 `.favorite-header` 容器包裹"已收藏"标题
- 与选择区域的 `.selection-header` 结构对齐,确保两侧标题水平对齐
### 4. 底部版权说明
新增页面底部版权信息:
```
@版权说明仅SVG和PNG分享无TTF下载如侵权反馈douboer@gmail.com
```
### 5. 预览区域 Padding 调整
-`padding: 0 16rpx` 改为 `padding: 8rpx`
- 四边统一间距,与选择/已收藏区域保持一致
### 关键样式变更
```css
/* 底部区域 */
.bottom-section {
display: flex;
flex: 1;
gap: 16rpx;
min-height: 0;
margin-top: 16rpx;
padding: 0; /* 移除额外 padding */
}
/* 选择和已收藏容器 */
.font-selection, .favorite-selection {
flex: 1;
display: flex;
flex-direction: column;
border: 1rpx solid #3EE4C3;
border-radius: 16rpx;
background: #fff;
padding: 8rpx;
overflow: hidden;
min-width: 0;
}
/* 版权说明 */
.copyright-footer {
text-align: center;
font-size: 20rpx;
color: #86909C;
padding: 16rpx 0;
flex-shrink: 0;
}
```
---
## 更新时间
2026年2月9日
## 修复:"选择"与搜索框垂直对齐问题
### 问题描述
"选择"文字与右侧搜索框无法垂直居中对齐,"选择"看起来偏上。
### 根本原因
1. **全局样式污染**`app.wxss``index.wxss` 中的全局 `.section-title` 样式设置了 `padding: 12rpx 0``margin-bottom: 16rpx`,导致"选择"文字上下有额外间距
2. **小程序 input 组件最小高度**:微信小程序的 `<input>` 组件有默认最小高度(约 48rpx无法通过 CSS 设置更小的高度,导致搜索框实际高度大于预期
### 解决方案
1. **统一高度为 48rpx**:适配 input 组件的最小高度限制
2. **覆盖全局样式**:在 `.selection-header .section-title` 中显式设置 `padding: 0; margin: 0`
3. **强制 flexbox 居中**
- 父容器 `.selection-header` 使用 `display: flex; align-items: center`
- `.section-title` 使用 `display: flex; align-items: center; height: 48rpx`
- `.search-container` 使用 `height: 48rpx; overflow: hidden`
### 关键代码
```css
.selection-header {
display: flex;
align-items: center;
gap: 8rpx;
padding: 0;
}
.selection-header .section-title {
padding: 0;
margin: 0;
font-size: 28rpx;
font-weight: 400;
flex-shrink: 0;
display: flex;
align-items: center;
height: 48rpx;
}
.search-container {
flex: 1;
display: flex;
align-items: center;
background: #FEFDFE;
border-radius: 24rpx;
padding: 0 12rpx;
height: 48rpx;
min-width: 0;
overflow: hidden;
}
.search-input {
flex: 1;
font-size: 24rpx;
color: #4E5969;
height: 48rpx;
line-height: 48rpx;
min-height: 0;
padding: 0;
margin: 0;
background: transparent;
}
```
### 经验教训
1. **检查全局样式**:修改特定组件样式前,先检查是否有全局样式影响
2. **小程序组件限制**:微信小程序原生组件(如 input、textarea有内置最小尺寸需要适配而非强制覆盖
3. **调试技巧**:当 flexbox `align-items: center` 不生效时,优先检查子元素的 padding/margin/line-height
### 其他修复
- **搜索框初始状态**:将 `showSearch` 初始值从 `false` 改为 `true`,搜索框默认完整显示(符合 Figma 设计)
- **字体选中状态同步**:在 `bootstrap()` 中恢复 `selectedFonts` 后调用 `updateFontTrees()`,确保预览区的字体在字体树中正确显示为已选中
---
## 更新时间
2026年2月8日远端渲染改造
## 本次关键变更
为解决“大字体多预览下载慢”的问题,小程序渲染链路从“本地下载字体并 Worker 渲染”改为“请求远端 API 返回 SVG”
- 新增 `apiserver/` 目录,服务端读取 `fonts.json` + `fonts/` 并渲染 SVG。
- 小程序新增 `miniprogram/utils/mp/render-api.js`,调用 `https://fonts.biboer.cn/api/render-svg`
- 小程序 PNG 导出改为调用 `https://fonts.biboer.cn/api/render-png`,不再依赖真机 Canvas 加载 SVG。
- `miniprogram/pages/index/index.js` 移除本地 `loadFontBuffer + worker.generateSvg` 依赖,改为远端渲染。
- `miniprogram/app.js` 新增全局配置:
- `svgRenderApiUrl`
- `apiTimeoutMs`
## 兼容性说明
- 字体清单仍使用 `https://fonts.biboer.cn/fonts.json`,字体分类、收藏、导出逻辑保持不变。
- 导出 PNG 仍在小程序端由 Canvas 渲染,导出 SVG 仍沿用原分享流程。
---
## 更新时间
2026年2月8日
## 更新内容
根据 Figma 设计稿 (https://www.figma.com/design/S7WVUzg3Z0DMWjYUC6dJzN/font2svg?node-id=584-64) 对小程序首页进行了全面重构。
### 1. 资源文件更新
- **图标素材**:从 web 端 `frontend/src/assets/icons/` 复制了所有 SVG 图标到 `miniprogram/assets/icons/`
- 新增的图标包括:
- 字体大小控制:`font-size-increase.svg`, `font-size-decrease.svg`
- 导出功能:`export.svg`, `export-svg.svg`, `export-png.svg`
- 颜色选择:`choose-color.svg`
- 字体管理:`font-icon.svg`, `expand.svg`
- 复选框:`checkbox.svg`, `icons_idx _32.svg`, `icons_idx _34.svg`
- 收藏功能:`icons_idx _18.svg`, `icons_idx _19.svg`
### 2. 页面结构重构 (index.wxml)
#### 顶部导航栏
- Logo 展示(星程字体图标)
- "TextToSVG" 品牌标题
- 字体大小滑块(带增减按钮)
- 颜色选择按钮
#### 输入区域
- 简化的输入框("此处输入内容"
- 导出按钮组(支持 SVG、PNG 导出)
#### 效果预览区域
- 显示多个选中字体的预览
- 每个预览项包含:
- 字体名称
- 复选框(控制是否显示)
- 预览图像
#### 字体选择区域(左右分栏)
- **左侧:字体选择**
- 树状分类结构
- 支持展开/收起
- 每个字体项包含:复选框、收藏按钮
- **右侧:已收藏字体**
- 显示已收藏的字体
- 同样支持树状结构
- 点击取消收藏
#### 颜色选择器弹窗
- 预设颜色调色板
- 自定义颜色输入
### 3. 样式更新 (index.wxss)
- **整体布局**:采用 flex 布局,优化空间利用
- **颜色方案**
- 主色:`#8552A1`, `#9B6BC2`(紫色系)
- 填充色:`#F7F8FA`, `#E5E6EB`
- 文字色:`#4E5969`, `#86909C`, `#C9CDD4`
- 边框色:`#f7e0e0`
- **组件样式**
- 顶部导航栏高度96rpx
- 输入框高度78rpx
- 字体树容器高度360rpx
- 圆角设计12rpx - 24rpx
- **响应式设计**
- 使用 flex 布局适应不同屏幕
- scroll-view 实现内容滚动
### 4. 功能逻辑更新 (index.js)
#### 新增功能
1. **多字体选择**
- 支持同时选择多个字体
- 每个字体独立预览
- `selectedFonts` 数组管理选中的字体
2. **字体分类树**
- 自动构建字体分类结构
- 支持展开/收起操作
- `fontCategories``favoriteCategories` 管理
3. **收藏功能**
- 收藏/取消收藏字体
- 收藏状态持久化存储
- 已收藏字体独立展示
4. **字体大小控制**
- 滑块调整24-320px
- 增加/减少按钮(步进 10px
- 实时预览更新
5. **颜色选择器**
- 弹窗式颜色选择器
- 预设颜色调色板
- 自定义颜色输入
6. **批量导出**
- 导出全部选中字体为 SVG
- 导出全部选中字体为 PNG
- 单个字体导出
#### 核心方法
- `updateFontTrees()` - 更新字体分类树
- `onToggleFont()` - 切换字体选择状态
- `onToggleFavorite()` - 切换收藏状态
- `onToggleCategory()` - 切换分类展开/收起
- `generatePreviewForFont()` - 生成单个字体预览
- `generateAllPreviews()` - 生成所有选中字体预览
- `exportAllSvg()` - 批量导出 SVG
- `exportAllPng()` - 批量导出 PNG
### 5. 全局样式更新 (app.wxss)
- 页面背景改为白色
- 容器高度设为 100vh支持全屏布局
- 添加 `overflow: hidden` 避免滚动问题
## 技术亮点
1. **树状结构**:使用 Map 数据结构高效管理字体分类
2. **性能优化**:字体 buffer 按需加载并缓存
3. **防抖处理**:输入和调整时使用 260ms 防抖
4. **状态持久化**:选中字体、收藏状态自动保存
5. **并发生成**:支持多个字体同时生成预览
## 使用说明
1. **选择字体**:在左侧"字体选择"区域勾选想要使用的字体
2. **输入文字**:在顶部输入框输入要转换的文本
3. **调整样式**
- 使用滑块或增减按钮调整字体大小
- 点击调色板图标选择颜色
4. **查看预览**:在"效果预览"区域查看所有选中字体的效果
5. **导出文件**
- 点击 SVG 图标导出为 SVG 格式
- 点击 PNG 图标导出为 PNG 格式并保存到相册
- 点击 export 图标可选择批量导出
## 注意事项
- 首次加载字体可能需要一些时间,请耐心等待
- 建议不要同时选择过多字体,以免影响性能
- PNG 导出到相册需要用户授权
- SVG 分享使用系统分享功能
## 后续优化建议
1. 添加字体搜索功能
2. 支持预览文字大小独立调整
3. 添加字体加载进度指示
4. 支持批量框选字体
5. 优化大字体渲染性能

28
miniprogram/app.js Normal file
View File

@@ -0,0 +1,28 @@
const { buildRuntimeConfig } = require('./config/server')
const { bootstrapRoute, checkRouteOnShow } = require('./utils/mp/route-manager')
const runtimeConfig = buildRuntimeConfig()
App({
globalData: {
...runtimeConfig,
apiTimeoutMs: 30000,
fonts: null,
defaultConfig: null,
routeReadyPromise: null,
},
onLaunch() {
this.globalData.routeReadyPromise = bootstrapRoute(this)
.catch((error) => {
console.warn('[app] 路由初始化失败,使用当前配置继续运行:', error)
return null
})
},
onShow() {
checkRouteOnShow(this).catch((error) => {
console.warn('[app] 回前台路由检查失败:', error)
})
},
})

15
miniprogram/app.json Normal file
View File

@@ -0,0 +1,15 @@
{
"pages": [
"pages/index/index",
"pages/font-picker/index"
],
"window": {
"navigationBarTitleText": "Font2SVG 小程序",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"backgroundTextStyle": "light"
},
"style": "v2",
"workers": "workers",
"sitemapLocation": "sitemap.json"
}

View File

@@ -0,0 +1,5 @@
{
"adapteByMiniprogram": {
"userName": "gh_d47f6e84d841"
}
}

58
miniprogram/app.wxss Normal file
View File

@@ -0,0 +1,58 @@
page {
background: #fff;
color: #1f2a37;
font-size: 28rpx;
height: 100vh;
overflow: hidden;
}
.container {
box-sizing: border-box;
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
padding: 16rpx 16rpx 40rpx 16rpx;
}
.card {
background: #ffffff;
border-radius: 20rpx;
box-shadow: 0 10rpx 30rpx rgba(15, 23, 42, 0.06);
padding: 24rpx;
margin-bottom: 20rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 600;
margin-bottom: 16rpx;
}
.row {
display: flex;
flex-direction: row;
align-items: center;
}
.space-between {
justify-content: space-between;
}
.btn-primary {
background: #1677ff;
color: #ffffff;
border: none;
border-radius: 16rpx;
}
.btn-secondary {
background: #edf2ff;
color: #1f2a37;
border: none;
border-radius: 16rpx;
}
.mono {
font-family: Menlo, Monaco, Consolas, 'Courier New', monospace;
}

View File

@@ -0,0 +1,19 @@
module.exports = {
inputText: '星程字体转换',
fontSize: 50,
textColor: '#dc2626',
selectedFontIds: [
'0001',
'0003',
'0006',
'0011',
'0015',
],
favoriteFontIds: [
'0001',
'0003',
'0006',
'0011',
'0015',
],
}

View File

@@ -0,0 +1,19 @@
{
"inputText": "星程字体转换",
"fontSize": 50,
"textColor": "#dc2626",
"selectedFontIds": [
"0001",
"0003",
"0006",
"0011",
"0015"
],
"favoriteFontIds": [
"0001",
"0003",
"0006",
"0011",
"0015"
]
}

2585
miniprogram/assets/fonts.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="34" height="88" fill="none" viewBox="0 0 34 88">
<rect width="33" height="87" x=".5" y=".5" fill="#2420A8" rx="7.5"/>
<rect width="33" height="87" x=".5" y=".5" stroke="#2420A8" rx="7.5"/>
<path fill="#fff" d="M8.72 35.978h10.8v-.81h1.89v.81h3.852v1.836H21.41v3.33c0 1.188-.648 1.782-1.944 1.782h-2.25l-.432-1.836c.72.072 1.386.126 2.034.126.468 0 .702-.18.702-.54v-2.862H8.72v-1.836Zm13.95-9.198v4.662H12.392v.774c0 .486.252.738.792.738h9.306c.27-.018.45-.108.558-.288.072-.126.144-.54.234-1.242l1.782.594c-.126.828-.252 1.404-.378 1.764-.216.576-.756.864-1.584.9H12.428c-1.296 0-1.926-.576-1.926-1.71V26.78H22.67Zm-10.278 2.97h8.388v-1.278h-8.388v1.278Zm.954 8.154c1.152.9 2.142 1.8 2.934 2.7l-1.404 1.404c-.684-.864-1.638-1.8-2.88-2.808l1.35-1.296Zm8.208 12.384v-4.734h1.89v6.534h-5.472v6.048h4.482v-4.698h1.89v7.308h-1.89v-.792H9.656v-6.516h1.89v4.698h4.482v-6.048h-5.472v-6.534h1.89v4.734h3.582v-6.084h1.944v6.084h3.582Z"/>
</svg>

After

Width:  |  Height:  |  Size: 989 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="9" fill="none" viewBox="0 0 11 9">
<path fill="#fff" d="M9.222.118a.5.5 0 0 0-.704.06L3.859 5.685 1.396 3.57a.5.5 0 0 0-.706.054l-.57.664a.5.5 0 0 0 .054.705l2.778 2.384c.02.026.044.05.07.072l.668.565a.5.5 0 0 0 .422.109.498.498 0 0 0 .284-.166l.57-.664a.503.503 0 0 0 .056-.08l4.927-5.826A.5.5 0 0 0 9.89.683L9.222.118Z"/>
</svg>

After

Width:  |  Height:  |  Size: 392 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="none" viewBox="0 0 12 12">
<rect width="9.917" height="9.917" x=".875" y=".875" stroke="#C9CDD4" stroke-width=".583" rx="4.958"/>
</svg>

After

Width:  |  Height:  |  Size: 208 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="none" viewBox="0 0 18 18">
<g clip-path="url(#a)">
<path fill="#8552A1" d="M9 0a9 9 0 0 0-9 9 9 9 0 0 0 9 9 9 9 0 0 0 9-9 9 9 0 0 0-9-9Zm5.934 6.21L8.16 12.988a.843.843 0 0 1-.599.247.844.844 0 0 1-.6-.247L3.066 9.09a.846.846 0 1 1 1.198-1.197L7.56 11.19l6.177-6.177a.847.847 0 1 1 1.197 1.198Z"/>
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M0 0h18v18H0z"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 488 B

View File

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" fill="none" viewBox="0 0 36 36">
<path fill="#FCFCFC" d="M22.774 13.238a6.734 6.734 0 1 1-4.77-1.98 6.702 6.702 0 0 1 4.77 1.98Z"/>
<path fill="#00E8CF" d="M18.005 6.76v4.498a6.703 6.703 0 0 0-4.769 1.98L10.05 10.05a11.238 11.238 0 0 1 7.955-3.292Z"/>
<path fill="#70FFEF" d="M18.005.761V6.76a11.239 11.239 0 0 0-7.955 3.292L5.813 5.815A17.162 17.162 0 0 1 18.005.76Z"/>
<path fill="#0064B5" d="m10.05 10.051 3.186 3.187a6.702 6.702 0 0 0-1.98 4.768H6.759a11.239 11.239 0 0 1 3.292-7.955Z"/>
<path fill="#0091FF" d="M10.05 10.051a11.238 11.238 0 0 0-3.292 7.955H.76A17.162 17.162 0 0 1 5.813 5.815l4.237 4.236Z"/>
<path fill="#31C4FF" d="M11.257 18.006a6.7 6.7 0 0 0 1.98 4.769l-3.187 3.187a11.24 11.24 0 0 1-3.292-7.956h4.499Z"/>
<path fill="#9EEBFF" d="M6.758 18.006a11.24 11.24 0 0 0 3.292 7.956l-4.237 4.236A17.162 17.162 0 0 1 .76 18.006h5.998Z"/>
<path fill="#5F4A9E" d="M18.005 24.754v4.5a11.239 11.239 0 0 1-7.955-3.292l3.186-3.187a6.702 6.702 0 0 0 4.769 1.98Z"/>
<path fill="#9D87E0" d="M10.05 25.962a11.24 11.24 0 0 0 7.955 3.291v5.998a17.16 17.16 0 0 1-12.192-5.053l4.237-4.236Z"/>
<path fill="#FF468C" d="M25.96 25.962a11.241 11.241 0 0 1-7.955 3.291v-4.499a6.7 6.7 0 0 0 4.769-1.98l3.186 3.188Z"/>
<path fill="#FFA1C8" d="m25.96 25.962 4.236 4.236a17.162 17.162 0 0 1-12.191 5.053v-5.998a11.239 11.239 0 0 0 7.955-3.291Z"/>
<path fill="#F03049" d="M24.753 18.006h4.499a11.241 11.241 0 0 1-3.292 7.956l-3.186-3.187a6.7 6.7 0 0 0 1.979-4.769Z"/>
<path fill="#FF636E" d="M29.252 18.006h5.998a17.163 17.163 0 0 1-5.053 12.192l-4.237-4.236a11.241 11.241 0 0 0 3.292-7.956Z"/>
<path fill="#FE8205" d="M25.96 10.051a11.24 11.24 0 0 1 3.292 7.955h-4.499a6.701 6.701 0 0 0-1.98-4.768l3.187-3.187Z"/>
<path fill="#FFA426" d="M35.25 18.006h-5.998a11.24 11.24 0 0 0-3.292-7.955l4.236-4.236a17.163 17.163 0 0 1 5.054 12.191Z"/>
<path fill="#FFC247" d="m25.96 10.051-3.186 3.187a6.702 6.702 0 0 0-4.77-1.98V6.76a11.24 11.24 0 0 1 7.956 3.292Z"/>
<path fill="#FFFD78" d="M30.197 5.815 25.96 10.05a11.24 11.24 0 0 0-7.955-3.292V.761a17.162 17.162 0 0 1 12.192 5.054Z"/>
<path fill="#000" d="m32.863 10.832 1.35-.653a19.376 19.376 0 0 0-.854-1.56L32.08 9.4c.286.467.546.948.783 1.432Z"/>
<path fill="#000" d="m34.88 11.743-1.406.525c.589 1.601.924 3.284.993 4.988H29.97a11.883 11.883 0 0 0-2.967-7.186l3.186-3.185c.317.347.62.707.905 1.08l1.19-.914a17.995 17.995 0 0 0-29.629 20.35l1.277-.786a16.459 16.459 0 0 1-2.405-7.859H6.03a11.882 11.882 0 0 0 2.985 7.18l-3.188 3.187a16.61 16.61 0 0 1-.91-1.075l-1.19.914A18 18 0 0 0 34.88 11.744ZM5.822 6.883l3.185 3.187a11.883 11.883 0 0 0-2.965 7.186H1.543a16.345 16.345 0 0 1 4.28-10.372Zm16.424 15.365a5.998 5.998 0 1 1 1.757-4.242 5.96 5.96 0 0 1-1.757 4.242ZM12.2 13.26a7.424 7.424 0 0 0-1.652 3.995H7.545a10.39 10.39 0 0 1 2.533-6.116l2.121 2.12Zm10.55-1.06a7.423 7.423 0 0 0-3.995-1.651V7.546a10.39 10.39 0 0 1 6.117 2.533L22.75 12.2Zm-5.495-1.655a7.422 7.422 0 0 0-3.993 1.658l-2.124-2.123a10.39 10.39 0 0 1 6.117-2.534v3Zm-6.711 8.211A7.423 7.423 0 0 0 12.2 22.75l-2.123 2.124a10.39 10.39 0 0 1-2.533-6.117h2.999Zm2.715 5.057a7.423 7.423 0 0 0 3.996 1.65v3.004a10.39 10.39 0 0 1-6.117-2.534l2.121-2.12Zm5.496 1.654a7.423 7.423 0 0 0 3.993-1.657l2.123 2.123a10.39 10.39 0 0 1-6.116 2.534v-3Zm7.177-.594-2.121-2.121a7.423 7.423 0 0 0 1.651-3.996h3.003a10.39 10.39 0 0 1-2.533 6.117Zm2.533-7.617h-2.999a7.423 7.423 0 0 0-1.658-3.993l2.124-2.123a10.39 10.39 0 0 1 2.533 6.116Zm-9.71-15.729a16.358 16.358 0 0 1 10.36 4.31l-3.181 3.18a11.89 11.89 0 0 0-7.18-2.985V1.527Zm-1.5.018v4.5a11.89 11.89 0 0 0-7.187 2.964L6.882 5.824a16.345 16.345 0 0 1 10.373-4.279Zm-7.187 25.459a11.89 11.89 0 0 0 7.187 2.965v4.499a16.354 16.354 0 0 1-10.373-4.28l3.186-3.184Zm8.687 7.481V29.98a11.898 11.898 0 0 0 7.186-2.976l3.186 3.186a16.37 16.37 0 0 1-10.372 4.295Zm11.42-5.37-3.18-3.18a11.882 11.882 0 0 0 2.983-7.179h4.506a16.352 16.352 0 0 1-4.31 10.36Z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="23" height="36" fill="none" viewBox="0 0 23 36">
<path fill="#3EE4C3" d="M3.42 4.593v13.353H0V2.27h9.135V0h3.52v2.271h9.26v13.354a2.15 2.15 0 0 1-.662 1.597c-.44.433-.977.649-1.61.649h-4.168l.749-2.047h1.747a.57.57 0 0 0 .4-.162.537.537 0 0 0 .174-.412l.025-10.657h-5.915v.524l-.624 1.248h2.72l2.87 8.111h-3.519l-2.52-7.188-3.57 7.188H4.493l4.642-9.36v-.523H3.42Z"/>
<path fill="#3EE4C3" d="M3.47 21.224v1.348H0v-3.82h9.01l-.374-.873h3.769l.4.874h6.564a2.716 2.716 0 0 1 1.947.799 2.735 2.735 0 0 1 .798 1.947v1.073h-3.47v-.574c0-.217-.074-.4-.224-.55a.747.747 0 0 0-.549-.224H3.47ZM1.272 29.46h19.494v3.793a2.716 2.716 0 0 1-.798 1.947 2.735 2.735 0 0 1-1.948.8H1.273v-6.539Zm15.05 2.146H5.717v2.221h9.434c.333 0 .612-.112.837-.336a1.14 1.14 0 0 0 .337-.837v-1.048Zm-5.191-5.965-5.167 3.195H.05l8.286-4.967h5.666l-2.645 1.647h5.116l5.491 3.32H16.15l-5.017-3.195Zm1.473-3.47h4.917l4.268 2.621h-4.892l-4.293-2.62ZM5.49 24.793H.6l4.293-2.62h4.892l-4.293 2.62Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1019 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="none" viewBox="0 0 12 12">
<path fill="#000" d="M8.31 6H6.818V4.364a.273.273 0 0 0-.273-.273h-1.09a.273.273 0 0 0-.273.273v1.639l-1.506.007a.123.123 0 0 0-.114.074l-.001.002c-.003.008-.004.017-.006.026-.001.008-.003.016-.003.025v.005c0 .005.002.01.003.014.002.01.005.021.01.032l.008.014c.005.008.008.016.014.023l2.254 2.518c.004.006.01.009.016.013.003.003.004.007.008.01.003.002.007.002.01.005a.139.139 0 0 0 .028.012l.01.004c.038.011.08.008.11-.017.008-.006.012-.014.017-.022.004-.003.009-.005.012-.009l2.346-2.515c.007-.008.01-.017.016-.026l.007-.011a.123.123 0 0 0 .01-.032c0-.004.004-.008.004-.013v-.013A.121.121 0 0 0 8.31 6ZM5.455 3.818h1.09a.272.272 0 0 0 .273-.272v-.273A.273.273 0 0 0 6.545 3h-1.09a.273.273 0 0 0-.273.273v.273c0 .15.122.272.273.272ZM6 0a6 6 0 1 0 0 12A6 6 0 0 0 6 0Zm0 10.91a4.909 4.909 0 1 1 0-9.82 4.909 4.909 0 0 1 0 9.818Z"/>
</svg>

After

Width:  |  Height:  |  Size: 935 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="#000" d="M12 4.5a7.5 7.5 0 0 0 0 15 7.5 7.5 0 0 0 0-15Zm4.242 6.567L12.53 14.78a.751.751 0 0 1-1.062 0l-3.71-3.713a.751.751 0 0 1 1.062-1.062L12 13.188l3.183-3.18a.751.751 0 0 1 1.061 0 .75.75 0 0 1-.002 1.06Z"/>
</svg>

After

Width:  |  Height:  |  Size: 330 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="25" height="12" fill="none" viewBox="0 0 25 12">
<rect width="25" height="11.908" fill="#FFE4BA" rx="4"/>
<path fill="#1D2129" d="M3 9.908V2h2.762c.535 0 .94.033 1.216.1.275.067.539.199.79.396.685.535 1.027 1.357 1.027 2.466 0 .842-.252 1.523-.755 2.042a2.27 2.27 0 0 1-.868.567c-.326.118-.726.177-1.198.177H4.582v2.16H3Zm2.656-6.68H4.582V6.52h.967c.512 0 .886-.11 1.122-.33.33-.291.495-.74.495-1.346 0-.519-.13-.918-.39-1.198-.259-.279-.633-.419-1.12-.419Zm4.296 4.52V2h3.045c.456 0 .805.033 1.044.1.24.067.459.187.655.36.402.37.602.952.602 1.747v3.54h-1.581V4.03c0-.283-.063-.488-.189-.614s-.334-.189-.626-.189h-1.369v4.52H9.952ZM19.335 2h2.95v5.713c0 .322-.037.61-.111.861a1.802 1.802 0 0 1-.325.638c-.181.22-.4.371-.655.454-.256.083-.628.124-1.116.124h-3.15V8.562h2.938c.346 0 .573-.053.679-.159.106-.106.16-.325.16-.655h-1.453c-.464 0-.848-.05-1.15-.148a2.173 2.173 0 0 1-.82-.49c-.575-.542-.862-1.29-.862-2.242 0-1.086.35-1.877 1.05-2.372a2.44 2.44 0 0 1 .797-.384c.28-.075.635-.112 1.068-.112Zm1.37 4.52V3.229h-1.311c-.913 0-1.37.55-1.37 1.652 0 .535.125.942.373 1.221.248.28.608.42 1.08.42h1.227Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="none" viewBox="0 0 48 48">
<circle cx="24" cy="24" r="24" fill="#2420A8" opacity=".2"/>
<circle cx="24" cy="24" r="18" fill="#2420A8"/>
<path fill="#F3EDF7" d="M9 31.301v-12.3h4.296c.833 0 1.463.051 1.891.155.429.104.839.31 1.23.615 1.065.832 1.598 2.112 1.598 3.837 0 1.31-.392 2.369-1.175 3.177a3.537 3.537 0 0 1-1.35.88c-.508.184-1.129.276-1.863.276H11.46v3.36H9Zm4.131-10.391h-1.67v5.122h1.505c.795 0 1.377-.172 1.744-.514.514-.453.771-1.15.771-2.093 0-.808-.202-1.43-.606-1.864-.404-.434-.985-.651-1.744-.651Zm6.683 7.031v-8.94h4.737c.71 0 1.251.051 1.625.155.373.104.713.29 1.019.56.624.575.936 1.481.936 2.717v5.508h-2.46v-5.783c0-.44-.098-.759-.294-.955-.196-.196-.52-.293-.973-.293h-2.13v7.031h-2.46ZM34.41 19H39v8.886c0 .502-.058.949-.174 1.34a2.807 2.807 0 0 1-.505.992 2.161 2.161 0 0 1-1.02.707c-.397.128-.975.193-1.734.193h-4.902v-1.91h4.571c.539 0 .89-.082 1.056-.248.165-.165.248-.505.248-1.019h-2.258c-.723 0-1.32-.076-1.79-.23a3.381 3.381 0 0 1-1.277-.761c-.893-.845-1.34-2.008-1.34-3.488 0-1.69.545-2.92 1.634-3.69a3.806 3.806 0 0 1 1.24-.598c.434-.116.988-.174 1.661-.174Zm2.13 7.032V20.91h-2.038c-1.42 0-2.13.856-2.13 2.57 0 .832.193 1.466.578 1.9.386.435.946.652 1.68.652h1.91Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="25" height="12" fill="none" viewBox="0 0 25 12">
<rect width="25" height="11.921" fill="#E3D6EE" rx="4"/>
<path fill="#1D2129" d="M4.968 2H8.4v1.248H5.556c-.328 0-.536.016-.624.048-.184.064-.276.22-.276.468 0 .208.088.368.264.48.096.064.356.096.78.096h.948c.608 0 1.088.128 1.44.384.392.288.588.712.588 1.272 0 .424-.12.812-.36 1.164-.176.28-.39.464-.642.552-.252.088-.674.132-1.266.132H3.084V6.596h2.868c.352 0 .592-.004.72-.012.288-.032.432-.196.432-.492 0-.24-.096-.404-.288-.492-.096-.048-.328-.072-.696-.072h-.972c-.384 0-.678-.024-.882-.072a1.575 1.575 0 0 1-.57-.264 1.542 1.542 0 0 1-.51-.63A2.019 2.019 0 0 1 3 3.704c0-.584.212-1.048.636-1.392.256-.208.7-.312 1.332-.312Zm5.995 0 1.488 4.02L14.083 2h1.704l-2.52 5.844h-1.728L9.21 2h1.752Zm8.322 0h3v5.808c0 .328-.037.62-.113.876a1.834 1.834 0 0 1-.33.648 1.412 1.412 0 0 1-.666.462c-.26.084-.638.126-1.134.126h-3.205V8.672h2.989c.351 0 .581-.054.69-.162.108-.108.162-.33.162-.666H19.2c-.472 0-.862-.05-1.17-.15a2.21 2.21 0 0 1-.834-.498c-.584-.552-.876-1.312-.876-2.28 0-1.104.356-1.908 1.068-2.412.256-.184.526-.314.81-.39.284-.076.646-.114 1.086-.114Zm1.392 4.596V3.248h-1.331c-.929 0-1.393.56-1.393 1.68 0 .544.126.958.378 1.242.252.284.618.426 1.099.426h1.247Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="none" viewBox="0 0 48 48">
<circle cx="24" cy="24" r="24" fill="#8552A1" opacity=".2"/>
<circle cx="24" cy="24" r="18" fill="#8552A1"/>
<path fill="#F3EDF7" d="M12.062 18.99H17.4v1.942h-4.425c-.51 0-.834.025-.97.074-.287.1-.43.343-.43.728 0 .324.137.573.41.747.15.1.555.15 1.214.15h1.475c.946 0 1.692.199 2.24.597.61.448.915 1.107.915 1.979 0 .66-.187 1.263-.56 1.81-.274.436-.607.722-.999.86-.392.136-1.048.204-1.97.204H9.13V26.14h4.461c.548 0 .921-.006 1.12-.019.448-.05.672-.305.672-.765 0-.373-.149-.629-.448-.765-.149-.075-.51-.112-1.082-.112h-1.512c-.598 0-1.055-.038-1.373-.112a2.451 2.451 0 0 1-.886-.411 2.399 2.399 0 0 1-.794-.98A3.14 3.14 0 0 1 9 21.64c0-.908.33-1.63.99-2.165.398-.324 1.088-.486 2.072-.486Zm9.325 0 2.314 6.254 2.54-6.254h2.65l-3.92 9.091h-2.688l-3.622-9.09h2.726Zm12.946 0H39v9.036c0 .51-.06.964-.177 1.362-.118.398-.29.734-.514 1.008a2.196 2.196 0 0 1-1.036.719c-.404.13-.992.196-1.764.196h-4.984V29.37h4.648c.548 0 .905-.084 1.073-.252.168-.168.252-.514.252-1.037h-2.296c-.734 0-1.34-.077-1.82-.233a3.438 3.438 0 0 1-1.297-.774c-.909-.86-1.363-2.042-1.363-3.547 0-1.718.554-2.969 1.662-3.753a3.86 3.86 0 0 1 1.26-.606c.442-.119 1.005-.178 1.69-.178Zm2.166 7.15v-5.208h-2.073c-1.443 0-2.165.871-2.165 2.613 0 .847.196 1.49.588 1.932.392.442.961.663 1.708.663h1.941Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="42" fill="none" viewBox="0 0 18 42">
<path fill="#00E8CF" d="M12.723 5.08H3.775v.442c0 .513.14.917.42 1.212.263.28.652.42 1.164.42h7.2c.56 0 .987-.125 1.282-.373.264-.233.42-.59.466-1.072l.07-.955a.771.771 0 0 1 .268-.56.882.882 0 0 1 .594-.21.794.794 0 0 1 .571.28.81.81 0 0 1 .198.606l-.07.955c-.078.948-.42 1.678-1.025 2.19-.606.529-1.39.793-2.354.793h-7.2c-.947 0-1.732-.303-2.353-.909-.606-.606-.909-1.398-.909-2.377V.816c0-.218.082-.408.245-.571A.809.809 0 0 1 2.936 0h9.787c.217 0 .411.082.582.245a.764.764 0 0 1 .256.57v3.45a.754.754 0 0 1-.256.582.842.842 0 0 1-.582.233ZM.839 11.068h10.043v-.979a.78.78 0 0 1 .244-.57.809.809 0 0 1 .595-.245c.217 0 .407.082.57.245a.782.782 0 0 1 .245.57v.98h3.659c.217 0 .407.08.57.244a.782.782 0 0 1 .245.57.808.808 0 0 1-.245.595.782.782 0 0 1-.57.245h-3.659v7.572a.809.809 0 0 1-.245.595.782.782 0 0 1-.57.244.81.81 0 0 1-.595-.244.809.809 0 0 1-.244-.595v-7.572H.839a.809.809 0 0 1-.594-.245.809.809 0 0 1-.245-.594c0-.218.082-.408.245-.571a.809.809 0 0 1 .594-.245Zm11.045-9.414h-8.11V3.45h8.11V1.654ZM6.198 14.866v2.866a.809.809 0 0 1-.245.595.782.782 0 0 1-.57.244.809.809 0 0 1-.595-.244.809.809 0 0 1-.244-.595v-2.866a.81.81 0 0 1 .244-.594.809.809 0 0 1 .595-.245c.217 0 .407.082.57.245a.81.81 0 0 1 .245.594ZM1.266 41.161v-7.456c0-.233.081-.431.244-.594a.809.809 0 0 1 .595-.245c.233 0 .427.081.582.245a.83.83 0 0 1 .233.594v6.64h4.66v-9.6H2.99a.83.83 0 0 1-.594-.233.771.771 0 0 1-.245-.582v-6.268c0-.218.082-.408.245-.571a.809.809 0 0 1 .594-.245c.218 0 .408.082.571.245a.782.782 0 0 1 .245.57v5.453H7.58v-7.2a.81.81 0 0 1 .245-.594.809.809 0 0 1 .594-.245c.218 0 .408.082.571.245a.809.809 0 0 1 .245.594v7.2h3.775v-5.569a.78.78 0 0 1 .244-.57.809.809 0 0 1 .595-.245.78.78 0 0 1 .57.244.782.782 0 0 1 .245.571v6.385a.771.771 0 0 1-.245.582.801.801 0 0 1-.57.233H9.235v9.6h4.66v-6.757a.81.81 0 0 1 .245-.594.782.782 0 0 1 .57-.245.81.81 0 0 1 .595.245.81.81 0 0 1 .245.594v7.573a.809.809 0 0 1-.245.594.81.81 0 0 1-.594.245H2.105a.809.809 0 0 1-.595-.245.809.809 0 0 1-.244-.594Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="10" fill="none" viewBox="0 0 11 10">
<path fill="#FF0D0D" d="M7.549 0C6.645 0 5.807.455 5.25 1.203 4.7.455 3.855 0 2.951 0 1.323 0 0 1.449 0 3.227c0 1.06.473 1.807.856 2.406 1.108 1.742 3.897 3.903 4.017 3.993a.603.603 0 0 0 .754 0c.12-.09 2.904-2.257 4.017-3.993.383-.599.856-1.347.856-2.406C10.5 1.449 9.177 0 7.549 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 392 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="17" fill="none" viewBox="0 0 18 17">
<path fill="#fff" stroke="#8552A1" stroke-width=".855" d="M12.94.428c2.52 0 4.632 2.255 4.632 5.103 0 1.68-.743 2.87-1.4 3.895-.92 1.437-2.554 3.077-4.008 4.394a49.45 49.45 0 0 1-2.774 2.34l-.008.006a.607.607 0 0 1-.673.06l-.09-.06-.009-.007-.27-.21a51.872 51.872 0 0 1-2.508-2.125c-1.455-1.316-3.088-2.955-4.004-4.396v-.001l-.249-.394C.997 8.096.428 7.001.428 5.531.428 2.683 2.54.428 5.06.428c1.402 0 2.725.706 3.595 1.888l.343.465.345-.463c.882-1.185 2.196-1.89 3.597-1.89Z"/>
</svg>

After

Width:  |  Height:  |  Size: 585 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" fill="none" viewBox="0 0 10 10">
<path fill="#0079F5" d="M7.432 9.596H2.164A2.165 2.165 0 0 1 0 7.432V2.164C0 .97.97 0 2.164 0h5.268c1.195 0 2.164.97 2.164 2.164v5.268c0 1.195-.97 2.164-2.164 2.164Z"/>
<path fill="#fff" d="M6.24 2.982a.36.36 0 0 0-.662 0l-1.272 2.98a.24.24 0 1 0 .444.186l.147-.352a.813.813 0 0 1 .75-.501h.524c.328 0 .624.198.75.5l.147.353a.24.24 0 1 0 .444-.187L6.24 2.982Zm.058 1.312a.42.42 0 1 1-.776.324.42.42 0 0 1 .776-.324Zm-3.01-.587a.253.253 0 0 0-.465 0l-.774 1.814a.18.18 0 1 0 .333.14l.08-.194a.495.495 0 0 1 .457-.305h.273c.2 0 .38.12.457.305l.08.194a.18.18 0 1 0 .333-.14l-.774-1.814ZM3.055 4.84a.222.222 0 1 1 0-.443.222.222 0 0 1 0 .443Z"/>
</svg>

After

Width:  |  Height:  |  Size: 749 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16">
<path fill="#5C5C66" d="M10.333 14.667a.333.333 0 0 1-.31-.213L8.55 10.667H2.784L1.31 14.454a.333.333 0 1 1-.622-.242l4.667-12a.333.333 0 0 1 .621 0l4.667 12a.335.335 0 0 1-.155.416.334.334 0 0 1-.156.039ZM3.043 10H8.29L5.667 3.253 3.043 10ZM15 4h-4a.333.333 0 1 1 0-.667h4A.333.333 0 1 1 15 4Z"/>
</svg>

After

Width:  |  Height:  |  Size: 403 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="#5C5C66" d="M15.5 22a.5.5 0 0 1-.466-.319L12.824 16H4.176l-2.21 5.681a.5.5 0 1 1-.93-.362l7-18a.5.5 0 0 1 .93 0l7 18A.5.5 0 0 1 15.5 22ZM4.564 15h7.872L8.5 4.88 4.564 15ZM19.5 9a.5.5 0 0 1-.5-.5V6h-2.5a.5.5 0 0 1 0-1H19V2.5a.5.5 0 0 1 1 0V5h2.5a.5.5 0 0 1 0 1H20v2.5a.5.5 0 0 1-.5.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 404 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#8552A1" rx="10" transform="matrix(-1 0 0 1 32 0)"/>
<path fill="#FEFDFE" d="m24.845 22.204-5.101-5.105-2.64 2.64 5.105 5.104a1.085 1.085 0 0 0 1.527 0l1.108-1.108a1.09 1.09 0 0 0 0-1.531ZM17.22 18.6l1.382-1.382-1.576-1.576a5.512 5.512 0 0 0-.63-7.032 5.51 5.51 0 0 0-7.785 0c-2.15 2.146-2.146 5.635 0 7.785a5.512 5.512 0 0 0 7.033.63l1.575 1.576Zm-7.541-3.288a3.983 3.983 0 0 1 0-5.635 3.983 3.983 0 0 1 5.635 0 3.983 3.983 0 0 1 0 5.635 3.983 3.983 0 0 1-5.635 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 611 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 20 20">
<path fill="#8552A1" d="M15.608 15.302a.717.717 0 0 1 .218.524.712.712 0 0 1-.218.524.729.729 0 0 1-.525.218H4.326a1.78 1.78 0 0 1-1.303-.542 1.78 1.78 0 0 1-.543-1.303V3.963c0-.207.073-.383.218-.525a.728.728 0 0 1 .525-.218c.203 0 .382.072.524.218a.728.728 0 0 1 .218.524v10.015c0 .303.11.56.327.78.219.217.473.326.776.326h10.015c.206 0 .38.073.525.219Zm-2.97 2.227a.717.717 0 0 1 .218.524.712.712 0 0 1-.218.524.729.729 0 0 1-.524.218H2.098a1.78 1.78 0 0 1-1.303-.542 1.785 1.785 0 0 1-.545-1.306V6.932c0-.206.073-.382.218-.524a.728.728 0 0 1 .524-.219c.203 0 .382.073.525.219a.728.728 0 0 1 .218.524v9.272c0 .304.109.561.327.78.218.217.476.327.779.327h9.273c.203 0 .378.072.524.218ZM18.153.892c.427.43.643.952.643 1.567v9.67c0 .615-.216 1.14-.643 1.566a2.136 2.136 0 0 1-1.567.643H6.914c-.616 0-1.14-.215-1.567-.643a2.129 2.129 0 0 1-.642-1.563V2.459c0-.615.215-1.14.642-1.567A2.136 2.136 0 0 1 6.914.25h9.67c.617 0 1.139.215 1.569.642Zm-.842 2.164c0-.364-.13-.673-.388-.933a1.263 1.263 0 0 0-.934-.388H7.514c-.364 0-.673.13-.934.388a1.27 1.27 0 0 0-.39.933v8.476c0 .364.13.672.387.933.258.26.57.388.934.388h8.475c.364 0 .673-.13.934-.388.26-.257.388-.57.388-.933V3.056h.003Z"/>
<path fill="#8552A1" d="M10.114 9.583 7.966 7.61l-.716.658 2.864 2.633L16.25 5.26l-.716-.659-5.42 4.983Z"/>
<path stroke="#8552A1" stroke-width=".5" d="M15.608 15.302a.717.717 0 0 1 .218.524.712.712 0 0 1-.218.524.729.729 0 0 1-.525.218H4.326a1.78 1.78 0 0 1-1.303-.542 1.78 1.78 0 0 1-.543-1.303V3.963c0-.207.073-.383.218-.525a.728.728 0 0 1 .525-.218c.203 0 .382.072.524.218a.728.728 0 0 1 .218.524v10.015c0 .303.11.56.327.78.219.217.473.326.776.326h10.015c.206 0 .38.073.525.219Zm-2.97 2.227a.717.717 0 0 1 .218.524.712.712 0 0 1-.218.524.729.729 0 0 1-.524.218H2.098a1.78 1.78 0 0 1-1.303-.542 1.785 1.785 0 0 1-.545-1.306V6.932c0-.206.073-.382.218-.524a.728.728 0 0 1 .524-.219c.203 0 .382.073.525.219a.728.728 0 0 1 .218.524v9.272c0 .304.109.561.327.78.218.217.476.327.779.327h9.273c.203 0 .378.072.524.218ZM18.153.892c.427.43.643.952.643 1.567v9.67c0 .615-.216 1.14-.643 1.566a2.136 2.136 0 0 1-1.567.643H6.914c-.616 0-1.14-.215-1.567-.643a2.129 2.129 0 0 1-.642-1.563V2.459c0-.615.215-1.14.642-1.567A2.136 2.136 0 0 1 6.914.25h9.67c.617 0 1.139.215 1.569.642Zm-.842 2.164c0-.364-.13-.673-.388-.933a1.263 1.263 0 0 0-.934-.388H7.514c-.364 0-.673.13-.934.388a1.27 1.27 0 0 0-.39.933v8.476c0 .364.13.672.387.933.258.26.57.388.934.388h8.475c.364 0 .673-.13.934-.388.26-.257.388-.57.388-.933V3.056h.003Z"/>
<path stroke="#8552A1" stroke-width=".5" d="M10.114 9.583 7.966 7.61l-.716.658 2.864 2.633L16.25 5.26l-.716-.659-5.42 4.983Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 20 20">
<path fill="#8552A1" d="M15.608 15.302a.717.717 0 0 1 .218.524.712.712 0 0 1-.218.524.729.729 0 0 1-.525.218H4.326a1.78 1.78 0 0 1-1.303-.542 1.78 1.78 0 0 1-.543-1.303V3.963c0-.207.073-.383.218-.525a.728.728 0 0 1 .525-.218c.203 0 .382.072.524.218a.728.728 0 0 1 .218.524v10.015c0 .303.11.56.327.78.219.217.473.326.776.326h10.015c.206 0 .38.073.525.219Zm-2.97 2.227a.717.717 0 0 1 .218.524.712.712 0 0 1-.218.524.729.729 0 0 1-.524.218H2.098a1.78 1.78 0 0 1-1.303-.542 1.785 1.785 0 0 1-.545-1.306V6.932c0-.206.073-.382.218-.524a.728.728 0 0 1 .524-.219c.203 0 .382.073.525.219a.728.728 0 0 1 .218.524v9.272c0 .304.109.561.327.78.218.217.476.327.779.327h9.273c.203 0 .378.072.524.218ZM18.153.892c.427.43.643.952.643 1.567v9.67c0 .615-.216 1.14-.643 1.566a2.136 2.136 0 0 1-1.567.643H6.914c-.616 0-1.14-.215-1.567-.643a2.129 2.129 0 0 1-.642-1.563V2.459c0-.615.215-1.14.642-1.567A2.136 2.136 0 0 1 6.914.25h9.67c.617 0 1.139.215 1.569.642Zm-.842 2.164c0-.364-.13-.673-.388-.933a1.263 1.263 0 0 0-.934-.388H7.514c-.364 0-.673.13-.934.388a1.27 1.27 0 0 0-.39.933v8.476c0 .364.13.672.387.933.258.26.57.388.934.388h8.475c.364 0 .673-.13.934-.388.26-.257.388-.57.388-.933V3.056h.003Z"/>
<path fill="#8552A1" d="m15.523 4.546-.806-.705-3.194 2.795L8.328 3.84l-.805.705 3.194 2.795-3.194 2.795.805.705 3.195-2.795 3.194 2.795.806-.705-3.195-2.795 3.195-2.795Z"/>
<path stroke="#8552A1" stroke-width=".5" d="M15.608 15.302a.717.717 0 0 1 .218.524.712.712 0 0 1-.218.524.729.729 0 0 1-.525.218H4.326a1.78 1.78 0 0 1-1.303-.542 1.78 1.78 0 0 1-.543-1.303V3.963c0-.207.073-.383.218-.525a.728.728 0 0 1 .525-.218c.203 0 .382.072.524.218a.728.728 0 0 1 .218.524v10.015c0 .303.11.56.327.78.219.217.473.326.776.326h10.015c.206 0 .38.073.525.219Zm-2.97 2.227a.717.717 0 0 1 .218.524.712.712 0 0 1-.218.524.729.729 0 0 1-.524.218H2.098a1.78 1.78 0 0 1-1.303-.542 1.785 1.785 0 0 1-.545-1.306V6.932c0-.206.073-.382.218-.524a.728.728 0 0 1 .524-.219c.203 0 .382.073.525.219a.728.728 0 0 1 .218.524v9.272c0 .304.109.561.327.78.218.217.476.327.779.327h9.273c.203 0 .378.072.524.218ZM18.153.892c.427.43.643.952.643 1.567v9.67c0 .615-.216 1.14-.643 1.566a2.136 2.136 0 0 1-1.567.643H6.914c-.616 0-1.14-.215-1.567-.643a2.129 2.129 0 0 1-.642-1.563V2.459c0-.615.215-1.14.642-1.567A2.136 2.136 0 0 1 6.914.25h9.67c.617 0 1.139.215 1.569.642Zm-.842 2.164c0-.364-.13-.673-.388-.933a1.263 1.263 0 0 0-.934-.388H7.514c-.364 0-.673.13-.934.388a1.27 1.27 0 0 0-.39.933v8.476c0 .364.13.672.387.933.258.26.57.388.934.388h8.475c.364 0 .673-.13.934-.388.26-.257.388-.57.388-.933V3.056h.003Z"/>
<path stroke="#8552A1" stroke-width=".5" d="m15.523 4.546-.806-.705-3.194 2.795L8.328 3.84l-.805.705 3.194 2.795-3.194 2.795.805.705 3.195-2.795 3.194 2.795.806-.705-3.195-2.795 3.195-2.795Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none" viewBox="0 0 15 15">
<path fill="#000" d="M15 7.5a7.5 7.5 0 1 1-15 0 7.5 7.5 0 0 1 15 0Z"/>
<path fill="#fff" d="M3 7.75A.75.75 0 0 1 3.75 7h7.5a.75.75 0 0 1 0 1.5h-7.5A.75.75 0 0 1 3 7.75Z"/>
</svg>

After

Width:  |  Height:  |  Size: 279 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="33" fill="none" viewBox="0 0 360 33">
<g clip-path="url(#a)">
<path fill="#8552A1" d="M7.305 15.333h7.898l-.856 1.51h8.49l.593-2.883H5.594L7.832 2.7 5.857.87h50.874L54.1 13.96H36.724l-.592 2.883h17.572l-.856 4.12H35.276l-.395 1.968h17.507l-.79 3.936H34.09l-.263 1.373h20.468l-.79 3.89H0l.79-3.89h19.744l.263-1.373H4.87l.79-3.936h15.927l.395-1.968h-9.938l-.724 1.373H3.16l4.145-7.003ZM20.007 5.63H43.24l.33-1.373H20.336l-.329 1.373Zm22.311 4.668.263-1.327H19.35l-.263 1.327h23.232Zm51.795 10.894.527-2.793h-9.872l.79-3.982h31.327l-.79 3.982h-10.004l-.526 2.793h8.753l-.855 4.165h-8.754l-.724 3.432h10.465l-.856 4.028H81.477l.856-4.028h10.2l.725-3.433H84.57l.856-4.165h8.687ZM85.36 4.622l-4.804.321-.856 4.303h5.66l-.855 4.21H78.12l-3.95 19.498h-8.687l3.95-19.498H62.72l.856-4.21h5.989l.79-3.662-5.66.366.855-4.21L86.216.411l-.856 4.21ZM89.572.87h29.09l-2.369 11.762h-29.09L89.573.87ZM59.101 31.307l4.212-16.111h5.594l-4.212 16.11H59.1Zm16.65 0 2.37-16.111h5.594l-2.37 16.11h-5.594ZM107.21 8.65l.724-3.8h-9.345l-.724 3.8h9.345Zm35.276 11.031.395-1.785 14.545-3.112h-28.958l.921-4.44h43.766l-1.053 5.31-17.111 4.027h21.258l-.856 4.44h-21.324l-1.118 5.72c-.176.886-.757 1.626-1.744 2.22-.988.596-2.117.893-3.39.893h-12.965l5.857-4.302.922-4.531h-21.258l.856-4.44h21.257ZM163.81 6.957h-25.009l-.461 2.472h-13.755l1.382-6.82h19.942l.197-.915L144.066 0h15.927l-.593 2.61h19.086l-1.382 6.82h-13.755l.461-2.472Zm48.044 2.197-4.41 13.044h2.962l2.896-14.371h-11.847l.856-4.348h11.846l.395-1.74L212.446 0h14.413l-.724 3.479h11.847l-.856 4.348H225.28l-2.962 14.371h3.028l.855-13.044h8.161l-1.58 23.022h-8.095l.329-5.63h-3.553l-1.317 6.408h-11.912l1.25-6.407h-3.488l-1.908 5.63h-8.161l7.766-23.023h8.161Zm-12.176-4.806-.197.961-5.594 27.645h-11.913l5.266-25.951-3.818.503 1.053-5.217L200.534.092l-.856 4.256Zm79.503 19.361 1.514 1.053 5.396-3.021H268.98l3.554-6.088h-4.081l.856-4.302h5.726l1.974-3.387h-4.607l.856-4.21h6.186l1.514-2.61L279.313 0h12.504l-2.04 3.753h9.872l-.856 4.211h-11.32l-1.776 3.387h14.018l-.856 4.302h-15.532l-1.119 2.106h14.216l-1.185 5.446-9.148 5.264 6.516 4.44h-11.846l-12.702-9.2h11.122Zm-33.565-9.566 3.751-6.545h-3.356l.856-4.257h4.936l1.118-1.922-1.316-1.373h9.346l-1.909 3.295h11.846l-.855 4.257h-13.426l-3.883 6.82h2.962l1.118-5.31h8.161l-1.053 5.31h3.686l-.79 4.073h-3.751l-.724 3.524 4.936-.457-.922 4.577-4.936.457-1.25 6.27h-9.872l1.053-5.309-9.938.916.921-4.577 9.938-.915.922-4.486h-8.424l.855-4.348Zm78.252 5.127 2.041-9.933h16.914l3.422-3.25h-6.581l-2.304 1.831h-11.122l8.687-6.82-1.25-1.052h13.623l-2.633 2.06h13.69l-.79 4.119-3.357 3.112h5.66l-2.04 9.932H360l-.856 4.12h-12.702L356.907 33h-10.596l-6.845-6.957-9.016 6.774h-11.715l13.097-9.429h-10.991l.856-4.119h2.171ZM311.956 5.446l.856-4.165L311.232 0h12.505l-1.119 5.447h2.567l-.856 4.348h-2.632l-.856 4.394 2.698-.55-.987 4.76-2.698.55-2.172 10.847c-.176.855-.735 1.572-1.678 2.151-.944.58-2.052.87-3.324.87h-10.53l5.133-4.12 1.514-7.46-4.278.87.987-4.806 4.212-.87 1.383-6.636h-4.147l.856-4.348h4.146Zm34.75 7.231-1.317 6.5h4.081l1.316-6.5h-4.08Zm-13.097.138-1.316 6.453h4.014l1.316-6.453h-4.014Z"/>
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M0 0h360v33H0z"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -0,0 +1,12 @@
{
"active": "B",
"cooldownMinutes": 0,
"servers": {
"A": {
"baseUrl": "https://fonts.biboer.cn"
},
"B": {
"baseUrl": "https://mac-tunnel.biboer.cn"
}
}
}

42
miniprogram/config/cdn.js Normal file
View File

@@ -0,0 +1,42 @@
// CDN 配置文件
// 管理所有静态资源的 CDN 地址
const { buildRuntimeConfig } = require('./server')
const runtimeConfig = buildRuntimeConfig()
const CDN_BASE_URL = runtimeConfig.fontsBaseUrl;
// 图标路径配置
const ICON_PATHS = {
// Logo
logo: `${CDN_BASE_URL}/assets/webicon.png`,
// 字体大小控制图标
fontSizeDecrease: `${CDN_BASE_URL}/assets/icons/font-size-decrease.svg`,
fontSizeIncrease: `${CDN_BASE_URL}/assets/icons/font-size-increase.svg`,
// 颜色选择器图标
chooseColor: `${CDN_BASE_URL}/assets/icons/choose-color.svg`,
// 导出按钮图标
export: `${CDN_BASE_URL}/assets/icons/export.svg`,
exportSvg: `${CDN_BASE_URL}/assets/icons/export-svg.svg`,
exportPng: `${CDN_BASE_URL}/assets/icons/export-png.svg`,
// 字体树图标
fontIcon: `${CDN_BASE_URL}/assets/icons/font-icon.svg`,
expand: `${CDN_BASE_URL}/assets/icons/expand.svg`,
selectAll: `${CDN_BASE_URL}/assets/icons/selectall.svg`,
unselectAll: `${CDN_BASE_URL}/assets/icons/unselectall.svg`,
checkbox: `${CDN_BASE_URL}/assets/icons/checkbox.svg`
};
// 字体资源路径
const FONT_BASE_URL = `${CDN_BASE_URL}/fonts`;
const FONTS_JSON_URL = runtimeConfig.fontsManifestUrl;
module.exports = {
CDN_BASE_URL,
ICON_PATHS,
FONT_BASE_URL,
FONTS_JSON_URL
};

View File

@@ -0,0 +1,68 @@
// 远端服务配置(统一修改入口)
// 更换服务器时,仅需修改这里。
const SERVER_CONFIG = {
protocol: 'https',
host: 'fonts.biboer.cn',
// 留空表示使用协议默认端口https:443 / http:80
port: '',
apiPrefix: '/api',
fontsManifestPath: '/miniprogram/assets/fonts.json',
defaultConfigPath: '/miniprogram/assets/default.json',
routeConfigPath: '/miniprogram/assets/route-config.json',
}
function buildOrigin() {
const protocol = String(SERVER_CONFIG.protocol || 'https').replace(/:$/, '')
const host = String(SERVER_CONFIG.host || '').trim()
const port = String(SERVER_CONFIG.port || '').trim()
if (!host) {
throw new Error('SERVER_CONFIG.host 未配置')
}
const hasDefaultPort = (protocol === 'https' && port === '443') || (protocol === 'http' && port === '80')
const portPart = !port || hasDefaultPort ? '' : `:${port}`
return `${protocol}://${host}${portPart}`
}
function normalizePath(path, fallback) {
const value = String(path || fallback || '').trim()
if (!value) {
return '/'
}
return value.startsWith('/') ? value : `/${value}`
}
function normalizeBaseUrl(baseUrl) {
const value = String(baseUrl || '').trim()
if (!value) {
return buildOrigin()
}
const withProtocol = /^https?:\/\//i.test(value) ? value : `https://${value}`
return withProtocol.replace(/\/+$/, '')
}
function buildRuntimeConfig(options = {}) {
const origin = normalizeBaseUrl(options.baseUrl)
const apiPrefix = normalizePath(SERVER_CONFIG.apiPrefix, '/api').replace(/\/$/, '')
const fontsManifestPath = normalizePath(SERVER_CONFIG.fontsManifestPath, '/miniprogram/assets/fonts.json')
const defaultConfigPath = normalizePath(SERVER_CONFIG.defaultConfigPath, '/miniprogram/assets/default.json')
const routeConfigPath = normalizePath(SERVER_CONFIG.routeConfigPath, '/miniprogram/assets/route-config.json')
return {
activeServerKey: String(options.activeServerKey || '').trim(),
fontsBaseUrl: origin,
fontsManifestUrl: `${origin}${fontsManifestPath}`,
defaultConfigUrl: `${origin}${defaultConfigPath}`,
routeConfigUrl: `${origin}${routeConfigPath}`,
svgRenderApiUrl: `${origin}${apiPrefix}/render-svg`,
pngRenderApiUrl: `${origin}${apiPrefix}/render-png`,
}
}
module.exports = {
SERVER_CONFIG,
buildRuntimeConfig,
}

View File

@@ -0,0 +1,11 @@
{
"ios": {
"name": "星程社"
},
"android": {
"name": "星程社"
},
"common": {
"name": "星程社"
}
}

View File

@@ -0,0 +1,144 @@
const { loadFontsManifest, listCategories } = require('../../utils/mp/font-loader')
const { loadFavorites, saveFavorites } = require('../../utils/mp/storage')
Page({
data: {
fonts: [],
filteredFonts: [],
categories: ['全部'],
categoryIndex: 0,
favoriteOnly: false,
favorites: [],
searchText: '',
selectedFontId: '',
},
async onLoad(options) {
const selectedFontId = options && options.selected ? decodeURIComponent(options.selected) : ''
this.setData({ selectedFontId })
wx.showLoading({ title: '加载字体中', mask: true })
try {
const favorites = loadFavorites()
const fonts = await loadFontsManifest()
const categories = listCategories(fonts)
this.fontMap = new Map(fonts.map((font) => [font.id, font]))
this.setData({
fonts,
categories,
favorites,
})
this.applyFilter()
} catch (error) {
wx.showToast({ title: '字体加载失败', icon: 'none' })
} finally {
wx.hideLoading()
}
},
applyFilter() {
const {
fonts,
favorites,
searchText,
categories,
categoryIndex,
favoriteOnly,
} = this.data
const keyword = String(searchText || '').trim().toLowerCase()
const selectedCategory = categories[categoryIndex] || '全部'
const favoriteSet = new Set(favorites)
const filteredFonts = fonts
.filter((font) => {
if (favoriteOnly && !favoriteSet.has(font.id)) {
return false
}
if (selectedCategory !== '全部' && selectedCategory !== '收藏' && font.category !== selectedCategory) {
return false
}
if (selectedCategory === '收藏' && !favoriteSet.has(font.id)) {
return false
}
if (!keyword) {
return true
}
return (
String(font.name || '').toLowerCase().includes(keyword) ||
String(font.category || '').toLowerCase().includes(keyword)
)
})
.map((font) => ({
...font,
isFavorite: favoriteSet.has(font.id),
}))
this.setData({ filteredFonts })
},
onSearchInput(event) {
this.setData({ searchText: event.detail.value || '' })
this.applyFilter()
},
onCategoryChange(event) {
this.setData({ categoryIndex: Number(event.detail.value) || 0 })
this.applyFilter()
},
onToggleFavoriteOnly() {
this.setData({ favoriteOnly: !this.data.favoriteOnly })
this.applyFilter()
},
onToggleFavorite(event) {
const fontId = event.currentTarget.dataset.fontId
if (!fontId) {
return
}
const next = new Set(this.data.favorites)
if (next.has(fontId)) {
next.delete(fontId)
} else {
next.add(fontId)
}
const favorites = saveFavorites(Array.from(next))
this.setData({ favorites })
this.applyFilter()
},
onSelectFont(event) {
const fontId = event.currentTarget.dataset.fontId
if (!fontId) {
return
}
this.setData({ selectedFontId: fontId })
},
onCancel() {
wx.navigateBack()
},
onConfirm() {
const { selectedFontId } = this.data
if (!selectedFontId) {
wx.showToast({ title: '请选择字体', icon: 'none' })
return
}
const font = this.fontMap ? this.fontMap.get(selectedFontId) : null
const eventChannel = this.getOpenerEventChannel()
eventChannel.emit('fontSelected', {
fontId: selectedFontId,
font,
})
wx.navigateBack()
},
})

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "选择字体"
}

View File

@@ -0,0 +1,53 @@
<view class="container">
<view class="card">
<input
class="search-input"
placeholder="搜索字体名称"
value="{{searchText}}"
bindinput="onSearchInput"
/>
<view class="toolbar row space-between">
<picker mode="selector" range="{{categories}}" value="{{categoryIndex}}" bindchange="onCategoryChange">
<view class="picker-btn">分类:{{categories[categoryIndex]}}</view>
</picker>
<button class="mini-btn" size="mini" bindtap="onToggleFavoriteOnly">
{{favoriteOnly ? '仅收藏中' : '全部字体'}}
</button>
</view>
</view>
<view class="card list-card">
<view class="summary">共 {{filteredFonts.length}} 个字体</view>
<scroll-view class="font-list" scroll-y>
<view
wx:for="{{filteredFonts}}"
wx:key="id"
class="font-item {{item.id === selectedFontId ? 'selected' : ''}}"
bindtap="onSelectFont"
data-font-id="{{item.id}}"
>
<view class="font-info">
<view class="font-name">{{item.name}}</view>
<view class="font-meta">{{item.category}}</view>
</view>
<view class="actions row">
<view
class="star"
catchtap="onToggleFavorite"
data-font-id="{{item.id}}"
>
{{item.isFavorite ? '★' : '☆'}}
</view>
<view wx:if="{{item.id === selectedFontId}}" class="selected-tag">已选</view>
</view>
</view>
<view wx:if="{{!filteredFonts.length}}" class="empty">没有匹配字体</view>
</scroll-view>
</view>
<view class="footer row space-between">
<button class="btn-secondary" bindtap="onCancel">取消</button>
<button class="btn-primary" bindtap="onConfirm">使用该字体</button>
</view>
</view>

View File

@@ -0,0 +1,102 @@
.search-input {
background: #f6f8fc;
border-radius: 12rpx;
padding: 18rpx 20rpx;
font-size: 28rpx;
}
.toolbar {
margin-top: 16rpx;
}
.picker-btn {
background: #f6f8fc;
border-radius: 12rpx;
padding: 14rpx 16rpx;
font-size: 26rpx;
}
.mini-btn {
margin: 0;
height: 56rpx;
line-height: 56rpx;
background: #edf2ff;
color: #274c95;
}
.list-card {
padding: 0;
}
.summary {
padding: 18rpx 22rpx;
color: #6b7280;
font-size: 24rpx;
border-bottom: 1rpx solid #f0f2f7;
}
.font-list {
height: calc(100vh - 410rpx);
}
.font-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18rpx 22rpx;
border-bottom: 1rpx solid #f2f3f8;
}
.font-item.selected {
background: #eef5ff;
}
.font-name {
font-size: 30rpx;
font-weight: 500;
}
.font-meta {
margin-top: 6rpx;
font-size: 22rpx;
color: #6b7280;
}
.actions {
align-items: center;
}
.star {
font-size: 36rpx;
color: #f59e0b;
width: 56rpx;
text-align: center;
}
.selected-tag {
background: #1677ff;
color: #fff;
border-radius: 999rpx;
font-size: 20rpx;
padding: 6rpx 14rpx;
}
.empty {
color: #9ca3af;
text-align: center;
padding: 60rpx 0;
}
.footer {
position: fixed;
left: 24rpx;
right: 24rpx;
bottom: 24rpx;
gap: 16rpx;
}
.footer button {
flex: 1;
height: 84rpx;
line-height: 84rpx;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "Font2SVG"
}

View File

@@ -0,0 +1,211 @@
<view class="container">
<!-- 顶部导航栏 -->
<view class="header-row">
<view class="logo-container">
<image class="logo" src="{{icons.logo}}" mode="aspectFit" />
</view>
<view class="app-title">TextToSVG</view>
<!-- 字体大小滑块 -->
<view class="font-size-control">
<image
class="font-size-icon"
src="{{icons.fontSizeDecrease}}"
mode="aspectFit"
bindtap="onDecreaseFontSize"
/>
<view class="font-slider-wrap">
<view class="font-size-value">{{fontSize}}</view>
<slider
class="font-slider"
min="20"
max="120"
step="1"
value="{{fontSize}}"
show-value="{{false}}"
activeColor="#9B6BC2"
backgroundColor="#E5E6EB"
block-size="18"
bindchanging="onFontSizeChanging"
bindchange="onFontSizeChange"
/>
</view>
<image
class="font-size-icon"
src="{{icons.fontSizeIncrease}}"
mode="aspectFit"
bindtap="onIncreaseFontSize"
/>
</view>
<view class="color-picker-btn" bindtap="onShowColorPicker">
<image class="color-icon" src="{{icons.chooseColor}}" />
</view>
</view>
<!-- 输入栏 -->
<view class="input-row">
<image class="content-icon" src="{{icons.content}}" />
<view class="text-input-container">
<input
class="text-input"
value="{{inputText}}"
placeholder="此处输入内容"
bindinput="onInputText"
confirm-type="done"
bindconfirm="onRegenerate"
/>
</view>
</view>
<!-- 效果预览区域 -->
<view class="preview-section">
<view class="section-title">效果预览</view>
<scroll-view class="preview-list" scroll-y>
<view wx:for="{{selectedFonts}}" wx:key="id" class="preview-item">
<view class="preview-header">
<image class="font-icon" src="{{icons.fontIcon}}" />
<view class="font-name-text">{{item.name}}</view>
<view class="export-btns-inline">
<image class="download-icon" src="{{icons.download}}" />
<view class="export-btn-sm export-svg-btn" bindtap="onExportSingleSvg" data-font-id="{{item.id}}">
<image class="export-icon-sm" src="{{icons.exportSvg}}" />
</view>
<view class="export-btn-sm export-png-btn" bindtap="onExportSinglePng" data-font-id="{{item.id}}">
<image class="export-icon-sm" src="{{icons.exportPng}}" />
</view>
</view>
</view>
<view class="preview-content" bindtap="onTogglePreviewFont" data-font-id="{{item.id}}">
<image
wx:if="{{item.previewSrc}}"
class="preview-image"
mode="widthFix"
src="{{item.previewSrc}}"
style="{{item.previewImageStyle}}"
/>
<view wx:elif="{{item.previewError}}" class="preview-error">{{item.previewError}}</view>
<view wx:else class="preview-loading">生成中...</view>
</view>
</view>
<view wx:if="{{!selectedFonts.length}}" class="preview-empty">请从下方选择字体</view>
</scroll-view>
</view>
<!-- 字体选择和已收藏字体 -->
<view class="bottom-section">
<!-- 字体选择 -->
<view class="font-selection">
<view class="selection-header">
<view class="section-title">选择</view>
<view class="search-container" wx:if="{{showSearch}}">
<image class="search-icon" src="{{icons.search}}" />
<input
class="search-input"
value="{{searchKeyword}}"
placeholder="搜索字体"
bindinput="onSearchInput"
/>
</view>
<view class="search-toggle" wx:if="{{!showSearch}}" bindtap="onToggleSearch">
<image class="search-icon" src="{{icons.search}}" />
</view>
</view>
<scroll-view class="font-tree" scroll-y>
<view wx:for="{{fontCategories}}" wx:key="category" class="font-category">
<view class="category-header" bindtap="onToggleCategory" data-category="{{item.category}}">
<image
class="expand-icon"
src="{{icons.expandIcon}}"
style="transform: rotate({{item.expanded ? '90deg' : '0deg'}})"
/>
<view class="category-name">{{item.category}}</view>
<view class="category-select-all" catchtap="onToggleSelectAllInCategory" data-category="{{item.category}}">
<image class="select-all-icon" src="{{item.allSelected ? icons.unselectAll : icons.selectAll}}" />
</view>
</view>
<view wx:if="{{item.expanded && item.fonts.length > 0}}" class="tree-vertical-line" />
<view wx:if="{{item.expanded}}" class="font-list select-font-list">
<view
wx:for="{{item.fonts}}"
wx:for-item="font"
wx:key="id"
class="font-item select-font-item"
>
<view class="tree-horizontal-line" />
<image class="font-item-icon" src="{{icons.fontIcon}}" />
<view class="font-item-name">{{font.name}}</view>
<view class="font-item-actions">
<view class="font-checkbox" bindtap="onToggleFont" data-font-id="{{font.id}}">
<image class="checkbox-icon-sm" src="{{font.selected ? icons.checkboxChecked : icons.checkbox}}" />
</view>
<view class="favorite-btn" bindtap="onToggleFavorite" data-font-id="{{font.id}}">
<image
class="favorite-icon"
src="{{font.isFavorite ? icons.favoriteRedIcon : icons.favoriteIcon}}"
/>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
<!-- 已收藏字体 -->
<view class="favorite-selection">
<view class="favorite-header">
<view class="section-title">已收藏</view>
</view>
<scroll-view class="font-tree favorite-list" scroll-y>
<view wx:for="{{favoriteFonts}}" wx:key="id" class="font-item favorite-font-item">
<image class="font-item-icon" src="{{icons.fontIcon}}" />
<view class="font-item-name">{{item.name}}</view>
<view class="font-item-actions">
<view class="font-checkbox" bindtap="onToggleFont" data-font-id="{{item.id}}">
<image class="checkbox-icon-sm" src="{{item.selected ? icons.checkboxChecked : icons.checkbox}}" />
</view>
<view class="favorite-btn" bindtap="onToggleFavorite" data-font-id="{{item.id}}">
<image class="favorite-icon" src="{{icons.favoriteRedIcon}}" />
</view>
</view>
</view>
<view wx:if="{{!favoriteFonts.length}}" class="empty-favorites">暂无收藏字体</view>
</scroll-view>
</view>
</view>
<!-- 版权说明 -->
<view class="copyright-footer">
<text>@版权说明仅SVG和PNG分享无TTF下载如侵权反馈</text>
<text class="copyright-email-link" bindtap="onTapFeedbackEmail">{{feedbackEmail}}</text>
</view>
<!-- 颜色选择器弹窗 -->
<view wx:if="{{showColorPicker}}" class="color-picker-modal" bindtap="onHideColorPicker">
<view class="color-picker-content" catchtap="onStopPropagation">
<view class="color-palette">
<view
wx:for="{{colorPalette}}"
wx:key="*this"
class="color-dot"
style="background: {{item}}; border: {{textColor === item ? '3rpx solid #9B6BC2' : '2rpx solid rgba(0,0,0,0.1)'}}"
data-color="{{item}}"
bindtap="onPickColor"
/>
</view>
<view class="color-input-row">
<text>自定义颜色:</text>
<input
class="color-input"
type="text"
maxlength="7"
value="{{textColor}}"
bindinput="onColorInput"
/>
</view>
</view>
</view>
<canvas id="exportCanvas" canvas-id="exportCanvas" type="2d" class="hidden-canvas" />
</view>

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