update at 2026-02-08 18:28:39
This commit is contained in:
89
apiserver/README.md
Normal file
89
apiserver/README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# apiserver
|
||||
|
||||
`apiserver/` 提供微信小程序用的远端渲染接口:
|
||||
- 小程序只上传参数(字体 ID、文字、字号、颜色等)
|
||||
- 服务端读取 `fonts.json` + `fonts/`,生成 SVG 后返回
|
||||
|
||||
## 1. 启动
|
||||
|
||||
在仓库根目录执行:
|
||||
|
||||
```bash
|
||||
python3 apiserver/server.py \
|
||||
--host 0.0.0.0 \
|
||||
--port 9300 \
|
||||
--static-root /home/gavin/font2svg
|
||||
```
|
||||
|
||||
其中 `--static-root` 目录必须包含:
|
||||
|
||||
- `fonts.json`
|
||||
- `fonts/`(字体文件目录)
|
||||
|
||||
如果不传 `--manifest`,默认读取 `<static-root>/fonts.json`。
|
||||
|
||||
## 2. API
|
||||
|
||||
### GET `/healthz`
|
||||
|
||||
返回服务健康状态和已加载字体数量。
|
||||
|
||||
### POST `/api/render-svg`
|
||||
|
||||
请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"fontId": "其他字体/AlimamaDaoLiTi",
|
||||
"text": "星程字体转换",
|
||||
"fontSize": 120,
|
||||
"fillColor": "#000000",
|
||||
"letterSpacing": 0,
|
||||
"maxCharsPerLine": 45
|
||||
}
|
||||
```
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"fontId": "其他字体/AlimamaDaoLiTi",
|
||||
"fontName": "AlimamaDaoLiTi",
|
||||
"width": 956.2,
|
||||
"height": 144.3,
|
||||
"svg": "<?xml ...>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Nginx 反向代理
|
||||
|
||||
在 `fonts.biboer.cn` 的 server 块中增加:
|
||||
|
||||
```nginx
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:9300;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
```
|
||||
|
||||
然后执行:
|
||||
|
||||
```bash
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
## 4. 约束
|
||||
|
||||
- 字体解析完全基于 `fonts.json`,`fontId` 必须存在。
|
||||
- 服务端启用 CORS,允许小程序访问。
|
||||
- 不依赖 Flask/FastAPI,使用 Python 标准库 HTTP 服务。
|
||||
1
apiserver/__init__.py
Normal file
1
apiserver/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Font2SVG API server package."""
|
||||
BIN
apiserver/__pycache__/renderer.cpython-312.pyc
Normal file
BIN
apiserver/__pycache__/renderer.cpython-312.pyc
Normal file
Binary file not shown.
275
apiserver/renderer.py
Normal file
275
apiserver/renderer.py
Normal file
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""服务端字体渲染核心:输入文本和字体文件,输出 SVG。"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
HB = None
|
||||
BoundsPen = None
|
||||
SVGPathPen = None
|
||||
TTFont = None
|
||||
|
||||
MAX_CHARS_PER_LINE = 45
|
||||
HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
|
||||
|
||||
|
||||
def _ensure_deps():
|
||||
global HB, BoundsPen, SVGPathPen, TTFont
|
||||
if HB is not None:
|
||||
return
|
||||
|
||||
try:
|
||||
import uharfbuzz as hb # type: ignore[import-not-found]
|
||||
from fontTools.pens.boundsPen import BoundsPen as BP # type: ignore[import-not-found]
|
||||
from fontTools.pens.svgPathPen import SVGPathPen as SP # type: ignore[import-not-found]
|
||||
from fontTools.ttLib import TTFont as FT # type: ignore[import-not-found]
|
||||
except ModuleNotFoundError as error:
|
||||
raise RuntimeError("缺少依赖,请先安装: fonttools uharfbuzz") from error
|
||||
|
||||
HB = hb
|
||||
BoundsPen = BP
|
||||
SVGPathPen = SP
|
||||
TTFont = FT
|
||||
|
||||
|
||||
def _normalize_line_breaks(text):
|
||||
return str(text or "").replace("\r\n", "\n").replace("\r", "\n")
|
||||
|
||||
|
||||
def wrap_text_by_chars(text, max_chars_per_line=MAX_CHARS_PER_LINE):
|
||||
if max_chars_per_line <= 0:
|
||||
return _normalize_line_breaks(text)
|
||||
|
||||
normalized = _normalize_line_breaks(text)
|
||||
lines = normalized.split("\n")
|
||||
wrapped_lines = []
|
||||
|
||||
for line in lines:
|
||||
chars = list(line)
|
||||
if not chars:
|
||||
wrapped_lines.append("")
|
||||
continue
|
||||
for i in range(0, len(chars), max_chars_per_line):
|
||||
wrapped_lines.append("".join(chars[i : i + max_chars_per_line]))
|
||||
|
||||
return "\n".join(wrapped_lines)
|
||||
|
||||
|
||||
def _normalize_hex_color(color, fallback="#000000"):
|
||||
value = str(color or "").strip()
|
||||
if HEX_COLOR_RE.match(value):
|
||||
return value
|
||||
return fallback
|
||||
|
||||
|
||||
def _format_number(value):
|
||||
text = f"{value:.2f}".rstrip("0").rstrip(".")
|
||||
return text if text else "0"
|
||||
|
||||
|
||||
def _shape_line(hb_font, line):
|
||||
buf = HB.Buffer()
|
||||
buf.add_str(line)
|
||||
buf.guess_segment_properties()
|
||||
HB.shape(hb_font, buf)
|
||||
return buf.glyph_infos, buf.glyph_positions
|
||||
|
||||
|
||||
def _positions_scale(positions, upem):
|
||||
sample = 0
|
||||
for pos in positions:
|
||||
if pos.x_advance:
|
||||
sample = abs(pos.x_advance)
|
||||
break
|
||||
if sample > upem * 4:
|
||||
return 1 / 64.0
|
||||
return 1.0
|
||||
|
||||
|
||||
def _compute_bounds(glyph_set, runs):
|
||||
min_x = None
|
||||
min_y = None
|
||||
max_x = None
|
||||
max_y = None
|
||||
|
||||
for glyph_name, x_pos, y_pos in runs:
|
||||
glyph = glyph_set[glyph_name]
|
||||
pen = BoundsPen(glyph_set)
|
||||
glyph.draw(pen)
|
||||
if pen.bounds is None:
|
||||
continue
|
||||
|
||||
xmin, ymin, xmax, ymax = pen.bounds
|
||||
xmin += x_pos
|
||||
xmax += x_pos
|
||||
ymin += y_pos
|
||||
ymax += y_pos
|
||||
|
||||
min_x = xmin if min_x is None else min(min_x, xmin)
|
||||
min_y = ymin if min_y is None else min(min_y, ymin)
|
||||
max_x = xmax if max_x is None else max(max_x, xmax)
|
||||
max_y = ymax if max_y is None else max(max_y, ymax)
|
||||
|
||||
return min_x, min_y, max_x, max_y
|
||||
|
||||
|
||||
def _font_name(ttfont):
|
||||
name_table = ttfont.get("name")
|
||||
if not name_table:
|
||||
return "Unknown"
|
||||
|
||||
for name_id in (1, 4):
|
||||
for record in name_table.names:
|
||||
if record.nameID != name_id:
|
||||
continue
|
||||
try:
|
||||
value = record.toUnicode().strip()
|
||||
except Exception:
|
||||
continue
|
||||
if value:
|
||||
return value
|
||||
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def _compose_svg(
|
||||
glyph_set,
|
||||
runs,
|
||||
bounds,
|
||||
*,
|
||||
fill_color="#000000",
|
||||
width_override=None,
|
||||
height_override=None,
|
||||
):
|
||||
min_x, min_y, max_x, max_y = bounds
|
||||
if min_x is None or min_y is None or max_x is None or max_y is None:
|
||||
raise ValueError("未生成有效字形轮廓。")
|
||||
|
||||
width = max_x - min_x
|
||||
height = max_y - min_y
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("计算得到的SVG尺寸无效。")
|
||||
|
||||
paths = []
|
||||
for glyph_name, x_pos, y_pos in runs:
|
||||
glyph = glyph_set[glyph_name]
|
||||
pen = SVGPathPen(glyph_set)
|
||||
glyph.draw(pen)
|
||||
d = pen.getCommands()
|
||||
if not d:
|
||||
continue
|
||||
|
||||
transform = f"translate({_format_number(x_pos)} {_format_number(y_pos)})"
|
||||
paths.append(f' <path d="{d}" transform="{transform}"/>')
|
||||
|
||||
if not paths:
|
||||
raise ValueError("未生成任何路径。")
|
||||
|
||||
view_box = f"{_format_number(min_x)} 0 {_format_number(width)} {_format_number(height)}"
|
||||
group_transform = f"translate(0 {_format_number(max_y)}) scale(1 -1)"
|
||||
|
||||
svg_content = (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n'
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" '
|
||||
f'width="{_format_number(width_override if width_override is not None else width)}" '
|
||||
f'height="{_format_number(height_override if height_override is not None else height)}" '
|
||||
f'viewBox="{view_box}">\n'
|
||||
f' <g transform="{group_transform}" fill="{_normalize_hex_color(fill_color)}" stroke="none">\n'
|
||||
f"{chr(10).join(paths)}\n"
|
||||
" </g>\n"
|
||||
"</svg>\n"
|
||||
)
|
||||
|
||||
return svg_content, width, height
|
||||
|
||||
|
||||
def render_svg_from_font_file(
|
||||
font_path,
|
||||
text,
|
||||
*,
|
||||
font_size=120,
|
||||
fill_color="#000000",
|
||||
letter_spacing=0.0,
|
||||
max_chars_per_line=MAX_CHARS_PER_LINE,
|
||||
):
|
||||
if not os.path.isfile(font_path):
|
||||
raise FileNotFoundError(f"字体文件不存在: {font_path}")
|
||||
|
||||
raw_text = str(text or "")
|
||||
if not raw_text.strip():
|
||||
raise ValueError("文本内容不能为空")
|
||||
|
||||
_ensure_deps()
|
||||
|
||||
ttfont = TTFont(font_path)
|
||||
glyph_set = ttfont.getGlyphSet()
|
||||
upem = ttfont["head"].unitsPerEm
|
||||
|
||||
hb_blob = HB.Blob.from_file_path(font_path)
|
||||
hb_face = HB.Face(hb_blob, 0)
|
||||
hb_font = HB.Font(hb_face)
|
||||
hb_font.scale = (upem, upem)
|
||||
HB.ot_font_set_funcs(hb_font)
|
||||
|
||||
normalized_text = wrap_text_by_chars(raw_text, int(max_chars_per_line or 0))
|
||||
lines = normalized_text.split("\n")
|
||||
|
||||
ascender = upem * 0.8
|
||||
descender = -upem * 0.2
|
||||
if "hhea" in ttfont:
|
||||
hhea = ttfont["hhea"]
|
||||
if hasattr(hhea, "ascent"):
|
||||
ascender = float(hhea.ascent)
|
||||
if hasattr(hhea, "descent"):
|
||||
descender = float(hhea.descent)
|
||||
line_advance = max(upem * 1.2, ascender - descender)
|
||||
|
||||
letter_spacing_raw = float(letter_spacing or 0.0) * upem
|
||||
runs = []
|
||||
|
||||
for line_index, line in enumerate(lines):
|
||||
if line == "":
|
||||
continue
|
||||
|
||||
infos, positions = _shape_line(hb_font, line)
|
||||
scale = _positions_scale(positions, upem)
|
||||
spacing_raw = letter_spacing_raw / scale
|
||||
x = 0.0
|
||||
y = 0.0
|
||||
y_base = -line_index * line_advance
|
||||
|
||||
for info, pos in zip(infos, positions):
|
||||
glyph_name = ttfont.getGlyphName(info.codepoint)
|
||||
x_pos = (x + pos.x_offset) * scale
|
||||
y_pos = (y + pos.y_offset) * scale
|
||||
runs.append((glyph_name, x_pos, y_pos + y_base))
|
||||
x += float(pos.x_advance) + spacing_raw
|
||||
y += float(pos.y_advance)
|
||||
|
||||
bounds = _compute_bounds(glyph_set, runs)
|
||||
min_x, min_y, max_x, max_y = bounds
|
||||
if min_x is None or min_y is None or max_x is None or max_y is None:
|
||||
raise ValueError("未生成有效字形轮廓。")
|
||||
|
||||
scale = float(font_size) / float(upem)
|
||||
width = (max_x - min_x) * scale
|
||||
height = (max_y - min_y) * scale
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("计算得到的SVG尺寸无效。")
|
||||
|
||||
svg_content, _, _ = _compose_svg(
|
||||
glyph_set,
|
||||
runs,
|
||||
bounds,
|
||||
fill_color=fill_color,
|
||||
width_override=width,
|
||||
height_override=height,
|
||||
)
|
||||
|
||||
return {
|
||||
"svg": svg_content,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"fontName": _font_name(ttfont),
|
||||
}
|
||||
332
apiserver/server.py
Normal file
332
apiserver/server.py
Normal file
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Font2SVG API 服务。"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse
|
||||
|
||||
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
|
||||
|
||||
LOGGER = logging.getLogger("font2svg.api")
|
||||
|
||||
|
||||
def _normalize_hex_color(color, fallback="#000000"):
|
||||
value = str(color or "").strip()
|
||||
if len(value) == 7 and value.startswith("#"):
|
||||
hex_part = value[1:]
|
||||
if all(ch in "0123456789abcdefABCDEF" for ch in hex_part):
|
||||
return value
|
||||
return fallback
|
||||
|
||||
|
||||
def _safe_number(value, fallback, min_value=None, max_value=None, cast=float):
|
||||
try:
|
||||
parsed = cast(value)
|
||||
except (TypeError, ValueError):
|
||||
parsed = fallback
|
||||
if min_value is not None:
|
||||
parsed = max(min_value, parsed)
|
||||
if max_value is not None:
|
||||
parsed = min(max_value, parsed)
|
||||
return parsed
|
||||
|
||||
|
||||
def _is_within_root(path, root):
|
||||
try:
|
||||
root_abs = os.path.abspath(root)
|
||||
path_abs = os.path.abspath(path)
|
||||
return os.path.commonpath([root_abs, path_abs]) == root_abs
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_manifest_path(path):
|
||||
value = str(path or "").strip()
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
if value.startswith("//"):
|
||||
parsed = urlparse(f"https:{value}")
|
||||
return parsed.path
|
||||
|
||||
if value.startswith("http://") or value.startswith("https://"):
|
||||
parsed = urlparse(value)
|
||||
return parsed.path
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _resolve_font_path(item, static_root):
|
||||
path = _normalize_manifest_path(item.get("path"))
|
||||
filename = str(item.get("filename") or "").strip()
|
||||
category = str(item.get("category") or "").strip()
|
||||
candidates = []
|
||||
|
||||
if path:
|
||||
cleaned = path.split("?", 1)[0].strip()
|
||||
candidates.append(os.path.join(static_root, cleaned.lstrip("/")))
|
||||
|
||||
if category and filename:
|
||||
candidates.append(os.path.join(static_root, "fonts", category, filename))
|
||||
|
||||
if filename:
|
||||
candidates.append(os.path.join(static_root, "fonts", filename))
|
||||
|
||||
for candidate in candidates:
|
||||
absolute = os.path.abspath(candidate)
|
||||
if _is_within_root(absolute, static_root) and os.path.isfile(absolute):
|
||||
return absolute
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class FontCatalog:
|
||||
def __init__(self, static_root, manifest_path):
|
||||
self.static_root = os.path.abspath(static_root)
|
||||
self.manifest_path = os.path.abspath(manifest_path)
|
||||
self._manifest_mtime = None
|
||||
self._font_map = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _reload(self):
|
||||
if not os.path.isfile(self.manifest_path):
|
||||
raise FileNotFoundError(f"未找到字体清单: {self.manifest_path}")
|
||||
|
||||
mtime = os.path.getmtime(self.manifest_path)
|
||||
if self._manifest_mtime == mtime and self._font_map:
|
||||
return
|
||||
|
||||
with open(self.manifest_path, "r", encoding="utf-8") as file:
|
||||
payload = json.load(file)
|
||||
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("fonts.json 格式无效,必须是数组")
|
||||
|
||||
font_map = {}
|
||||
for item in payload:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
font_id = str(item.get("id") or "").strip()
|
||||
if not font_id:
|
||||
continue
|
||||
|
||||
font_path = _resolve_font_path(item, self.static_root)
|
||||
if not font_path:
|
||||
continue
|
||||
|
||||
font_map[font_id] = {
|
||||
"path": font_path,
|
||||
"name": str(item.get("name") or "").strip(),
|
||||
"category": str(item.get("category") or "").strip(),
|
||||
}
|
||||
|
||||
if not font_map:
|
||||
raise ValueError("未解析到可用字体路径,请检查 fonts.json 与 fonts/ 目录")
|
||||
|
||||
self._font_map = font_map
|
||||
self._manifest_mtime = mtime
|
||||
LOGGER.info("字体清单已加载: %s 个字体", len(font_map))
|
||||
|
||||
def get(self, font_id):
|
||||
with self._lock:
|
||||
self._reload()
|
||||
item = self._font_map.get(font_id)
|
||||
|
||||
if not item:
|
||||
raise KeyError(f"字体不存在: {font_id}")
|
||||
if not os.path.isfile(item["path"]):
|
||||
raise FileNotFoundError(f"字体文件不存在: {item['path']}")
|
||||
|
||||
return item
|
||||
|
||||
def health(self):
|
||||
with self._lock:
|
||||
self._reload()
|
||||
return {
|
||||
"manifestPath": self.manifest_path,
|
||||
"fontCount": len(self._font_map),
|
||||
}
|
||||
|
||||
|
||||
class RenderHandler(BaseHTTPRequestHandler):
|
||||
catalog = None
|
||||
|
||||
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 _send_json(self, status_code, payload):
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status_code)
|
||||
self._set_cors_headers()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_OPTIONS(self): # noqa: N802
|
||||
self.send_response(204)
|
||||
self._set_cors_headers()
|
||||
self.end_headers()
|
||||
|
||||
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
|
||||
|
||||
self._send_json(404, {"ok": False, "error": "Not Found"})
|
||||
|
||||
def do_POST(self): # noqa: N802
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path != "/api/render-svg":
|
||||
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,
|
||||
)
|
||||
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
|
||||
|
||||
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,
|
||||
}
|
||||
self._send_json(200, {"ok": True, "data": response_data})
|
||||
|
||||
def log_message(self, format_str, *args):
|
||||
LOGGER.info("%s - %s", self.address_string(), format_str % args)
|
||||
|
||||
|
||||
def build_server(host, port, static_root, manifest_path):
|
||||
catalog = FontCatalog(static_root=static_root, manifest_path=manifest_path)
|
||||
RenderHandler.catalog = catalog
|
||||
return ThreadingHTTPServer((host, port), RenderHandler)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Font2SVG 渲染 API 服务")
|
||||
parser.add_argument("--host", default=os.getenv("FONT2SVG_API_HOST", "0.0.0.0"))
|
||||
parser.add_argument("--port", type=int, default=int(os.getenv("FONT2SVG_API_PORT", "9300")))
|
||||
parser.add_argument(
|
||||
"--static-root",
|
||||
default=os.getenv("FONT2SVG_STATIC_ROOT", os.getcwd()),
|
||||
help="静态资源根目录(包含 fonts/ 与 fonts.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--manifest",
|
||||
default=os.getenv("FONT2SVG_MANIFEST_PATH", ""),
|
||||
help="字体清单路径,默认使用 <static-root>/fonts.json",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||
)
|
||||
|
||||
static_root = os.path.abspath(args.static_root)
|
||||
manifest_path = args.manifest.strip() or os.path.join(static_root, "fonts.json")
|
||||
|
||||
server = build_server(
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
static_root=static_root,
|
||||
manifest_path=manifest_path,
|
||||
)
|
||||
|
||||
LOGGER.info("Font2SVG API 服务启动: http://%s:%s", args.host, args.port)
|
||||
LOGGER.info("静态目录: %s", static_root)
|
||||
LOGGER.info("字体清单: %s", manifest_path)
|
||||
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
LOGGER.info("收到退出信号,正在停止服务")
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user