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

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),
}