update at 2026-02-08 18:28:39
@@ -7,3 +7,9 @@ SUPABASE_SECRET_KEY=sb_secret_8uVHDbhMxE4HOlMBxMWjjw_vsHoZVI8
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5tDM8CzT8ABCdYKrfzH8
|
||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET=hCejmpYoaCGehw2I4jcJo2qd2TwB62
|
||||
|
||||
# 微信小程序 appID和apprSecret
|
||||
mpAppID=wxeda897f274ff33cf
|
||||
mpAppSecret=a2240b3b6bbc3f7757ad689a89d334cc
|
||||
|
||||
|
||||
|
||||
|
||||
8
.gitignore
vendored
@@ -27,3 +27,11 @@ dist-ssr
|
||||
*.ttf
|
||||
frontend/vite.config.ts
|
||||
frontend/public/fonts.json
|
||||
|
||||
# secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
private.*.key
|
||||
miniprogram/private.*.key
|
||||
project.private.config.json
|
||||
|
||||
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 面板请求详情截图
|
||||
- 具体报错信息
|
||||
- 是否在真机还是模拟器测试
|
||||
14
README.md
@@ -1,8 +1,10 @@
|
||||
# font2svg
|
||||
|
||||
本仓库提供两条能力链路:
|
||||
本仓库提供三条能力链路:
|
||||
|
||||
- Web 应用(`frontend/`):本地字体预览、多字体对比、导出 `SVG/PNG`
|
||||
- 微信小程序(`miniprogram/`):移动端预览、导出 `SVG/PNG`、文件分享
|
||||
- 小程序渲染服务(`apiserver/`):服务端渲染 SVG API
|
||||
- Python CLI(根目录脚本):图片转 SVG、字体文本转 SVG
|
||||
|
||||
## 文档导航
|
||||
@@ -12,6 +14,7 @@
|
||||
- 使用说明:`USAGE.md`
|
||||
- 迭代计划:`PLAN.md`
|
||||
- 前端子项目说明:`frontend/README.md`
|
||||
- 小程序子项目说明:`miniprogram/README.md`
|
||||
|
||||
## 界面快照
|
||||
|
||||
@@ -35,8 +38,12 @@ font2svg/
|
||||
│ │ ├── fonts/ # 字体唯一来源目录
|
||||
│ │ └── fonts.json # 字体清单(脚本生成)
|
||||
│ └── src/
|
||||
├── miniprogram/ # 微信小程序(原生)
|
||||
│ ├── pages/ # 输入/预览/字体选择页面
|
||||
│ └── assets/fonts.json # 小程序字体清单(脚本同步)
|
||||
├── apiserver/ # 小程序远端渲染 API
|
||||
├── scripts/
|
||||
│ └── generate-font-list.py # 生成 frontend/public/fonts.json
|
||||
│ └── generate-font-list.py # 同步生成 Web + 小程序字体清单
|
||||
├── font2svg.py # 字体文本转 SVG(Python CLI)
|
||||
├── pic2svg.py # 图片转 SVG(Python CLI)
|
||||
└── DETAIL-DESIGN.md # 详细设计
|
||||
@@ -67,6 +74,9 @@ pnpm run dev
|
||||
pnpm run build
|
||||
pnpm run preview
|
||||
pnpm run prepare-fonts
|
||||
pnpm run mp:syntax
|
||||
pnpm run mp:lint
|
||||
pnpm run mp:test
|
||||
|
||||
# 前端子项目
|
||||
pnpm -C frontend run dev
|
||||
|
||||
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. 是否有任何报错信息
|
||||
47
USAGE.md
@@ -3,6 +3,7 @@
|
||||
本文覆盖两类使用方式:
|
||||
|
||||
- Web 应用(推荐):`frontend/` 图形界面
|
||||
- 微信小程序:`miniprogram/` 移动端图形界面
|
||||
- Python CLI:`font2svg.py` / `pic2svg.py`
|
||||
|
||||
## 1. Web 应用
|
||||
@@ -57,7 +58,49 @@ pnpm run preview
|
||||
- 多个条目:自动打包 ZIP
|
||||
- 文件名:`字体名_文本前8字符.扩展名`
|
||||
|
||||
## 2. Python CLI
|
||||
## 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 安装依赖
|
||||
|
||||
@@ -105,7 +148,7 @@ python pic2svg.py --indir images --outdir output
|
||||
- `--optimize-curves`:启用曲线优化
|
||||
- `--circle-fit`:圆拟合误差阈值
|
||||
|
||||
## 3. 常见问题
|
||||
## 4. 常见问题
|
||||
|
||||
### Q1:前端看不到字体?
|
||||
|
||||
|
||||
89
apiserver/README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# apiserver
|
||||
|
||||
`apiserver/` 提供微信小程序用的远端渲染接口:
|
||||
- 小程序只上传参数(字体 ID、文字、字号、颜色等)
|
||||
- 服务端读取 `fonts.json` + `fonts/`,生成 SVG 后返回
|
||||
|
||||
## 1. 启动
|
||||
|
||||
在仓库根目录执行:
|
||||
|
||||
```bash
|
||||
python3 apiserver/server.py \
|
||||
--host 0.0.0.0 \
|
||||
--port 9300 \
|
||||
--static-root /home/gavin/font2svg
|
||||
```
|
||||
|
||||
其中 `--static-root` 目录必须包含:
|
||||
|
||||
- `fonts.json`
|
||||
- `fonts/`(字体文件目录)
|
||||
|
||||
如果不传 `--manifest`,默认读取 `<static-root>/fonts.json`。
|
||||
|
||||
## 2. API
|
||||
|
||||
### GET `/healthz`
|
||||
|
||||
返回服务健康状态和已加载字体数量。
|
||||
|
||||
### POST `/api/render-svg`
|
||||
|
||||
请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"fontId": "其他字体/AlimamaDaoLiTi",
|
||||
"text": "星程字体转换",
|
||||
"fontSize": 120,
|
||||
"fillColor": "#000000",
|
||||
"letterSpacing": 0,
|
||||
"maxCharsPerLine": 45
|
||||
}
|
||||
```
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"fontId": "其他字体/AlimamaDaoLiTi",
|
||||
"fontName": "AlimamaDaoLiTi",
|
||||
"width": 956.2,
|
||||
"height": 144.3,
|
||||
"svg": "<?xml ...>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 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
|
||||
```
|
||||
|
||||
## 4. 约束
|
||||
|
||||
- 字体解析完全基于 `fonts.json`,`fontId` 必须存在。
|
||||
- 服务端启用 CORS,允许小程序访问。
|
||||
- 不依赖 Flask/FastAPI,使用 Python 标准库 HTTP 服务。
|
||||
1
apiserver/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Font2SVG API server package."""
|
||||
BIN
apiserver/__pycache__/renderer.cpython-312.pyc
Normal file
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),
|
||||
}
|
||||
332
apiserver/server.py
Normal file
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Font2SVG API 服务。"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
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
|
||||
|
||||
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 _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 _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 do_OPTIONS(self): # noqa: N802
|
||||
self.send_response(204)
|
||||
self._set_cors_headers()
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self): # noqa: N802
|
||||
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"})
|
||||
|
||||
def do_POST(self): # noqa: N802
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path != "/api/render-svg":
|
||||
self._send_json(404, {"ok": False, "error": "Not Found"})
|
||||
return
|
||||
|
||||
try:
|
||||
content_length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
except ValueError:
|
||||
self._send_json(400, {"ok": False, "error": "请求长度无效"})
|
||||
return
|
||||
|
||||
if content_length <= 0 or content_length > 256 * 1024:
|
||||
self._send_json(400, {"ok": False, "error": "请求体大小无效"})
|
||||
return
|
||||
|
||||
try:
|
||||
raw_body = self.rfile.read(content_length)
|
||||
payload = json.loads(raw_body.decode("utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
self._send_json(400, {"ok": False, "error": "请求体不是有效 JSON"})
|
||||
return
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
self._send_json(400, {"ok": False, "error": "请求体格式错误"})
|
||||
return
|
||||
|
||||
font_id = str(payload.get("fontId") or "").strip()
|
||||
text = str(payload.get("text") or "")
|
||||
if not font_id:
|
||||
self._send_json(400, {"ok": False, "error": "缺少 fontId"})
|
||||
return
|
||||
if not text.strip():
|
||||
self._send_json(400, {"ok": False, "error": "文本内容不能为空"})
|
||||
return
|
||||
|
||||
font_size = _safe_number(payload.get("fontSize"), 120.0, 8.0, 1024.0, float)
|
||||
letter_spacing = _safe_number(payload.get("letterSpacing"), 0.0, -2.0, 5.0, float)
|
||||
max_chars_per_line = _safe_number(
|
||||
payload.get("maxCharsPerLine"),
|
||||
MAX_CHARS_PER_LINE,
|
||||
1,
|
||||
300,
|
||||
int,
|
||||
)
|
||||
fill_color = _normalize_hex_color(payload.get("fillColor"), "#000000")
|
||||
|
||||
try:
|
||||
font_info = self.catalog.get(font_id)
|
||||
result = render_svg_from_font_file(
|
||||
font_info["path"],
|
||||
text,
|
||||
font_size=font_size,
|
||||
fill_color=fill_color,
|
||||
letter_spacing=letter_spacing,
|
||||
max_chars_per_line=max_chars_per_line,
|
||||
)
|
||||
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
|
||||
|
||||
response_data = {
|
||||
"svg": result["svg"],
|
||||
"width": result["width"],
|
||||
"height": result["height"],
|
||||
"fontName": result.get("fontName") or font_info.get("name") or "Unknown",
|
||||
"fontId": font_id,
|
||||
}
|
||||
self._send_json(200, {"ok": True, "data": response_data})
|
||||
|
||||
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/ 与 fonts.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--manifest",
|
||||
default=os.getenv("FONT2SVG_MANIFEST_PATH", ""),
|
||||
help="字体清单路径,默认使用 <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)
|
||||
manifest_path = args.manifest.strip() or os.path.join(static_root, "fonts.json")
|
||||
|
||||
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())
|
||||
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);
|
||||
104
fonts.conf
Normal file
@@ -0,0 +1,104 @@
|
||||
# Font2SVG - Nginx 配置(fonts.biboer.cn)
|
||||
# 用途:为微信小程序提供 fonts.json 与字体文件静态托管
|
||||
|
||||
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,OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Origin,Range,Accept,Content-Type" 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 服务)
|
||||
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;
|
||||
}
|
||||
|
||||
# fonts.json:短缓存,便于更新
|
||||
location = /fonts.json {
|
||||
expires 1h;
|
||||
add_header Cache-Control "public, must-revalidate";
|
||||
add_header Access-Control-Allow-Origin "*" always;
|
||||
add_header Access-Control-Allow-Methods "GET,HEAD,OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Origin,Range,Accept,Content-Type" 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";
|
||||
add_header Access-Control-Allow-Origin "*" always;
|
||||
add_header Access-Control-Allow-Methods "GET,HEAD,OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Origin,Range,Accept,Content-Type" always;
|
||||
add_header Access-Control-Expose-Headers "Content-Length,Content-Range" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# 默认仅允许静态文件
|
||||
location / {
|
||||
# 处理预检请求(if 在 location 内合法)
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
add_header Access-Control-Allow-Methods "GET,HEAD,OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Origin,Range,Accept,Content-Type";
|
||||
add_header Access-Control-Max-Age 1728000;
|
||||
add_header Content-Length 0;
|
||||
add_header Content-Type "text/plain; charset=utf-8";
|
||||
return 204;
|
||||
}
|
||||
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# 禁止访问隐藏文件
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
}
|
||||
103
miniprogram/ICON_FIX.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# 小程序图标修复清单
|
||||
|
||||
## 修复时间
|
||||
2026年2月8日
|
||||
|
||||
## 问题
|
||||
所有图标在小程序预览中都无法显示
|
||||
|
||||
## 根本原因
|
||||
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` | 颜色选择器 | 顶部导航栏右侧 |
|
||||
| `export.svg` | 导出选项 | 输入框右侧第一个按钮 |
|
||||
| `export-svg.svg` | 导出 SVG | 输入框右侧第二个按钮 |
|
||||
| `export-png.svg` | 导出 PNG | 输入框右侧第三个按钮 |
|
||||
| `font-icon.svg` | 字体图标 | 预览列表、字体树列表项 |
|
||||
| `checkbox.svg` | 复选框选中状态 | 所有复选框 |
|
||||
| `expand.svg` | 展开/收起 | 字体分类树 |
|
||||
| `selectall.svg` | 已收藏 | 收藏按钮(红心) |
|
||||
| `unselectall.svg` | 未收藏 | 收藏按钮(空心) |
|
||||
|
||||
### 4. 复选框实现
|
||||
由于避免使用带空格的文件名,复选框采用了组合方案:
|
||||
|
||||
```xml
|
||||
<view class="checkbox-wrapper {{selected ? 'checked' : ''}}">
|
||||
<image wx:if="{{selected}}" class="checkbox-icon-sm" src="/assets/icons/checkbox.svg" />
|
||||
</view>
|
||||
```
|
||||
|
||||
样式:
|
||||
```css
|
||||
.checkbox-wrapper {
|
||||
border: 1rpx solid #C9CDD4; /* 未选中:灰色边框 */
|
||||
border-radius: 3rpx;
|
||||
}
|
||||
|
||||
.checkbox-wrapper.checked {
|
||||
border-color: #9B6BC2; /* 选中:紫色边框和背景 */
|
||||
background: #9B6BC2;
|
||||
}
|
||||
```
|
||||
|
||||
## 修改的文件
|
||||
|
||||
1. **index.wxml**
|
||||
- 所有图标路径更新为绝对路径
|
||||
- Logo 改为 webicon.png
|
||||
- 复选框结构调整
|
||||
|
||||
2. **index.wxss**
|
||||
- 添加 `.checkbox-wrapper` 样式
|
||||
- 添加 `.checkbox-wrapper.checked` 样式
|
||||
|
||||
## 验证清单
|
||||
|
||||
请在微信开发者工具中验证以下内容:
|
||||
|
||||
- [ ] 顶部 Logo (webicon.png) 正常显示
|
||||
- [ ] 字体大小增减图标正常显示
|
||||
- [ ] 颜色选择图标正常显示
|
||||
- [ ] 导出按钮组图标正常显示(3个按钮)
|
||||
- [ ] 预览列表中的字体图标正常显示
|
||||
- [ ] 预览列表中的复选框正常显示(选中/未选中)
|
||||
- [ ] 字体选择列表中的展开/收起图标正常显示
|
||||
- [ ] 字体选择列表中的字体图标正常显示
|
||||
- [ ] 字体选择列表中的复选框正常工作
|
||||
- [ ] 字体选择列表中的收藏图标正常显示(已收藏/未收藏)
|
||||
- [ ] 已收藏字体列表中的所有图标正常显示
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **路径规范**:小程序中使用绝对路径 `/assets/icons/xxx.svg` 会从项目根目录开始查找
|
||||
2. **SVG 支持**:微信小程序的 `<image>` 组件支持 SVG 格式
|
||||
3. **文件名**:避免使用包含空格的文件名,可能在某些情况下导致问题
|
||||
4. **图标尺寸**:所有图标已按照 Figma 设计稿的尺寸设置 CSS
|
||||
|
||||
## 如果图标仍不显示
|
||||
|
||||
1. 清除缓存:微信开发者工具 -> 工具 -> 清除缓存
|
||||
2. 重新编译:点击"编译"按钮
|
||||
3. 检查控制台:查看是否有资源加载错误
|
||||
4. 验证文件:确认 `/miniprogram/assets/icons/` 目录下所有图标文件存在
|
||||
54
miniprogram/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# miniprogram
|
||||
|
||||
`miniprogram/` 是 `font2svg` 的微信小程序版本,面向移动端提供字体预览与导出能力。
|
||||
|
||||
## 已实现能力
|
||||
|
||||
- 文本输入 + 字体选择(搜索/分类/收藏)
|
||||
- 远端 API 生成 SVG(服务端读取字体并渲染)
|
||||
- SVG 预览
|
||||
- 导出 SVG 并调用 `wx.shareFileMessage` 分享
|
||||
- SVG 渲染到 Canvas 并导出 PNG,保存到系统相册
|
||||
|
||||
## 目录说明
|
||||
|
||||
```text
|
||||
miniprogram/
|
||||
├── pages/
|
||||
│ ├── index/ # 首页:输入、预览、导出
|
||||
│ └── font-picker/ # 字体选择页
|
||||
├── utils/
|
||||
│ ├── core/ # 纯算法模块
|
||||
│ └── mp/ # 小程序 API 适配层
|
||||
├── assets/fonts.json # 字体清单(由脚本生成)
|
||||
├── 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. 编译运行。
|
||||
|
||||
## 字体清单格式(由服务端解析)
|
||||
|
||||
`assets/fonts.json` 每项字段:
|
||||
|
||||
- `id`: 字体唯一 ID
|
||||
- `name`: 字体显示名
|
||||
- `category`: 分类
|
||||
- `path`: 字体地址(支持相对路径或完整 URL)
|
||||
|
||||
如果 `path` 是相对路径(例如 `/fonts/a.ttf`),服务端会根据静态根目录拼接到实际文件路径。
|
||||
|
||||
## 调试命令(仓库根目录)
|
||||
|
||||
```bash
|
||||
pnpm run mp:syntax
|
||||
pnpm run mp:lint
|
||||
pnpm run mp:test
|
||||
```
|
||||
180
miniprogram/UPDATE_LOG.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# 小程序 UI 更新日志
|
||||
|
||||
## 更新时间
|
||||
2026年2月8日(远端渲染改造)
|
||||
|
||||
## 本次关键变更
|
||||
|
||||
为解决“大字体多预览下载慢”的问题,小程序渲染链路从“本地下载字体并 Worker 渲染”改为“请求远端 API 返回 SVG”:
|
||||
|
||||
- 新增 `apiserver/` 目录,服务端读取 `fonts.json` + `fonts/` 并渲染 SVG。
|
||||
- 小程序新增 `miniprogram/utils/mp/render-api.js`,调用 `https://fonts.biboer.cn/api/render-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. 优化大字体渲染性能
|
||||
9
miniprogram/app.js
Normal file
@@ -0,0 +1,9 @@
|
||||
App({
|
||||
globalData: {
|
||||
fontsManifestUrl: 'https://fonts.biboer.cn/fonts.json',
|
||||
fontsBaseUrl: 'https://fonts.biboer.cn',
|
||||
svgRenderApiUrl: 'https://fonts.biboer.cn/api/render-svg',
|
||||
apiTimeoutMs: 30000,
|
||||
fonts: null,
|
||||
},
|
||||
})
|
||||
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;
|
||||
}
|
||||
170
miniprogram/assets/fonts.js
Normal file
@@ -0,0 +1,170 @@
|
||||
module.exports = [
|
||||
{
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"id": "王漢宗/王漢宗顏楷體繁",
|
||||
"name": "王漢宗顏楷體繁",
|
||||
"filename": "王漢宗顏楷體繁.ttf",
|
||||
"category": "王漢宗",
|
||||
"path": "/fonts/王漢宗/王漢宗顏楷體繁.ttf"
|
||||
}
|
||||
]
|
||||
170
miniprogram/assets/fonts.json
Normal file
@@ -0,0 +1,170 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"id": "王漢宗/王漢宗顏楷體繁",
|
||||
"name": "王漢宗顏楷體繁",
|
||||
"filename": "王漢宗顏楷體繁.ttf",
|
||||
"category": "王漢宗",
|
||||
"path": "/fonts/王漢宗/王漢宗顏楷體繁.ttf"
|
||||
}
|
||||
]
|
||||
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 |
BIN
miniprogram/assets/icons/checkbox.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
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 |
BIN
miniprogram/assets/icons/choose-color.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
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 |
BIN
miniprogram/assets/icons/expand.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
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 |
BIN
miniprogram/assets/icons/export-png.png
Normal file
|
After Width: | Height: | Size: 4.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 |
BIN
miniprogram/assets/icons/export-svg.png
Normal file
|
After Width: | Height: | Size: 4.4 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 |
BIN
miniprogram/assets/icons/export.png
Normal file
|
After Width: | Height: | Size: 1.5 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 |
BIN
miniprogram/assets/icons/font-icon.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
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 |
BIN
miniprogram/assets/icons/font-size-decrease.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
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 |
BIN
miniprogram/assets/icons/font-size-increase.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
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 |
3
miniprogram/assets/icons/icons_idx _12.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none" viewBox="0 0 15 15">
|
||||
<path fill="#000" d="M7.5 0a7.5 7.5 0 0 0 0 15 7.5 7.5 0 0 0 0-15Zm4.242 6.567L8.03 10.28a.751.751 0 0 1-1.062 0l-3.71-3.713A.751.751 0 0 1 4.32 5.505L7.5 8.688l3.183-3.18a.75.75 0 1 1 1.06 1.06Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 304 B |
4
miniprogram/assets/icons/icons_idx _18.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16">
|
||||
<path fill="#0079F5" d="M12.391 16H3.61A3.61 3.61 0 0 1 0 12.391V3.61A3.609 3.609 0 0 1 3.609 0h8.782A3.61 3.61 0 0 1 16 3.609v8.782A3.61 3.61 0 0 1 12.391 16Z"/>
|
||||
<path fill="#fff" d="M10.404 4.972a.6.6 0 0 0-1.103 0L7.18 9.94a.402.402 0 1 0 .74.312l.244-.588c.21-.506.704-.835 1.252-.835h.873c.547 0 1.041.33 1.251.835l.244.588a.402.402 0 1 0 .74-.312l-2.12-4.968ZM10.5 7.16a.7.7 0 1 1-1.293.54.7.7 0 0 1 1.293-.54Zm-5.018-.978a.422.422 0 0 0-.775 0L3.416 9.206a.301.301 0 1 0 .555.233l.134-.323a.825.825 0 0 1 .762-.509h.455c.333 0 .634.201.762.51l.134.322a.301.301 0 1 0 .555-.233L5.483 6.18Zm-.388 1.89a.37.37 0 1 1 0-.74.37.37 0 0 1 0 .74Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 755 B |
3
miniprogram/assets/icons/icons_idx _19.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 |
3
miniprogram/assets/icons/icons_idx _29.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none" viewBox="0 0 15 15">
|
||||
<path fill="#000" d="M7.5 0a7.5 7.5 0 0 0 0 15 7.5 7.5 0 0 0 0-15Zm4.242 6.567L8.03 10.28a.751.751 0 0 1-1.062 0l-3.71-3.713A.751.751 0 0 1 4.32 5.505L7.5 8.688l3.183-3.18a.75.75 0 1 1 1.06 1.06Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 304 B |
4
miniprogram/assets/icons/icons_idx _32.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/icons_idx _33.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="20" fill="none" viewBox="0 0 22 20">
|
||||
<path fill="#5C5C66" d="M14.503 20a.5.5 0 0 1-.466-.319L11.826 14h-8.65L.97 19.681a.501.501 0 0 1-.928.024.5.5 0 0 1-.004-.386l7-18a.5.5 0 0 1 .932 0l7 18a.5.5 0 0 1-.466.681ZM3.567 13h7.871L7.503 2.88 3.567 13Zm14.936-6a.5.5 0 0 1-.5-.5V4h-2.5a.5.5 0 0 1 0-1h2.5V.5a.5.5 0 0 1 1 0V3h2.5a.5.5 0 1 1 0 1h-2.5v2.5a.5.5 0 0 1-.5.5Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 437 B |
3
miniprogram/assets/icons/icons_idx _34.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/icons_idx _35.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="6" fill="none" viewBox="0 0 15 6">
|
||||
<path fill="#000" fill-opacity=".8" d="m0-.271 5.657 5.657a2 2 0 0 0 2.828 0l5.657-5.657H0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 198 B |
3
miniprogram/assets/icons/icons_idx _36.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/icons_idx _37.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/icons_idx _38.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="13" fill="none" viewBox="0 0 15 13">
|
||||
<path fill="#5C5C66" d="M9.668 12.667a.333.333 0 0 1-.31-.213L7.885 8.667H2.119L.646 12.454a.333.333 0 1 1-.622-.242l4.667-12a.333.333 0 0 1 .621 0l4.667 12a.333.333 0 0 1-.31.455ZM2.378 8h5.248L5.002 1.253 2.378 8Zm11.957-6h-4a.333.333 0 1 1 0-.667h4a.333.333 0 0 1 0 .667Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 383 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 |
BIN
miniprogram/assets/icons/selectall.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
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 |
BIN
miniprogram/assets/icons/unselectall.png
Normal file
|
After Width: | Height: | Size: 7.0 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 |
40
miniprogram/config/cdn.js
Normal file
@@ -0,0 +1,40 @@
|
||||
// CDN 配置文件
|
||||
// 管理所有静态资源的 CDN 地址
|
||||
|
||||
const CDN_BASE_URL = 'https://fonts.biboer.cn';
|
||||
|
||||
// 图标路径配置
|
||||
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 = `${CDN_BASE_URL}/fonts.json`;
|
||||
|
||||
module.exports = {
|
||||
CDN_BASE_URL,
|
||||
ICON_PATHS,
|
||||
FONT_BASE_URL,
|
||||
FONTS_JSON_URL
|
||||
};
|
||||
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;
|
||||
}
|
||||
605
miniprogram/pages/index/index.js
Normal file
@@ -0,0 +1,605 @@
|
||||
const { loadFontsManifest } = require('../../utils/mp/font-loader')
|
||||
const { loadAppState, saveAppState, loadFavorites, saveFavorites } = require('../../utils/mp/storage')
|
||||
const { saveSvgToUserPath, shareLocalFile, buildFilename } = require('../../utils/mp/file-export')
|
||||
const { exportSvgToPngByCanvas, savePngToAlbum } = require('../../utils/mp/canvas-export')
|
||||
const { renderSvgByApi } = require('../../utils/mp/render-api')
|
||||
// const { ICON_PATHS } = require('../../config/cdn') // CDN 方案暂时注释
|
||||
|
||||
const COLOR_PALETTE = ['#000000', '#1d4ed8', '#047857', '#b45309', '#dc2626', '#7c3aed']
|
||||
|
||||
// 临时使用本地图标
|
||||
const LOCAL_ICON_PATHS = {
|
||||
logo: '/assets/icons/webicon.png',
|
||||
fontSizeDecrease: '/assets/icons/font-size-decrease.png',
|
||||
fontSizeIncrease: '/assets/icons/font-size-increase.png',
|
||||
chooseColor: '/assets/icons/choose-color.png',
|
||||
export: '/assets/icons/export.png',
|
||||
exportSvg: '/assets/icons/export-svg.png',
|
||||
exportPng: '/assets/icons/export-png.png',
|
||||
// 字体树图标(参考web项目)
|
||||
fontIcon: 'https://fonts.biboer.cn/assets/icons/icons_idx%20_18.svg', // 字体item图标
|
||||
expandIcon: 'https://fonts.biboer.cn/assets/icons/icons_idx%20_12.svg', // 展开分类图标
|
||||
collapseIcon: 'https://fonts.biboer.cn/assets/icons/zhedie.svg', // 折叠分类图标
|
||||
favoriteIcon: 'https://fonts.biboer.cn/assets/icons/icons_idx%20_19.svg', // 收藏图标
|
||||
checkbox: '/assets/icons/checkbox.png', // 预览checkbox
|
||||
search: 'https://fonts.biboer.cn/assets/icons/search.svg' // 搜索图标
|
||||
}
|
||||
|
||||
function toSvgDataUri(svg) {
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`
|
||||
}
|
||||
|
||||
function normalizeHexColor(input) {
|
||||
const fallback = '#000000'
|
||||
const value = String(input || '').trim()
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(value)) {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
inputText: '星程字体转换',
|
||||
fontSize: 120,
|
||||
letterSpacingInput: '0',
|
||||
textColor: '#000000',
|
||||
colorPalette: COLOR_PALETTE,
|
||||
selectedFonts: [], // 当前已选中的字体列表
|
||||
fontCategories: [], // 字体分类树
|
||||
favoriteCategories: [], // 已收藏字体分类树
|
||||
showColorPicker: false,
|
||||
favorites: [],
|
||||
// 使用本地图标路径
|
||||
icons: LOCAL_ICON_PATHS,
|
||||
// 搜索功能
|
||||
searchKeyword: '',
|
||||
showSearch: false,
|
||||
},
|
||||
|
||||
async onLoad() {
|
||||
// 调试:打印图标配置
|
||||
console.log('========== 图标配置 ==========')
|
||||
console.log('icons:', this.data.icons)
|
||||
console.log('logo URL:', this.data.icons.logo)
|
||||
console.log('============================')
|
||||
|
||||
this.fontMap = new Map()
|
||||
this.generateTimer = null
|
||||
|
||||
await this.bootstrap()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
const favorites = loadFavorites()
|
||||
this.setData({ favorites })
|
||||
this.updateFontTrees()
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
if (this.generateTimer) {
|
||||
clearTimeout(this.generateTimer)
|
||||
this.generateTimer = null
|
||||
}
|
||||
},
|
||||
|
||||
async bootstrap() {
|
||||
wx.showLoading({ title: '加载中', mask: true })
|
||||
try {
|
||||
const state = loadAppState()
|
||||
const fonts = await loadFontsManifest()
|
||||
const favorites = loadFavorites()
|
||||
|
||||
for (const font of fonts) {
|
||||
this.fontMap.set(font.id, font)
|
||||
}
|
||||
|
||||
this.setData({
|
||||
inputText: state.inputText || this.data.inputText,
|
||||
fontSize: Number(state.fontSize) > 0 ? Number(state.fontSize) : this.data.fontSize,
|
||||
letterSpacingInput:
|
||||
typeof state.letterSpacing === 'number' ? String(state.letterSpacing) : this.data.letterSpacingInput,
|
||||
textColor: normalizeHexColor(state.textColor || this.data.textColor),
|
||||
favorites,
|
||||
})
|
||||
|
||||
// 构建字体树
|
||||
this.updateFontTrees()
|
||||
|
||||
// 如果有保存的选中字体,恢复它们
|
||||
if (state.selectedFontIds && state.selectedFontIds.length > 0) {
|
||||
const selectedFonts = state.selectedFontIds
|
||||
.map(id => this.fontMap.get(id))
|
||||
.filter(font => font)
|
||||
.map(font => ({
|
||||
id: font.id,
|
||||
name: font.name,
|
||||
category: font.category,
|
||||
showInPreview: true,
|
||||
previewSrc: '',
|
||||
}))
|
||||
|
||||
this.setData({ selectedFonts })
|
||||
await this.generateAllPreviews()
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error && error.message ? error.message : '初始化失败'
|
||||
wx.showToast({ title: message, icon: 'none', duration: 2200 })
|
||||
} finally {
|
||||
wx.hideLoading()
|
||||
}
|
||||
},
|
||||
|
||||
// 构建字体分类树
|
||||
updateFontTrees() {
|
||||
const categoryMap = new Map()
|
||||
const favoriteCategoryMap = new Map()
|
||||
const favorites = this.data.favorites
|
||||
const keyword = (this.data.searchKeyword || '').trim().toLowerCase()
|
||||
|
||||
this.fontMap.forEach(font => {
|
||||
const category = font.category || '其他'
|
||||
const isFavorite = favorites.includes(font.id)
|
||||
|
||||
// 应用搜索过滤
|
||||
if (keyword) {
|
||||
const matchesSearch = font.name.toLowerCase().includes(keyword) ||
|
||||
category.toLowerCase().includes(keyword)
|
||||
if (!matchesSearch) return
|
||||
}
|
||||
|
||||
// 所有字体树
|
||||
if (!categoryMap.has(category)) {
|
||||
categoryMap.set(category, {
|
||||
category,
|
||||
expanded: true,
|
||||
fonts: [],
|
||||
})
|
||||
}
|
||||
const selectedIds = this.data.selectedFonts.map(f => f.id)
|
||||
categoryMap.get(category).fonts.push({
|
||||
...font,
|
||||
selected: selectedIds.includes(font.id),
|
||||
isFavorite,
|
||||
})
|
||||
|
||||
// 已收藏字体树
|
||||
if (isFavorite) {
|
||||
if (!favoriteCategoryMap.has(category)) {
|
||||
favoriteCategoryMap.set(category, {
|
||||
category,
|
||||
expanded: true,
|
||||
fonts: [],
|
||||
})
|
||||
}
|
||||
favoriteCategoryMap.get(category).fonts.push({
|
||||
...font,
|
||||
selected: selectedIds.includes(font.id),
|
||||
isFavorite: true,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const fontCategories = Array.from(categoryMap.values())
|
||||
const favoriteCategories = Array.from(favoriteCategoryMap.values())
|
||||
|
||||
this.setData({
|
||||
fontCategories,
|
||||
favoriteCategories,
|
||||
})
|
||||
},
|
||||
|
||||
// 切换字体选择
|
||||
async onToggleFont(e) {
|
||||
const fontId = e.currentTarget.dataset.fontId
|
||||
if (!fontId) return
|
||||
|
||||
const font = this.fontMap.get(fontId)
|
||||
if (!font) return
|
||||
|
||||
const selectedFonts = this.data.selectedFonts
|
||||
const index = selectedFonts.findIndex(f => f.id === fontId)
|
||||
|
||||
if (index >= 0) {
|
||||
// 取消选择
|
||||
selectedFonts.splice(index, 1)
|
||||
} else {
|
||||
// 添加选择
|
||||
selectedFonts.push({
|
||||
id: font.id,
|
||||
name: font.name,
|
||||
category: font.category,
|
||||
showInPreview: true,
|
||||
previewSrc: '',
|
||||
})
|
||||
}
|
||||
|
||||
this.setData({ selectedFonts })
|
||||
this.updateFontTrees()
|
||||
|
||||
// 保存状态
|
||||
saveAppState({
|
||||
inputText: this.data.inputText,
|
||||
selectedFontIds: selectedFonts.map(f => f.id),
|
||||
fontSize: Number(this.data.fontSize),
|
||||
letterSpacing: Number(this.data.letterSpacingInput || 0),
|
||||
textColor: this.data.textColor,
|
||||
})
|
||||
|
||||
// 如果是新选中的字体,生成预览
|
||||
if (index < 0) {
|
||||
await this.generatePreviewForFont(fontId)
|
||||
}
|
||||
},
|
||||
|
||||
// 切换字体在预览中的显示
|
||||
onTogglePreviewFont(e) {
|
||||
const fontId = e.currentTarget.dataset.fontId
|
||||
if (!fontId) return
|
||||
|
||||
const selectedFonts = this.data.selectedFonts
|
||||
const index = selectedFonts.findIndex(f => f.id === fontId)
|
||||
|
||||
if (index >= 0) {
|
||||
selectedFonts[index].showInPreview = !selectedFonts[index].showInPreview
|
||||
this.setData({ selectedFonts })
|
||||
}
|
||||
},
|
||||
|
||||
// 切换收藏
|
||||
onToggleFavorite(e) {
|
||||
const fontId = e.currentTarget.dataset.fontId
|
||||
if (!fontId) return
|
||||
|
||||
let favorites = [...this.data.favorites]
|
||||
const index = favorites.indexOf(fontId)
|
||||
|
||||
if (index >= 0) {
|
||||
favorites.splice(index, 1)
|
||||
} else {
|
||||
favorites.push(fontId)
|
||||
}
|
||||
|
||||
this.setData({ favorites })
|
||||
saveFavorites(favorites)
|
||||
this.updateFontTrees()
|
||||
},
|
||||
|
||||
// 切换分类展开/收起
|
||||
onToggleCategory(e) {
|
||||
const category = e.currentTarget.dataset.category
|
||||
if (!category) return
|
||||
|
||||
const fontCategories = this.data.fontCategories
|
||||
const index = fontCategories.findIndex(c => c.category === category)
|
||||
|
||||
if (index >= 0) {
|
||||
fontCategories[index].expanded = !fontCategories[index].expanded
|
||||
this.setData({ fontCategories })
|
||||
}
|
||||
},
|
||||
|
||||
// 切换收藏分类展开/收起
|
||||
onToggleFavoriteCategory(e) {
|
||||
const category = e.currentTarget.dataset.category
|
||||
if (!category) return
|
||||
|
||||
const favoriteCategories = this.data.favoriteCategories
|
||||
const index = favoriteCategories.findIndex(c => c.category === category)
|
||||
|
||||
if (index >= 0) {
|
||||
favoriteCategories[index].expanded = !favoriteCategories[index].expanded
|
||||
this.setData({ favoriteCategories })
|
||||
}
|
||||
},
|
||||
|
||||
// 生成单个字体的预览
|
||||
async generatePreviewForFont(fontId) {
|
||||
const text = String(this.data.inputText || '')
|
||||
if (!text.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const letterSpacing = Number(this.data.letterSpacingInput || 0)
|
||||
const fillColor = normalizeHexColor(this.data.textColor)
|
||||
|
||||
const result = await renderSvgByApi({
|
||||
fontId,
|
||||
text,
|
||||
fontSize: Number(this.data.fontSize),
|
||||
fillColor,
|
||||
letterSpacing,
|
||||
maxCharsPerLine: 45,
|
||||
})
|
||||
|
||||
const previewImageSrc = toSvgDataUri(result.svg)
|
||||
|
||||
// 更新对应字体的预览
|
||||
const selectedFonts = this.data.selectedFonts
|
||||
const index = selectedFonts.findIndex(f => f.id === fontId)
|
||||
|
||||
if (index >= 0) {
|
||||
selectedFonts[index].previewSrc = previewImageSrc
|
||||
selectedFonts[index].svg = result.svg
|
||||
selectedFonts[index].width = result.width
|
||||
selectedFonts[index].height = result.height
|
||||
this.setData({ selectedFonts })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('生成预览失败', error)
|
||||
}
|
||||
},
|
||||
|
||||
// 生成所有选中字体的预览
|
||||
async generateAllPreviews() {
|
||||
const text = String(this.data.inputText || '')
|
||||
if (!text.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
wx.showLoading({ title: '生成预览中', mask: true })
|
||||
|
||||
try {
|
||||
for (const font of this.data.selectedFonts) {
|
||||
await this.generatePreviewForFont(font.id)
|
||||
}
|
||||
} finally {
|
||||
wx.hideLoading()
|
||||
}
|
||||
},
|
||||
|
||||
scheduleGenerate() {
|
||||
if (this.generateTimer) {
|
||||
clearTimeout(this.generateTimer)
|
||||
}
|
||||
|
||||
this.generateTimer = setTimeout(() => {
|
||||
this.generateAllPreviews()
|
||||
}, 260)
|
||||
},
|
||||
|
||||
onInputText(event) {
|
||||
this.setData({ inputText: event.detail.value || '' })
|
||||
saveAppState({
|
||||
inputText: event.detail.value || '',
|
||||
selectedFontIds: this.data.selectedFonts.map(f => f.id),
|
||||
fontSize: Number(this.data.fontSize),
|
||||
letterSpacing: Number(this.data.letterSpacingInput || 0),
|
||||
textColor: this.data.textColor,
|
||||
})
|
||||
this.scheduleGenerate()
|
||||
},
|
||||
|
||||
onFontSizeChanging(event) {
|
||||
this.setData({ fontSize: Number(event.detail.value) || this.data.fontSize })
|
||||
},
|
||||
|
||||
onFontSizeChange(event) {
|
||||
this.setData({ fontSize: Number(event.detail.value) || this.data.fontSize })
|
||||
saveAppState({
|
||||
inputText: this.data.inputText,
|
||||
selectedFontIds: this.data.selectedFonts.map(f => f.id),
|
||||
fontSize: Number(event.detail.value),
|
||||
letterSpacing: Number(this.data.letterSpacingInput || 0),
|
||||
textColor: this.data.textColor,
|
||||
})
|
||||
this.scheduleGenerate()
|
||||
},
|
||||
|
||||
onDecreaseFontSize() {
|
||||
const newSize = Math.max(24, this.data.fontSize - 10)
|
||||
this.setData({ fontSize: newSize })
|
||||
saveAppState({
|
||||
inputText: this.data.inputText,
|
||||
selectedFontIds: this.data.selectedFonts.map(f => f.id),
|
||||
fontSize: newSize,
|
||||
letterSpacing: Number(this.data.letterSpacingInput || 0),
|
||||
textColor: this.data.textColor,
|
||||
})
|
||||
this.scheduleGenerate()
|
||||
},
|
||||
|
||||
onIncreaseFontSize() {
|
||||
const newSize = Math.min(320, this.data.fontSize + 10)
|
||||
this.setData({ fontSize: newSize })
|
||||
saveAppState({
|
||||
inputText: this.data.inputText,
|
||||
selectedFontIds: this.data.selectedFonts.map(f => f.id),
|
||||
fontSize: newSize,
|
||||
letterSpacing: Number(this.data.letterSpacingInput || 0),
|
||||
textColor: this.data.textColor,
|
||||
})
|
||||
this.scheduleGenerate()
|
||||
},
|
||||
|
||||
onShowColorPicker() {
|
||||
this.setData({ showColorPicker: true })
|
||||
},
|
||||
|
||||
onHideColorPicker() {
|
||||
this.setData({ showColorPicker: false })
|
||||
},
|
||||
|
||||
onStopPropagation() {
|
||||
// 阻止事件冒泡
|
||||
},
|
||||
|
||||
onColorInput(event) {
|
||||
this.setData({ textColor: event.detail.value || '' })
|
||||
this.scheduleGenerate()
|
||||
},
|
||||
|
||||
onPickColor(event) {
|
||||
const color = event.currentTarget.dataset.color
|
||||
if (!color) {
|
||||
return
|
||||
}
|
||||
this.setData({ textColor: color })
|
||||
saveAppState({
|
||||
inputText: this.data.inputText,
|
||||
selectedFontIds: this.data.selectedFonts.map(f => f.id),
|
||||
fontSize: Number(this.data.fontSize),
|
||||
letterSpacing: Number(this.data.letterSpacingInput || 0),
|
||||
textColor: color,
|
||||
})
|
||||
this.scheduleGenerate()
|
||||
},
|
||||
|
||||
onRegenerate() {
|
||||
this.generateAllPreviews()
|
||||
},
|
||||
|
||||
onShowExportOptions() {
|
||||
wx.showActionSheet({
|
||||
itemList: ['导出全部为 SVG', '导出全部为 PNG'],
|
||||
success: (res) => {
|
||||
if (res.tapIndex === 0) {
|
||||
this.exportAllSvg()
|
||||
} else if (res.tapIndex === 1) {
|
||||
this.exportAllPng()
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
async exportAllSvg() {
|
||||
const selectedFonts = this.data.selectedFonts.filter(f => f.showInPreview && f.svg)
|
||||
|
||||
if (selectedFonts.length === 0) {
|
||||
wx.showToast({ title: '请先生成预览', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
wx.showLoading({ title: '导出 SVG 中', mask: true })
|
||||
|
||||
try {
|
||||
for (const font of selectedFonts) {
|
||||
const filePath = await saveSvgToUserPath(font.svg, font.name, this.data.inputText)
|
||||
const filename = buildFilename(font.name, this.data.inputText, 'svg')
|
||||
await shareLocalFile(filePath, filename)
|
||||
}
|
||||
wx.showToast({ title: 'SVG 导出完成', icon: 'success' })
|
||||
} catch (error) {
|
||||
const message = error && error.errMsg ? error.errMsg : error.message
|
||||
wx.showToast({ title: message || '导出 SVG 失败', icon: 'none', duration: 2400 })
|
||||
} finally {
|
||||
wx.hideLoading()
|
||||
}
|
||||
},
|
||||
|
||||
async exportAllPng() {
|
||||
const selectedFonts = this.data.selectedFonts.filter(f => f.showInPreview && f.svg)
|
||||
|
||||
if (selectedFonts.length === 0) {
|
||||
wx.showToast({ title: '请先生成预览', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
wx.showLoading({ title: '导出 PNG 中', mask: true })
|
||||
|
||||
try {
|
||||
for (const font of selectedFonts) {
|
||||
const width = Math.max(64, Math.round(font.width || 1024))
|
||||
const height = Math.max(64, Math.round(font.height || 1024))
|
||||
|
||||
const pngPath = await exportSvgToPngByCanvas(this, {
|
||||
svgString: font.svg,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
|
||||
await savePngToAlbum(pngPath)
|
||||
}
|
||||
wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' })
|
||||
} catch (error) {
|
||||
const message = error && error.errMsg ? error.errMsg : error.message
|
||||
wx.showToast({ title: message || '导出 PNG 失败', icon: 'none', duration: 2400 })
|
||||
} finally {
|
||||
wx.hideLoading()
|
||||
}
|
||||
},
|
||||
|
||||
async onExportSvg() {
|
||||
const selectedFonts = this.data.selectedFonts.filter(f => f.showInPreview && f.svg)
|
||||
|
||||
if (selectedFonts.length === 0) {
|
||||
wx.showToast({ title: '请先生成预览', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
// 如果只有一个字体,直接导出
|
||||
if (selectedFonts.length === 1) {
|
||||
const font = selectedFonts[0]
|
||||
wx.showLoading({ title: '导出 SVG 中', mask: true })
|
||||
try {
|
||||
const filePath = await saveSvgToUserPath(font.svg, font.name, this.data.inputText)
|
||||
const filename = buildFilename(font.name, this.data.inputText, 'svg')
|
||||
await shareLocalFile(filePath, filename)
|
||||
wx.showToast({ title: 'SVG 已分享', icon: 'success' })
|
||||
} catch (error) {
|
||||
const message = error && error.errMsg ? error.errMsg : error.message
|
||||
wx.showToast({ title: message || '导出 SVG 失败', icon: 'none', duration: 2400 })
|
||||
} finally {
|
||||
wx.hideLoading()
|
||||
}
|
||||
} else {
|
||||
this.exportAllSvg()
|
||||
}
|
||||
},
|
||||
|
||||
async onExportPng() {
|
||||
const selectedFonts = this.data.selectedFonts.filter(f => f.showInPreview && f.svg)
|
||||
|
||||
if (selectedFonts.length === 0) {
|
||||
wx.showToast({ title: '请先生成预览', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
// 如果只有一个字体,直接导出
|
||||
if (selectedFonts.length === 1) {
|
||||
const font = selectedFonts[0]
|
||||
wx.showLoading({ title: '导出 PNG 中', mask: true })
|
||||
|
||||
try {
|
||||
const width = Math.max(64, Math.round(font.width || 1024))
|
||||
const height = Math.max(64, Math.round(font.height || 1024))
|
||||
|
||||
const pngPath = await exportSvgToPngByCanvas(this, {
|
||||
svgString: font.svg,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
|
||||
const saveResult = await savePngToAlbum(pngPath)
|
||||
if (saveResult.success) {
|
||||
wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' })
|
||||
} else {
|
||||
wx.showToast({ title: '保存失败,请重试', icon: 'none' })
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error && error.errMsg ? error.errMsg : error.message
|
||||
wx.showToast({ title: message || '导出 PNG 失败', icon: 'none', duration: 2400 })
|
||||
} finally {
|
||||
wx.hideLoading()
|
||||
}
|
||||
} else {
|
||||
this.exportAllPng()
|
||||
}
|
||||
},
|
||||
|
||||
// 搜索功能
|
||||
onToggleSearch() {
|
||||
this.setData({ showSearch: !this.data.showSearch })
|
||||
if (!this.data.showSearch) {
|
||||
this.setData({ searchKeyword: '' })
|
||||
this.updateFontTrees()
|
||||
}
|
||||
},
|
||||
|
||||
onSearchInput(e) {
|
||||
const keyword = e.detail.value || ''
|
||||
this.setData({ searchKeyword: keyword })
|
||||
this.updateFontTrees()
|
||||
},
|
||||
})
|
||||
3
miniprogram/pages/index/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "Font2SVG"
|
||||
}
|
||||
217
miniprogram/pages/index/index.wxml
Normal file
@@ -0,0 +1,217 @@
|
||||
<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}}"
|
||||
bindtap="onDecreaseFontSize"
|
||||
/>
|
||||
<slider
|
||||
class="font-slider"
|
||||
min="24"
|
||||
max="320"
|
||||
step="1"
|
||||
value="{{fontSize}}"
|
||||
show-value="false"
|
||||
activeColor="#9B6BC2"
|
||||
backgroundColor="#E5E6EB"
|
||||
block-size="18"
|
||||
bindchanging="onFontSizeChanging"
|
||||
bindchange="onFontSizeChange"
|
||||
/>
|
||||
<image
|
||||
class="font-size-icon"
|
||||
src="{{icons.fontSizeIncrease}}"
|
||||
bindtap="onIncreaseFontSize"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="color-picker-btn" bindtap="onShowColorPicker">
|
||||
<image class="color-icon" src="{{icons.chooseColor}}" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 输入栏和导出按钮 -->
|
||||
<view class="input-row">
|
||||
<view class="text-input-container">
|
||||
<input
|
||||
class="text-input"
|
||||
value="{{inputText}}"
|
||||
placeholder="此处输入内容"
|
||||
bindinput="onInputText"
|
||||
confirm-type="done"
|
||||
bindconfirm="onRegenerate"
|
||||
/>
|
||||
</view>
|
||||
<view class="export-buttons">
|
||||
<view class="export-btn" bindtap="onShowExportOptions">
|
||||
<image class="export-icon" src="{{icons.export}}" />
|
||||
</view>
|
||||
<view class="export-btn" bindtap="onExportSvg">
|
||||
<image class="export-icon" src="{{icons.exportSvg}}" />
|
||||
</view>
|
||||
<view class="export-btn" bindtap="onExportPng">
|
||||
<image class="export-icon" src="{{icons.exportPng}}" />
|
||||
</view>
|
||||
</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="preview-checkbox" bindtap="onTogglePreviewFont" data-font-id="{{item.id}}">
|
||||
<view class="checkbox-wrapper {{item.showInPreview ? 'checked' : ''}}">
|
||||
<image wx:if="{{item.showInPreview}}" class="checkbox-icon" src="{{icons.checkbox}}" />
|
||||
</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}}"
|
||||
/>
|
||||
<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" 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="{{item.expanded ? icons.collapseIcon : icons.expandIcon}}"
|
||||
/>
|
||||
<view class="category-name">{{item.category}}</view>
|
||||
</view>
|
||||
<view wx:if="{{item.expanded}}" class="font-list">
|
||||
<view
|
||||
wx:for="{{item.fonts}}"
|
||||
wx:for-item="font"
|
||||
wx:key="id"
|
||||
class="font-item"
|
||||
>
|
||||
<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}}">
|
||||
<view class="checkbox-wrapper {{font.selected ? 'checked' : ''}}">
|
||||
<image wx:if="{{font.selected}}" class="checkbox-icon-sm" src="{{icons.checkbox}}" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="favorite-btn" bindtap="onToggleFavorite" data-font-id="{{font.id}}">
|
||||
<image
|
||||
class="favorite-icon"
|
||||
src="{{icons.favoriteIcon}}"
|
||||
style="opacity: {{font.isFavorite ? '1' : '0.3'}}"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 已收藏字体 -->
|
||||
<view class="favorite-selection">
|
||||
<view class="section-title">已收藏</view>
|
||||
<scroll-view class="font-tree" scroll-y>
|
||||
<view wx:for="{{favoriteCategories}}" wx:key="category" class="font-category">
|
||||
<view class="category-header" bindtap="onToggleFavoriteCategory" data-category="{{item.category}}">
|
||||
<image
|
||||
class="expand-icon"
|
||||
src="{{item.expanded ? icons.collapseIcon : icons.expandIcon}}"
|
||||
/>
|
||||
<view class="category-name">{{item.category}}</view>
|
||||
</view>
|
||||
<view wx:if="{{item.expanded}}" class="font-list">
|
||||
<view
|
||||
wx:for="{{item.fonts}}"
|
||||
wx:for-item="font"
|
||||
wx:key="id"
|
||||
class="font-item"
|
||||
>
|
||||
<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}}">
|
||||
<view class="checkbox-wrapper {{font.selected ? 'checked' : ''}}">
|
||||
<image wx:if="{{font.selected}}" class="checkbox-icon-sm" src="{{icons.checkbox}}" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="favorite-btn" bindtap="onToggleFavorite" data-font-id="{{font.id}}">
|
||||
<image class="favorite-icon" src="{{icons.favoriteIcon}}" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:if="{{!favoriteCategories.length}}" class="empty-favorites">暂无收藏字体</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</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>
|
||||
426
miniprogram/pages/index/index.wxss
Normal file
@@ -0,0 +1,426 @@
|
||||
/* 顶部导航栏 */
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
height: 96rpx;
|
||||
padding: 0 16rpx;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #8552A1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.font-size-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
gap: 4rpx; /* 改为 2rpx 的 gap,小程序 rpx = 物理像素*2 */
|
||||
padding: 0 12rpx;
|
||||
}
|
||||
|
||||
.font-size-icon {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.font-slider {
|
||||
flex: 1;
|
||||
height: 4rpx;
|
||||
}
|
||||
|
||||
.color-picker-btn {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.color-icon {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
}
|
||||
|
||||
/* 输入栏和导出按钮 */
|
||||
.input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
height: 78rpx;
|
||||
padding: 0 16rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.text-input-container {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
background: #F7F8FA;
|
||||
border-radius: 12rpx;
|
||||
padding: 0 6rpx;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 20rpx;
|
||||
color: #4E5969;
|
||||
}
|
||||
|
||||
.export-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
border: 1rpx solid #E5E6EB;
|
||||
border-radius: 10rpx;
|
||||
padding: 5rpx 10rpx;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
width: 62rpx;
|
||||
height: 62rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.export-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 效果预览区域 */
|
||||
.preview-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 16rpx;
|
||||
padding: 0 16rpx;
|
||||
border: 1rpx solid #f7e0e0;
|
||||
border-radius: 12rpx;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
padding: 12rpx 0;
|
||||
font-size: 28rpx;
|
||||
font-weight: 400;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.preview-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preview-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
padding-bottom: 4rpx;
|
||||
border-bottom: 0.5rpx solid #C9CDD4;
|
||||
}
|
||||
|
||||
.font-icon {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.font-name-text {
|
||||
flex: 1;
|
||||
font-size: 20rpx;
|
||||
color: #86909C;
|
||||
}
|
||||
|
||||
.preview-checkbox {
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
}
|
||||
|
||||
.checkbox-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1rpx solid #C9CDD4;
|
||||
border-radius: 3rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.checkbox-wrapper.checked {
|
||||
border-color: #9B6BC2;
|
||||
background: #9B6BC2;
|
||||
}
|
||||
|
||||
.checkbox-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
background: transparent;
|
||||
padding: 4rpx 0;
|
||||
min-height: 80rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preview-loading {
|
||||
font-size: 24rpx;
|
||||
color: #86909C;
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
text-align: center;
|
||||
padding: 80rpx 0;
|
||||
font-size: 24rpx;
|
||||
color: #C9CDD4;
|
||||
}
|
||||
|
||||
/* 字体选择和已收藏字体 */
|
||||
.bottom-section {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
height: 600rpx;
|
||||
margin-top: 16rpx;
|
||||
padding: 0 16rpx;
|
||||
}
|
||||
|
||||
.font-selection,
|
||||
.favorite-selection {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1rpx solid #f7e0e0;
|
||||
border-radius: 16rpx;
|
||||
background: #fff;
|
||||
padding: 9rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 搜索相关样式 */
|
||||
.selection-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 4rpx 0;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
background: #F7F8FA;
|
||||
border-radius: 8rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
height: 56rpx;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
font-size: 22rpx;
|
||||
color: #4E5969;
|
||||
}
|
||||
|
||||
.search-toggle {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
background: #F7F8FA;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.font-tree {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.font-category {
|
||||
margin-bottom: 14rpx;
|
||||
}
|
||||
|
||||
.category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-bottom: 14rpx;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
width: 20rpx;
|
||||
height: 20rpx;
|
||||
transition: transform 0.3s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
font-size: 21rpx;
|
||||
font-weight: 500;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.font-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.font-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9rpx;
|
||||
padding-bottom: 9rpx;
|
||||
border-bottom: 1rpx solid #C9CDD4;
|
||||
margin-bottom: 9rpx;
|
||||
}
|
||||
|
||||
.font-item-icon {
|
||||
width: 18rpx;
|
||||
height: 18rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.font-item-name {
|
||||
flex: 1;
|
||||
font-size: 20rpx;
|
||||
color: #86909C;
|
||||
}
|
||||
|
||||
.font-item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9rpx;
|
||||
}
|
||||
|
||||
.font-checkbox {
|
||||
width: 21rpx;
|
||||
height: 21rpx;
|
||||
}
|
||||
|
||||
.checkbox-icon-sm {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.favorite-btn {
|
||||
width: 21rpx;
|
||||
height: 21rpx;
|
||||
}
|
||||
|
||||
.favorite-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.empty-favorites {
|
||||
text-align: center;
|
||||
padding: 60rpx 0;
|
||||
font-size: 24rpx;
|
||||
color: #C9CDD4;
|
||||
}
|
||||
|
||||
/* 颜色选择器弹窗 */
|
||||
.color-picker-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.color-picker-content {
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
padding: 40rpx;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.color-palette {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20rpx;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.color-dot {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.color-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.color-input {
|
||||
flex: 1;
|
||||
height: 60rpx;
|
||||
background: #F7F8FA;
|
||||
border-radius: 10rpx;
|
||||
padding: 0 16rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
/* 画布 */
|
||||
.hidden-canvas {
|
||||
position: fixed;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
}
|
||||
45
miniprogram/project.config.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"description": "font2svg miniprogram",
|
||||
"packOptions": {
|
||||
"ignore": [],
|
||||
"include": []
|
||||
},
|
||||
"setting": {
|
||||
"es6": true,
|
||||
"enhance": true,
|
||||
"postcss": true,
|
||||
"minified": true,
|
||||
"coverView": true,
|
||||
"showShadowRootInWxmlPanel": true,
|
||||
"packNpmManually": false,
|
||||
"compileHotReLoad": true,
|
||||
"ignoreUploadUnusedFiles": true,
|
||||
"compileWorklet": false,
|
||||
"uglifyFileName": false,
|
||||
"uploadWithSourceMap": true,
|
||||
"packNpmRelationList": [],
|
||||
"minifyWXSS": true,
|
||||
"minifyWXML": true,
|
||||
"localPlugins": false,
|
||||
"disableUseStrict": false,
|
||||
"useCompilerPlugins": false,
|
||||
"condition": true,
|
||||
"swc": false,
|
||||
"disableSWC": true,
|
||||
"babelSetting": {
|
||||
"ignore": [],
|
||||
"disablePlugins": [],
|
||||
"outputPath": ""
|
||||
}
|
||||
},
|
||||
"compileType": "miniprogram",
|
||||
"libVersion": "2.32.3",
|
||||
"appid": "wxeda897f274ff33cf",
|
||||
"projectname": "font2svg-miniprogram",
|
||||
"condition": {},
|
||||
"simulatorPluginLibVersion": {
|
||||
"wxext14566970e7e9f62": "2.27.3"
|
||||
},
|
||||
"editorSetting": {},
|
||||
"projectArchitecture": "multiPlatform"
|
||||
}
|
||||
68
miniprogram/project.miniapp.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"miniVersion": "v2",
|
||||
"name": "%name%",
|
||||
"version": "0.0.1",
|
||||
"versionCode": 100,
|
||||
"i18nFilePath": "i18n",
|
||||
"mini-ohos": {
|
||||
"sdkVersion": "0.5.1"
|
||||
},
|
||||
"mini-android": {
|
||||
"resourcePath": "miniapp/android/nativeResources",
|
||||
"sdkVersion": "1.6.24",
|
||||
"toolkitVersion": "0.11.0",
|
||||
"useExtendedSdk": {
|
||||
"media": false,
|
||||
"bluetooth": false,
|
||||
"network": false,
|
||||
"scanner": false,
|
||||
"xweb": false
|
||||
},
|
||||
"icons": {
|
||||
"hdpi": "",
|
||||
"xhdpi": "",
|
||||
"xxhdpi": "",
|
||||
"xxxhdpi": ""
|
||||
},
|
||||
"splashscreen": {
|
||||
"hdpi": "",
|
||||
"xhdpi": "",
|
||||
"xxhdpi": ""
|
||||
},
|
||||
"enableVConsole": "open",
|
||||
"privacy": {
|
||||
"enable": true
|
||||
}
|
||||
},
|
||||
"mini-ios": {
|
||||
"sdkVersion": "1.6.28",
|
||||
"toolkitVersion": "0.0.9",
|
||||
"useExtendedSdk": {
|
||||
"WeAppOpenFuns": true,
|
||||
"WeAppNetwork": false,
|
||||
"WeAppBluetooth": false,
|
||||
"WeAppMedia": false,
|
||||
"WeAppLBS": false,
|
||||
"WeAppOthers": false
|
||||
},
|
||||
"enableVConsole": "open",
|
||||
"icons": {
|
||||
"mainIcon120": "",
|
||||
"mainIcon180": "",
|
||||
"spotlightIcon80": "",
|
||||
"spotlightIcon120": "",
|
||||
"settingsIcon58": "",
|
||||
"settingsIcon87": "",
|
||||
"notificationIcon40": "",
|
||||
"notificationIcon60": "",
|
||||
"appStore1024": ""
|
||||
},
|
||||
"splashScreen": {
|
||||
"customImage": ""
|
||||
},
|
||||
"privacy": {
|
||||
"enable": false
|
||||
},
|
||||
"enableOpenUrlNavigate": true
|
||||
}
|
||||
}
|
||||
9
miniprogram/sitemap.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"desc": "Font2SVG 小程序 sitemap",
|
||||
"rules": [
|
||||
{
|
||||
"action": "allow",
|
||||
"page": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
192
miniprogram/utils/core/svg-builder.js
Normal file
@@ -0,0 +1,192 @@
|
||||
const { wrapTextByChars } = require('./text-layout')
|
||||
|
||||
function formatNumber(value) {
|
||||
const text = Number(value).toFixed(2).replace(/\.?0+$/, '')
|
||||
return text || '0'
|
||||
}
|
||||
|
||||
function getGlyphPath(glyph) {
|
||||
const path = glyph && glyph.path
|
||||
if (!path || !Array.isArray(path.commands) || !path.commands.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const commands = []
|
||||
for (const cmd of path.commands) {
|
||||
switch (cmd.type) {
|
||||
case 'M':
|
||||
commands.push(`M${formatNumber(cmd.x)} ${formatNumber(cmd.y)}`)
|
||||
break
|
||||
case 'L':
|
||||
commands.push(`L${formatNumber(cmd.x)} ${formatNumber(cmd.y)}`)
|
||||
break
|
||||
case 'Q':
|
||||
commands.push(`Q${formatNumber(cmd.x1)} ${formatNumber(cmd.y1)} ${formatNumber(cmd.x)} ${formatNumber(cmd.y)}`)
|
||||
break
|
||||
case 'C':
|
||||
commands.push(`C${formatNumber(cmd.x1)} ${formatNumber(cmd.y1)} ${formatNumber(cmd.x2)} ${formatNumber(cmd.y2)} ${formatNumber(cmd.x)} ${formatNumber(cmd.y)}`)
|
||||
break
|
||||
case 'Z':
|
||||
commands.push('Z')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return commands.join(' ')
|
||||
}
|
||||
|
||||
function getGlyphBounds(glyph) {
|
||||
const path = glyph && glyph.path
|
||||
if (!path || !Array.isArray(path.commands) || !path.commands.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
let xMin = Infinity
|
||||
let yMin = Infinity
|
||||
let xMax = -Infinity
|
||||
let yMax = -Infinity
|
||||
|
||||
for (const cmd of path.commands) {
|
||||
if (typeof cmd.x === 'number') {
|
||||
xMin = Math.min(xMin, cmd.x)
|
||||
xMax = Math.max(xMax, cmd.x)
|
||||
yMin = Math.min(yMin, cmd.y)
|
||||
yMax = Math.max(yMax, cmd.y)
|
||||
}
|
||||
if (typeof cmd.x1 === 'number') {
|
||||
xMin = Math.min(xMin, cmd.x1)
|
||||
xMax = Math.max(xMax, cmd.x1)
|
||||
yMin = Math.min(yMin, cmd.y1)
|
||||
yMax = Math.max(yMax, cmd.y1)
|
||||
}
|
||||
if (typeof cmd.x2 === 'number') {
|
||||
xMin = Math.min(xMin, cmd.x2)
|
||||
xMax = Math.max(xMax, cmd.x2)
|
||||
yMin = Math.min(yMin, cmd.y2)
|
||||
yMax = Math.max(yMax, cmd.y2)
|
||||
}
|
||||
}
|
||||
|
||||
if (xMin === Infinity) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { xMin, yMin, xMax, yMax }
|
||||
}
|
||||
|
||||
function generateSvgFromFont(options) {
|
||||
const {
|
||||
text,
|
||||
font,
|
||||
fontSize = 100,
|
||||
fillColor = '#000000',
|
||||
letterSpacing = 0,
|
||||
maxCharsPerLine,
|
||||
} = options
|
||||
|
||||
if (!text || !String(text).trim()) {
|
||||
throw new Error('文本内容不能为空')
|
||||
}
|
||||
|
||||
if (!font) {
|
||||
throw new Error('字体对象不能为空')
|
||||
}
|
||||
|
||||
const normalizedText = wrapTextByChars(text, maxCharsPerLine)
|
||||
|
||||
const scale = fontSize / font.unitsPerEm
|
||||
const letterSpacingRaw = letterSpacing * font.unitsPerEm
|
||||
|
||||
const glyphRuns = []
|
||||
let minX = null
|
||||
let minY = null
|
||||
let maxX = null
|
||||
let maxY = null
|
||||
let maxLineAdvance = 0
|
||||
|
||||
const ascender = Number.isFinite(font.ascender) ? font.ascender : font.unitsPerEm * 0.8
|
||||
const descender = Number.isFinite(font.descender) ? font.descender : -font.unitsPerEm * 0.2
|
||||
const lineAdvance = Math.max(font.unitsPerEm * 1.2, ascender - descender)
|
||||
const lines = normalizedText.split('\n')
|
||||
|
||||
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
||||
const line = lines[lineIndex] || ''
|
||||
const yPos = -lineIndex * lineAdvance
|
||||
let xPos = 0
|
||||
|
||||
for (const char of Array.from(line)) {
|
||||
const glyph = font.charToGlyph(char)
|
||||
glyphRuns.push({ glyph, xPos, yPos })
|
||||
|
||||
const bounds = getGlyphBounds(glyph)
|
||||
if (bounds) {
|
||||
const adjustedXMin = bounds.xMin + xPos
|
||||
const adjustedYMin = bounds.yMin + yPos
|
||||
const adjustedXMax = bounds.xMax + xPos
|
||||
const adjustedYMax = bounds.yMax + yPos
|
||||
|
||||
minX = minX === null ? adjustedXMin : Math.min(minX, adjustedXMin)
|
||||
minY = minY === null ? adjustedYMin : Math.min(minY, adjustedYMin)
|
||||
maxX = maxX === null ? adjustedXMax : Math.max(maxX, adjustedXMax)
|
||||
maxY = maxY === null ? adjustedYMax : Math.max(maxY, adjustedYMax)
|
||||
}
|
||||
|
||||
xPos += (glyph.advanceWidth || 0) + letterSpacingRaw
|
||||
}
|
||||
|
||||
maxLineAdvance = Math.max(maxLineAdvance, xPos)
|
||||
}
|
||||
|
||||
if (minX === null || maxX === null) {
|
||||
minX = 0
|
||||
maxX = maxLineAdvance
|
||||
}
|
||||
|
||||
if (minX === null || minY === null || maxX === null || maxY === null) {
|
||||
throw new Error('未生成有效字形轮廓')
|
||||
}
|
||||
|
||||
const width = (maxX - minX) * scale
|
||||
const height = (maxY - minY) * scale
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
throw new Error('计算得到的 SVG 尺寸无效')
|
||||
}
|
||||
|
||||
const paths = []
|
||||
for (const run of glyphRuns) {
|
||||
const d = getGlyphPath(run.glyph)
|
||||
if (!d) {
|
||||
continue
|
||||
}
|
||||
const transform = `translate(${formatNumber(run.xPos)} ${formatNumber(run.yPos)})`
|
||||
paths.push(` <path d=\"${d}\" transform=\"${transform}\"/>`)
|
||||
}
|
||||
|
||||
if (!paths.length) {
|
||||
throw new Error('未生成任何路径')
|
||||
}
|
||||
|
||||
const viewBox = `${formatNumber(minX)} 0 ${formatNumber(maxX - minX)} ${formatNumber(maxY - minY)}`
|
||||
const groupTransform = `translate(0 ${formatNumber(maxY)}) scale(1 -1)`
|
||||
|
||||
const svg = `<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"${viewBox}\" width=\"${formatNumber(width)}\" height=\"${formatNumber(height)}\" preserveAspectRatio=\"xMidYMid meet\">\n <g transform=\"${groupTransform}\" fill=\"${fillColor}\" stroke=\"none\">\n${paths.join('\n')}\n </g>\n</svg>`
|
||||
|
||||
const fontName =
|
||||
(font.names && font.names.fontFamily && (font.names.fontFamily.en || font.names.fontFamily.zh)) ||
|
||||
(font.names && font.names.fullName && (font.names.fullName.en || font.names.fullName.zh)) ||
|
||||
'Unknown'
|
||||
|
||||
return {
|
||||
svg,
|
||||
width,
|
||||
height,
|
||||
fontName,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateSvgFromFont,
|
||||
}
|
||||
36
miniprogram/utils/core/text-layout.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const MAX_CHARS_PER_LINE = 45
|
||||
|
||||
function normalizeLineBreaks(text) {
|
||||
return String(text || '').replace(/\r\n?/g, '\n')
|
||||
}
|
||||
|
||||
function wrapTextByChars(text, maxCharsPerLine = MAX_CHARS_PER_LINE) {
|
||||
if (maxCharsPerLine <= 0) {
|
||||
return normalizeLineBreaks(text)
|
||||
}
|
||||
|
||||
const normalized = normalizeLineBreaks(text)
|
||||
const lines = normalized.split('\n')
|
||||
const wrappedLines = []
|
||||
|
||||
for (const line of lines) {
|
||||
const chars = Array.from(line)
|
||||
|
||||
if (!chars.length) {
|
||||
wrappedLines.push('')
|
||||
continue
|
||||
}
|
||||
|
||||
for (let i = 0; i < chars.length; i += maxCharsPerLine) {
|
||||
wrappedLines.push(chars.slice(i, i + maxCharsPerLine).join(''))
|
||||
}
|
||||
}
|
||||
|
||||
return wrappedLines.join('\n')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_CHARS_PER_LINE,
|
||||
normalizeLineBreaks,
|
||||
wrapTextByChars,
|
||||
}
|
||||
131
miniprogram/utils/mp/canvas-export.js
Normal file
@@ -0,0 +1,131 @@
|
||||
const {
|
||||
canvasToTempFilePath,
|
||||
saveImageToPhotosAlbum,
|
||||
writeFile,
|
||||
openSetting,
|
||||
showModal,
|
||||
} = require('./wx-promisify')
|
||||
|
||||
function getWindowDpr() {
|
||||
if (typeof wx.getWindowInfo === 'function') {
|
||||
return wx.getWindowInfo().pixelRatio || 1
|
||||
}
|
||||
const info = wx.getSystemInfoSync()
|
||||
return info.pixelRatio || 1
|
||||
}
|
||||
|
||||
function queryCanvasNode(page, selector) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const query = wx.createSelectorQuery().in(page)
|
||||
query
|
||||
.select(selector)
|
||||
.fields({ node: true, size: true })
|
||||
.exec((result) => {
|
||||
const target = result && result[0]
|
||||
if (!target || !target.node) {
|
||||
reject(new Error('未找到导出画布节点'))
|
||||
return
|
||||
}
|
||||
resolve(target)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function writeSvgTempFile(svgString) {
|
||||
const path = `${wx.env.USER_DATA_PATH}/font2svg_preview_${Date.now()}.svg`
|
||||
await writeFile(path, svgString, 'utf8')
|
||||
return path
|
||||
}
|
||||
|
||||
async function exportSvgToPngByCanvas(page, options) {
|
||||
const {
|
||||
svgString,
|
||||
width,
|
||||
height,
|
||||
selector = '#exportCanvas',
|
||||
backgroundColor = '#ffffff',
|
||||
} = options
|
||||
|
||||
if (!svgString) {
|
||||
throw new Error('缺少 SVG 内容')
|
||||
}
|
||||
|
||||
const canvasNode = await queryCanvasNode(page, selector)
|
||||
const canvas = canvasNode.node
|
||||
const ctx = canvas.getContext('2d')
|
||||
const dpr = getWindowDpr()
|
||||
|
||||
const renderWidth = Math.max(1, Math.min(2048, Math.round(width || canvasNode.width || 1024)))
|
||||
const renderHeight = Math.max(1, Math.min(2048, Math.round(height || canvasNode.height || 1024)))
|
||||
|
||||
canvas.width = renderWidth * dpr
|
||||
canvas.height = renderHeight * dpr
|
||||
|
||||
if (typeof ctx.setTransform === 'function') {
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
} else {
|
||||
ctx.scale(dpr, dpr)
|
||||
}
|
||||
|
||||
ctx.fillStyle = backgroundColor
|
||||
ctx.fillRect(0, 0, renderWidth, renderHeight)
|
||||
|
||||
const svgPath = await writeSvgTempFile(svgString)
|
||||
const image = canvas.createImage()
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
image.onload = resolve
|
||||
image.onerror = () => reject(new Error('加载 SVG 到画布失败'))
|
||||
image.src = svgPath
|
||||
})
|
||||
|
||||
ctx.drawImage(image, 0, 0, renderWidth, renderHeight)
|
||||
|
||||
const fileRes = await canvasToTempFilePath(
|
||||
{
|
||||
canvas,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: renderWidth,
|
||||
height: renderHeight,
|
||||
destWidth: renderWidth,
|
||||
destHeight: renderHeight,
|
||||
fileType: 'png',
|
||||
},
|
||||
page
|
||||
)
|
||||
|
||||
return fileRes.tempFilePath
|
||||
}
|
||||
|
||||
async function savePngToAlbum(filePath) {
|
||||
try {
|
||||
await saveImageToPhotosAlbum(filePath)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const errMsg = String(error && error.errMsg ? error.errMsg : error)
|
||||
const needAuth = errMsg.includes('auth deny') || errMsg.includes('authorize')
|
||||
|
||||
if (needAuth) {
|
||||
const modalRes = await showModal({
|
||||
title: '需要相册权限',
|
||||
content: '请在设置中开启“保存到相册”权限后重试。',
|
||||
confirmText: '去设置',
|
||||
})
|
||||
if (modalRes.confirm) {
|
||||
await openSetting()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
needAuth,
|
||||
error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
exportSvgToPngByCanvas,
|
||||
savePngToAlbum,
|
||||
}
|
||||
49
miniprogram/utils/mp/file-export.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const { writeFile } = require('./wx-promisify')
|
||||
|
||||
function sanitizeFilename(filename) {
|
||||
return String(filename || 'font2svg')
|
||||
.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
|
||||
.replace(/\s+/g, '_')
|
||||
.slice(0, 80)
|
||||
}
|
||||
|
||||
function buildFilename(fontName, text, ext) {
|
||||
const safeFont = sanitizeFilename(fontName || 'font')
|
||||
const safeText = sanitizeFilename(Array.from(text || '').slice(0, 8).join('') || 'text')
|
||||
return `${safeFont}_${safeText}.${ext}`
|
||||
}
|
||||
|
||||
async function writeTextToUserPath(text, ext, preferredName) {
|
||||
const filename = preferredName || `font2svg_${Date.now()}.${ext}`
|
||||
const filePath = `${wx.env.USER_DATA_PATH}/${filename}`
|
||||
await writeFile(filePath, text, 'utf8')
|
||||
return filePath
|
||||
}
|
||||
|
||||
async function saveSvgToUserPath(svgText, fontName, text) {
|
||||
const filename = buildFilename(fontName, text, 'svg')
|
||||
return writeTextToUserPath(svgText, 'svg', filename)
|
||||
}
|
||||
|
||||
async function shareLocalFile(filePath, fileName) {
|
||||
if (typeof wx.shareFileMessage !== 'function') {
|
||||
throw new Error('当前微信版本不支持文件分享')
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.shareFileMessage({
|
||||
filePath,
|
||||
fileName,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sanitizeFilename,
|
||||
buildFilename,
|
||||
writeTextToUserPath,
|
||||
saveSvgToUserPath,
|
||||
shareLocalFile,
|
||||
}
|
||||
130
miniprogram/utils/mp/font-loader.js
Normal file
@@ -0,0 +1,130 @@
|
||||
const { request, downloadFile, readFile } = require('./wx-promisify')
|
||||
|
||||
const localFonts = require('../../assets/fonts')
|
||||
|
||||
const fontBufferCache = new Map()
|
||||
const MAX_FONT_CACHE = 4
|
||||
|
||||
function normalizePath(path, baseUrl) {
|
||||
if (!path) {
|
||||
return ''
|
||||
}
|
||||
if (/^https?:\/\//i.test(path)) {
|
||||
return path
|
||||
}
|
||||
if (path.startsWith('//')) {
|
||||
return `https:${path}`
|
||||
}
|
||||
if (path.startsWith('/')) {
|
||||
return `${baseUrl}${path}`
|
||||
}
|
||||
return `${baseUrl}/${path}`
|
||||
}
|
||||
|
||||
function normalizeFontItem(item, baseUrl) {
|
||||
const path = item.path || item.url || ''
|
||||
const normalizedPath = normalizePath(path, baseUrl)
|
||||
const filename = item.filename || normalizedPath.split('/').pop() || `${item.name || 'font'}.ttf`
|
||||
return {
|
||||
id: item.id || `${item.category || '默认'}/${item.name || filename}`,
|
||||
name: item.name || filename.replace(/\.[^.]+$/, ''),
|
||||
category: item.category || '默认',
|
||||
filename,
|
||||
path,
|
||||
url: normalizedPath,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeManifest(fonts, baseUrl) {
|
||||
if (!Array.isArray(fonts)) {
|
||||
return []
|
||||
}
|
||||
return fonts
|
||||
.map((item) => normalizeFontItem(item, baseUrl))
|
||||
.filter((item) => item.url)
|
||||
}
|
||||
|
||||
async function loadFontsManifest(options = {}) {
|
||||
const app = getApp()
|
||||
const manifestUrl = options.manifestUrl || app.globalData.fontsManifestUrl
|
||||
const baseUrl = options.baseUrl || app.globalData.fontsBaseUrl
|
||||
|
||||
if (Array.isArray(app.globalData.fonts) && app.globalData.fonts.length > 0) {
|
||||
return app.globalData.fonts
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request({
|
||||
url: manifestUrl,
|
||||
method: 'GET',
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw new Error(`获取字体清单失败,状态码: ${response.statusCode}`)
|
||||
}
|
||||
|
||||
const fonts = normalizeManifest(response.data, baseUrl)
|
||||
if (!fonts.length) {
|
||||
throw new Error('字体清单为空')
|
||||
}
|
||||
|
||||
app.globalData.fonts = fonts
|
||||
return fonts
|
||||
} catch (error) {
|
||||
console.warn('远程字体清单加载失败,回退到本地清单:', error)
|
||||
const fallbackFonts = normalizeManifest(localFonts, baseUrl)
|
||||
app.globalData.fonts = fallbackFonts
|
||||
return fallbackFonts
|
||||
}
|
||||
}
|
||||
|
||||
function setLruCache(key, value) {
|
||||
if (fontBufferCache.has(key)) {
|
||||
fontBufferCache.delete(key)
|
||||
}
|
||||
fontBufferCache.set(key, value)
|
||||
|
||||
while (fontBufferCache.size > MAX_FONT_CACHE) {
|
||||
const firstKey = fontBufferCache.keys().next().value
|
||||
fontBufferCache.delete(firstKey)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFontBuffer(fontItem) {
|
||||
const cacheKey = fontItem.id
|
||||
if (fontBufferCache.has(cacheKey)) {
|
||||
const cached = fontBufferCache.get(cacheKey)
|
||||
setLruCache(cacheKey, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
if (!fontItem.url) {
|
||||
throw new Error('字体地址为空')
|
||||
}
|
||||
|
||||
const downloadRes = await downloadFile({ url: fontItem.url })
|
||||
if (downloadRes.statusCode < 200 || downloadRes.statusCode >= 300) {
|
||||
throw new Error(`字体下载失败,状态码: ${downloadRes.statusCode}`)
|
||||
}
|
||||
|
||||
const readRes = await readFile(downloadRes.tempFilePath)
|
||||
const result = {
|
||||
tempFilePath: downloadRes.tempFilePath,
|
||||
buffer: readRes.data,
|
||||
}
|
||||
|
||||
setLruCache(cacheKey, result)
|
||||
return result
|
||||
}
|
||||
|
||||
function listCategories(fonts) {
|
||||
const set = new Set(fonts.map((font) => font.category || '默认'))
|
||||
return ['全部', '收藏', ...Array.from(set)]
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadFontsManifest,
|
||||
loadFontBuffer,
|
||||
listCategories,
|
||||
}
|
||||
67
miniprogram/utils/mp/render-api.js
Normal file
@@ -0,0 +1,67 @@
|
||||
const { request } = require('./wx-promisify')
|
||||
|
||||
function buildApiUrl() {
|
||||
const app = getApp()
|
||||
const apiUrl = app && app.globalData ? app.globalData.svgRenderApiUrl : ''
|
||||
if (!apiUrl) {
|
||||
throw new Error('未配置渲染 API 地址')
|
||||
}
|
||||
return apiUrl
|
||||
}
|
||||
|
||||
function normalizeResult(data) {
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('渲染服务返回格式无效')
|
||||
}
|
||||
|
||||
if (typeof data.svg !== 'string' || !data.svg.trim()) {
|
||||
throw new Error('渲染服务未返回有效 SVG')
|
||||
}
|
||||
|
||||
return {
|
||||
svg: data.svg,
|
||||
width: Number(data.width) || 0,
|
||||
height: Number(data.height) || 0,
|
||||
fontName: data.fontName || 'Unknown',
|
||||
fontId: data.fontId || '',
|
||||
}
|
||||
}
|
||||
|
||||
async function renderSvgByApi(payload) {
|
||||
const app = getApp()
|
||||
const timeout = app && app.globalData && app.globalData.apiTimeoutMs
|
||||
? Number(app.globalData.apiTimeoutMs)
|
||||
: 30000
|
||||
|
||||
const response = await request({
|
||||
url: buildApiUrl(),
|
||||
method: 'POST',
|
||||
timeout,
|
||||
header: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
fontId: payload.fontId,
|
||||
text: payload.text,
|
||||
fontSize: payload.fontSize,
|
||||
fillColor: payload.fillColor,
|
||||
letterSpacing: payload.letterSpacing,
|
||||
maxCharsPerLine: payload.maxCharsPerLine,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response || response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw new Error(`渲染服务请求失败,状态码: ${response && response.statusCode}`)
|
||||
}
|
||||
|
||||
const body = response.data || {}
|
||||
if (!body.ok) {
|
||||
throw new Error(body.error || '渲染服务返回错误')
|
||||
}
|
||||
|
||||
return normalizeResult(body.data)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
renderSvgByApi,
|
||||
}
|
||||
60
miniprogram/utils/mp/storage.js
Normal file
@@ -0,0 +1,60 @@
|
||||
const STORAGE_KEYS = {
|
||||
APP_STATE: 'font2svg:app-state',
|
||||
FAVORITES: 'font2svg:favorites',
|
||||
}
|
||||
|
||||
function getStorage(key, fallbackValue) {
|
||||
try {
|
||||
const value = wx.getStorageSync(key)
|
||||
if (value === '' || value === undefined || value === null) {
|
||||
return fallbackValue
|
||||
}
|
||||
return value
|
||||
} catch (error) {
|
||||
console.warn('读取本地存储失败:', key, error)
|
||||
return fallbackValue
|
||||
}
|
||||
}
|
||||
|
||||
function setStorage(key, value) {
|
||||
try {
|
||||
wx.setStorageSync(key, value)
|
||||
} catch (error) {
|
||||
console.warn('写入本地存储失败:', key, error)
|
||||
}
|
||||
}
|
||||
|
||||
function loadAppState() {
|
||||
return getStorage(STORAGE_KEYS.APP_STATE, {})
|
||||
}
|
||||
|
||||
function saveAppState(partialState) {
|
||||
const current = loadAppState()
|
||||
const next = {
|
||||
...current,
|
||||
...partialState,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
setStorage(STORAGE_KEYS.APP_STATE, next)
|
||||
return next
|
||||
}
|
||||
|
||||
function loadFavorites() {
|
||||
return getStorage(STORAGE_KEYS.FAVORITES, [])
|
||||
}
|
||||
|
||||
function saveFavorites(favorites) {
|
||||
const unique = Array.from(new Set(favorites))
|
||||
setStorage(STORAGE_KEYS.FAVORITES, unique)
|
||||
return unique
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
STORAGE_KEYS,
|
||||
getStorage,
|
||||
setStorage,
|
||||
loadAppState,
|
||||
saveAppState,
|
||||
loadFavorites,
|
||||
saveFavorites,
|
||||
}
|
||||
93
miniprogram/utils/mp/worker-manager.js
Normal file
@@ -0,0 +1,93 @@
|
||||
let singleton = null
|
||||
|
||||
class SvgWorkerManager {
|
||||
constructor() {
|
||||
this.worker = wx.createWorker('workers/svg-generator/index.js')
|
||||
this.pending = new Map()
|
||||
this.timeoutMs = 30000
|
||||
|
||||
this.worker.onMessage((message) => {
|
||||
const { requestId, success, data, error } = message || {}
|
||||
const pendingTask = this.pending.get(requestId)
|
||||
if (!pendingTask) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(pendingTask.timer)
|
||||
this.pending.delete(requestId)
|
||||
|
||||
if (success) {
|
||||
pendingTask.resolve(data)
|
||||
} else {
|
||||
pendingTask.reject(new Error(error || 'Worker 执行失败'))
|
||||
}
|
||||
})
|
||||
|
||||
this.worker.onError((error) => {
|
||||
this.rejectAll(error)
|
||||
})
|
||||
|
||||
if (typeof this.worker.onProcessKilled === 'function') {
|
||||
this.worker.onProcessKilled(() => {
|
||||
this.rejectAll(new Error('Worker 进程被系统回收'))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
rejectAll(error) {
|
||||
for (const [requestId, pendingTask] of this.pending.entries()) {
|
||||
clearTimeout(pendingTask.timer)
|
||||
pendingTask.reject(error)
|
||||
this.pending.delete(requestId)
|
||||
}
|
||||
}
|
||||
|
||||
request(type, payload, timeoutMs) {
|
||||
const requestId = `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(requestId)
|
||||
reject(new Error(`Worker 超时: ${type}`))
|
||||
}, timeoutMs || this.timeoutMs)
|
||||
|
||||
this.pending.set(requestId, { resolve, reject, timer })
|
||||
|
||||
this.worker.postMessage({
|
||||
requestId,
|
||||
type,
|
||||
payload,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
loadFont(fontId, fontBuffer) {
|
||||
return this.request('load-font', {
|
||||
fontId,
|
||||
fontBuffer,
|
||||
}, 45000)
|
||||
}
|
||||
|
||||
generateSvg(params) {
|
||||
return this.request('generate-svg', params, 45000)
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
return this.request('clear-cache', {})
|
||||
}
|
||||
|
||||
terminate() {
|
||||
this.rejectAll(new Error('Worker 已终止'))
|
||||
this.worker.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
function getSvgWorkerManager() {
|
||||
if (!singleton) {
|
||||
singleton = new SvgWorkerManager()
|
||||
}
|
||||
return singleton
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSvgWorkerManager,
|
||||
}
|
||||
110
miniprogram/utils/mp/wx-promisify.js
Normal file
@@ -0,0 +1,110 @@
|
||||
function request(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.request({
|
||||
...options,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function downloadFile(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.downloadFile({
|
||||
...options,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function saveImageToPhotosAlbum(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.saveImageToPhotosAlbum({
|
||||
filePath,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function canvasToTempFilePath(options, component) {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.canvasToTempFilePath(
|
||||
{
|
||||
...options,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
},
|
||||
component
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function readFile(filePath, encoding) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fs = wx.getFileSystemManager()
|
||||
fs.readFile({
|
||||
filePath,
|
||||
encoding,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function writeFile(filePath, data, encoding) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fs = wx.getFileSystemManager()
|
||||
fs.writeFile({
|
||||
filePath,
|
||||
data,
|
||||
encoding,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function saveFile(tempFilePath, filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fs = wx.getFileSystemManager()
|
||||
fs.saveFile({
|
||||
tempFilePath,
|
||||
filePath,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function openSetting() {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.openSetting({
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function showModal(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.showModal({
|
||||
...options,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
request,
|
||||
downloadFile,
|
||||
saveImageToPhotosAlbum,
|
||||
canvasToTempFilePath,
|
||||
readFile,
|
||||
writeFile,
|
||||
saveFile,
|
||||
openSetting,
|
||||
showModal,
|
||||
}
|
||||
105
miniprogram/workers/svg-generator/index.js
Normal file
@@ -0,0 +1,105 @@
|
||||
const opentype = require('./vendor/opentype.js')
|
||||
const { generateSvgFromFont } = require('./svg-builder')
|
||||
|
||||
const MAX_FONT_CACHE = 4
|
||||
const fontCache = new Map()
|
||||
|
||||
function touchCache(key, value) {
|
||||
if (fontCache.has(key)) {
|
||||
fontCache.delete(key)
|
||||
}
|
||||
fontCache.set(key, value)
|
||||
|
||||
while (fontCache.size > MAX_FONT_CACHE) {
|
||||
const firstKey = fontCache.keys().next().value
|
||||
fontCache.delete(firstKey)
|
||||
}
|
||||
}
|
||||
|
||||
function sendResult(requestId, success, data, error) {
|
||||
worker.postMessage({
|
||||
requestId,
|
||||
success,
|
||||
data,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
function handleLoadFont(requestId, payload) {
|
||||
const { fontId, fontBuffer } = payload || {}
|
||||
if (!fontId || !fontBuffer) {
|
||||
throw new Error('加载字体参数无效')
|
||||
}
|
||||
|
||||
const font = opentype.parse(fontBuffer)
|
||||
touchCache(fontId, {
|
||||
font,
|
||||
loadedAt: Date.now(),
|
||||
})
|
||||
|
||||
return { fontId }
|
||||
}
|
||||
|
||||
function handleGenerateSvg(payload) {
|
||||
const {
|
||||
fontId,
|
||||
text,
|
||||
fontSize,
|
||||
fillColor,
|
||||
letterSpacing,
|
||||
maxCharsPerLine,
|
||||
} = payload || {}
|
||||
|
||||
if (!fontId) {
|
||||
throw new Error('缺少 fontId')
|
||||
}
|
||||
|
||||
const cached = fontCache.get(fontId)
|
||||
if (!cached || !cached.font) {
|
||||
throw new Error('字体未加载,请先加载字体')
|
||||
}
|
||||
|
||||
touchCache(fontId, cached)
|
||||
|
||||
return generateSvgFromFont({
|
||||
text,
|
||||
font: cached.font,
|
||||
fontSize,
|
||||
fillColor,
|
||||
letterSpacing,
|
||||
maxCharsPerLine,
|
||||
})
|
||||
}
|
||||
|
||||
worker.onMessage((message) => {
|
||||
const { requestId, type, payload } = message || {}
|
||||
|
||||
try {
|
||||
if (!requestId) {
|
||||
throw new Error('缺少 requestId')
|
||||
}
|
||||
|
||||
if (type === 'load-font') {
|
||||
const data = handleLoadFont(requestId, payload)
|
||||
sendResult(requestId, true, data)
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'generate-svg') {
|
||||
const data = handleGenerateSvg(payload)
|
||||
sendResult(requestId, true, data)
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'clear-cache') {
|
||||
fontCache.clear()
|
||||
sendResult(requestId, true, { ok: true })
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(`未知的任务类型: ${type}`)
|
||||
} catch (error) {
|
||||
const messageText = error && error.message ? error.message : String(error)
|
||||
sendResult(requestId, false, null, messageText)
|
||||
}
|
||||
})
|
||||
188
miniprogram/workers/svg-generator/svg-builder.js
Normal file
@@ -0,0 +1,188 @@
|
||||
const { wrapTextByChars } = require('./text-layout')
|
||||
|
||||
function formatNumber(value) {
|
||||
const text = Number(value).toFixed(2).replace(/\.?0+$/, '')
|
||||
return text || '0'
|
||||
}
|
||||
|
||||
function getGlyphPath(glyph) {
|
||||
const path = glyph && glyph.path
|
||||
if (!path || !Array.isArray(path.commands) || !path.commands.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const commands = []
|
||||
for (const cmd of path.commands) {
|
||||
switch (cmd.type) {
|
||||
case 'M':
|
||||
commands.push(`M${formatNumber(cmd.x)} ${formatNumber(cmd.y)}`)
|
||||
break
|
||||
case 'L':
|
||||
commands.push(`L${formatNumber(cmd.x)} ${formatNumber(cmd.y)}`)
|
||||
break
|
||||
case 'Q':
|
||||
commands.push(`Q${formatNumber(cmd.x1)} ${formatNumber(cmd.y1)} ${formatNumber(cmd.x)} ${formatNumber(cmd.y)}`)
|
||||
break
|
||||
case 'C':
|
||||
commands.push(`C${formatNumber(cmd.x1)} ${formatNumber(cmd.y1)} ${formatNumber(cmd.x2)} ${formatNumber(cmd.y2)} ${formatNumber(cmd.x)} ${formatNumber(cmd.y)}`)
|
||||
break
|
||||
case 'Z':
|
||||
commands.push('Z')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return commands.join(' ')
|
||||
}
|
||||
|
||||
function getGlyphBounds(glyph) {
|
||||
const path = glyph && glyph.path
|
||||
if (!path || !Array.isArray(path.commands) || !path.commands.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
let xMin = Infinity
|
||||
let yMin = Infinity
|
||||
let xMax = -Infinity
|
||||
let yMax = -Infinity
|
||||
|
||||
for (const cmd of path.commands) {
|
||||
if (typeof cmd.x === 'number') {
|
||||
xMin = Math.min(xMin, cmd.x)
|
||||
xMax = Math.max(xMax, cmd.x)
|
||||
yMin = Math.min(yMin, cmd.y)
|
||||
yMax = Math.max(yMax, cmd.y)
|
||||
}
|
||||
if (typeof cmd.x1 === 'number') {
|
||||
xMin = Math.min(xMin, cmd.x1)
|
||||
xMax = Math.max(xMax, cmd.x1)
|
||||
yMin = Math.min(yMin, cmd.y1)
|
||||
yMax = Math.max(yMax, cmd.y1)
|
||||
}
|
||||
if (typeof cmd.x2 === 'number') {
|
||||
xMin = Math.min(xMin, cmd.x2)
|
||||
xMax = Math.max(xMax, cmd.x2)
|
||||
yMin = Math.min(yMin, cmd.y2)
|
||||
yMax = Math.max(yMax, cmd.y2)
|
||||
}
|
||||
}
|
||||
|
||||
if (xMin === Infinity) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { xMin, yMin, xMax, yMax }
|
||||
}
|
||||
|
||||
function generateSvgFromFont(options) {
|
||||
const {
|
||||
text,
|
||||
font,
|
||||
fontSize = 100,
|
||||
fillColor = '#000000',
|
||||
letterSpacing = 0,
|
||||
maxCharsPerLine,
|
||||
} = options
|
||||
|
||||
if (!text || !String(text).trim()) {
|
||||
throw new Error('文本内容不能为空')
|
||||
}
|
||||
|
||||
const normalizedText = wrapTextByChars(text, maxCharsPerLine)
|
||||
|
||||
const scale = fontSize / font.unitsPerEm
|
||||
const letterSpacingRaw = letterSpacing * font.unitsPerEm
|
||||
|
||||
const glyphRuns = []
|
||||
let minX = null
|
||||
let minY = null
|
||||
let maxX = null
|
||||
let maxY = null
|
||||
let maxLineAdvance = 0
|
||||
|
||||
const ascender = Number.isFinite(font.ascender) ? font.ascender : font.unitsPerEm * 0.8
|
||||
const descender = Number.isFinite(font.descender) ? font.descender : -font.unitsPerEm * 0.2
|
||||
const lineAdvance = Math.max(font.unitsPerEm * 1.2, ascender - descender)
|
||||
const lines = normalizedText.split('\n')
|
||||
|
||||
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
||||
const line = lines[lineIndex] || ''
|
||||
const yPos = -lineIndex * lineAdvance
|
||||
let xPos = 0
|
||||
|
||||
for (const char of Array.from(line)) {
|
||||
const glyph = font.charToGlyph(char)
|
||||
glyphRuns.push({ glyph, xPos, yPos })
|
||||
|
||||
const bounds = getGlyphBounds(glyph)
|
||||
if (bounds) {
|
||||
const adjustedXMin = bounds.xMin + xPos
|
||||
const adjustedYMin = bounds.yMin + yPos
|
||||
const adjustedXMax = bounds.xMax + xPos
|
||||
const adjustedYMax = bounds.yMax + yPos
|
||||
|
||||
minX = minX === null ? adjustedXMin : Math.min(minX, adjustedXMin)
|
||||
minY = minY === null ? adjustedYMin : Math.min(minY, adjustedYMin)
|
||||
maxX = maxX === null ? adjustedXMax : Math.max(maxX, adjustedXMax)
|
||||
maxY = maxY === null ? adjustedYMax : Math.max(maxY, adjustedYMax)
|
||||
}
|
||||
|
||||
xPos += (glyph.advanceWidth || 0) + letterSpacingRaw
|
||||
}
|
||||
|
||||
maxLineAdvance = Math.max(maxLineAdvance, xPos)
|
||||
}
|
||||
|
||||
if (minX === null || maxX === null) {
|
||||
minX = 0
|
||||
maxX = maxLineAdvance
|
||||
}
|
||||
|
||||
if (minX === null || minY === null || maxX === null || maxY === null) {
|
||||
throw new Error('未生成有效字形轮廓')
|
||||
}
|
||||
|
||||
const width = (maxX - minX) * scale
|
||||
const height = (maxY - minY) * scale
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
throw new Error('计算得到的 SVG 尺寸无效')
|
||||
}
|
||||
|
||||
const paths = []
|
||||
for (const run of glyphRuns) {
|
||||
const d = getGlyphPath(run.glyph)
|
||||
if (!d) {
|
||||
continue
|
||||
}
|
||||
const transform = `translate(${formatNumber(run.xPos)} ${formatNumber(run.yPos)})`
|
||||
paths.push(` <path d=\"${d}\" transform=\"${transform}\"/>`)
|
||||
}
|
||||
|
||||
if (!paths.length) {
|
||||
throw new Error('未生成任何路径')
|
||||
}
|
||||
|
||||
const viewBox = `${formatNumber(minX)} 0 ${formatNumber(maxX - minX)} ${formatNumber(maxY - minY)}`
|
||||
const groupTransform = `translate(0 ${formatNumber(maxY)}) scale(1 -1)`
|
||||
|
||||
const svg = `<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"${viewBox}\" width=\"${formatNumber(width)}\" height=\"${formatNumber(height)}\" preserveAspectRatio=\"xMidYMid meet\">\n <g transform=\"${groupTransform}\" fill=\"${fillColor}\" stroke=\"none\">\n${paths.join('\n')}\n </g>\n</svg>`
|
||||
|
||||
const fontName =
|
||||
(font.names && font.names.fontFamily && (font.names.fontFamily.en || font.names.fontFamily.zh)) ||
|
||||
(font.names && font.names.fullName && (font.names.fullName.en || font.names.fullName.zh)) ||
|
||||
'Unknown'
|
||||
|
||||
return {
|
||||
svg,
|
||||
width,
|
||||
height,
|
||||
fontName,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateSvgFromFont,
|
||||
}
|
||||
34
miniprogram/workers/svg-generator/text-layout.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const MAX_CHARS_PER_LINE = 45
|
||||
|
||||
function normalizeLineBreaks(text) {
|
||||
return String(text || '').replace(/\r\n?/g, '\n')
|
||||
}
|
||||
|
||||
function wrapTextByChars(text, maxCharsPerLine = MAX_CHARS_PER_LINE) {
|
||||
if (maxCharsPerLine <= 0) {
|
||||
return normalizeLineBreaks(text)
|
||||
}
|
||||
|
||||
const normalized = normalizeLineBreaks(text)
|
||||
const lines = normalized.split('\n')
|
||||
const wrappedLines = []
|
||||
|
||||
for (const line of lines) {
|
||||
const chars = Array.from(line)
|
||||
if (!chars.length) {
|
||||
wrappedLines.push('')
|
||||
continue
|
||||
}
|
||||
|
||||
for (let i = 0; i < chars.length; i += maxCharsPerLine) {
|
||||
wrappedLines.push(chars.slice(i, i + maxCharsPerLine).join(''))
|
||||
}
|
||||
}
|
||||
|
||||
return wrappedLines.join('\n')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_CHARS_PER_LINE,
|
||||
wrapTextByChars,
|
||||
}
|
||||
14477
miniprogram/workers/svg-generator/vendor/opentype.js
vendored
Normal file
562
package-lock.json
generated
Normal file
@@ -0,0 +1,562 @@
|
||||
{
|
||||
"name": "font2svg",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "font2svg",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"sharp": "^0.34.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
||||
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
|
||||
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
||||
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
||||
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
||||
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
||||
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
||||
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.0.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.7.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.34.5",
|
||||
"@img/sharp-darwin-x64": "0.34.5",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
||||
"@img/sharp-linux-arm": "0.34.5",
|
||||
"@img/sharp-linux-arm64": "0.34.5",
|
||||
"@img/sharp-linux-ppc64": "0.34.5",
|
||||
"@img/sharp-linux-riscv64": "0.34.5",
|
||||
"@img/sharp-linux-s390x": "0.34.5",
|
||||
"@img/sharp-linux-x64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-x64": "0.34.5",
|
||||
"@img/sharp-wasm32": "0.34.5",
|
||||
"@img/sharp-win32-arm64": "0.34.5",
|
||||
"@img/sharp-win32-ia32": "0.34.5",
|
||||
"@img/sharp-win32-x64": "0.34.5"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,13 @@
|
||||
"dev": "cd frontend && pnpm run dev",
|
||||
"build": "cd frontend && pnpm run build",
|
||||
"preview": "cd frontend && pnpm run preview",
|
||||
"prepare-fonts": "python3 scripts/generate-font-list.py"
|
||||
"api:serve": "if [ -x .venv/bin/python ]; then .venv/bin/python apiserver/server.py; else python3 apiserver/server.py; fi",
|
||||
"prepare-fonts": "python3 scripts/generate-font-list.py",
|
||||
"mp:syntax": "find miniprogram -name '*.js' -print0 | xargs -0 -n1 node --check",
|
||||
"mp:lint": "node scripts/check-miniprogram-lint.js",
|
||||
"mp:test": "node scripts/check-miniprogram-core-test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"sharp": "^0.34.5"
|
||||
}
|
||||
}
|
||||
|
||||
60
scripts/check-miniprogram-core-test.js
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const { wrapTextByChars } = require('../miniprogram/utils/core/text-layout')
|
||||
const { generateSvgFromFont } = require('../miniprogram/utils/core/svg-builder')
|
||||
const opentype = require('../frontend/node_modules/opentype.js/dist/opentype.js')
|
||||
|
||||
function findFirstFontFile(rootDir) {
|
||||
const entries = fs.readdirSync(rootDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(rootDir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
const nested = findFirstFontFile(fullPath)
|
||||
if (nested) return nested
|
||||
continue
|
||||
}
|
||||
|
||||
if (/\.(ttf|otf)$/i.test(entry.name)) {
|
||||
return fullPath
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
function run() {
|
||||
console.log('开始执行小程序核心模块测试...')
|
||||
|
||||
const wrapped = wrapTextByChars('123456', 2)
|
||||
assert(wrapped === '12\n34\n56', 'wrapTextByChars 结果不符合预期')
|
||||
|
||||
const fontFile = findFirstFontFile(path.join(__dirname, '..', 'frontend', 'public', 'fonts'))
|
||||
assert(fontFile, '未找到可用字体文件')
|
||||
|
||||
const buffer = fs.readFileSync(fontFile)
|
||||
const font = opentype.parse(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength))
|
||||
|
||||
const result = generateSvgFromFont({
|
||||
text: '星程字体转换',
|
||||
font,
|
||||
fontSize: 120,
|
||||
fillColor: '#000000',
|
||||
letterSpacing: 0,
|
||||
})
|
||||
|
||||
assert(typeof result.svg === 'string' && result.svg.includes('<svg'), 'SVG 内容无效')
|
||||
assert(result.svg.includes('<path'), 'SVG 未生成路径')
|
||||
assert(result.width > 0 && result.height > 0, 'SVG 尺寸计算无效')
|
||||
|
||||
console.log('小程序核心模块测试通过。')
|
||||
}
|
||||
|
||||
run()
|
||||
65
scripts/check-miniprogram-lint.js
Executable file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const ROOT = path.join(__dirname, '..', 'miniprogram')
|
||||
const TARGET_EXTENSIONS = new Set(['.js', '.json', '.wxml', '.wxss'])
|
||||
|
||||
function walk(dir, files = []) {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name)
|
||||
if (fullPath.includes(`${path.sep}workers${path.sep}svg-generator${path.sep}vendor${path.sep}`)) {
|
||||
continue
|
||||
}
|
||||
if (fullPath.includes(`${path.sep}i18n${path.sep}`)) {
|
||||
continue
|
||||
}
|
||||
if (fullPath.endsWith('.miniapp.json')) {
|
||||
continue
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
walk(fullPath, files)
|
||||
continue
|
||||
}
|
||||
const ext = path.extname(entry.name)
|
||||
if (TARGET_EXTENSIONS.has(ext)) {
|
||||
files.push(fullPath)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log('开始执行小程序代码规范检查...')
|
||||
|
||||
const files = walk(ROOT)
|
||||
const violations = []
|
||||
|
||||
for (const file of files) {
|
||||
const content = fs.readFileSync(file, 'utf8')
|
||||
const lines = content.split(/\r?\n/)
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i]
|
||||
if (/\t/.test(line)) {
|
||||
violations.push(`${file}:${i + 1} 使用了 Tab 缩进`)
|
||||
}
|
||||
if (/\s+$/.test(line)) {
|
||||
violations.push(`${file}:${i + 1} 存在行尾空格`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error('发现代码规范问题:')
|
||||
for (const violation of violations) {
|
||||
console.error(`- ${violation}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('小程序代码规范检查通过。')
|
||||
}
|
||||
|
||||
main()
|
||||
112
scripts/deploy-assets.sh
Executable file
@@ -0,0 +1,112 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Font2SVG 静态资源部署脚本
|
||||
# 用于将图标和 logo 上传到 fonts.biboer.cn 服务器
|
||||
|
||||
set -e # 遇到错误立即退出
|
||||
|
||||
# ===== 配置区域 =====
|
||||
SERVER="gavin@fonts.biboer.cn"
|
||||
REMOTE_DIR="/home/gavin/font2svg"
|
||||
LOCAL_ASSETS_DIR="assets"
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
# ===== 函数定义 =====
|
||||
log_info() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
check_files() {
|
||||
log_info "检查本地文件..."
|
||||
|
||||
if [ ! -d "$LOCAL_ASSETS_DIR/icons" ]; then
|
||||
log_error "图标目录不存在: $LOCAL_ASSETS_DIR/icons"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SVG_COUNT=$(find "$LOCAL_ASSETS_DIR/icons" -name "*.svg" | wc -l | tr -d ' ')
|
||||
PNG_COUNT=$(find "$LOCAL_ASSETS_DIR/icons" -name "*.png" | wc -l | tr -d ' ')
|
||||
log_info "发现 $SVG_COUNT 个 SVG 图标, $PNG_COUNT 个 PNG 图标"
|
||||
}
|
||||
|
||||
create_remote_dirs() {
|
||||
log_info "创建远程目录..."
|
||||
ssh $SERVER "mkdir -p $REMOTE_DIR/assets/icons"
|
||||
}
|
||||
|
||||
upload_assets() {
|
||||
log_info "上传静态资源到 $SERVER..."
|
||||
|
||||
# 上传图标(SVG 和 PNG)
|
||||
rsync -avz --progress \
|
||||
--include="*.svg" \
|
||||
--include="*.png" \
|
||||
--include="*/" \
|
||||
--exclude="*" \
|
||||
"$LOCAL_ASSETS_DIR/" "$SERVER:$REMOTE_DIR/assets/"
|
||||
|
||||
log_info "静态资源上传完成"
|
||||
}
|
||||
|
||||
set_permissions() {
|
||||
log_info "设置文件权限..."
|
||||
ssh $SERVER "chmod -R 755 $REMOTE_DIR/assets"
|
||||
log_info "权限设置完成"
|
||||
}
|
||||
|
||||
verify_deployment() {
|
||||
log_info "验证部署..."
|
||||
|
||||
# 检查目录是否存在
|
||||
if ssh $SERVER "[ -d $REMOTE_DIR/assets/icons ]"; then
|
||||
log_info "✓ assets/icons 目录存在"
|
||||
else
|
||||
log_error "✗ assets/icons 目录不存在"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 统计远程文件数量
|
||||
REMOTE_SVG_COUNT=$(ssh $SERVER "find $REMOTE_DIR/assets/icons -name '*.svg' | wc -l" | tr -d ' ')
|
||||
REMOTE_PNG_COUNT=$(ssh $SERVER "find $REMOTE_DIR/assets/icons -name '*.png' | wc -l" | tr -d ' ')
|
||||
log_info "远程服务器上有 $REMOTE_SVG_COUNT 个 SVG, $REMOTE_PNG_COUNT 个 PNG"
|
||||
}
|
||||
|
||||
show_urls() {
|
||||
log_info "=========================================="
|
||||
log_info "部署完成!资源 URL 示例:"
|
||||
log_info " Logo: https://fonts.biboer.cn/assets/webicon.png"
|
||||
log_info " 图标: https://fonts.biboer.cn/assets/icons/[图标名].svg"
|
||||
log_info "=========================================="
|
||||
}
|
||||
|
||||
# ===== 主流程 =====
|
||||
main() {
|
||||
log_info "开始部署 Font2SVG 静态资源..."
|
||||
echo ""
|
||||
|
||||
check_files
|
||||
create_remote_dirs
|
||||
upload_assets
|
||||
set_permissions
|
||||
verify_deployment
|
||||
|
||||
echo ""
|
||||
show_urls
|
||||
log_info "部署完成!"
|
||||
}
|
||||
|
||||
# 运行主流程
|
||||
main
|
||||
170
scripts/deploy-fonts.sh
Executable file
@@ -0,0 +1,170 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Font2SVG 字体资源部署脚本
|
||||
# 用于将字体文件上传到 fonts.biboer.cn 服务器
|
||||
|
||||
set -e # 遇到错误立即退出
|
||||
|
||||
# ===== 配置区域 =====
|
||||
SERVER="user@fonts.biboer.cn" # 请替换为你的 SSH 用户名
|
||||
REMOTE_DIR="/home/gavin/font2svg"
|
||||
LOCAL_FONTS_DIR="frontend/public/fonts"
|
||||
LOCAL_FONTS_JSON="frontend/public/fonts.json"
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# ===== 函数定义 =====
|
||||
log_info() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
check_files() {
|
||||
log_info "检查本地文件..."
|
||||
|
||||
if [ ! -d "$LOCAL_FONTS_DIR" ]; then
|
||||
log_error "字体目录不存在: $LOCAL_FONTS_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$LOCAL_FONTS_JSON" ]; then
|
||||
log_error "fonts.json 文件不存在: $LOCAL_FONTS_JSON"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FONT_COUNT=$(find "$LOCAL_FONTS_DIR" -name "*.ttf" -o -name "*.otf" | wc -l)
|
||||
log_info "发现 $FONT_COUNT 个字体文件"
|
||||
}
|
||||
|
||||
create_remote_dirs() {
|
||||
log_info "创建远程目录..."
|
||||
ssh $SERVER "mkdir -p $REMOTE_DIR/fonts"
|
||||
}
|
||||
|
||||
upload_fonts() {
|
||||
log_info "上传字体文件到 $SERVER..."
|
||||
log_warn "这可能需要几分钟,取决于字体文件大小..."
|
||||
|
||||
# 使用 rsync 进行增量上传(只上传修改过的文件)
|
||||
rsync -avz --progress \
|
||||
--exclude=".DS_Store" \
|
||||
--exclude="Thumbs.db" \
|
||||
"$LOCAL_FONTS_DIR/" "$SERVER:$REMOTE_DIR/fonts/"
|
||||
|
||||
log_info "字体文件上传完成"
|
||||
}
|
||||
|
||||
upload_fonts_json() {
|
||||
log_info "上传 fonts.json..."
|
||||
scp "$LOCAL_FONTS_JSON" "$SERVER:$REMOTE_DIR/"
|
||||
log_info "fonts.json 上传完成"
|
||||
}
|
||||
|
||||
set_permissions() {
|
||||
log_info "设置文件权限..."
|
||||
ssh $SERVER "chmod -R 755 $REMOTE_DIR"
|
||||
log_info "权限设置完成"
|
||||
}
|
||||
|
||||
verify_deployment() {
|
||||
log_info "验证部署结果..."
|
||||
|
||||
# 检查 fonts.json
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://fonts.biboer.cn/fonts.json")
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
log_info "fonts.json 可访问 ✓"
|
||||
else
|
||||
log_error "fonts.json 访问失败 (HTTP $HTTP_CODE)"
|
||||
log_warn "请检查 Cloudflare DNS 配置和 Nginx 配置"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查 CORS 头
|
||||
CORS_HEADER=$(curl -s -I "https://fonts.biboer.cn/fonts.json" | grep -i "access-control-allow-origin")
|
||||
|
||||
if [ -n "$CORS_HEADER" ]; then
|
||||
log_info "CORS 配置正确 ✓"
|
||||
else
|
||||
log_warn "未检测到 CORS 头,请检查 Nginx 配置"
|
||||
fi
|
||||
}
|
||||
|
||||
restart_nginx() {
|
||||
log_info "重启 Nginx..."
|
||||
ssh $SERVER "sudo systemctl restart nginx" || {
|
||||
log_warn "Nginx 重启失败,可能需要手动执行"
|
||||
return 1
|
||||
}
|
||||
log_info "Nginx 重启成功"
|
||||
}
|
||||
|
||||
show_summary() {
|
||||
echo ""
|
||||
echo "======================================="
|
||||
log_info "部署完成!"
|
||||
echo "======================================="
|
||||
echo ""
|
||||
echo "下一步操作:"
|
||||
echo "1. 在小程序后台配置域名:"
|
||||
echo " https://mp.weixin.qq.com/ → 开发 → 服务器域名"
|
||||
echo " 添加 downloadFile 合法域名: https://fonts.biboer.cn"
|
||||
echo ""
|
||||
echo "2. 测试字体加载(在小程序开发者工具控制台):"
|
||||
echo " wx.request({"
|
||||
echo " url: 'https://fonts.biboer.cn/fonts.json',"
|
||||
echo " success: (res) => console.log(res.data)"
|
||||
echo " })"
|
||||
echo ""
|
||||
echo "3. 验证 CDN 缓存状态:"
|
||||
echo " curl -I https://fonts.biboer.cn/fonts.json | grep cf-cache-status"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ===== 主流程 =====
|
||||
main() {
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo " Font2SVG 字体资源部署脚本"
|
||||
echo " 目标服务器: fonts.biboer.cn"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
|
||||
# 检查是否在项目根目录
|
||||
if [ ! -f "package.json" ]; then
|
||||
log_error "请在项目根目录执行此脚本"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 执行部署步骤
|
||||
check_files
|
||||
create_remote_dirs
|
||||
upload_fonts
|
||||
upload_fonts_json
|
||||
set_permissions
|
||||
|
||||
# 可选:重启 Nginx(需要 sudo 权限)
|
||||
read -p "是否重启 Nginx?(y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
restart_nginx
|
||||
fi
|
||||
|
||||
# 验证部署
|
||||
verify_deployment
|
||||
show_summary
|
||||
}
|
||||
|
||||
# 执行主函数
|
||||
main "$@"
|
||||
56
scripts/deploy-icons.sh
Normal file
@@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 部署图标到 CDN (fonts.biboer.cn)
|
||||
# 使用方法: ./deploy-icons.sh
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ICONS_DIR="$SCRIPT_DIR/frontend/src/assets/icons"
|
||||
REMOTE_USER="root"
|
||||
REMOTE_HOST="fonts.biboer.cn"
|
||||
REMOTE_PATH="/var/www/fonts"
|
||||
ICONS_REMOTE_PATH="$REMOTE_PATH/icons"
|
||||
|
||||
echo "🚀 开始部署图标到 CDN..."
|
||||
echo "源目录: $ICONS_DIR"
|
||||
echo "目标: $REMOTE_HOST:$ICONS_REMOTE_PATH"
|
||||
echo ""
|
||||
|
||||
# 检查源目录
|
||||
if [ ! -d "$ICONS_DIR" ]; then
|
||||
echo "❌ 错误: 图标目录不存在 $ICONS_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 统计文件数量
|
||||
ICON_COUNT=$(find "$ICONS_DIR" -type f \( -name "*.svg" -o -name "*.png" \) | wc -l)
|
||||
echo "📦 找到 $ICON_COUNT 个图标文件"
|
||||
echo ""
|
||||
|
||||
# 创建远程目录
|
||||
echo "📁 创建远程目录..."
|
||||
ssh "$REMOTE_USER@$REMOTE_HOST" "mkdir -p $ICONS_REMOTE_PATH"
|
||||
|
||||
# 上传图标文件(SVG 和 PNG)
|
||||
echo "📤 上传图标文件..."
|
||||
rsync -avz --progress \
|
||||
--include="*.svg" \
|
||||
--include="*.png" \
|
||||
--exclude="*" \
|
||||
"$ICONS_DIR/" \
|
||||
"$REMOTE_USER@$REMOTE_HOST:$ICONS_REMOTE_PATH/"
|
||||
|
||||
# 设置权限
|
||||
echo "🔐 设置文件权限..."
|
||||
ssh "$REMOTE_USER@$REMOTE_HOST" "chmod -R 644 $ICONS_REMOTE_PATH/*"
|
||||
|
||||
echo ""
|
||||
echo "✅ 部署完成!"
|
||||
echo ""
|
||||
echo "📝 图标访问地址示例:"
|
||||
echo " https://fonts.biboer.cn/icons/webicon.png"
|
||||
echo " https://fonts.biboer.cn/icons/font-icon.svg"
|
||||
echo ""
|
||||
echo "🔗 测试访问:"
|
||||
curl -I "https://fonts.biboer.cn/icons/webicon.png" 2>/dev/null | head -n 1 || echo "⚠️ 无法访问,请检查服务器配置"
|
||||
@@ -1,7 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
生成字体清单 JSON 文件
|
||||
扫描 frontend/public/fonts/ 目录下的所有字体文件,生成 frontend/public/fonts.json
|
||||
生成字体清单文件
|
||||
扫描 frontend/public/fonts/ 目录下的所有字体文件,同时生成:
|
||||
1. frontend/public/fonts.json
|
||||
2. miniprogram/assets/fonts.json
|
||||
3. miniprogram/assets/fonts.js
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -44,6 +47,23 @@ def scan_fonts(font_dir='frontend/public/fonts'):
|
||||
|
||||
return fonts
|
||||
|
||||
def write_fonts_json(fonts, output_file):
|
||||
"""写入字体清单 JSON 文件"""
|
||||
os.makedirs(os.path.dirname(output_file), exist_ok=True)
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(fonts, f, ensure_ascii=False, indent=2)
|
||||
print(f"字体清单已保存到: {output_file}")
|
||||
|
||||
|
||||
def write_fonts_js(fonts, output_file):
|
||||
"""写入小程序可 require 的 JS 清单文件"""
|
||||
os.makedirs(os.path.dirname(output_file), exist_ok=True)
|
||||
content = "module.exports = " + json.dumps(fonts, ensure_ascii=False, indent=2) + "\n"
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
print(f"字体清单已保存到: {output_file}")
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
# 扫描字体(唯一来源:frontend/public/fonts)
|
||||
@@ -51,14 +71,10 @@ def main():
|
||||
|
||||
print(f"找到 {len(fonts)} 个字体文件")
|
||||
|
||||
# 保存到 JSON 文件
|
||||
output_file = 'frontend/public/fonts.json'
|
||||
os.makedirs(os.path.dirname(output_file), exist_ok=True)
|
||||
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(fonts, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"字体清单已保存到: {output_file}")
|
||||
# 同步写入 Web 与小程序清单
|
||||
write_fonts_json(fonts, 'frontend/public/fonts.json')
|
||||
write_fonts_json(fonts, 'miniprogram/assets/fonts.json')
|
||||
write_fonts_js(fonts, 'miniprogram/assets/fonts.js')
|
||||
|
||||
# 统计信息
|
||||
categories = {}
|
||||
|
||||
160
scripts/generate-fonts-json.py
Executable file
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Font2SVG - fonts.json 生成脚本
|
||||
扫描 frontend/public/fonts/ 目录,生成小程序所需的 fonts.json
|
||||
URL 格式:https://fonts.biboer.cn/fonts/{category}/{fontname}.ttf
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
# 配置
|
||||
BASE_URL = "https://fonts.biboer.cn/fonts"
|
||||
FONTS_DIR = Path(__file__).parent.parent / "frontend" / "public" / "fonts"
|
||||
OUTPUT_FILE = Path(__file__).parent.parent / "frontend" / "public" / "fonts.json"
|
||||
|
||||
def scan_fonts(fonts_dir: Path) -> list:
|
||||
"""
|
||||
扫描字体目录,生成字体列表
|
||||
|
||||
Args:
|
||||
fonts_dir: 字体根目录
|
||||
|
||||
Returns:
|
||||
字体信息列表
|
||||
"""
|
||||
fonts = []
|
||||
|
||||
# 遍历所有分类目录
|
||||
for category_dir in fonts_dir.iterdir():
|
||||
if not category_dir.is_dir():
|
||||
continue
|
||||
|
||||
category = category_dir.name
|
||||
|
||||
# 跳过隐藏目录
|
||||
if category.startswith('.'):
|
||||
continue
|
||||
|
||||
# 遍历分类下的所有字体文件
|
||||
for font_file in category_dir.glob("*.ttf"):
|
||||
# 获取文件信息
|
||||
font_name = font_file.stem # 不含扩展名的文件名
|
||||
file_size = font_file.stat().st_size
|
||||
|
||||
# 构建 URL(需要编码中文路径)
|
||||
encoded_category = quote(category)
|
||||
encoded_filename = quote(font_file.name)
|
||||
url = f"{BASE_URL}/{encoded_category}/{encoded_filename}"
|
||||
|
||||
# 创建字体信息对象
|
||||
font_info = {
|
||||
"id": f"{category}/{font_name}",
|
||||
"name": font_name,
|
||||
"category": category,
|
||||
"path": url,
|
||||
"size": file_size
|
||||
}
|
||||
|
||||
fonts.append(font_info)
|
||||
print(f"✓ {category}/{font_name} ({file_size} bytes)")
|
||||
|
||||
return fonts
|
||||
|
||||
def sort_fonts(fonts: list) -> list:
|
||||
"""
|
||||
对字体列表排序
|
||||
1. 按分类排序
|
||||
2. 同分类内按名称排序
|
||||
"""
|
||||
return sorted(fonts, key=lambda x: (x["category"], x["name"]))
|
||||
|
||||
def save_fonts_json(fonts: list, output_file: Path):
|
||||
"""
|
||||
保存 fonts.json
|
||||
"""
|
||||
# 确保输出目录存在
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 写入 JSON
|
||||
with output_file.open("w", encoding="utf-8") as f:
|
||||
json.dump(fonts, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"\n✓ fonts.json 已保存到: {output_file}")
|
||||
print(f"✓ 共 {len(fonts)} 个字体")
|
||||
|
||||
def print_summary(fonts: list):
|
||||
"""
|
||||
打印统计信息
|
||||
"""
|
||||
# 按分类统计
|
||||
categories = {}
|
||||
total_size = 0
|
||||
|
||||
for font in fonts:
|
||||
category = font["category"]
|
||||
size = font["size"]
|
||||
|
||||
if category not in categories:
|
||||
categories[category] = {"count": 0, "size": 0}
|
||||
|
||||
categories[category]["count"] += 1
|
||||
categories[category]["size"] += size
|
||||
total_size += size
|
||||
|
||||
print("\n" + "="*50)
|
||||
print("字体统计信息")
|
||||
print("="*50)
|
||||
|
||||
for category, stats in sorted(categories.items()):
|
||||
size_mb = stats["size"] / 1024 / 1024
|
||||
print(f"{category:20s} {stats['count']:3d} 个字体 {size_mb:8.2f} MB")
|
||||
|
||||
print("-"*50)
|
||||
total_mb = total_size / 1024 / 1024
|
||||
print(f"{'总计':20s} {len(fonts):3d} 个字体 {total_mb:8.2f} MB")
|
||||
print("="*50)
|
||||
|
||||
def main():
|
||||
print("="*50)
|
||||
print("Font2SVG - fonts.json 生成工具")
|
||||
print("目标域名: fonts.biboer.cn")
|
||||
print("="*50)
|
||||
print()
|
||||
|
||||
# 检查字体目录是否存在
|
||||
if not FONTS_DIR.exists():
|
||||
print(f"❌ 错误:字体目录不存在: {FONTS_DIR}")
|
||||
return
|
||||
|
||||
print(f"扫描目录: {FONTS_DIR}")
|
||||
print()
|
||||
|
||||
# 扫描字体
|
||||
fonts = scan_fonts(FONTS_DIR)
|
||||
|
||||
if not fonts:
|
||||
print("❌ 未找到任何字体文件")
|
||||
return
|
||||
|
||||
# 排序
|
||||
fonts = sort_fonts(fonts)
|
||||
|
||||
# 保存
|
||||
save_fonts_json(fonts, OUTPUT_FILE)
|
||||
|
||||
# 统计信息
|
||||
print_summary(fonts)
|
||||
|
||||
print()
|
||||
print("下一步操作:")
|
||||
print("1. 检查生成的 fonts.json 内容")
|
||||
print("2. 运行部署脚本: bash scripts/deploy-fonts.sh")
|
||||
print("3. 验证访问: curl https://fonts.biboer.cn/fonts.json")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||