#!/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' ') 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 = ( '\n' '\n' f' \n' f"{chr(10).join(paths)}\n" " \n" "\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), }