update at 2026-02-08 22:31:25

This commit is contained in:
douboer
2026-02-08 22:31:25 +08:00
parent 0f5a7f0d85
commit f078dd3261
39 changed files with 587 additions and 213 deletions

View File

@@ -2,7 +2,7 @@
`apiserver/` 提供微信小程序用的远端渲染接口:
- 小程序只上传参数(字体 ID、文字、字号、颜色等
- 服务端读取 `fonts.json` + `fonts/`,生成 SVG 后返回
- 服务端读取 `fonts.json` + `fonts/`,生成 SVG/PNG 后返回
## 1. 启动
@@ -43,6 +43,11 @@ python3 apiserver/server.py \
}
```
### POST `/api/render-png`
请求体与 `/api/render-svg` 相同,成功时直接返回 `image/png` 二进制。
小程序应使用 `wx.request({ responseType: 'arraybuffer' })` 接收。
成功响应:
```json
@@ -82,8 +87,20 @@ location /api/ {
sudo nginx -t && sudo systemctl reload nginx
```
## 4. 约束
## 4. systemd可选
仓库内提供示例:`apiserver/font2svg-api.service.example`,可复制到:
```bash
sudo cp apiserver/font2svg-api.service.example /etc/systemd/system/font2svg-api.service
sudo systemctl daemon-reload
sudo systemctl enable --now font2svg-api
sudo systemctl status font2svg-api
```
## 5. 约束
- 字体解析完全基于 `fonts.json``fontId` 必须存在。
- 服务端启用 CORS允许小程序访问。
- 不依赖 Flask/FastAPI使用 Python 标准库 HTTP 服务。
- `/api/render-png` 依赖 `node + sharp`(使用 `apiserver/svg_to_png.js` 转换)。

View File

@@ -0,0 +1,14 @@
[Unit]
Description=Font2SVG API Server
After=network.target
[Service]
Type=simple
User=gavin
WorkingDirectory=/home/gavin/font2svg
ExecStart=/home/gavin/font2svg/.venv/bin/python /home/gavin/font2svg/apiserver/server.py --host 127.0.0.1 --port 9300 --static-root /home/gavin/font2svg
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target

55
apiserver/png_renderer.py Normal file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""服务端 PNG 渲染:通过 sharp 将 SVG 转为 PNG。"""
import os
import subprocess
def _script_path():
return os.path.join(os.path.dirname(__file__), "svg_to_png.js")
def render_png_from_svg(svg_text, width, height, *, timeout_seconds=20):
if not svg_text or not str(svg_text).strip():
raise ValueError("SVG 内容为空")
script = _script_path()
if not os.path.isfile(script):
raise FileNotFoundError(f"未找到 SVG 转 PNG 脚本: {script}")
safe_width = max(1, min(4096, int(round(float(width or 0) or 0))))
safe_height = max(1, min(4096, int(round(float(height or 0) or 0))))
cmd = [
"node",
script,
"--width",
str(safe_width),
"--height",
str(safe_height),
]
try:
completed = subprocess.run(
cmd,
input=str(svg_text).encode("utf-8"),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout_seconds,
check=False,
)
except FileNotFoundError as error:
raise RuntimeError("未找到 node请先安装 Node.js") from error
if completed.returncode != 0:
stderr = completed.stderr.decode("utf-8", errors="replace").strip()
if "Cannot find module 'sharp'" in stderr:
raise RuntimeError("缺少 sharp 依赖,请在项目根目录执行: npm install")
raise RuntimeError(stderr or f"PNG 渲染失败,退出码: {completed.returncode}")
png_bytes = completed.stdout
if not png_bytes:
raise RuntimeError("PNG 渲染失败,返回空内容")
return png_bytes

View File

@@ -14,6 +14,10 @@ try:
from .renderer import MAX_CHARS_PER_LINE, render_svg_from_font_file
except ImportError:
from renderer import MAX_CHARS_PER_LINE, render_svg_from_font_file
try:
from .png_renderer import render_png_from_svg
except ImportError:
from png_renderer import render_png_from_svg
LOGGER = logging.getLogger("font2svg.api")
@@ -175,6 +179,66 @@ class RenderHandler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(body)
def _send_binary(self, status_code, body, content_type):
self.send_response(status_code)
self._set_cors_headers()
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _parse_render_payload(self):
try:
content_length = int(self.headers.get("Content-Length", "0") or "0")
except ValueError:
raise ValueError("请求长度无效") from None
if content_length <= 0 or content_length > 256 * 1024:
raise ValueError("请求体大小无效")
try:
raw_body = self.rfile.read(content_length)
payload = json.loads(raw_body.decode("utf-8"))
except json.JSONDecodeError:
raise ValueError("请求体不是有效 JSON") from None
if not isinstance(payload, dict):
raise ValueError("请求体格式错误")
font_id = str(payload.get("fontId") or "").strip()
text = str(payload.get("text") or "")
if not font_id:
raise ValueError("缺少 fontId")
if not text.strip():
raise ValueError("文本内容不能为空")
return {
"fontId": font_id,
"text": text,
"fontSize": _safe_number(payload.get("fontSize"), 120.0, 8.0, 1024.0, float),
"letterSpacing": _safe_number(payload.get("letterSpacing"), 0.0, -2.0, 5.0, float),
"maxCharsPerLine": _safe_number(
payload.get("maxCharsPerLine"),
MAX_CHARS_PER_LINE,
1,
300,
int,
),
"fillColor": _normalize_hex_color(payload.get("fillColor"), "#000000"),
}
def _render_svg_core(self, render_params):
font_info = self.catalog.get(render_params["fontId"])
result = render_svg_from_font_file(
font_info["path"],
render_params["text"],
font_size=render_params["fontSize"],
fill_color=render_params["fillColor"],
letter_spacing=render_params["letterSpacing"],
max_chars_per_line=render_params["maxCharsPerLine"],
)
return font_info, result
def do_OPTIONS(self): # noqa: N802
self.send_response(204)
self._set_cors_headers()
@@ -195,61 +259,13 @@ class RenderHandler(BaseHTTPRequestHandler):
def do_POST(self): # noqa: N802
parsed = urlparse(self.path)
if parsed.path != "/api/render-svg":
if parsed.path not in ("/api/render-svg", "/api/render-png"):
self._send_json(404, {"ok": False, "error": "Not Found"})
return
try:
content_length = int(self.headers.get("Content-Length", "0") or "0")
except ValueError:
self._send_json(400, {"ok": False, "error": "请求长度无效"})
return
if content_length <= 0 or content_length > 256 * 1024:
self._send_json(400, {"ok": False, "error": "请求体大小无效"})
return
try:
raw_body = self.rfile.read(content_length)
payload = json.loads(raw_body.decode("utf-8"))
except json.JSONDecodeError:
self._send_json(400, {"ok": False, "error": "请求体不是有效 JSON"})
return
if not isinstance(payload, dict):
self._send_json(400, {"ok": False, "error": "请求体格式错误"})
return
font_id = str(payload.get("fontId") or "").strip()
text = str(payload.get("text") or "")
if not font_id:
self._send_json(400, {"ok": False, "error": "缺少 fontId"})
return
if not text.strip():
self._send_json(400, {"ok": False, "error": "文本内容不能为空"})
return
font_size = _safe_number(payload.get("fontSize"), 120.0, 8.0, 1024.0, float)
letter_spacing = _safe_number(payload.get("letterSpacing"), 0.0, -2.0, 5.0, float)
max_chars_per_line = _safe_number(
payload.get("maxCharsPerLine"),
MAX_CHARS_PER_LINE,
1,
300,
int,
)
fill_color = _normalize_hex_color(payload.get("fillColor"), "#000000")
try:
font_info = self.catalog.get(font_id)
result = render_svg_from_font_file(
font_info["path"],
text,
font_size=font_size,
fill_color=fill_color,
letter_spacing=letter_spacing,
max_chars_per_line=max_chars_per_line,
)
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
@@ -264,12 +280,27 @@ class RenderHandler(BaseHTTPRequestHandler):
self._send_json(500, {"ok": False, "error": str(error)})
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
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": font_id,
"fontId": render_params["fontId"],
}
self._send_json(200, {"ok": True, "data": response_data})

61
apiserver/svg_to_png.js Normal file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env node
const sharp = require('sharp')
function parseArgs(argv) {
const parsed = {}
for (let i = 2; i < argv.length; i += 1) {
const key = argv[i]
const value = argv[i + 1]
if (key === '--width' && value) {
parsed.width = Number(value)
i += 1
continue
}
if (key === '--height' && value) {
parsed.height = Number(value)
i += 1
}
}
return parsed
}
async function main() {
const args = parseArgs(process.argv)
const chunks = []
process.stdin.on('data', (chunk) => {
chunks.push(chunk)
})
process.stdin.on('end', async () => {
try {
const svgBuffer = Buffer.concat(chunks)
if (!svgBuffer.length) {
throw new Error('empty svg input')
}
const width = Number.isFinite(args.width) && args.width > 0 ? Math.round(args.width) : null
const height = Number.isFinite(args.height) && args.height > 0 ? Math.round(args.height) : null
let pipeline = sharp(svgBuffer, { density: 300 })
if (width || height) {
pipeline = pipeline.resize({
width: width || undefined,
height: height || undefined,
fit: 'contain',
background: { r: 255, g: 255, b: 255, alpha: 1 },
withoutEnlargement: false,
})
}
const pngBuffer = await pipeline.png({ compressionLevel: 9 }).toBuffer()
process.stdout.write(pngBuffer)
} catch (error) {
process.stderr.write(`svg_to_png_error: ${error && error.message ? error.message : String(error)}\n`)
process.exit(1)
}
})
}
main()