update at 2026-02-08 22:31:25
This commit is contained in:
@@ -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` 转换)。
|
||||
|
||||
14
apiserver/font2svg-api.service.example
Normal file
14
apiserver/font2svg-api.service.example
Normal 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
55
apiserver/png_renderer.py
Normal 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
|
||||
@@ -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
61
apiserver/svg_to_png.js
Normal 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()
|
||||
Reference in New Issue
Block a user