update at 2026-02-08 18:28:39
This commit is contained in:
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),
|
||||
}
|
||||
Reference in New Issue
Block a user