Compare commits
84 Commits
6616d284ed
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82ab714405 | ||
|
|
b7dac22327 | ||
|
|
1fb3b51beb | ||
|
|
e4e552cca6 | ||
|
|
35e17b4ba2 | ||
|
|
797da6eb76 | ||
|
|
88648748e7 | ||
|
|
46327636b0 | ||
|
|
3fc17e7f45 | ||
|
|
a8f6168433 | ||
|
|
9d8316332b | ||
|
|
e55d0d00c0 | ||
|
|
63f11b0067 | ||
|
|
cc6c9c8a99 | ||
|
|
b0c7ea4cba | ||
|
|
3d5d517439 | ||
|
|
b3add14421 | ||
|
|
74eebc19db | ||
|
|
2a737f2857 | ||
|
|
eb27b93d1e | ||
|
|
3bbd9e3069 | ||
|
|
22685a412b | ||
|
|
91fa46bd0c | ||
|
|
494c9aec0e | ||
|
|
05ccc985c5 | ||
|
|
2329d36260 | ||
|
|
4c7cbc8ae2 | ||
|
|
831d708838 | ||
|
|
859ec836df | ||
|
|
1076ca1fa0 | ||
|
|
7899b42e5c | ||
|
|
b5f5ade1f3 | ||
|
|
0dbb991522 | ||
|
|
5e4fffbce4 | ||
|
|
a582bf09a8 | ||
|
|
4903ff63c1 | ||
|
|
50e23655e4 | ||
|
|
6fb59d5b8f | ||
|
|
19c47413ec | ||
|
|
95e37e1c20 | ||
|
|
866ec53ebd | ||
|
|
afc2b8447e | ||
|
|
8fd471a8f9 | ||
|
|
58b2d15eae | ||
|
|
0c24d94f4c | ||
|
|
a8c31c1f09 | ||
|
|
b43155dd0f | ||
|
|
b6742cb13a | ||
|
|
917f210dae | ||
|
|
ffb7367d3a | ||
|
|
49c70efed0 | ||
|
|
77a0c7b741 | ||
|
|
f078dd3261 | ||
|
|
0f5a7f0d85 | ||
|
|
e2a46e413a | ||
|
|
9722953746 | ||
|
|
2628b80735 | ||
|
|
5f3c886728 | ||
|
|
7a72cd579f | ||
|
|
5ea54595c6 | ||
|
|
c396b72798 | ||
|
|
e448861eb3 | ||
|
|
27c1577095 | ||
|
|
66da32f2ad | ||
|
|
d2469faae0 | ||
|
|
4291a182f4 | ||
|
|
400813c7fd | ||
|
|
6a2585511f | ||
|
|
91fcd79203 | ||
|
|
0b2595b0e0 | ||
|
|
0f7ade6945 | ||
|
|
d5cfaa4605 | ||
|
|
be510798ff | ||
|
|
6733ce3e9f | ||
|
|
7fcb84aed6 | ||
|
|
2034aec6f7 | ||
|
|
50c20700c0 | ||
|
|
dcaac46f65 | ||
|
|
593027578a | ||
|
|
d77f7446a2 | ||
|
|
951eda9c58 | ||
|
|
b0e89a56e1 | ||
|
|
fb9e6cb1a9 | ||
|
|
12dd29c84b |
16
.gitignore
vendored
@@ -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
@@ -0,0 +1,5 @@
|
|||||||
|
# 项目 AI 协作说明
|
||||||
|
## 项目目录结构
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
151
CDN-DEPLOYMENT.md
Normal 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
@@ -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
@@ -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. 统一日志级别,移除生产环境调试日志
|
||||||
164
README.md
@@ -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`
|
||||||
|
|
||||||
## 界面快照
|
## 界面快照
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 当前功能
|
## 核心特性
|
||||||
|
|
||||||
- 字体库加载
|
- 字体树 + 分类折叠 + 搜索 + 收藏
|
||||||
- 从 `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 # 字体文本转 SVG(Python CLI)
|
||||||
- `font/`: 原始字体目录(按分类子目录组织)
|
├── pic2svg.py # 图片转 SVG(Python 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
@@ -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
@@ -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 字体转 SVG(font2svg.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 图片转 SVG(pic2svg.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
|
||||||
|
```
|
||||||
|
|||||||
180
apiserver/README.md
Normal 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. systemd(Linux,可选)
|
||||||
|
|
||||||
|
仓库内提供示例:`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. launchd(macOS,可选)
|
||||||
|
|
||||||
|
仓库内提供示例:`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
@@ -0,0 +1 @@
|
|||||||
|
"""Font2SVG API server package."""
|
||||||
41
apiserver/font2svg-api.launchd.plist.example
Normal 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>
|
||||||
14
apiserver/font2svg-api.service.example
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 -> 本机 80(Nginx)
|
||||||
|
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
@@ -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/ {
|
||||||
|
# 预检请求:直接返回 204(CORS 头由 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
@@ -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?
|
|
||||||
@@ -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 脚本,后续应在计划中补齐
|
||||||
|
|||||||
1
frontend/dist/assets/download-CsugWKTX.js
vendored
Normal 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};
|
||||||
1
frontend/dist/assets/index-BvZNih7U.css
vendored
Normal file
4
frontend/dist/assets/index-JIsg-Hgz.js
vendored
Normal file
2
frontend/dist/assets/jszip.min-D7KnG0-e.js
vendored
Normal file
9
frontend/dist/assets/webicon-K25S575h.svg
vendored
Normal file
|
After Width: | Height: | Size: 170 KiB |
11
frontend/dist/assets/weixin-nJMOnnsQ.svg
vendored
Normal file
|
After Width: | Height: | Size: 88 KiB |
11
frontend/dist/default.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"inputText": "星程字体转换",
|
||||||
|
"fontSize": 50,
|
||||||
|
"textColor": "#dc2626",
|
||||||
|
"selectedFontIds": [
|
||||||
|
"0001"
|
||||||
|
],
|
||||||
|
"favoriteFontIds": [
|
||||||
|
"0001"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
frontend/dist/favicon.png
vendored
Normal file
|
After Width: | Height: | Size: 59 KiB |
9
frontend/dist/favicon.svg
vendored
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
frontend/dist/favicon_new.png
vendored
Normal file
|
After Width: | Height: | Size: 33 KiB |
17
frontend/dist/index.html
vendored
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
11
frontend/public/default.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"inputText": "星程字体转换",
|
||||||
|
"fontSize": 50,
|
||||||
|
"textColor": "#dc2626",
|
||||||
|
"selectedFontIds": [
|
||||||
|
"0001"
|
||||||
|
],
|
||||||
|
"favoriteFontIds": [
|
||||||
|
"0001"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
frontend/public/favicon.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
frontend/public/favicon_new.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -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="关闭"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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>
|
||||||
|
|||||||
BIN
frontend/src/assets/favicon_new.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
4
frontend/src/assets/icons/search.svg
Normal 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 |
6
frontend/src/assets/icons/selectall.svg
Normal 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 |
6
frontend/src/assets/icons/unselectall.svg
Normal 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 |
11
frontend/src/assets/icons/weixin.svg
Normal file
|
After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 473 KiB After Width: | Height: | Size: 556 KiB |
BIN
frontend/src/assets/xcs.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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[] {
|
||||||
|
|||||||
140
frontend/src/utils/render-api.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
5
miniprogram/app.miniapp.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"adapteByMiniprogram": {
|
||||||
|
"userName": "gh_d47f6e84d841"
|
||||||
|
}
|
||||||
|
}
|
||||||
58
miniprogram/app.wxss
Normal 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;
|
||||||
|
}
|
||||||
19
miniprogram/assets/default.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
module.exports = {
|
||||||
|
inputText: '星程字体转换',
|
||||||
|
fontSize: 50,
|
||||||
|
textColor: '#dc2626',
|
||||||
|
selectedFontIds: [
|
||||||
|
'0001',
|
||||||
|
'0003',
|
||||||
|
'0006',
|
||||||
|
'0011',
|
||||||
|
'0015',
|
||||||
|
],
|
||||||
|
favoriteFontIds: [
|
||||||
|
'0001',
|
||||||
|
'0003',
|
||||||
|
'0006',
|
||||||
|
'0011',
|
||||||
|
'0015',
|
||||||
|
],
|
||||||
|
}
|
||||||
19
miniprogram/assets/default.json
Normal 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
5
miniprogram/assets/icons/Button.svg
Normal 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 |
3
miniprogram/assets/icons/check.svg
Normal 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 |
3
miniprogram/assets/icons/checkbox-no.svg
Normal 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 |
10
miniprogram/assets/icons/checkbox.svg
Normal 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 |
21
miniprogram/assets/icons/choose-color.svg
Normal 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 |
4
miniprogram/assets/icons/content.svg
Normal 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 |
3
miniprogram/assets/icons/download.svg
Normal 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 |
3
miniprogram/assets/icons/expand.svg
Normal 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 |
4
miniprogram/assets/icons/export-png-s.svg
Normal 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 |
5
miniprogram/assets/icons/export-png.svg
Normal 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 |
4
miniprogram/assets/icons/export-svg-s.svg
Normal 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 |
5
miniprogram/assets/icons/export-svg.svg
Normal 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 |
3
miniprogram/assets/icons/export.svg
Normal 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 |
3
miniprogram/assets/icons/favorite-red.svg
Normal 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 |
3
miniprogram/assets/icons/favorite.svg
Normal 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 |
4
miniprogram/assets/icons/font-icon.svg
Normal 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 |
3
miniprogram/assets/icons/font-size-decrease.svg
Normal 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 |
3
miniprogram/assets/icons/font-size-increase.svg
Normal 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 |
4
miniprogram/assets/icons/search.svg
Normal 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 |
6
miniprogram/assets/icons/selectall.svg
Normal 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 |
6
miniprogram/assets/icons/unselectall.svg
Normal 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 |
BIN
miniprogram/assets/icons/webicon.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
4
miniprogram/assets/icons/zhedie.svg
Normal 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 |
10
miniprogram/assets/icons/星程字体转换.svg
Normal 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 |
12
miniprogram/assets/route-config.json
Normal 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
@@ -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
|
||||||
|
};
|
||||||
68
miniprogram/config/server.js
Normal 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,
|
||||||
|
}
|
||||||
11
miniprogram/i18n/base.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"ios": {
|
||||||
|
"name": "星程社"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"name": "星程社"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"name": "星程社"
|
||||||
|
}
|
||||||
|
}
|
||||||
144
miniprogram/pages/font-picker/index.js
Normal 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()
|
||||||
|
},
|
||||||
|
})
|
||||||
3
miniprogram/pages/font-picker/index.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "选择字体"
|
||||||
|
}
|
||||||
53
miniprogram/pages/font-picker/index.wxml
Normal 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>
|
||||||
102
miniprogram/pages/font-picker/index.wxss
Normal 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;
|
||||||
|
}
|
||||||
1109
miniprogram/pages/index/index.js
Normal file
3
miniprogram/pages/index/index.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "Font2SVG"
|
||||||
|
}
|
||||||
211
miniprogram/pages/index/index.wxml
Normal 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>
|
||||||