283 lines
8.6 KiB
Python
283 lines
8.6 KiB
Python
#!/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())
|