diff --git a/apiserver/README.md b/apiserver/README.md index 19862f2..40e5119 100644 --- a/apiserver/README.md +++ b/apiserver/README.md @@ -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`,默认读取 `/fonts.json`。 +如果不传 `--manifest`,默认优先读取 `/miniprogram/assets/fonts.json`,不存在时回退到 `/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`),字体文件统一从 `/fonts/` 读取,`fontId` 必须存在。 - 服务端启用 CORS,允许小程序访问。 - 不依赖 Flask/FastAPI,使用 Python 标准库 HTTP 服务。 - `/api/render-png` 依赖 `node + sharp`(使用 `apiserver/svg_to_png.js` 转换)。 diff --git a/apiserver/server.py b/apiserver/server.py index 966e6a7..2e035e0 100644 --- a/apiserver/server.py +++ b/apiserver/server.py @@ -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,69 +258,84 @@ class RenderHandler(BaseHTTPRequestHandler): return font_info, result def do_OPTIONS(self): # noqa: N802 - self.send_response(204) - self._set_cors_headers() - self.end_headers() + 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 - parsed = urlparse(self.path) - if parsed.path == "/healthz": - try: - data = self.catalog.health() - self._send_json(200, {"ok": True, "data": data}) - except Exception as error: - LOGGER.exception("健康检查失败") - self._send_json(500, {"ok": False, "error": str(error)}) - return + start_time = time.perf_counter() + self._response_status = None + try: + parsed = urlparse(self.path) + if parsed.path == "/healthz": + try: + data = self.catalog.health() + self._send_json(200, {"ok": True, "data": data}) + except Exception as error: + LOGGER.exception("健康检查失败") + self._send_json(500, {"ok": False, "error": str(error)}) + return - self._send_json(404, {"ok": False, "error": "Not Found"}) + self._send_json(404, {"ok": False, "error": "Not Found"}) + finally: + self._log_request_timing(start_time) def do_POST(self): # noqa: N802 - parsed = urlparse(self.path) - if parsed.path not in ("/api/render-svg", "/api/render-png"): - self._send_json(404, {"ok": False, "error": "Not Found"}) - return - + start_time = time.perf_counter() + self._response_status = None try: - render_params = self._parse_render_payload() - font_info, result = self._render_svg_core(render_params) - except KeyError as error: - self._send_json(404, {"ok": False, "error": str(error)}) - return - except FileNotFoundError as error: - self._send_json(404, {"ok": False, "error": str(error)}) - return - except ValueError as error: - self._send_json(400, {"ok": False, "error": str(error)}) - return - except Exception as error: - LOGGER.exception("渲染失败") - self._send_json(500, {"ok": False, "error": str(error)}) - return + parsed = urlparse(self.path) + if parsed.path not in ("/api/render-svg", "/api/render-png"): + self._send_json(404, {"ok": False, "error": "Not Found"}) + return - if parsed.path == "/api/render-png": try: - png_bytes = render_png_from_svg( - result["svg"], - result["width"], - result["height"], - ) + render_params = self._parse_render_payload() + font_info, result = self._render_svg_core(render_params) + except KeyError as error: + self._send_json(404, {"ok": False, "error": str(error)}) + return + except FileNotFoundError as error: + self._send_json(404, {"ok": False, "error": str(error)}) + return + except ValueError as error: + self._send_json(400, {"ok": False, "error": str(error)}) + return except Exception as error: - LOGGER.exception("PNG 渲染失败") + LOGGER.exception("渲染失败") self._send_json(500, {"ok": False, "error": str(error)}) return - self._send_binary(200, png_bytes, "image/png") - return + if parsed.path == "/api/render-png": + try: + png_bytes = render_png_from_svg( + result["svg"], + result["width"], + result["height"], + ) + except Exception as error: + LOGGER.exception("PNG 渲染失败") + self._send_json(500, {"ok": False, "error": str(error)}) + return - response_data = { - "svg": result["svg"], - "width": result["width"], - "height": result["height"], - "fontName": result.get("fontName") or font_info.get("name") or "Unknown", - "fontId": render_params["fontId"], - } - self._send_json(200, {"ok": True, "data": response_data}) + self._send_binary(200, png_bytes, "image/png") + return + + response_data = { + "svg": result["svg"], + "width": result["width"], + "height": result["height"], + "fontName": result.get("fontName") or font_info.get("name") or "Unknown", + "fontId": render_params["fontId"], + } + self._send_json(200, {"ok": True, "data": response_data}) + finally: + self._log_request_timing(start_time) def log_message(self, format_str, *args): LOGGER.info("%s - %s", self.address_string(), format_str % args) @@ -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="字体清单路径,默认使用 /fonts.json", + help="字体清单路径,默认优先使用 /miniprogram/assets/fonts.json,其次 /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, diff --git a/frontend/public/default.json b/frontend/public/default.json new file mode 100644 index 0000000..b482310 --- /dev/null +++ b/frontend/public/default.json @@ -0,0 +1,11 @@ +{ + "inputText": "星程字体转换", + "fontSize": 50, + "textColor": "#dc2626", + "selectedFontIds": [ + "0001" + ], + "favoriteFontIds": [ + "0001" + ] +} diff --git a/miniprogram/README.md b/miniprogram/README.md index c146d3b..5e52cb4 100644 --- a/miniprogram/README.md +++ b/miniprogram/README.md @@ -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 diff --git a/miniprogram/app.js b/miniprogram/app.js index 1ed08e5..993d999 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -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, }, }) diff --git a/miniprogram/assets/default.js b/miniprogram/assets/default.js new file mode 100644 index 0000000..57c06fe --- /dev/null +++ b/miniprogram/assets/default.js @@ -0,0 +1,19 @@ +module.exports = { + inputText: '星程字体转换', + fontSize: 50, + textColor: '#dc2626', + selectedFontIds: [ + '0001', + '0003', + '0006', + '0011', + '0015', + ], + favoriteFontIds: [ + '0001', + '0003', + '0006', + '0011', + '0015', + ], +} diff --git a/miniprogram/assets/default.json b/miniprogram/assets/default.json new file mode 100644 index 0000000..621348a --- /dev/null +++ b/miniprogram/assets/default.json @@ -0,0 +1,19 @@ +{ + "inputText": "星程字体转换", + "fontSize": 50, + "textColor": "#dc2626", + "selectedFontIds": [ + "0001", + "0003", + "0006", + "0011", + "0015" + ], + "favoriteFontIds": [ + "0001", + "0003", + "0006", + "0011", + "0015" + ] +} diff --git a/miniprogram/assets/fonts.js b/miniprogram/assets/fonts.js index 4beb935..75019c0 100644 --- a/miniprogram/assets/fonts.js +++ b/miniprogram/assets/fonts.js @@ -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": "王漢宗", diff --git a/miniprogram/assets/fonts.json b/miniprogram/assets/fonts.json index 141559d..4be1f6c 100644 --- a/miniprogram/assets/fonts.json +++ b/miniprogram/assets/fonts.json @@ -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": "王漢宗", diff --git a/miniprogram/config/cdn.js b/miniprogram/config/cdn.js index 6c00f7f..a0e9e56 100644 --- a/miniprogram/config/cdn.js +++ b/miniprogram/config/cdn.js @@ -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, diff --git a/miniprogram/config/server.js b/miniprogram/config/server.js new file mode 100644 index 0000000..9c09435 --- /dev/null +++ b/miniprogram/config/server.js @@ -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, +} diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js index c0bd411..94891cc 100644 --- a/miniprogram/pages/index/index.js +++ b/miniprogram/pages/index/index.js @@ -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 }) @@ -317,7 +477,7 @@ Page({ cat.allSelected = false } }) - + favoriteFonts.sort((a, b) => { const categoryCompare = String(a.category || '').localeCompare(String(b.category || '')) if (categoryCompare !== 0) return categoryCompare @@ -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,19 +620,32 @@ Page({ const newSelectedFonts = [] this.fontMap.forEach(font => { if (selectedIdSet.has(font.id)) { - newSelectedFonts.push({ - id: font.id, - name: font.name, - category: font.category, - showInPreview: true, - previewSrc: '', - }) + 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, + category: font.category, + showInPreview: true, + 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', + }) + }, + }) + }, }) diff --git a/miniprogram/pages/index/index.wxml b/miniprogram/pages/index/index.wxml index e040ffb..d7ff281 100644 --- a/miniprogram/pages/index/index.wxml +++ b/miniprogram/pages/index/index.wxml @@ -177,7 +177,8 @@ - @版权说明:仅SVG和PNG分享,无TTF下载,如侵权,反馈:douboer@gmail.com + @版权说明:仅SVG和PNG分享,无TTF下载,如侵权,反馈: + {{feedbackEmail}} diff --git a/miniprogram/pages/index/index.wxss b/miniprogram/pages/index/index.wxss index f0f7acb..c30f40a 100644 --- a/miniprogram/pages/index/index.wxss +++ b/miniprogram/pages/index/index.wxss @@ -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; } /* 搜索相关样式 */ diff --git a/miniprogram/utils/mp/font-loader.js b/miniprogram/utils/mp/font-loader.js index 4fcdffa..9233346 100644 --- a/miniprogram/utils/mp/font-loader.js +++ b/miniprogram/utils/mp/font-loader.js @@ -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, } diff --git a/miniprogram/utils/mp/render-api.js b/miniprogram/utils/mp/render-api.js index f3cc0c0..6ee881f 100644 --- a/miniprogram/utils/mp/render-api.js +++ b/miniprogram/utils/mp/render-api.js @@ -86,9 +86,12 @@ async function renderPngByApi(payload) { ? Number(app.globalData.apiTimeoutMs) : 30000 const baseApiUrl = buildApiUrl() - const apiUrl = /\/api\/render-svg$/.test(baseApiUrl) - ? baseApiUrl.replace(/\/api\/render-svg$/, '/api/render-png') - : `${baseApiUrl.replace(/\/$/, '')}/render-png` + 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, diff --git a/scripts/check-miniprogram-core-test.js b/scripts/check-miniprogram-core-test.js index 65e6200..0d0a58a 100755 --- a/scripts/check-miniprogram-core-test.js +++ b/scripts/check-miniprogram-core-test.js @@ -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) diff --git a/scripts/deploy-fonts.sh b/scripts/deploy-fonts.sh index fef35a7..098d090 100755 --- a/scripts/deploy-fonts.sh +++ b/scripts/deploy-fonts.sh @@ -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 权限) diff --git a/scripts/generate-font-list.py b/scripts/generate-font-list.py index 2ca1997..c0fcd8f 100644 --- a/scripts/generate-font-list.py +++ b/scripts/generate-font-list.py @@ -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 位数字 id(0001、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 = {} diff --git a/scripts/generate-fonts-json.py b/scripts/generate-fonts-json.py index 854eb04..ac315cb 100755 --- a/scripts/generate-fonts-json.py +++ b/scripts/generate-fonts-json.py @@ -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): """