update at 2026-02-09 16:09:44
This commit is contained in:
@@ -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` 转换)。
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user