update at 2026-02-08 18:28:39

This commit is contained in:
douboer
2026-02-08 18:28:39 +08:00
parent e2a46e413a
commit 0f5a7f0d85
97 changed files with 22029 additions and 59 deletions

89
apiserver/README.md Normal file
View 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
View File

@@ -0,0 +1 @@
"""Font2SVG API server package."""

Binary file not shown.

275
apiserver/renderer.py Normal file
View 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
View 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())