Files
font2pic/font2svg.py
2026-02-06 11:56:47 +08:00

283 lines
8.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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' <path d="{d}" transform="{transform}"/>')
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 = (
'<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n'
'<svg xmlns="http://www.w3.org/2000/svg" '
f'width="{_format_number(width)}" '
f'height="{_format_number(height)}" '
f'viewBox="{view_box}">\n'
f' <g transform="{group_transform}" fill="#000000" stroke="none">\n'
f"{chr(10).join(paths)}\n"
" </g>\n"
"</svg>\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())