#!/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"
)
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())