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

View File

@@ -7,6 +7,7 @@ import json
import logging
import os
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
@@ -164,12 +165,29 @@ class FontCatalog:
class RenderHandler(BaseHTTPRequestHandler):
catalog = None
def send_response(self, code, message=None):
self._response_status = code
super().send_response(code, message)
def _set_cors_headers(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type,Authorization")
self.send_header("Access-Control-Max-Age", "86400")
def _log_request_timing(self, start_time):
elapsed_ms = (time.perf_counter() - start_time) * 1000
status = getattr(self, "_response_status", "-")
client_ip = self.client_address[0] if self.client_address else "-"
LOGGER.info(
"请求完成 method=%s path=%s status=%s duration_ms=%.2f client=%s",
self.command,
self.path,
status,
elapsed_ms,
client_ip,
)
def _send_json(self, status_code, payload):
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status_code)
@@ -240,11 +258,19 @@ class RenderHandler(BaseHTTPRequestHandler):
return font_info, result
def do_OPTIONS(self): # noqa: N802
start_time = time.perf_counter()
self._response_status = None
try:
self.send_response(204)
self._set_cors_headers()
self.end_headers()
finally:
self._log_request_timing(start_time)
def do_GET(self): # noqa: N802
start_time = time.perf_counter()
self._response_status = None
try:
parsed = urlparse(self.path)
if parsed.path == "/healthz":
try:
@@ -256,8 +282,13 @@ class RenderHandler(BaseHTTPRequestHandler):
return
self._send_json(404, {"ok": False, "error": "Not Found"})
finally:
self._log_request_timing(start_time)
def do_POST(self): # noqa: N802
start_time = time.perf_counter()
self._response_status = None
try:
parsed = urlparse(self.path)
if parsed.path not in ("/api/render-svg", "/api/render-png"):
self._send_json(404, {"ok": False, "error": "Not Found"})
@@ -303,6 +334,8 @@ class RenderHandler(BaseHTTPRequestHandler):
"fontId": render_params["fontId"],
}
self._send_json(200, {"ok": True, "data": response_data})
finally:
self._log_request_timing(start_time)
def log_message(self, format_str, *args):
LOGGER.info("%s - %s", self.address_string(), format_str % args)
@@ -321,12 +354,12 @@ def main():
parser.add_argument(
"--static-root",
default=os.getenv("FONT2SVG_STATIC_ROOT", os.getcwd()),
help="静态资源根目录(包含 fonts/ 与 fonts.json",
help="静态资源根目录(包含 fonts/ 与配置清单",
)
parser.add_argument(
"--manifest",
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()
@@ -336,7 +369,14 @@ def main():
)
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(
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/
│ ├── index/ # 首页:输入、预览、导出
│ └── font-picker/ # 字体选择页
├── config/
│ └── server.js # 远端地址/端口/API 路径统一配置
├── utils/
│ ├── core/ # 纯算法模块
│ └── mp/ # 小程序 API 适配层
├── assets/fonts.json # 字体清单(由脚本生成)
├── assets/default.json # 首次加载默认配置(内容/颜色/字号/默认字体)
├── app.js / app.json / app.wxss
└── project.config.json
```
@@ -34,6 +37,19 @@ miniprogram/
5. Nginx 配置 `/api/` 反向代理到渲染服务。
6. 编译运行。
## 服务器配置(换服务器只改一处)
修改 `miniprogram/config/server.js` 中的 `SERVER_CONFIG`
- `protocol`: `https` / `http`
- `host`: 服务器域名
- `port`: 端口(默认 443/80 可留空)
- `apiPrefix`: API 前缀(默认 `/api`
- `fontsManifestPath`: 字体清单路径(默认 `/miniprogram/assets/fonts.json`
- `defaultConfigPath`: 默认配置路径(默认 `/miniprogram/assets/default.json`
`app.js` 和 API 调用会自动使用该配置生成完整 URL。
## 导出说明
- `SVG`:受微信限制,`shareFileMessage` 需由单次点击直接触发,建议逐个字体导出。
@@ -50,6 +66,29 @@ miniprogram/
如果 `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

View File

@@ -1,9 +1,12 @@
const { buildRuntimeConfig } = require('./config/server')
const runtimeConfig = buildRuntimeConfig()
App({
globalData: {
fontsManifestUrl: 'https://fonts.biboer.cn/fonts.json',
fontsBaseUrl: 'https://fonts.biboer.cn',
svgRenderApiUrl: 'https://fonts.biboer.cn/api/render-svg',
...runtimeConfig,
apiTimeoutMs: 30000,
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 = [
{
"id": "其他字体/AlimamaDaoLiTi",
"id": "0001",
"name": "AlimamaDaoLiTi",
"filename": "AlimamaDaoLiTi.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/AlimamaDaoLiTi.ttf"
},
{
"id": "其他字体/Hangeuljaemin4-Regular",
"id": "0002",
"name": "Hangeuljaemin4-Regular",
"filename": "Hangeuljaemin4-Regular.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/Hangeuljaemin4-Regular.ttf"
},
{
"id": "其他字体/I.顏體",
"id": "0003",
"name": "I.顏體",
"filename": "I.顏體.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/I.顏體.ttf"
},
{
"id": "其他字体/XCDUANZHUANGSONGTI",
"id": "0004",
"name": "XCDUANZHUANGSONGTI",
"filename": "XCDUANZHUANGSONGTI.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/XCDUANZHUANGSONGTI.ttf"
},
{
"id": "其他字体/qiji-combo",
"id": "0005",
"name": "qiji-combo",
"filename": "qiji-combo.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/qiji-combo.ttf"
},
{
"id": "其他字体/临海隶书",
"id": "0006",
"name": "临海隶书",
"filename": "临海隶书.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/临海隶书.ttf"
},
{
"id": "其他字体/京華老宋体_KingHwa_OldSong",
"id": "0007",
"name": "京華老宋体_KingHwa_OldSong",
"filename": "京華老宋体_KingHwa_OldSong.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/京華老宋体_KingHwa_OldSong.ttf"
},
{
"id": "其他字体/优设标题黑",
"id": "0008",
"name": "优设标题黑",
"filename": "优设标题黑.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/优设标题黑.ttf"
},
{
"id": "其他字体/包图小白体",
"id": "0009",
"name": "包图小白体",
"filename": "包图小白体.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/包图小白体.ttf"
},
{
"id": "其他字体/源界明朝",
"id": "0010",
"name": "源界明朝",
"filename": "源界明朝.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/源界明朝.ttf"
},
{
"id": "其他字体/演示佛系体",
"id": "0011",
"name": "演示佛系体",
"filename": "演示佛系体.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/演示佛系体.ttf"
},
{
"id": "其他字体/站酷快乐体",
"id": "0012",
"name": "站酷快乐体",
"filename": "站酷快乐体.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/站酷快乐体.ttf"
},
{
"id": "其他字体/问藏书房",
"id": "0013",
"name": "问藏书房",
"filename": "问藏书房.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/问藏书房.ttf"
},
{
"id": "其他字体/霞鹜臻楷",
"id": "0014",
"name": "霞鹜臻楷",
"filename": "霞鹜臻楷.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/霞鹜臻楷.ttf"
},
{
"id": "庞门正道/庞门正道标题体",
"id": "0015",
"name": "庞门正道标题体",
"filename": "庞门正道标题体.ttf",
"category": "庞门正道",
"path": "/fonts/庞门正道/庞门正道标题体.ttf"
},
{
"id": "庞门正道-测试/庞门正道标题体",
"id": "0016",
"name": "庞门正道标题体",
"filename": "庞门正道标题体.ttf",
"category": "庞门正道-测试",
"path": "/fonts/庞门正道-测试/庞门正道标题体.ttf"
},
{
"id": "王漢宗/王漢宗勘亭流繁",
"id": "0017",
"name": "王漢宗勘亭流繁",
"filename": "王漢宗勘亭流繁.ttf",
"category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗勘亭流繁.ttf"
},
{
"id": "王漢宗/王漢宗新潮體",
"id": "0018",
"name": "王漢宗新潮體",
"filename": "王漢宗新潮體.ttf",
"category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗新潮體.ttf"
},
{
"id": "王漢宗/王漢宗波卡體空陰",
"id": "0019",
"name": "王漢宗波卡體空陰",
"filename": "王漢宗波卡體空陰.ttf",
"category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗波卡體空陰.ttf"
},
{
"id": "王漢宗/王漢宗細黑體繁",
"id": "0020",
"name": "王漢宗細黑體繁",
"filename": "王漢宗細黑體繁.ttf",
"category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗細黑體繁.ttf"
},
{
"id": "王漢宗/王漢宗綜藝體雙空陰",
"id": "0021",
"name": "王漢宗綜藝體雙空陰",
"filename": "王漢宗綜藝體雙空陰.ttf",
"category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗綜藝體雙空陰.ttf"
},
{
"id": "王漢宗/王漢宗超明體繁",
"id": "0022",
"name": "王漢宗超明體繁",
"filename": "王漢宗超明體繁.ttf",
"category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗超明體繁.ttf"
},
{
"id": "王漢宗/王漢宗酷儷海報",
"id": "0023",
"name": "王漢宗酷儷海報",
"filename": "王漢宗酷儷海報.ttf",
"category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗酷儷海報.ttf"
},
{
"id": "王漢宗/王漢宗顏楷體繁",
"id": "0024",
"name": "王漢宗顏楷體繁",
"filename": "王漢宗顏楷體繁.ttf",
"category": "王漢宗",

View File

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

View File

@@ -1,7 +1,9 @@
// 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 = {
@@ -30,7 +32,7 @@ const ICON_PATHS = {
// 字体资源路径
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 = {
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 {
shareSvgFromUserTap,
@@ -11,8 +11,11 @@ const COLOR_PALETTE = ['#000000', '#1d4ed8', '#047857', '#b45309', '#dc2626', '#
const FONT_SIZE_MIN = 20
const FONT_SIZE_MAX = 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 MAX_PREVIEW_IMAGE_WIDTH = 2400
const FEEDBACK_EMAIL = 'douboer@gmail.com'
// 临时使用本地图标 - 根据Figma annotation配置
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)))
}
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) {
if (!error) {
return fallback
@@ -84,6 +112,25 @@ function writePngBufferToTempFile(pngBuffer, fontName) {
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) {
const text = String(Number(value).toFixed(2))
return text.replace(/\.?0+$/, '')
@@ -146,9 +193,9 @@ function applyLocalStyleToFontItem(font, fontSize, color) {
Page({
data: {
inputText: '星程字体转换',
fontSize: FONT_SIZE_MAX,
fontSize: 50,
letterSpacingInput: '0',
textColor: '#000000',
textColor: '#dc2626',
colorPalette: COLOR_PALETTE,
selectedFonts: [], // 当前已选中的字体列表
fontCategories: [], // 字体分类树
@@ -160,6 +207,7 @@ Page({
// 搜索功能
searchKeyword: '',
showSearch: true,
feedbackEmail: FEEDBACK_EMAIL,
},
async onLoad() {
@@ -170,6 +218,8 @@ Page({
console.log('============================')
this.fontMap = new Map()
this.legacyFontIdMap = new Map()
this.previewCache = new Map()
this.generateTimer = null
this.categoryExpandedMap = {}
@@ -177,7 +227,11 @@ Page({
},
onShow() {
const favorites = loadFavorites()
const rawFavorites = loadFavorites()
const favorites = this.normalizeFontIdList(rawFavorites)
if (normalizeSelectedFontIds(rawFavorites).join(',') !== favorites.join(',')) {
saveFavorites(favorites)
}
this.setData({ favorites })
this.updateFontTrees()
},
@@ -196,35 +250,126 @@ Page({
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() {
wx.showLoading({ title: '加载中', mask: true })
try {
const state = loadAppState()
const fonts = await loadFontsManifest()
const favorites = loadFavorites()
const isFirstLaunch = !state || !state.updatedAt
const [fonts, defaultConfig] = await Promise.all([
loadFontsManifest(),
loadDefaultConfig(),
])
this.buildFontMaps(fonts)
for (const font of fonts) {
this.fontMap.set(font.id, font)
const rawFavorites = loadFavorites()
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({
inputText: state.inputText || this.data.inputText,
fontSize: clampFontSize(state.fontSize, this.data.fontSize),
letterSpacingInput:
typeof state.letterSpacing === 'number' ? String(state.letterSpacing) : this.data.letterSpacingInput,
textColor: normalizeHexColor(state.textColor || this.data.textColor),
inputText: initialInputText,
fontSize: initialFontSize,
letterSpacingInput: initialLetterSpacingInput,
textColor: initialTextColor,
favorites,
})
this.categoryExpandedMap = state.categoryExpandedMap && typeof state.categoryExpandedMap === 'object'
this.categoryExpandedMap = !isFirstLaunch && state.categoryExpandedMap && typeof state.categoryExpandedMap === 'object'
? { ...state.categoryExpandedMap }
: {}
// 构建字体树
this.updateFontTrees()
// 如果有保存的选中字体,恢复它们
if (state.selectedFontIds && state.selectedFontIds.length > 0) {
const selectedFonts = state.selectedFontIds
// 恢复选中字体(首次使用走 default.json后续走本地用户配置
const rawSelectedFontIds = isFirstLaunch ? defaultConfig.selectedFontIds : state.selectedFontIds
const normalizedStoredSelectedIds = normalizeSelectedFontIds(rawSelectedFontIds)
const initialSelectedFontIds = this.normalizeFontIdList(normalizedStoredSelectedIds)
if (initialSelectedFontIds.length > 0) {
const selectedFonts = initialSelectedFontIds
.map(id => this.fontMap.get(id))
.filter(font => font)
.map(font => ({
@@ -240,6 +385,21 @@ Page({
this.updateFontTrees()
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) {
const message = error && error.message ? error.message : '初始化失败'
wx.showToast({ title: message, icon: 'none', duration: 2200 })
@@ -430,7 +590,7 @@ Page({
},
// 切换分类全选/取消全选
onToggleSelectAllInCategory(e) {
async onToggleSelectAllInCategory(e) {
const category = e.currentTarget.dataset.category
if (!category) return
@@ -438,8 +598,9 @@ Page({
if (!categoryFonts || categoryFonts.fonts.length === 0) return
const allSelected = categoryFonts.allSelected
const selectedFonts = [...this.data.selectedFonts]
const selectedIdSet = new Set(selectedFonts.map(f => f.id))
const previousSelectedMap = new Map(this.data.selectedFonts.map(font => [font.id, font]))
const selectedIdSet = new Set(previousSelectedMap.keys())
const addedFontIds = []
if (allSelected) {
// 取消全选:移除该分类下的所有字体
@@ -449,6 +610,9 @@ Page({
} else {
// 全选:添加该分类下的所有字体
categoryFonts.fonts.forEach(font => {
if (!selectedIdSet.has(font.id)) {
addedFontIds.push(font.id)
}
selectedIdSet.add(font.id)
})
}
@@ -456,6 +620,13 @@ Page({
const newSelectedFonts = []
this.fontMap.forEach(font => {
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({
id: font.id,
name: font.name,
@@ -464,11 +635,17 @@ Page({
previewSrc: '',
})
}
}
})
this.setData({ selectedFonts: newSelectedFonts })
this.updateFontTrees()
this.scheduleGenerate()
if (addedFontIds.length > 0) {
for (const fontId of addedFontIds) {
await this.generatePreviewForFont(fontId)
}
}
saveAppState({
inputText: this.data.inputText,
@@ -488,6 +665,26 @@ Page({
try {
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({
fontId,
@@ -496,7 +693,12 @@ Page({
fontSize: PREVIEW_RENDER_FONT_SIZE,
fillColor: '#000000',
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.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">
@版权说明仅SVG和PNG分享无TTF下载如侵权反馈douboer@gmail.com
<text>@版权说明仅SVG和PNG分享无TTF下载如侵权反馈</text>
<text class="copyright-email-link" bindtap="onTapFeedbackEmail">{{feedbackEmail}}</text>
</view>
<!-- 颜色选择器弹窗 -->

View File

@@ -233,7 +233,7 @@
.preview-content {
background: transparent;
padding: 4rpx 0;
min-height: 80rpx;
min-height: 40rpx;
display: flex;
align-items: center;
justify-content: flex-start;
@@ -284,6 +284,11 @@
padding: 8rpx 8rpx;
overflow: hidden;
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 localFonts = require('../../assets/fonts')
const localDefaultConfig = require('../../assets/default')
const fontBufferCache = new Map()
const MAX_FONT_CACHE = 4
@@ -44,6 +45,70 @@ function normalizeManifest(fonts, baseUrl) {
.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 = {}) {
const app = getApp()
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) {
if (fontBufferCache.has(key)) {
fontBufferCache.delete(key)
@@ -125,6 +224,7 @@ function listCategories(fonts) {
module.exports = {
loadFontsManifest,
loadDefaultConfig,
loadFontBuffer,
listCategories,
}

View File

@@ -86,9 +86,12 @@ async function renderPngByApi(payload) {
? Number(app.globalData.apiTimeoutMs)
: 30000
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(/\/$/, '')}/render-png`
)
const response = await request({
url: apiUrl,

View File

@@ -36,7 +36,14 @@ function run() {
const wrapped = wrapTextByChars('123456', 2)
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, '未找到可用字体文件')
const buffer = fs.readFileSync(fontFile)

View File

@@ -8,8 +8,12 @@ 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"
REMOTE_MP_ASSETS_DIR="$REMOTE_DIR/miniprogram/assets"
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'
@@ -38,8 +42,13 @@ check_files() {
exit 1
fi
if [ ! -f "$LOCAL_FONTS_JSON" ]; then
log_error "fonts.json 文件不存在: $LOCAL_FONTS_JSON"
if [ ! -f "$LOCAL_WEB_FONTS_JSON" ]; then
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
fi
@@ -49,7 +58,7 @@ check_files() {
create_remote_dirs() {
log_info "创建远程目录..."
ssh $SERVER "mkdir -p $REMOTE_DIR/fonts"
ssh $SERVER "mkdir -p $REMOTE_DIR/fonts $REMOTE_MP_ASSETS_DIR"
}
upload_fonts() {
@@ -65,34 +74,55 @@ upload_fonts() {
log_info "字体文件上传完成"
}
upload_fonts_json() {
log_info "上传 fonts.json..."
scp "$LOCAL_FONTS_JSON" "$SERVER:$REMOTE_DIR/"
log_info "fonts.json 上传完成"
upload_web_config() {
log_info "上传 Web 配置..."
scp "$LOCAL_WEB_FONTS_JSON" "$SERVER:$REMOTE_DIR/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() {
log_info "设置文件权限..."
ssh $SERVER "chmod -R 755 $REMOTE_DIR"
ssh $SERVER "chmod -R 755 $REMOTE_DIR/fonts $REMOTE_MP_ASSETS_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")
# 检查小程序 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
log_info "fonts.json 可访问 ✓"
if [ "$MP_HTTP_CODE" = "200" ]; then
log_info "小程序 fonts.json 可访问 ✓"
else
log_error "fonts.json 访问失败 (HTTP $HTTP_CODE)"
log_error "小程序 fonts.json 访问失败 (HTTP $MP_HTTP_CODE)"
log_warn "请检查 Cloudflare DNS 配置和 Nginx 配置"
exit 1
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_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
log_info "CORS 配置正确 ✓"
@@ -123,11 +153,12 @@ show_summary() {
echo ""
echo "2. 测试字体加载(在小程序开发者工具控制台):"
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 " })"
echo ""
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 ""
}
@@ -151,7 +182,8 @@ main() {
check_files
create_remote_dirs
upload_fonts
upload_fonts_json
upload_web_config
upload_miniprogram_config
set_permissions
# 可选:重启 Nginx需要 sudo 权限)

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""
生成字体清单文件
扫描 frontend/public/fonts/ 目录下的所有字体文件,同时生成:
扫描 fonts/ 目录下的所有字体文件,同时生成:
1. frontend/public/fonts.json
2. miniprogram/assets/fonts.json
3. miniprogram/assets/fonts.js
@@ -11,7 +11,7 @@ import os
import json
from pathlib import Path
def scan_fonts(font_dir='frontend/public/fonts'):
def scan_fonts(font_dir='fonts'):
"""扫描字体目录,返回字体信息列表"""
fonts = []
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()
# 生成字体信息
# 生成基础信息id 在后续统一按序号回填
font_info = {
'id': f"{category_name}/{font_file.stem}",
'id': '',
'name': font_file.stem,
'filename': font_file.name,
'category': category_name,
'path': f"/fonts/{relative_path}",
'relativePath': relative_path,
}
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
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):
"""写入字体清单 JSON 文件"""
os.makedirs(os.path.dirname(output_file), exist_ok=True)
@@ -66,15 +90,19 @@ def write_fonts_js(fonts, output_file):
def main():
"""主函数"""
# 扫描字体(唯一来源:frontend/public/fonts
fonts = scan_fonts('frontend/public/fonts')
# 扫描字体(唯一来源:仓库根目录 fonts/
fonts = scan_fonts('fonts')
print(f"找到 {len(fonts)} 个字体文件")
# 同步写入 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')
# Web 清单:统一指向根目录 fonts
web_fonts = build_manifest(fonts, '/fonts')
write_fonts_json(web_fonts, 'frontend/public/fonts.json')
# 小程序清单:同样指向根目录 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 = {}

View File

@@ -3,7 +3,7 @@
"""
Font2SVG - fonts.json 生成脚本
扫描 frontend/public/fonts/ 目录,生成小程序所需的 fonts.json
扫描 fonts/ 目录,生成字体清单
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"
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"
def scan_fonts(fonts_dir: Path) -> list:
@@ -51,9 +51,9 @@ def scan_fonts(fonts_dir: Path) -> list:
encoded_filename = quote(font_file.name)
url = f"{BASE_URL}/{encoded_category}/{encoded_filename}"
# 创建字体信息对象
# 创建字体信息对象id 在后续统一按序号回填)
font_info = {
"id": f"{category}/{font_name}",
"id": "",
"name": font_name,
"category": category,
"path": url,
@@ -71,7 +71,10 @@ def sort_fonts(fonts: list) -> list:
1. 按分类排序
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):
"""