#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 字体转SVG工具 - 字形轮廓矢量化 目标:从字体文件中提取字形轮廓并输出SVG """ import argparse import os import re HB = None BoundsPen = None SVGPathPen = None TTFont = None 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 e: raise RuntimeError( "缺少依赖,请先执行: pip install fonttools uharfbuzz" ) from e HB = hb BoundsPen = BP SVGPathPen = SP TTFont = FT def _format_number(value): text = f"{value:.2f}".rstrip("0").rstrip(".") return text if text else "0" def _sanitize_name(text, fallback): safe = re.sub(r"[\\/:*?\"<>|]", "", text.strip()) safe = re.sub(r"\s+", "_", safe) safe = safe[:40] or fallback return safe def _default_output_name(text, font_path=None): text_part = _sanitize_name(text, "text") if font_path: font_base = os.path.splitext(os.path.basename(font_path))[0] font_part = _sanitize_name(font_base, "font") return f"{font_part}_{text_part}.svg" return f"{text_part}.svg" def _shape_text(font_path, text): _ensure_deps() ttfont = TTFont(font_path) upem = ttfont["head"].unitsPerEm blob = HB.Blob.from_file_path(font_path) face = HB.Face(blob, 0) font = HB.Font(face) font.scale = (upem, upem) HB.ot_font_set_funcs(font) buf = HB.Buffer() buf.add_str(text) buf.guess_segment_properties() HB.shape(font, buf) return ttfont, buf.glyph_infos, buf.glyph_positions, upem 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 _glyph_runs(ttfont, infos, positions, letter_spacing): glyph_set = ttfont.getGlyphSet() upem = ttfont["head"].unitsPerEm scale = _positions_scale(positions, upem) spacing_raw = letter_spacing / scale x = 0.0 y = 0.0 runs = [] 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)) x += float(pos.x_advance) + spacing_raw y += float(pos.y_advance) return glyph_set, runs def _compute_bounds(glyph_set, runs): _ensure_deps() 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 _build_svg(glyph_set, runs, bounds, output_path): _ensure_deps() 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 " f"{_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" ) with open(output_path, "w", encoding="utf-8") as f: f.write(svg_content) def main(): parser = argparse.ArgumentParser( description="将字体字形轮廓转换为SVG", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" 示例: python font2svg.py --font font.ttf --text "Hello" python font2svg.py --font font.ttf --text "你好" --outdir output python font2svg.py --font font.ttf --text "Hello" --letter-spacing 20 python font2svg.py --fontdir font --text "星程紫微" --outdir svg """, ) input_group = parser.add_mutually_exclusive_group(required=True) input_group.add_argument("--font", help="字体文件路径(ttf/otf)") input_group.add_argument("--fontdir", help="字体目录(遍历ttf/otf)") parser.add_argument("--text", required=True, help="要渲染的文字内容") parser.add_argument( "--outdir", help="输出目录(可选,自动创建,文件名自动生成)", ) parser.add_argument( "--letter-spacing", type=float, default=0.0, help="字距(字体单位),默认 0", ) args = parser.parse_args() if args.text.strip() == "": parser.error("文字内容不能为空") if args.font and not os.path.isfile(args.font): parser.error(f"字体文件不存在: {args.font}") if args.fontdir and not os.path.isdir(args.fontdir): parser.error(f"字体目录不存在: {args.fontdir}") try: _ensure_deps() if args.outdir: os.makedirs(args.outdir, exist_ok=True) if args.fontdir: font_files = [ os.path.join(args.fontdir, name) for name in sorted(os.listdir(args.fontdir)) if os.path.splitext(name)[1].lower() in {".ttf", ".otf"} ] if not font_files: print("字体目录中未找到可处理的字体文件。") return 1 success = 0 failed = 0 for font_path in font_files: output_name = _default_output_name(args.text, font_path=font_path) output_path = ( os.path.join(args.outdir, output_name) if args.outdir else output_name ) try: ttfont, infos, positions, _ = _shape_text(font_path, args.text) glyph_set, runs = _glyph_runs( ttfont, infos, positions, args.letter_spacing ) bounds = _compute_bounds(glyph_set, runs) _build_svg(glyph_set, runs, bounds, output_path) print(f"SVG文件已保存: {output_path}") success += 1 except Exception as e: print(f"转换失败: {font_path} -> {e}") failed += 1 print(f"批量转换完成: 成功 {success} 个,失败 {failed} 个") return 0 if success > 0 and failed == 0 else 1 output_name = _default_output_name(args.text) output_path = ( os.path.join(args.outdir, output_name) if args.outdir else output_name ) ttfont, infos, positions, _ = _shape_text(args.font, args.text) glyph_set, runs = _glyph_runs(ttfont, infos, positions, args.letter_spacing) bounds = _compute_bounds(glyph_set, runs) _build_svg(glyph_set, runs, bounds, output_path) except Exception as e: print(f"转换失败: {e}") return 1 print(f"SVG文件已保存: {output_path}") return 0 if __name__ == "__main__": raise SystemExit(main())