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,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="字体清单路径,默认使用 <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,