first commit
This commit is contained in:
282
font2svg.py
Normal file
282
font2svg.py
Normal file
@@ -0,0 +1,282 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user