update at 2026-02-09 16:09:44

This commit is contained in:
douboer
2026-02-09 16:09:44 +08:00
parent ffb7367d3a
commit 917f210dae
20 changed files with 790 additions and 184 deletions

View File

@@ -2,7 +2,7 @@
`apiserver/` 提供微信小程序用的远端渲染接口: `apiserver/` 提供微信小程序用的远端渲染接口:
- 小程序只上传参数(字体 ID、文字、字号、颜色等 - 小程序只上传参数(字体 ID、文字、字号、颜色等
- 服务端读取 `fonts.json` + `fonts/`,生成 SVG/PNG 后返回 - 服务端读取字体清单 + 字体目录,生成 SVG/PNG 后返回
## 1. 启动 ## 1. 启动
@@ -17,10 +17,10 @@ python3 apiserver/server.py \
其中 `--static-root` 目录必须包含: 其中 `--static-root` 目录必须包含:
- `fonts.json` - `fonts/`(统一字体目录)
- `fonts/`(字体文件目录 - `miniprogram/assets/fonts.json`(小程序清单
如果不传 `--manifest`,默认读取 `<static-root>/fonts.json` 如果不传 `--manifest`,默认优先读取 `<static-root>/miniprogram/assets/fonts.json`,不存在时回退到 `<static-root>/fonts.json`
## 2. API ## 2. API
@@ -34,7 +34,7 @@ python3 apiserver/server.py \
```json ```json
{ {
"fontId": "其他字体/AlimamaDaoLiTi", "fontId": "0001",
"text": "星程字体转换", "text": "星程字体转换",
"fontSize": 120, "fontSize": 120,
"fillColor": "#000000", "fillColor": "#000000",
@@ -54,7 +54,7 @@ python3 apiserver/server.py \
{ {
"ok": true, "ok": true,
"data": { "data": {
"fontId": "其他字体/AlimamaDaoLiTi", "fontId": "0001",
"fontName": "AlimamaDaoLiTi", "fontName": "AlimamaDaoLiTi",
"width": 956.2, "width": 956.2,
"height": 144.3, "height": 144.3,
@@ -100,7 +100,7 @@ sudo systemctl status font2svg-api
## 5. 约束 ## 5. 约束
- 字体解析完全基于 `fonts.json``fontId` 必须存在。 - 字体解析完全基于字体清单(默认 `miniprogram/assets/fonts.json`),字体文件统一从 `<static-root>/fonts/` 读取`fontId` 必须存在。
- 服务端启用 CORS允许小程序访问。 - 服务端启用 CORS允许小程序访问。
- 不依赖 Flask/FastAPI使用 Python 标准库 HTTP 服务。 - 不依赖 Flask/FastAPI使用 Python 标准库 HTTP 服务。
- `/api/render-png` 依赖 `node + sharp`(使用 `apiserver/svg_to_png.js` 转换)。 - `/api/render-png` 依赖 `node + sharp`(使用 `apiserver/svg_to_png.js` 转换)。

View File

@@ -7,6 +7,7 @@ import json
import logging import logging
import os import os
import threading import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -164,12 +165,29 @@ class FontCatalog:
class RenderHandler(BaseHTTPRequestHandler): class RenderHandler(BaseHTTPRequestHandler):
catalog = None catalog = None
def send_response(self, code, message=None):
self._response_status = code
super().send_response(code, message)
def _set_cors_headers(self): def _set_cors_headers(self):
self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS") 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-Allow-Headers", "Content-Type,Authorization")
self.send_header("Access-Control-Max-Age", "86400") self.send_header("Access-Control-Max-Age", "86400")
def _log_request_timing(self, start_time):
elapsed_ms = (time.perf_counter() - start_time) * 1000
status = getattr(self, "_response_status", "-")
client_ip = self.client_address[0] if self.client_address else "-"
LOGGER.info(
"请求完成 method=%s path=%s status=%s duration_ms=%.2f client=%s",
self.command,
self.path,
status,
elapsed_ms,
client_ip,
)
def _send_json(self, status_code, payload): def _send_json(self, status_code, payload):
body = json.dumps(payload, ensure_ascii=False).encode("utf-8") body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status_code) self.send_response(status_code)
@@ -240,11 +258,19 @@ class RenderHandler(BaseHTTPRequestHandler):
return font_info, result return font_info, result
def do_OPTIONS(self): # noqa: N802 def do_OPTIONS(self): # noqa: N802
start_time = time.perf_counter()
self._response_status = None
try:
self.send_response(204) self.send_response(204)
self._set_cors_headers() self._set_cors_headers()
self.end_headers() self.end_headers()
finally:
self._log_request_timing(start_time)
def do_GET(self): # noqa: N802 def do_GET(self): # noqa: N802
start_time = time.perf_counter()
self._response_status = None
try:
parsed = urlparse(self.path) parsed = urlparse(self.path)
if parsed.path == "/healthz": if parsed.path == "/healthz":
try: try:
@@ -256,8 +282,13 @@ class RenderHandler(BaseHTTPRequestHandler):
return return
self._send_json(404, {"ok": False, "error": "Not Found"}) self._send_json(404, {"ok": False, "error": "Not Found"})
finally:
self._log_request_timing(start_time)
def do_POST(self): # noqa: N802 def do_POST(self): # noqa: N802
start_time = time.perf_counter()
self._response_status = None
try:
parsed = urlparse(self.path) parsed = urlparse(self.path)
if parsed.path not in ("/api/render-svg", "/api/render-png"): if parsed.path not in ("/api/render-svg", "/api/render-png"):
self._send_json(404, {"ok": False, "error": "Not Found"}) self._send_json(404, {"ok": False, "error": "Not Found"})
@@ -303,6 +334,8 @@ class RenderHandler(BaseHTTPRequestHandler):
"fontId": render_params["fontId"], "fontId": render_params["fontId"],
} }
self._send_json(200, {"ok": True, "data": response_data}) self._send_json(200, {"ok": True, "data": response_data})
finally:
self._log_request_timing(start_time)
def log_message(self, format_str, *args): def log_message(self, format_str, *args):
LOGGER.info("%s - %s", self.address_string(), format_str % args) LOGGER.info("%s - %s", self.address_string(), format_str % args)
@@ -321,12 +354,12 @@ def main():
parser.add_argument( parser.add_argument(
"--static-root", "--static-root",
default=os.getenv("FONT2SVG_STATIC_ROOT", os.getcwd()), default=os.getenv("FONT2SVG_STATIC_ROOT", os.getcwd()),
help="静态资源根目录(包含 fonts/ 与 fonts.json", help="静态资源根目录(包含 fonts/ 与配置清单",
) )
parser.add_argument( parser.add_argument(
"--manifest", "--manifest",
default=os.getenv("FONT2SVG_MANIFEST_PATH", ""), default=os.getenv("FONT2SVG_MANIFEST_PATH", ""),
help="字体清单路径,默认使用 <static-root>/fonts.json", help="字体清单路径,默认优先使用 <static-root>/miniprogram/assets/fonts.json其次 <static-root>/fonts.json",
) )
args = parser.parse_args() args = parser.parse_args()
@@ -336,7 +369,14 @@ def main():
) )
static_root = os.path.abspath(args.static_root) static_root = os.path.abspath(args.static_root)
manifest_path = args.manifest.strip() or os.path.join(static_root, "fonts.json") if args.manifest.strip():
manifest_path = args.manifest.strip()
else:
manifest_candidates = [
os.path.join(static_root, "miniprogram", "assets", "fonts.json"),
os.path.join(static_root, "fonts.json"),
]
manifest_path = next((item for item in manifest_candidates if os.path.isfile(item)), manifest_candidates[0])
server = build_server( server = build_server(
host=args.host, host=args.host,

View File

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

View File

@@ -17,10 +17,13 @@ miniprogram/
├── pages/ ├── pages/
│ ├── index/ # 首页:输入、预览、导出 │ ├── index/ # 首页:输入、预览、导出
│ └── font-picker/ # 字体选择页 │ └── font-picker/ # 字体选择页
├── config/
│ └── server.js # 远端地址/端口/API 路径统一配置
├── utils/ ├── utils/
│ ├── core/ # 纯算法模块 │ ├── core/ # 纯算法模块
│ └── mp/ # 小程序 API 适配层 │ └── mp/ # 小程序 API 适配层
├── assets/fonts.json # 字体清单(由脚本生成) ├── assets/fonts.json # 字体清单(由脚本生成)
├── assets/default.json # 首次加载默认配置(内容/颜色/字号/默认字体)
├── app.js / app.json / app.wxss ├── app.js / app.json / app.wxss
└── project.config.json └── project.config.json
``` ```
@@ -34,6 +37,19 @@ miniprogram/
5. Nginx 配置 `/api/` 反向代理到渲染服务。 5. Nginx 配置 `/api/` 反向代理到渲染服务。
6. 编译运行。 6. 编译运行。
## 服务器配置(换服务器只改一处)
修改 `miniprogram/config/server.js` 中的 `SERVER_CONFIG`
- `protocol`: `https` / `http`
- `host`: 服务器域名
- `port`: 端口(默认 443/80 可留空)
- `apiPrefix`: API 前缀(默认 `/api`
- `fontsManifestPath`: 字体清单路径(默认 `/miniprogram/assets/fonts.json`
- `defaultConfigPath`: 默认配置路径(默认 `/miniprogram/assets/default.json`
`app.js` 和 API 调用会自动使用该配置生成完整 URL。
## 导出说明 ## 导出说明
- `SVG`:受微信限制,`shareFileMessage` 需由单次点击直接触发,建议逐个字体导出。 - `SVG`:受微信限制,`shareFileMessage` 需由单次点击直接触发,建议逐个字体导出。
@@ -50,6 +66,29 @@ miniprogram/
如果 `path` 是相对路径(例如 `/fonts/a.ttf`),服务端会根据静态根目录拼接到实际文件路径。 如果 `path` 是相对路径(例如 `/fonts/a.ttf`),服务端会根据静态根目录拼接到实际文件路径。
推荐部署结构:
- 字体目录统一放在服务器根目录:`/fonts/`
- Web 配置文件独立管理:`/fonts.json`(可选 `/default.json`
- 小程序配置文件独立管理:`/miniprogram/assets/fonts.json``/miniprogram/assets/default.json`
## 首次默认配置default.json
- 默认配置文件与 `fonts.json` 同目录:由 `config/server.js` 自动拼接(默认是 `https://fonts.biboer.cn/miniprogram/assets/default.json`
- 小程序会在首次加载时读取该配置(远端失败则回退本地 `miniprogram/assets/default.js`
- 配置只在首次加载生效,后续始终使用用户本地已保存配置(选择、收藏、颜色、字号、内容)
示例:
```json
{
"inputText": "星程字体转换",
"fontSize": 50,
"textColor": "#dc2626",
"selectedFontIds": ["0001"],
"favoriteFontIds": ["0001"]
}
```
## 调试命令(仓库根目录) ## 调试命令(仓库根目录)
```bash ```bash

View File

@@ -1,9 +1,12 @@
const { buildRuntimeConfig } = require('./config/server')
const runtimeConfig = buildRuntimeConfig()
App({ App({
globalData: { globalData: {
fontsManifestUrl: 'https://fonts.biboer.cn/fonts.json', ...runtimeConfig,
fontsBaseUrl: 'https://fonts.biboer.cn',
svgRenderApiUrl: 'https://fonts.biboer.cn/api/render-svg',
apiTimeoutMs: 30000, apiTimeoutMs: 30000,
fonts: null, fonts: null,
defaultConfig: null,
}, },
}) })

View File

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

View File

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

View File

@@ -1,167 +1,167 @@
module.exports = [ module.exports = [
{ {
"id": "其他字体/AlimamaDaoLiTi", "id": "0001",
"name": "AlimamaDaoLiTi", "name": "AlimamaDaoLiTi",
"filename": "AlimamaDaoLiTi.ttf", "filename": "AlimamaDaoLiTi.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/AlimamaDaoLiTi.ttf" "path": "/fonts/其他字体/AlimamaDaoLiTi.ttf"
}, },
{ {
"id": "其他字体/Hangeuljaemin4-Regular", "id": "0002",
"name": "Hangeuljaemin4-Regular", "name": "Hangeuljaemin4-Regular",
"filename": "Hangeuljaemin4-Regular.ttf", "filename": "Hangeuljaemin4-Regular.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/Hangeuljaemin4-Regular.ttf" "path": "/fonts/其他字体/Hangeuljaemin4-Regular.ttf"
}, },
{ {
"id": "其他字体/I.顏體", "id": "0003",
"name": "I.顏體", "name": "I.顏體",
"filename": "I.顏體.ttf", "filename": "I.顏體.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/I.顏體.ttf" "path": "/fonts/其他字体/I.顏體.ttf"
}, },
{ {
"id": "其他字体/XCDUANZHUANGSONGTI", "id": "0004",
"name": "XCDUANZHUANGSONGTI", "name": "XCDUANZHUANGSONGTI",
"filename": "XCDUANZHUANGSONGTI.ttf", "filename": "XCDUANZHUANGSONGTI.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/XCDUANZHUANGSONGTI.ttf" "path": "/fonts/其他字体/XCDUANZHUANGSONGTI.ttf"
}, },
{ {
"id": "其他字体/qiji-combo", "id": "0005",
"name": "qiji-combo", "name": "qiji-combo",
"filename": "qiji-combo.ttf", "filename": "qiji-combo.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/qiji-combo.ttf" "path": "/fonts/其他字体/qiji-combo.ttf"
}, },
{ {
"id": "其他字体/临海隶书", "id": "0006",
"name": "临海隶书", "name": "临海隶书",
"filename": "临海隶书.ttf", "filename": "临海隶书.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/临海隶书.ttf" "path": "/fonts/其他字体/临海隶书.ttf"
}, },
{ {
"id": "其他字体/京華老宋体_KingHwa_OldSong", "id": "0007",
"name": "京華老宋体_KingHwa_OldSong", "name": "京華老宋体_KingHwa_OldSong",
"filename": "京華老宋体_KingHwa_OldSong.ttf", "filename": "京華老宋体_KingHwa_OldSong.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/京華老宋体_KingHwa_OldSong.ttf" "path": "/fonts/其他字体/京華老宋体_KingHwa_OldSong.ttf"
}, },
{ {
"id": "其他字体/优设标题黑", "id": "0008",
"name": "优设标题黑", "name": "优设标题黑",
"filename": "优设标题黑.ttf", "filename": "优设标题黑.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/优设标题黑.ttf" "path": "/fonts/其他字体/优设标题黑.ttf"
}, },
{ {
"id": "其他字体/包图小白体", "id": "0009",
"name": "包图小白体", "name": "包图小白体",
"filename": "包图小白体.ttf", "filename": "包图小白体.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/包图小白体.ttf" "path": "/fonts/其他字体/包图小白体.ttf"
}, },
{ {
"id": "其他字体/源界明朝", "id": "0010",
"name": "源界明朝", "name": "源界明朝",
"filename": "源界明朝.ttf", "filename": "源界明朝.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/源界明朝.ttf" "path": "/fonts/其他字体/源界明朝.ttf"
}, },
{ {
"id": "其他字体/演示佛系体", "id": "0011",
"name": "演示佛系体", "name": "演示佛系体",
"filename": "演示佛系体.ttf", "filename": "演示佛系体.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/演示佛系体.ttf" "path": "/fonts/其他字体/演示佛系体.ttf"
}, },
{ {
"id": "其他字体/站酷快乐体", "id": "0012",
"name": "站酷快乐体", "name": "站酷快乐体",
"filename": "站酷快乐体.ttf", "filename": "站酷快乐体.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/站酷快乐体.ttf" "path": "/fonts/其他字体/站酷快乐体.ttf"
}, },
{ {
"id": "其他字体/问藏书房", "id": "0013",
"name": "问藏书房", "name": "问藏书房",
"filename": "问藏书房.ttf", "filename": "问藏书房.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/问藏书房.ttf" "path": "/fonts/其他字体/问藏书房.ttf"
}, },
{ {
"id": "其他字体/霞鹜臻楷", "id": "0014",
"name": "霞鹜臻楷", "name": "霞鹜臻楷",
"filename": "霞鹜臻楷.ttf", "filename": "霞鹜臻楷.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/霞鹜臻楷.ttf" "path": "/fonts/其他字体/霞鹜臻楷.ttf"
}, },
{ {
"id": "庞门正道/庞门正道标题体", "id": "0015",
"name": "庞门正道标题体", "name": "庞门正道标题体",
"filename": "庞门正道标题体.ttf", "filename": "庞门正道标题体.ttf",
"category": "庞门正道", "category": "庞门正道",
"path": "/fonts/庞门正道/庞门正道标题体.ttf" "path": "/fonts/庞门正道/庞门正道标题体.ttf"
}, },
{ {
"id": "庞门正道-测试/庞门正道标题体", "id": "0016",
"name": "庞门正道标题体", "name": "庞门正道标题体",
"filename": "庞门正道标题体.ttf", "filename": "庞门正道标题体.ttf",
"category": "庞门正道-测试", "category": "庞门正道-测试",
"path": "/fonts/庞门正道-测试/庞门正道标题体.ttf" "path": "/fonts/庞门正道-测试/庞门正道标题体.ttf"
}, },
{ {
"id": "王漢宗/王漢宗勘亭流繁", "id": "0017",
"name": "王漢宗勘亭流繁", "name": "王漢宗勘亭流繁",
"filename": "王漢宗勘亭流繁.ttf", "filename": "王漢宗勘亭流繁.ttf",
"category": "王漢宗", "category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗勘亭流繁.ttf" "path": "/fonts/王漢宗/王漢宗勘亭流繁.ttf"
}, },
{ {
"id": "王漢宗/王漢宗新潮體", "id": "0018",
"name": "王漢宗新潮體", "name": "王漢宗新潮體",
"filename": "王漢宗新潮體.ttf", "filename": "王漢宗新潮體.ttf",
"category": "王漢宗", "category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗新潮體.ttf" "path": "/fonts/王漢宗/王漢宗新潮體.ttf"
}, },
{ {
"id": "王漢宗/王漢宗波卡體空陰", "id": "0019",
"name": "王漢宗波卡體空陰", "name": "王漢宗波卡體空陰",
"filename": "王漢宗波卡體空陰.ttf", "filename": "王漢宗波卡體空陰.ttf",
"category": "王漢宗", "category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗波卡體空陰.ttf" "path": "/fonts/王漢宗/王漢宗波卡體空陰.ttf"
}, },
{ {
"id": "王漢宗/王漢宗細黑體繁", "id": "0020",
"name": "王漢宗細黑體繁", "name": "王漢宗細黑體繁",
"filename": "王漢宗細黑體繁.ttf", "filename": "王漢宗細黑體繁.ttf",
"category": "王漢宗", "category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗細黑體繁.ttf" "path": "/fonts/王漢宗/王漢宗細黑體繁.ttf"
}, },
{ {
"id": "王漢宗/王漢宗綜藝體雙空陰", "id": "0021",
"name": "王漢宗綜藝體雙空陰", "name": "王漢宗綜藝體雙空陰",
"filename": "王漢宗綜藝體雙空陰.ttf", "filename": "王漢宗綜藝體雙空陰.ttf",
"category": "王漢宗", "category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗綜藝體雙空陰.ttf" "path": "/fonts/王漢宗/王漢宗綜藝體雙空陰.ttf"
}, },
{ {
"id": "王漢宗/王漢宗超明體繁", "id": "0022",
"name": "王漢宗超明體繁", "name": "王漢宗超明體繁",
"filename": "王漢宗超明體繁.ttf", "filename": "王漢宗超明體繁.ttf",
"category": "王漢宗", "category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗超明體繁.ttf" "path": "/fonts/王漢宗/王漢宗超明體繁.ttf"
}, },
{ {
"id": "王漢宗/王漢宗酷儷海報", "id": "0023",
"name": "王漢宗酷儷海報", "name": "王漢宗酷儷海報",
"filename": "王漢宗酷儷海報.ttf", "filename": "王漢宗酷儷海報.ttf",
"category": "王漢宗", "category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗酷儷海報.ttf" "path": "/fonts/王漢宗/王漢宗酷儷海報.ttf"
}, },
{ {
"id": "王漢宗/王漢宗顏楷體繁", "id": "0024",
"name": "王漢宗顏楷體繁", "name": "王漢宗顏楷體繁",
"filename": "王漢宗顏楷體繁.ttf", "filename": "王漢宗顏楷體繁.ttf",
"category": "王漢宗", "category": "王漢宗",

View File

@@ -1,167 +1,167 @@
[ [
{ {
"id": "其他字体/AlimamaDaoLiTi", "id": "0001",
"name": "AlimamaDaoLiTi", "name": "AlimamaDaoLiTi",
"filename": "AlimamaDaoLiTi.ttf", "filename": "AlimamaDaoLiTi.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/AlimamaDaoLiTi.ttf" "path": "/fonts/其他字体/AlimamaDaoLiTi.ttf"
}, },
{ {
"id": "其他字体/Hangeuljaemin4-Regular", "id": "0002",
"name": "Hangeuljaemin4-Regular", "name": "Hangeuljaemin4-Regular",
"filename": "Hangeuljaemin4-Regular.ttf", "filename": "Hangeuljaemin4-Regular.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/Hangeuljaemin4-Regular.ttf" "path": "/fonts/其他字体/Hangeuljaemin4-Regular.ttf"
}, },
{ {
"id": "其他字体/I.顏體", "id": "0003",
"name": "I.顏體", "name": "I.顏體",
"filename": "I.顏體.ttf", "filename": "I.顏體.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/I.顏體.ttf" "path": "/fonts/其他字体/I.顏體.ttf"
}, },
{ {
"id": "其他字体/XCDUANZHUANGSONGTI", "id": "0004",
"name": "XCDUANZHUANGSONGTI", "name": "XCDUANZHUANGSONGTI",
"filename": "XCDUANZHUANGSONGTI.ttf", "filename": "XCDUANZHUANGSONGTI.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/XCDUANZHUANGSONGTI.ttf" "path": "/fonts/其他字体/XCDUANZHUANGSONGTI.ttf"
}, },
{ {
"id": "其他字体/qiji-combo", "id": "0005",
"name": "qiji-combo", "name": "qiji-combo",
"filename": "qiji-combo.ttf", "filename": "qiji-combo.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/qiji-combo.ttf" "path": "/fonts/其他字体/qiji-combo.ttf"
}, },
{ {
"id": "其他字体/临海隶书", "id": "0006",
"name": "临海隶书", "name": "临海隶书",
"filename": "临海隶书.ttf", "filename": "临海隶书.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/临海隶书.ttf" "path": "/fonts/其他字体/临海隶书.ttf"
}, },
{ {
"id": "其他字体/京華老宋体_KingHwa_OldSong", "id": "0007",
"name": "京華老宋体_KingHwa_OldSong", "name": "京華老宋体_KingHwa_OldSong",
"filename": "京華老宋体_KingHwa_OldSong.ttf", "filename": "京華老宋体_KingHwa_OldSong.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/京華老宋体_KingHwa_OldSong.ttf" "path": "/fonts/其他字体/京華老宋体_KingHwa_OldSong.ttf"
}, },
{ {
"id": "其他字体/优设标题黑", "id": "0008",
"name": "优设标题黑", "name": "优设标题黑",
"filename": "优设标题黑.ttf", "filename": "优设标题黑.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/优设标题黑.ttf" "path": "/fonts/其他字体/优设标题黑.ttf"
}, },
{ {
"id": "其他字体/包图小白体", "id": "0009",
"name": "包图小白体", "name": "包图小白体",
"filename": "包图小白体.ttf", "filename": "包图小白体.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/包图小白体.ttf" "path": "/fonts/其他字体/包图小白体.ttf"
}, },
{ {
"id": "其他字体/源界明朝", "id": "0010",
"name": "源界明朝", "name": "源界明朝",
"filename": "源界明朝.ttf", "filename": "源界明朝.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/源界明朝.ttf" "path": "/fonts/其他字体/源界明朝.ttf"
}, },
{ {
"id": "其他字体/演示佛系体", "id": "0011",
"name": "演示佛系体", "name": "演示佛系体",
"filename": "演示佛系体.ttf", "filename": "演示佛系体.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/演示佛系体.ttf" "path": "/fonts/其他字体/演示佛系体.ttf"
}, },
{ {
"id": "其他字体/站酷快乐体", "id": "0012",
"name": "站酷快乐体", "name": "站酷快乐体",
"filename": "站酷快乐体.ttf", "filename": "站酷快乐体.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/站酷快乐体.ttf" "path": "/fonts/其他字体/站酷快乐体.ttf"
}, },
{ {
"id": "其他字体/问藏书房", "id": "0013",
"name": "问藏书房", "name": "问藏书房",
"filename": "问藏书房.ttf", "filename": "问藏书房.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/问藏书房.ttf" "path": "/fonts/其他字体/问藏书房.ttf"
}, },
{ {
"id": "其他字体/霞鹜臻楷", "id": "0014",
"name": "霞鹜臻楷", "name": "霞鹜臻楷",
"filename": "霞鹜臻楷.ttf", "filename": "霞鹜臻楷.ttf",
"category": "其他字体", "category": "其他字体",
"path": "/fonts/其他字体/霞鹜臻楷.ttf" "path": "/fonts/其他字体/霞鹜臻楷.ttf"
}, },
{ {
"id": "庞门正道/庞门正道标题体", "id": "0015",
"name": "庞门正道标题体", "name": "庞门正道标题体",
"filename": "庞门正道标题体.ttf", "filename": "庞门正道标题体.ttf",
"category": "庞门正道", "category": "庞门正道",
"path": "/fonts/庞门正道/庞门正道标题体.ttf" "path": "/fonts/庞门正道/庞门正道标题体.ttf"
}, },
{ {
"id": "庞门正道-测试/庞门正道标题体", "id": "0016",
"name": "庞门正道标题体", "name": "庞门正道标题体",
"filename": "庞门正道标题体.ttf", "filename": "庞门正道标题体.ttf",
"category": "庞门正道-测试", "category": "庞门正道-测试",
"path": "/fonts/庞门正道-测试/庞门正道标题体.ttf" "path": "/fonts/庞门正道-测试/庞门正道标题体.ttf"
}, },
{ {
"id": "王漢宗/王漢宗勘亭流繁", "id": "0017",
"name": "王漢宗勘亭流繁", "name": "王漢宗勘亭流繁",
"filename": "王漢宗勘亭流繁.ttf", "filename": "王漢宗勘亭流繁.ttf",
"category": "王漢宗", "category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗勘亭流繁.ttf" "path": "/fonts/王漢宗/王漢宗勘亭流繁.ttf"
}, },
{ {
"id": "王漢宗/王漢宗新潮體", "id": "0018",
"name": "王漢宗新潮體", "name": "王漢宗新潮體",
"filename": "王漢宗新潮體.ttf", "filename": "王漢宗新潮體.ttf",
"category": "王漢宗", "category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗新潮體.ttf" "path": "/fonts/王漢宗/王漢宗新潮體.ttf"
}, },
{ {
"id": "王漢宗/王漢宗波卡體空陰", "id": "0019",
"name": "王漢宗波卡體空陰", "name": "王漢宗波卡體空陰",
"filename": "王漢宗波卡體空陰.ttf", "filename": "王漢宗波卡體空陰.ttf",
"category": "王漢宗", "category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗波卡體空陰.ttf" "path": "/fonts/王漢宗/王漢宗波卡體空陰.ttf"
}, },
{ {
"id": "王漢宗/王漢宗細黑體繁", "id": "0020",
"name": "王漢宗細黑體繁", "name": "王漢宗細黑體繁",
"filename": "王漢宗細黑體繁.ttf", "filename": "王漢宗細黑體繁.ttf",
"category": "王漢宗", "category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗細黑體繁.ttf" "path": "/fonts/王漢宗/王漢宗細黑體繁.ttf"
}, },
{ {
"id": "王漢宗/王漢宗綜藝體雙空陰", "id": "0021",
"name": "王漢宗綜藝體雙空陰", "name": "王漢宗綜藝體雙空陰",
"filename": "王漢宗綜藝體雙空陰.ttf", "filename": "王漢宗綜藝體雙空陰.ttf",
"category": "王漢宗", "category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗綜藝體雙空陰.ttf" "path": "/fonts/王漢宗/王漢宗綜藝體雙空陰.ttf"
}, },
{ {
"id": "王漢宗/王漢宗超明體繁", "id": "0022",
"name": "王漢宗超明體繁", "name": "王漢宗超明體繁",
"filename": "王漢宗超明體繁.ttf", "filename": "王漢宗超明體繁.ttf",
"category": "王漢宗", "category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗超明體繁.ttf" "path": "/fonts/王漢宗/王漢宗超明體繁.ttf"
}, },
{ {
"id": "王漢宗/王漢宗酷儷海報", "id": "0023",
"name": "王漢宗酷儷海報", "name": "王漢宗酷儷海報",
"filename": "王漢宗酷儷海報.ttf", "filename": "王漢宗酷儷海報.ttf",
"category": "王漢宗", "category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗酷儷海報.ttf" "path": "/fonts/王漢宗/王漢宗酷儷海報.ttf"
}, },
{ {
"id": "王漢宗/王漢宗顏楷體繁", "id": "0024",
"name": "王漢宗顏楷體繁", "name": "王漢宗顏楷體繁",
"filename": "王漢宗顏楷體繁.ttf", "filename": "王漢宗顏楷體繁.ttf",
"category": "王漢宗", "category": "王漢宗",

View File

@@ -1,7 +1,9 @@
// CDN 配置文件 // CDN 配置文件
// 管理所有静态资源的 CDN 地址 // 管理所有静态资源的 CDN 地址
const CDN_BASE_URL = 'https://fonts.biboer.cn'; const { buildRuntimeConfig } = require('./server')
const runtimeConfig = buildRuntimeConfig()
const CDN_BASE_URL = runtimeConfig.fontsBaseUrl;
// 图标路径配置 // 图标路径配置
const ICON_PATHS = { const ICON_PATHS = {
@@ -30,7 +32,7 @@ const ICON_PATHS = {
// 字体资源路径 // 字体资源路径
const FONT_BASE_URL = `${CDN_BASE_URL}/fonts`; const FONT_BASE_URL = `${CDN_BASE_URL}/fonts`;
const FONTS_JSON_URL = `${CDN_BASE_URL}/fonts.json`; const FONTS_JSON_URL = runtimeConfig.fontsManifestUrl;
module.exports = { module.exports = {
CDN_BASE_URL, CDN_BASE_URL,

View File

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

View File

@@ -1,4 +1,4 @@
const { loadFontsManifest } = require('../../utils/mp/font-loader') const { loadFontsManifest, loadDefaultConfig } = require('../../utils/mp/font-loader')
const { loadAppState, saveAppState, loadFavorites, saveFavorites } = require('../../utils/mp/storage') const { loadAppState, saveAppState, loadFavorites, saveFavorites } = require('../../utils/mp/storage')
const { const {
shareSvgFromUserTap, shareSvgFromUserTap,
@@ -11,8 +11,11 @@ const COLOR_PALETTE = ['#000000', '#1d4ed8', '#047857', '#b45309', '#dc2626', '#
const FONT_SIZE_MIN = 20 const FONT_SIZE_MIN = 20
const FONT_SIZE_MAX = 120 const FONT_SIZE_MAX = 120
const PREVIEW_RENDER_FONT_SIZE = 120 const PREVIEW_RENDER_FONT_SIZE = 120
const PREVIEW_MAX_CHARS_PER_LINE = 45
const PREVIEW_CACHE_LIMIT = 300
const MIN_PREVIEW_IMAGE_WIDTH = 24 const MIN_PREVIEW_IMAGE_WIDTH = 24
const MAX_PREVIEW_IMAGE_WIDTH = 2400 const MAX_PREVIEW_IMAGE_WIDTH = 2400
const FEEDBACK_EMAIL = 'douboer@gmail.com'
// 临时使用本地图标 - 根据Figma annotation配置 // 临时使用本地图标 - 根据Figma annotation配置
const LOCAL_ICON_PATHS = { const LOCAL_ICON_PATHS = {
@@ -58,6 +61,31 @@ function clampFontSize(value, fallback = PREVIEW_RENDER_FONT_SIZE) {
return Math.min(FONT_SIZE_MAX, Math.max(FONT_SIZE_MIN, Math.round(base))) return Math.min(FONT_SIZE_MAX, Math.max(FONT_SIZE_MIN, Math.round(base)))
} }
function normalizeSelectedFontIds(fontIds) {
if (!Array.isArray(fontIds)) {
return []
}
return Array.from(
new Set(
fontIds
.map((id) => String(id || '').trim())
.filter(Boolean),
),
)
}
function buildLegacyFontId(font) {
if (!font || typeof font !== 'object') {
return ''
}
const category = String(font.category || '').trim()
const name = String(font.name || '').trim()
if (!category || !name) {
return ''
}
return `${category}/${name}`
}
function extractErrorMessage(error, fallback) { function extractErrorMessage(error, fallback) {
if (!error) { if (!error) {
return fallback return fallback
@@ -84,6 +112,25 @@ function writePngBufferToTempFile(pngBuffer, fontName) {
return filePath return filePath
} }
function buildPreviewCacheKey(fontId, text, letterSpacing, maxCharsPerLine = PREVIEW_MAX_CHARS_PER_LINE) {
const safeFontId = String(fontId || '')
const safeText = String(text || '')
const spacingNumber = Number(letterSpacing)
const safeSpacing = Number.isFinite(spacingNumber) ? spacingNumber.toFixed(4) : '0.0000'
return [safeFontId, safeSpacing, String(maxCharsPerLine), safeText].join('::')
}
function setPreviewCache(previewCache, key, value) {
previewCache.set(key, value)
if (previewCache.size <= PREVIEW_CACHE_LIMIT) {
return
}
const oldestKey = previewCache.keys().next().value
if (oldestKey !== undefined) {
previewCache.delete(oldestKey)
}
}
function formatSvgNumber(value) { function formatSvgNumber(value) {
const text = String(Number(value).toFixed(2)) const text = String(Number(value).toFixed(2))
return text.replace(/\.?0+$/, '') return text.replace(/\.?0+$/, '')
@@ -146,9 +193,9 @@ function applyLocalStyleToFontItem(font, fontSize, color) {
Page({ Page({
data: { data: {
inputText: '星程字体转换', inputText: '星程字体转换',
fontSize: FONT_SIZE_MAX, fontSize: 50,
letterSpacingInput: '0', letterSpacingInput: '0',
textColor: '#000000', textColor: '#dc2626',
colorPalette: COLOR_PALETTE, colorPalette: COLOR_PALETTE,
selectedFonts: [], // 当前已选中的字体列表 selectedFonts: [], // 当前已选中的字体列表
fontCategories: [], // 字体分类树 fontCategories: [], // 字体分类树
@@ -160,6 +207,7 @@ Page({
// 搜索功能 // 搜索功能
searchKeyword: '', searchKeyword: '',
showSearch: true, showSearch: true,
feedbackEmail: FEEDBACK_EMAIL,
}, },
async onLoad() { async onLoad() {
@@ -170,6 +218,8 @@ Page({
console.log('============================') console.log('============================')
this.fontMap = new Map() this.fontMap = new Map()
this.legacyFontIdMap = new Map()
this.previewCache = new Map()
this.generateTimer = null this.generateTimer = null
this.categoryExpandedMap = {} this.categoryExpandedMap = {}
@@ -177,7 +227,11 @@ Page({
}, },
onShow() { onShow() {
const favorites = loadFavorites() const rawFavorites = loadFavorites()
const favorites = this.normalizeFontIdList(rawFavorites)
if (normalizeSelectedFontIds(rawFavorites).join(',') !== favorites.join(',')) {
saveFavorites(favorites)
}
this.setData({ favorites }) this.setData({ favorites })
this.updateFontTrees() this.updateFontTrees()
}, },
@@ -196,35 +250,126 @@ Page({
this.setData({ selectedFonts }) this.setData({ selectedFonts })
}, },
buildFontMaps(fonts) {
this.fontMap = new Map()
this.legacyFontIdMap = new Map()
if (!Array.isArray(fonts)) {
return
}
fonts.forEach((font) => {
const fontId = String(font.id || '').trim()
if (!fontId) {
return
}
this.fontMap.set(fontId, font)
const legacyId = buildLegacyFontId(font)
if (legacyId && !this.legacyFontIdMap.has(legacyId)) {
this.legacyFontIdMap.set(legacyId, fontId)
}
})
},
resolveFontId(fontId) {
const normalizedId = String(fontId || '').trim()
if (!normalizedId) {
return ''
}
const hasFontMap = this.fontMap instanceof Map && this.fontMap.size > 0
if (!hasFontMap) {
return normalizedId
}
if (this.fontMap.has(normalizedId)) {
return normalizedId
}
if (this.legacyFontIdMap instanceof Map && this.legacyFontIdMap.has(normalizedId)) {
return this.legacyFontIdMap.get(normalizedId) || ''
}
return ''
},
normalizeFontIdList(fontIds) {
const normalized = normalizeSelectedFontIds(fontIds)
return Array.from(
new Set(
normalized
.map((fontId) => this.resolveFontId(fontId))
.filter(Boolean),
),
)
},
async bootstrap() { async bootstrap() {
wx.showLoading({ title: '加载中', mask: true }) wx.showLoading({ title: '加载中', mask: true })
try { try {
const state = loadAppState() const state = loadAppState()
const fonts = await loadFontsManifest() const isFirstLaunch = !state || !state.updatedAt
const favorites = loadFavorites() const [fonts, defaultConfig] = await Promise.all([
loadFontsManifest(),
loadDefaultConfig(),
])
this.buildFontMaps(fonts)
for (const font of fonts) { const rawFavorites = loadFavorites()
this.fontMap.set(font.id, font) const normalizedStoredFavorites = this.normalizeFontIdList(rawFavorites)
const normalizedDefaultFavorites = this.normalizeFontIdList(defaultConfig.favoriteFontIds)
const favorites = isFirstLaunch ? normalizedDefaultFavorites : normalizedStoredFavorites
if (
normalizeSelectedFontIds(rawFavorites).join(',') !== favorites.join(',') ||
(isFirstLaunch && favorites.length > 0)
) {
saveFavorites(favorites)
} }
const initialInputText = isFirstLaunch
? ((typeof defaultConfig.inputText === 'string' && defaultConfig.inputText) || this.data.inputText)
: (state.inputText || this.data.inputText)
const initialFontSize = isFirstLaunch
? clampFontSize(defaultConfig.fontSize, this.data.fontSize)
: clampFontSize(state.fontSize, this.data.fontSize)
const initialLetterSpacingInput = isFirstLaunch
? (
typeof defaultConfig.letterSpacing === 'number'
? String(defaultConfig.letterSpacing)
: this.data.letterSpacingInput
)
: (
typeof state.letterSpacing === 'number'
? String(state.letterSpacing)
: this.data.letterSpacingInput
)
const initialTextColor = isFirstLaunch
? normalizeHexColor(defaultConfig.textColor || this.data.textColor)
: normalizeHexColor(state.textColor || this.data.textColor)
this.setData({ this.setData({
inputText: state.inputText || this.data.inputText, inputText: initialInputText,
fontSize: clampFontSize(state.fontSize, this.data.fontSize), fontSize: initialFontSize,
letterSpacingInput: letterSpacingInput: initialLetterSpacingInput,
typeof state.letterSpacing === 'number' ? String(state.letterSpacing) : this.data.letterSpacingInput, textColor: initialTextColor,
textColor: normalizeHexColor(state.textColor || this.data.textColor),
favorites, favorites,
}) })
this.categoryExpandedMap = state.categoryExpandedMap && typeof state.categoryExpandedMap === 'object' this.categoryExpandedMap = !isFirstLaunch && state.categoryExpandedMap && typeof state.categoryExpandedMap === 'object'
? { ...state.categoryExpandedMap } ? { ...state.categoryExpandedMap }
: {} : {}
// 构建字体树 // 构建字体树
this.updateFontTrees() this.updateFontTrees()
// 如果有保存的选中字体,恢复它们 // 恢复选中字体(首次使用走 default.json后续走本地用户配置
if (state.selectedFontIds && state.selectedFontIds.length > 0) { const rawSelectedFontIds = isFirstLaunch ? defaultConfig.selectedFontIds : state.selectedFontIds
const selectedFonts = state.selectedFontIds const normalizedStoredSelectedIds = normalizeSelectedFontIds(rawSelectedFontIds)
const initialSelectedFontIds = this.normalizeFontIdList(normalizedStoredSelectedIds)
if (initialSelectedFontIds.length > 0) {
const selectedFonts = initialSelectedFontIds
.map(id => this.fontMap.get(id)) .map(id => this.fontMap.get(id))
.filter(font => font) .filter(font => font)
.map(font => ({ .map(font => ({
@@ -240,6 +385,21 @@ Page({
this.updateFontTrees() this.updateFontTrees()
await this.generateAllPreviews() await this.generateAllPreviews()
} }
const migratedSelectedIdsChanged = !isFirstLaunch &&
normalizedStoredSelectedIds.join(',') !== initialSelectedFontIds.join(',')
// 首次加载后立即固化配置,后续全部以用户配置为准
if (isFirstLaunch || migratedSelectedIdsChanged) {
saveAppState({
inputText: this.data.inputText,
selectedFontIds: this.data.selectedFonts.map(font => font.id),
fontSize: Number(this.data.fontSize),
letterSpacing: Number(this.data.letterSpacingInput || 0),
textColor: this.data.textColor,
categoryExpandedMap: this.categoryExpandedMap,
})
}
} catch (error) { } catch (error) {
const message = error && error.message ? error.message : '初始化失败' const message = error && error.message ? error.message : '初始化失败'
wx.showToast({ title: message, icon: 'none', duration: 2200 }) wx.showToast({ title: message, icon: 'none', duration: 2200 })
@@ -430,7 +590,7 @@ Page({
}, },
// 切换分类全选/取消全选 // 切换分类全选/取消全选
onToggleSelectAllInCategory(e) { async onToggleSelectAllInCategory(e) {
const category = e.currentTarget.dataset.category const category = e.currentTarget.dataset.category
if (!category) return if (!category) return
@@ -438,8 +598,9 @@ Page({
if (!categoryFonts || categoryFonts.fonts.length === 0) return if (!categoryFonts || categoryFonts.fonts.length === 0) return
const allSelected = categoryFonts.allSelected const allSelected = categoryFonts.allSelected
const selectedFonts = [...this.data.selectedFonts] const previousSelectedMap = new Map(this.data.selectedFonts.map(font => [font.id, font]))
const selectedIdSet = new Set(selectedFonts.map(f => f.id)) const selectedIdSet = new Set(previousSelectedMap.keys())
const addedFontIds = []
if (allSelected) { if (allSelected) {
// 取消全选:移除该分类下的所有字体 // 取消全选:移除该分类下的所有字体
@@ -449,6 +610,9 @@ Page({
} else { } else {
// 全选:添加该分类下的所有字体 // 全选:添加该分类下的所有字体
categoryFonts.fonts.forEach(font => { categoryFonts.fonts.forEach(font => {
if (!selectedIdSet.has(font.id)) {
addedFontIds.push(font.id)
}
selectedIdSet.add(font.id) selectedIdSet.add(font.id)
}) })
} }
@@ -456,6 +620,13 @@ Page({
const newSelectedFonts = [] const newSelectedFonts = []
this.fontMap.forEach(font => { this.fontMap.forEach(font => {
if (selectedIdSet.has(font.id)) { if (selectedIdSet.has(font.id)) {
const existingFont = previousSelectedMap.get(font.id)
if (existingFont) {
newSelectedFonts.push({
...existingFont,
showInPreview: typeof existingFont.showInPreview === 'boolean' ? existingFont.showInPreview : true,
})
} else {
newSelectedFonts.push({ newSelectedFonts.push({
id: font.id, id: font.id,
name: font.name, name: font.name,
@@ -464,11 +635,17 @@ Page({
previewSrc: '', previewSrc: '',
}) })
} }
}
}) })
this.setData({ selectedFonts: newSelectedFonts }) this.setData({ selectedFonts: newSelectedFonts })
this.updateFontTrees() this.updateFontTrees()
this.scheduleGenerate()
if (addedFontIds.length > 0) {
for (const fontId of addedFontIds) {
await this.generatePreviewForFont(fontId)
}
}
saveAppState({ saveAppState({
inputText: this.data.inputText, inputText: this.data.inputText,
@@ -488,6 +665,26 @@ Page({
try { try {
const letterSpacing = Number(this.data.letterSpacingInput || 0) const letterSpacing = Number(this.data.letterSpacingInput || 0)
const cacheKey = buildPreviewCacheKey(fontId, text, letterSpacing, PREVIEW_MAX_CHARS_PER_LINE)
const cached = this.previewCache.get(cacheKey)
if (cached) {
const selectedFonts = this.data.selectedFonts
const index = selectedFonts.findIndex(f => f.id === fontId)
if (index >= 0) {
selectedFonts[index].baseSvg = cached.svg
selectedFonts[index].baseWidth = cached.width
selectedFonts[index].baseHeight = cached.height
selectedFonts[index].renderFontSize = PREVIEW_RENDER_FONT_SIZE
selectedFonts[index] = applyLocalStyleToFontItem(
selectedFonts[index],
Number(this.data.fontSize),
this.data.textColor,
)
this.setData({ selectedFonts })
}
return
}
const result = await renderSvgByApi({ const result = await renderSvgByApi({
fontId, fontId,
@@ -496,7 +693,12 @@ Page({
fontSize: PREVIEW_RENDER_FONT_SIZE, fontSize: PREVIEW_RENDER_FONT_SIZE,
fillColor: '#000000', fillColor: '#000000',
letterSpacing, letterSpacing,
maxCharsPerLine: 45, maxCharsPerLine: PREVIEW_MAX_CHARS_PER_LINE,
})
setPreviewCache(this.previewCache, cacheKey, {
svg: result.svg,
width: result.width,
height: result.height,
}) })
// 更新对应字体的预览 // 更新对应字体的预览
@@ -850,4 +1052,42 @@ Page({
this.setData({ searchKeyword: keyword }) this.setData({ searchKeyword: keyword })
this.updateFontTrees() this.updateFontTrees()
}, },
onTapFeedbackEmail() {
const email = this.data.feedbackEmail || FEEDBACK_EMAIL
const mailtoUrl = `mailto:${email}`
// 微信环境对 mailto 支持不稳定:优先尝试跳转,失败时自动复制邮箱
if (typeof wx.openUrl === 'function') {
wx.openUrl({
url: mailtoUrl,
fail: () => {
this.copyFeedbackEmail(email)
},
})
return
}
this.copyFeedbackEmail(email)
},
copyFeedbackEmail(email) {
wx.setClipboardData({
data: String(email || FEEDBACK_EMAIL),
success: () => {
wx.showModal({
title: '已复制邮箱',
content: '请打开邮件应用并粘贴收件人地址发送反馈。',
showCancel: false,
confirmText: '知道了',
})
},
fail: () => {
wx.showToast({
title: '邮箱复制失败',
icon: 'none',
})
},
})
},
}) })

View File

@@ -177,7 +177,8 @@
<!-- 版权说明 --> <!-- 版权说明 -->
<view class="copyright-footer"> <view class="copyright-footer">
@版权说明仅SVG和PNG分享无TTF下载如侵权反馈douboer@gmail.com <text>@版权说明仅SVG和PNG分享无TTF下载如侵权反馈</text>
<text class="copyright-email-link" bindtap="onTapFeedbackEmail">{{feedbackEmail}}</text>
</view> </view>
<!-- 颜色选择器弹窗 --> <!-- 颜色选择器弹窗 -->

View File

@@ -233,7 +233,7 @@
.preview-content { .preview-content {
background: transparent; background: transparent;
padding: 4rpx 0; padding: 4rpx 0;
min-height: 80rpx; min-height: 40rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
@@ -284,6 +284,11 @@
padding: 8rpx 8rpx; padding: 8rpx 8rpx;
overflow: hidden; overflow: hidden;
min-width: 0; min-width: 0;
box-sizing: border-box;
}
.favorite-selection {
border-left: 2rpx solid #3EE4C3;
} }
/* 搜索相关样式 */ /* 搜索相关样式 */

View File

@@ -1,6 +1,7 @@
const { request, downloadFile, readFile } = require('./wx-promisify') const { request, downloadFile, readFile } = require('./wx-promisify')
const localFonts = require('../../assets/fonts') const localFonts = require('../../assets/fonts')
const localDefaultConfig = require('../../assets/default')
const fontBufferCache = new Map() const fontBufferCache = new Map()
const MAX_FONT_CACHE = 4 const MAX_FONT_CACHE = 4
@@ -44,6 +45,70 @@ function normalizeManifest(fonts, baseUrl) {
.filter((item) => item.url) .filter((item) => item.url)
} }
function normalizeDefaultConfig(config) {
const payload = config && typeof config === 'object' ? config : {}
const selectedRaw = Array.isArray(payload.selectedFontIds)
? payload.selectedFontIds
: (Array.isArray(payload.selectedFonts) ? payload.selectedFonts : [])
const favoriteRaw = Array.isArray(payload.favoriteFontIds)
? payload.favoriteFontIds
: (Array.isArray(payload.favoriteFonts) ? payload.favoriteFonts : [])
const selectedFontIds = Array.from(
new Set(
selectedRaw
.map((item) => String(item || '').trim())
.filter(Boolean),
),
)
const favoriteFontIds = Array.from(
new Set(
favoriteRaw
.map((item) => String(item || '').trim())
.filter(Boolean),
),
)
const result = {
selectedFontIds,
favoriteFontIds,
}
if (typeof payload.inputText === 'string') {
result.inputText = payload.inputText
}
if (typeof payload.textColor === 'string') {
result.textColor = payload.textColor
}
if (payload.fontSize !== undefined && payload.fontSize !== null && payload.fontSize !== '') {
const fontSize = Number(payload.fontSize)
if (Number.isFinite(fontSize)) {
result.fontSize = fontSize
}
}
if (payload.letterSpacing !== undefined && payload.letterSpacing !== null && payload.letterSpacing !== '') {
const letterSpacing = Number(payload.letterSpacing)
if (Number.isFinite(letterSpacing)) {
result.letterSpacing = letterSpacing
}
}
return result
}
function buildDefaultConfigUrl(manifestUrl, baseUrl) {
const manifest = String(manifestUrl || '').trim()
if (manifest) {
const withoutHash = manifest.split('#')[0]
const [pathPart] = withoutHash.split('?')
const slashIndex = pathPart.lastIndexOf('/')
if (slashIndex >= 0) {
return `${pathPart.slice(0, slashIndex + 1)}default.json`
}
}
return normalizePath('/miniprogram/assets/default.json', baseUrl)
}
async function loadFontsManifest(options = {}) { async function loadFontsManifest(options = {}) {
const app = getApp() const app = getApp()
const manifestUrl = options.manifestUrl || app.globalData.fontsManifestUrl const manifestUrl = options.manifestUrl || app.globalData.fontsManifestUrl
@@ -79,6 +144,40 @@ async function loadFontsManifest(options = {}) {
} }
} }
async function loadDefaultConfig(options = {}) {
const app = getApp()
const manifestUrl = options.manifestUrl || app.globalData.fontsManifestUrl
const baseUrl = options.baseUrl || app.globalData.fontsBaseUrl
const defaultConfigUrl = options.defaultConfigUrl ||
app.globalData.defaultConfigUrl ||
buildDefaultConfigUrl(manifestUrl, baseUrl)
if (app.globalData.defaultConfig && typeof app.globalData.defaultConfig === 'object') {
return app.globalData.defaultConfig
}
try {
const response = await request({
url: defaultConfigUrl,
method: 'GET',
timeout: 10000,
})
if (response.statusCode < 200 || response.statusCode >= 300) {
throw new Error(`获取默认配置失败,状态码: ${response.statusCode}`)
}
const remoteConfig = normalizeDefaultConfig(response.data)
app.globalData.defaultConfig = remoteConfig
return remoteConfig
} catch (error) {
console.warn('远程 default.json 加载失败,回退到本地默认配置:', error)
const fallbackConfig = normalizeDefaultConfig(localDefaultConfig)
app.globalData.defaultConfig = fallbackConfig
return fallbackConfig
}
}
function setLruCache(key, value) { function setLruCache(key, value) {
if (fontBufferCache.has(key)) { if (fontBufferCache.has(key)) {
fontBufferCache.delete(key) fontBufferCache.delete(key)
@@ -125,6 +224,7 @@ function listCategories(fonts) {
module.exports = { module.exports = {
loadFontsManifest, loadFontsManifest,
loadDefaultConfig,
loadFontBuffer, loadFontBuffer,
listCategories, listCategories,
} }

View File

@@ -86,9 +86,12 @@ async function renderPngByApi(payload) {
? Number(app.globalData.apiTimeoutMs) ? Number(app.globalData.apiTimeoutMs)
: 30000 : 30000
const baseApiUrl = buildApiUrl() const baseApiUrl = buildApiUrl()
const apiUrl = /\/api\/render-svg$/.test(baseApiUrl) const configuredPngApiUrl = app && app.globalData ? app.globalData.pngRenderApiUrl : ''
const apiUrl = configuredPngApiUrl || (
/\/api\/render-svg$/.test(baseApiUrl)
? baseApiUrl.replace(/\/api\/render-svg$/, '/api/render-png') ? baseApiUrl.replace(/\/api\/render-svg$/, '/api/render-png')
: `${baseApiUrl.replace(/\/$/, '')}/render-png` : `${baseApiUrl.replace(/\/$/, '')}/render-png`
)
const response = await request({ const response = await request({
url: apiUrl, url: apiUrl,

View File

@@ -36,7 +36,14 @@ function run() {
const wrapped = wrapTextByChars('123456', 2) const wrapped = wrapTextByChars('123456', 2)
assert(wrapped === '12\n34\n56', 'wrapTextByChars 结果不符合预期') assert(wrapped === '12\n34\n56', 'wrapTextByChars 结果不符合预期')
const fontFile = findFirstFontFile(path.join(__dirname, '..', 'frontend', 'public', 'fonts')) const fontRootCandidates = [
path.join(__dirname, '..', 'fonts'),
path.join(__dirname, '..', 'frontend', 'public', 'fonts'),
]
const fontRoot = fontRootCandidates.find((dir) => fs.existsSync(dir))
assert(fontRoot, '未找到字体目录fonts 或 frontend/public/fonts')
const fontFile = findFirstFontFile(fontRoot)
assert(fontFile, '未找到可用字体文件') assert(fontFile, '未找到可用字体文件')
const buffer = fs.readFileSync(fontFile) const buffer = fs.readFileSync(fontFile)

View File

@@ -8,8 +8,12 @@ set -e # 遇到错误立即退出
# ===== 配置区域 ===== # ===== 配置区域 =====
SERVER="user@fonts.biboer.cn" # 请替换为你的 SSH 用户名 SERVER="user@fonts.biboer.cn" # 请替换为你的 SSH 用户名
REMOTE_DIR="/home/gavin/font2svg" REMOTE_DIR="/home/gavin/font2svg"
LOCAL_FONTS_DIR="frontend/public/fonts" REMOTE_MP_ASSETS_DIR="$REMOTE_DIR/miniprogram/assets"
LOCAL_FONTS_JSON="frontend/public/fonts.json" LOCAL_FONTS_DIR="fonts"
LOCAL_WEB_FONTS_JSON="frontend/public/fonts.json"
LOCAL_WEB_DEFAULT_JSON="frontend/public/default.json"
LOCAL_MP_FONTS_JSON="miniprogram/assets/fonts.json"
LOCAL_MP_DEFAULT_JSON="miniprogram/assets/default.json"
# 颜色输出 # 颜色输出
RED='\033[0;31m' RED='\033[0;31m'
@@ -38,8 +42,13 @@ check_files() {
exit 1 exit 1
fi fi
if [ ! -f "$LOCAL_FONTS_JSON" ]; then if [ ! -f "$LOCAL_WEB_FONTS_JSON" ]; then
log_error "fonts.json 文件不存在: $LOCAL_FONTS_JSON" log_error "Web fonts.json 文件不存在: $LOCAL_WEB_FONTS_JSON"
exit 1
fi
if [ ! -f "$LOCAL_MP_FONTS_JSON" ]; then
log_error "小程序 fonts.json 文件不存在: $LOCAL_MP_FONTS_JSON"
exit 1 exit 1
fi fi
@@ -49,7 +58,7 @@ check_files() {
create_remote_dirs() { create_remote_dirs() {
log_info "创建远程目录..." log_info "创建远程目录..."
ssh $SERVER "mkdir -p $REMOTE_DIR/fonts" ssh $SERVER "mkdir -p $REMOTE_DIR/fonts $REMOTE_MP_ASSETS_DIR"
} }
upload_fonts() { upload_fonts() {
@@ -65,34 +74,55 @@ upload_fonts() {
log_info "字体文件上传完成" log_info "字体文件上传完成"
} }
upload_fonts_json() { upload_web_config() {
log_info "上传 fonts.json..." log_info "上传 Web 配置..."
scp "$LOCAL_FONTS_JSON" "$SERVER:$REMOTE_DIR/" scp "$LOCAL_WEB_FONTS_JSON" "$SERVER:$REMOTE_DIR/fonts.json"
log_info "fonts.json 上传完成" if [ -f "$LOCAL_WEB_DEFAULT_JSON" ]; then
scp "$LOCAL_WEB_DEFAULT_JSON" "$SERVER:$REMOTE_DIR/default.json"
fi
log_info "Web 配置上传完成"
}
upload_miniprogram_config() {
log_info "上传小程序配置..."
scp "$LOCAL_MP_FONTS_JSON" "$SERVER:$REMOTE_MP_ASSETS_DIR/fonts.json"
if [ -f "$LOCAL_MP_DEFAULT_JSON" ]; then
scp "$LOCAL_MP_DEFAULT_JSON" "$SERVER:$REMOTE_MP_ASSETS_DIR/default.json"
else
log_warn "未找到小程序 default.json已跳过: $LOCAL_MP_DEFAULT_JSON"
fi
log_info "小程序配置上传完成"
} }
set_permissions() { set_permissions() {
log_info "设置文件权限..." log_info "设置文件权限..."
ssh $SERVER "chmod -R 755 $REMOTE_DIR" ssh $SERVER "chmod -R 755 $REMOTE_DIR/fonts $REMOTE_MP_ASSETS_DIR"
log_info "权限设置完成" log_info "权限设置完成"
} }
verify_deployment() { verify_deployment() {
log_info "验证部署结果..." log_info "验证部署结果..."
# 检查 fonts.json # 检查小程序 fonts.json
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://fonts.biboer.cn/fonts.json") MP_HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://fonts.biboer.cn/miniprogram/assets/fonts.json")
if [ "$HTTP_CODE" = "200" ]; then if [ "$MP_HTTP_CODE" = "200" ]; then
log_info "fonts.json 可访问 ✓" log_info "小程序 fonts.json 可访问 ✓"
else else
log_error "fonts.json 访问失败 (HTTP $HTTP_CODE)" log_error "小程序 fonts.json 访问失败 (HTTP $MP_HTTP_CODE)"
log_warn "请检查 Cloudflare DNS 配置和 Nginx 配置" log_warn "请检查 Cloudflare DNS 配置和 Nginx 配置"
exit 1 exit 1
fi fi
WEB_HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://fonts.biboer.cn/fonts.json")
if [ "$WEB_HTTP_CODE" = "200" ]; then
log_info "Web fonts.json 可访问 ✓"
else
log_warn "Web fonts.json 不可访问 (HTTP $WEB_HTTP_CODE)"
fi
# 检查 CORS 头 # 检查 CORS 头
CORS_HEADER=$(curl -s -I "https://fonts.biboer.cn/fonts.json" | grep -i "access-control-allow-origin") CORS_HEADER=$(curl -s -I "https://fonts.biboer.cn/miniprogram/assets/fonts.json" | grep -i "access-control-allow-origin")
if [ -n "$CORS_HEADER" ]; then if [ -n "$CORS_HEADER" ]; then
log_info "CORS 配置正确 ✓" log_info "CORS 配置正确 ✓"
@@ -123,11 +153,12 @@ show_summary() {
echo "" echo ""
echo "2. 测试字体加载(在小程序开发者工具控制台):" echo "2. 测试字体加载(在小程序开发者工具控制台):"
echo " wx.request({" echo " wx.request({"
echo " url: 'https://fonts.biboer.cn/fonts.json'," echo " url: 'https://fonts.biboer.cn/miniprogram/assets/fonts.json',"
echo " success: (res) => console.log(res.data)" echo " success: (res) => console.log(res.data)"
echo " })" echo " })"
echo "" echo ""
echo "3. 验证 CDN 缓存状态:" echo "3. 验证 CDN 缓存状态:"
echo " curl -I https://fonts.biboer.cn/miniprogram/assets/fonts.json | grep cf-cache-status"
echo " curl -I https://fonts.biboer.cn/fonts.json | grep cf-cache-status" echo " curl -I https://fonts.biboer.cn/fonts.json | grep cf-cache-status"
echo "" echo ""
} }
@@ -151,7 +182,8 @@ main() {
check_files check_files
create_remote_dirs create_remote_dirs
upload_fonts upload_fonts
upload_fonts_json upload_web_config
upload_miniprogram_config
set_permissions set_permissions
# 可选:重启 Nginx需要 sudo 权限) # 可选:重启 Nginx需要 sudo 权限)

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
生成字体清单文件 生成字体清单文件
扫描 frontend/public/fonts/ 目录下的所有字体文件,同时生成: 扫描 fonts/ 目录下的所有字体文件,同时生成:
1. frontend/public/fonts.json 1. frontend/public/fonts.json
2. miniprogram/assets/fonts.json 2. miniprogram/assets/fonts.json
3. miniprogram/assets/fonts.js 3. miniprogram/assets/fonts.js
@@ -11,7 +11,7 @@ import os
import json import json
from pathlib import Path from pathlib import Path
def scan_fonts(font_dir='frontend/public/fonts'): def scan_fonts(font_dir='fonts'):
"""扫描字体目录,返回字体信息列表""" """扫描字体目录,返回字体信息列表"""
fonts = [] fonts = []
font_dir_path = Path(font_dir) font_dir_path = Path(font_dir)
@@ -34,19 +34,43 @@ def scan_fonts(font_dir='frontend/public/fonts'):
relative_path = font_file.relative_to(font_dir_path).as_posix() relative_path = font_file.relative_to(font_dir_path).as_posix()
# 生成字体信息 # 生成基础信息id 在后续统一按序号回填
font_info = { font_info = {
'id': f"{category_name}/{font_file.stem}", 'id': '',
'name': font_file.stem, 'name': font_file.stem,
'filename': font_file.name, 'filename': font_file.name,
'category': category_name, 'category': category_name,
'path': f"/fonts/{relative_path}", 'relativePath': relative_path,
} }
fonts.append(font_info) fonts.append(font_info)
# 统一排序后分配 4 位数字 id0001、0002...
fonts = sorted(fonts, key=lambda x: (x['category'], x['name'], x['filename']))
for index, font in enumerate(fonts, start=1):
font['id'] = f"{index:04d}"
return fonts return fonts
def build_manifest(fonts, path_prefix):
"""根据路径前缀构建对外清单"""
prefix = f"/{str(path_prefix or '').strip('/')}"
if prefix == '/':
prefix = ''
manifest = []
for font in fonts:
manifest.append({
'id': font['id'],
'name': font['name'],
'filename': font['filename'],
'category': font['category'],
'path': f"{prefix}/{font['relativePath']}",
})
return manifest
def write_fonts_json(fonts, output_file): def write_fonts_json(fonts, output_file):
"""写入字体清单 JSON 文件""" """写入字体清单 JSON 文件"""
os.makedirs(os.path.dirname(output_file), exist_ok=True) os.makedirs(os.path.dirname(output_file), exist_ok=True)
@@ -66,15 +90,19 @@ def write_fonts_js(fonts, output_file):
def main(): def main():
"""主函数""" """主函数"""
# 扫描字体(唯一来源:frontend/public/fonts # 扫描字体(唯一来源:仓库根目录 fonts/
fonts = scan_fonts('frontend/public/fonts') fonts = scan_fonts('fonts')
print(f"找到 {len(fonts)} 个字体文件") print(f"找到 {len(fonts)} 个字体文件")
# 同步写入 Web 与小程序清单 # Web 清单:统一指向根目录 fonts
write_fonts_json(fonts, 'frontend/public/fonts.json') web_fonts = build_manifest(fonts, '/fonts')
write_fonts_json(fonts, 'miniprogram/assets/fonts.json') write_fonts_json(web_fonts, 'frontend/public/fonts.json')
write_fonts_js(fonts, 'miniprogram/assets/fonts.js')
# 小程序清单:同样指向根目录 fonts与 web 共用一份字体目录)
miniprogram_fonts = build_manifest(fonts, '/fonts')
write_fonts_json(miniprogram_fonts, 'miniprogram/assets/fonts.json')
write_fonts_js(miniprogram_fonts, 'miniprogram/assets/fonts.js')
# 统计信息 # 统计信息
categories = {} categories = {}

View File

@@ -3,7 +3,7 @@
""" """
Font2SVG - fonts.json 生成脚本 Font2SVG - fonts.json 生成脚本
扫描 frontend/public/fonts/ 目录,生成小程序所需的 fonts.json 扫描 fonts/ 目录,生成字体清单
URL 格式https://fonts.biboer.cn/fonts/{category}/{fontname}.ttf URL 格式https://fonts.biboer.cn/fonts/{category}/{fontname}.ttf
""" """
@@ -14,7 +14,7 @@ from urllib.parse import quote
# 配置 # 配置
BASE_URL = "https://fonts.biboer.cn/fonts" BASE_URL = "https://fonts.biboer.cn/fonts"
FONTS_DIR = Path(__file__).parent.parent / "frontend" / "public" / "fonts" FONTS_DIR = Path(__file__).parent.parent / "fonts"
OUTPUT_FILE = Path(__file__).parent.parent / "frontend" / "public" / "fonts.json" OUTPUT_FILE = Path(__file__).parent.parent / "frontend" / "public" / "fonts.json"
def scan_fonts(fonts_dir: Path) -> list: def scan_fonts(fonts_dir: Path) -> list:
@@ -51,9 +51,9 @@ def scan_fonts(fonts_dir: Path) -> list:
encoded_filename = quote(font_file.name) encoded_filename = quote(font_file.name)
url = f"{BASE_URL}/{encoded_category}/{encoded_filename}" url = f"{BASE_URL}/{encoded_category}/{encoded_filename}"
# 创建字体信息对象 # 创建字体信息对象id 在后续统一按序号回填)
font_info = { font_info = {
"id": f"{category}/{font_name}", "id": "",
"name": font_name, "name": font_name,
"category": category, "category": category,
"path": url, "path": url,
@@ -71,7 +71,10 @@ def sort_fonts(fonts: list) -> list:
1. 按分类排序 1. 按分类排序
2. 同分类内按名称排序 2. 同分类内按名称排序
""" """
return sorted(fonts, key=lambda x: (x["category"], x["name"])) sorted_fonts = sorted(fonts, key=lambda x: (x["category"], x["name"]))
for index, font in enumerate(sorted_fonts, start=1):
font["id"] = f"{index:04d}"
return sorted_fonts
def save_fonts_json(fonts: list, output_file: Path): def save_fonts_json(fonts: list, output_file: Path):
""" """