1. 폰트 다운로드
https://learn.microsoft.com/ko-kr/windows/apps/design/downloads/#fonts
2. Python 패키지 설치
PYTHON -m pip install --upgrade pip
PYTHON -m pip install fonttools cairosvg pillow
3. MSYS2 Cairo 설치
CD /D c:\PortableApps\cmd_msys64\usr\bin
bash
-----
pacman -Syu --noconfirm
pacman -S --noconfirm --needed mingw-w64-x86_64-cairo
-----
DIR c:\PortableApps\cmd_msys64\mingw64\bin\libcairo-2.dll
4. 아래 스크립트로 실행
PYTHON export_glyph_png.py ^
--font "C:\Windows\Fonts\Segoe Fluent Icons.ttf" ^
--out "d:\downloads\segoe_fluent_png" ^
--size 256 ^
--padding 16 ^
--oversample 4 ^
--save-svg ^
--cairo-dll-dir "c:\PortableApps\cmd_msys64\mingw64\bin"
from __future__ import annotations
import argparse
import csv
import importlib
import io
import os
import re
import sys
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from xml.sax.saxutils import escape
from fontTools.ttLib import TTFont
from fontTools.pens.svgPathPen import SVGPathPen
from fontTools.pens.boundsPen import BoundsPen
from PIL import Image
Bounds = Tuple[float, float, float, float]
# os.add_dll_directory()가 반환하는 핸들을 유지해야 DLL 검색 경로가 계속 유효함
_DLL_DIRECTORY_HANDLES = []
try:
RESAMPLE_LANCZOS = Image.Resampling.LANCZOS
except AttributeError:
RESAMPLE_LANCZOS = Image.LANCZOS
def sanitize_filename(value: str, max_len: int = 120) -> str:
"""
Windows 파일명으로 사용할 수 없는 문자를 제거한다.
"""
value = re.sub(r'[<>:"/\\|?*\x00-\x1F]', "_", value)
value = value.strip().strip(".")
if not value:
value = "glyph"
return value[:max_len]
def get_windows_font_dirs() -> List[Path]:
"""
Windows 시스템 폰트 디렉터리와 사용자 폰트 디렉터리를 반환한다.
"""
font_dirs = [
Path(r"C:\Windows\Fonts"),
]
local_app_data = os.environ.get("LOCALAPPDATA")
if local_app_data:
font_dirs.append(Path(local_app_data) / "Microsoft" / "Windows" / "Fonts")
return font_dirs
def resolve_font_path(font_arg: str) -> Path:
"""
사용자가 지정한 font 경로가 실제 파일이면 그대로 사용한다.
Windows의 Fonts 폴더는 Explorer에서 보이는 폰트 표시명과 실제 파일명이 다를 수 있다.
예:
표시명: Segoe Fluent Icons
실제 파일명: SegoeIcons.ttf
따라서 경로가 존재하지 않으면 일반적인 후보를 자동 검색한다.
"""
input_path = Path(font_arg)
if input_path.exists():
return input_path.resolve()
font_dirs = get_windows_font_dirs()
# Windows 11 Segoe Fluent Icons의 일반적인 실제 파일명 후보
known_candidates = [
"SegoeIcons.ttf",
"segmdl2.ttf",
]
requested_name = input_path.name.lower()
requested_stem = input_path.stem.lower()
# 1차: 알려진 후보 파일명 직접 확인
for font_dir in font_dirs:
if not font_dir.exists():
continue
for candidate_name in known_candidates:
candidate = font_dir / candidate_name
if candidate.exists():
if (
"segoe fluent icons" in requested_stem
or "fluent" in requested_stem
or "icon" in requested_stem
):
print(f"[INFO] Requested font path not found: {font_arg}")
print(f"[INFO] Auto-selected candidate font: {candidate}")
return candidate.resolve()
# 2차: 파일명 기준 후보 검색
candidate_files: List[Path] = []
search_words = []
for token in re.split(r"[\s._-]+", requested_stem):
token = token.strip().lower()
if len(token) >= 3:
search_words.append(token)
# Segoe Fluent Icons 요청인 경우 폭넓게 검색
if "segoe" in requested_stem or "fluent" in requested_stem or "icon" in requested_stem:
search_words.extend(["segoe", "icon", "icons", "fluent"])
search_words = sorted(set(search_words))
for font_dir in font_dirs:
if not font_dir.exists():
continue
for file in font_dir.glob("*"):
if file.suffix.lower() not in [".ttf", ".otf", ".ttc"]:
continue
file_name_lower = file.name.lower()
if any(word in file_name_lower for word in search_words):
candidate_files.append(file)
candidate_files = sorted(set(candidate_files), key=lambda p: p.name.lower())
if candidate_files:
print("[ERROR] Font file was not found exactly.")
print(f"[ERROR] Requested: {font_arg}")
print()
print("[INFO] Candidate font files:")
for idx, candidate in enumerate(candidate_files[:30], start=1):
print(f" {idx:02d}. {candidate}")
print()
print("[ACTION] --font 값에 위 후보 중 실제 사용할 파일의 전체 경로를 넣으세요.")
print('[HINT] Windows 11 Segoe Fluent Icons는 보통 "C:\\Windows\\Fonts\\SegoeIcons.ttf" 입니다.')
raise FileNotFoundError(
f"Font file not found: {font_arg}"
)
def load_cairosvg(cairo_dll_dir: Optional[str]):
"""
Windows에서 Cairo DLL 경로가 필요한 경우 먼저 등록한 뒤 CairoSVG를 import한다.
"""
if cairo_dll_dir:
cairo_path = Path(cairo_dll_dir).resolve()
if not cairo_path.exists():
raise FileNotFoundError(
f"Cairo DLL directory does not exist: {cairo_path}"
)
expected_dll = cairo_path / "libcairo-2.dll"
if not expected_dll.exists():
print(f"[WARN] libcairo-2.dll not found in: {cairo_path}")
print("[WARN] CairoSVG import may fail.")
os.environ["CAIROCFFI_DLL_DIRECTORIES"] = str(cairo_path)
os.environ["PATH"] = str(cairo_path) + os.pathsep + os.environ.get("PATH", "")
add_dll_directory = getattr(os, "add_dll_directory", None)
if callable(add_dll_directory):
handle = add_dll_directory(str(cairo_path))
_DLL_DIRECTORY_HANDLES.append(handle)
try:
return importlib.import_module("cairosvg")
except OSError as exc:
raise RuntimeError(
"CairoSVG import failed because native Cairo DLL was not found.\n\n"
"Windows 해결 방법:\n"
"1) Cairo DLL이 있는 디렉터리를 확인합니다.\n"
"2) 보통 MSYS2 사용 시 경로는 다음과 같습니다.\n"
" C:\\msys64\\mingw64\\bin\n"
"3) 현재 환경에서는 예를 들어 다음처럼 실행합니다.\n"
" --cairo-dll-dir \"c:\\PortableApps\\cmd_msys64\\mingw64\\bin\"\n\n"
f"Original error:\n{exc}"
) from exc
def build_unicode_map(font: TTFont) -> Dict[str, List[int]]:
"""
glyph_name -> [unicode codepoint...] 형태의 매핑을 만든다.
getBestCmap() 하나만 쓰지 않고 Unicode cmap subtable 전체를 확인한다.
아이콘 폰트는 Private Use Area, Symbol cmap 등을 사용할 수 있으므로
가능한 Unicode 매핑을 모두 수집한다.
"""
result: Dict[str, set[int]] = {}
if "cmap" not in font:
return {}
for table in font["cmap"].tables:
if not table.isUnicode():
continue
for codepoint, glyph_name in table.cmap.items():
result.setdefault(glyph_name, set()).add(int(codepoint))
return {
glyph_name: sorted(codepoints)
for glyph_name, codepoints in result.items()
}
def get_glyph_path_and_bounds(
font: TTFont,
glyph_name: str,
) -> Tuple[str, Optional[Bounds]]:
"""
실제 glyph outline을 SVG path command와 bbox로 추출한다.
"""
glyph_set = font.getGlyphSet()
if glyph_name not in glyph_set:
return "", None
# SVG path 추출
path_pen = SVGPathPen(glyph_set)
glyph_set[glyph_name].draw(path_pen)
path_data = path_pen.getCommands()
# 실제 outline bbox 추출
bounds_pen = BoundsPen(glyph_set)
glyph_set[glyph_name].draw(bounds_pen)
bounds = bounds_pen.bounds
return path_data, bounds
def get_advance_width(font: TTFont, glyph_name: str) -> Optional[int]:
"""
hmtx 테이블에서 glyph advance width를 가져온다.
"""
if "hmtx" not in font:
return None
metrics = font["hmtx"].metrics
if glyph_name not in metrics:
return None
advance_width, _left_side_bearing = metrics[glyph_name]
return int(advance_width)
def get_vertical_metrics(font: TTFont) -> Tuple[int, int]:
"""
metrics layout용 vertical box를 구한다.
우선순위:
1. OS/2 sTypoAscender / sTypoDescender
2. hhea ascent / descent
3. unitsPerEm fallback
"""
if "OS/2" in font:
os2 = font["OS/2"]
ascender = int(getattr(os2, "sTypoAscender", 0))
descender = int(getattr(os2, "sTypoDescender", 0))
if ascender != 0 or descender != 0:
return ascender, descender
if "hhea" in font:
hhea = font["hhea"]
return int(hhea.ascent), int(hhea.descent)
units_per_em = int(font["head"].unitsPerEm)
return units_per_em, 0
def make_transform_fit(
bounds: Bounds,
size: int,
padding: int,
) -> Tuple[str, float]:
"""
glyph outline bbox 기준으로 아이콘을 정확히 중앙 배치하고 최대 크기로 맞춘다.
특징:
- 실제 outline의 bbox를 기준으로 함
- 아이콘별로 256x256 안에서 최대한 크게 배치
- PNG 아이콘 추출용 기본 추천
"""
x_min, y_min, x_max, y_max = bounds
glyph_w = x_max - x_min
glyph_h = y_max - y_min
if glyph_w <= 0 or glyph_h <= 0:
raise ValueError(f"Invalid glyph bounds: {bounds}")
usable = max(1, size - padding * 2)
scale = usable / max(glyph_w, glyph_h)
target_w = glyph_w * scale
target_h = glyph_h * scale
# Font 좌표계: Y 위쪽 증가
# SVG 좌표계: Y 아래쪽 증가
# matrix(a b c d e f):
# X = a*x + c*y + e
# Y = b*x + d*y + f
tx = (size - target_w) / 2.0 - x_min * scale
ty = (size - target_h) / 2.0 + y_max * scale
transform = f"matrix({scale:.10f} 0 0 {-scale:.10f} {tx:.10f} {ty:.10f})"
return transform, scale
def make_transform_metrics(
font: TTFont,
glyph_name: str,
size: int,
padding: int,
) -> Tuple[str, float]:
"""
font metrics 기준으로 원래 font cell 내 위치와 크기를 보존한다.
특징:
- glyph별 bbox로 꽉 채우지 않음
- advance width, ascender, descender 기준
- 폰트 내부 레이아웃 비교용
"""
units_per_em = int(font["head"].unitsPerEm)
advance_width = get_advance_width(font, glyph_name)
if not advance_width:
advance_width = units_per_em
ascender, descender = get_vertical_metrics(font)
cell_w = max(1, int(advance_width))
cell_h = max(1, int(ascender - descender))
usable = max(1, size - padding * 2)
scale = min(usable / cell_w, usable / cell_h)
target_w = cell_w * scale
target_h = cell_h * scale
tx = (size - target_w) / 2.0
ty = (size - target_h) / 2.0 + ascender * scale
transform = f"matrix({scale:.10f} 0 0 {-scale:.10f} {tx:.10f} {ty:.10f})"
return transform, scale
def build_svg(
path_data: str,
transform: str,
size: int,
fill: str,
fill_rule: str,
) -> str:
"""
투명 배경 SVG를 생성한다.
배경 rect를 넣지 않으므로 PNG 변환 후에도 alpha가 유지된다.
"""
return f"""<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg"
width="{size}"
height="{size}"
viewBox="0 0 {size} {size}">
<g transform="{escape(transform)}">
<path d="{escape(path_data)}"
fill="{escape(fill)}"
fill-rule="{escape(fill_rule)}"/>
</g>
</svg>
"""
def render_svg_to_png(
cairosvg_module,
svg_text: str,
output_png: Path,
size: int,
oversample: int,
) -> None:
"""
SVG를 고해상도로 먼저 렌더링한 뒤 LANCZOS로 256x256에 다운샘플링한다.
oversample=4, size=256이면:
1. 1024x1024 PNG로 1차 렌더링
2. 256x256으로 고품질 축소
"""
render_size = size * oversample
png_bytes = cairosvg_module.svg2png(
bytestring=svg_text.encode("utf-8"),
output_width=render_size,
output_height=render_size,
)
image = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
if oversample > 1:
image = image.resize((size, size), RESAMPLE_LANCZOS)
image.save(output_png)
def make_output_filename(
glyph_index: int,
glyph_name: str,
codepoints: List[int],
) -> str:
"""
출력 PNG 파일명을 만든다.
"""
if codepoints:
unicode_part = "_".join(f"U+{cp:04X}" for cp in codepoints[:6])
else:
unicode_part = "UNENCODED"
safe_glyph_name = sanitize_filename(glyph_name)
return f"{glyph_index:05d}_{unicode_part}_{safe_glyph_name}.png"
def write_empty_png(path: Path, size: int) -> None:
"""
outline이 없는 glyph용 투명 PNG 생성.
"""
image = Image.new("RGBA", (size, size), (0, 0, 0, 0))
image.save(path)
def export_glyphs(args: argparse.Namespace) -> None:
"""
전체 glyph를 PNG로 export한다.
"""
font_path = resolve_font_path(args.font)
output_dir = Path(args.out).resolve()
svg_dir = output_dir / "_svg"
output_dir.mkdir(parents=True, exist_ok=True)
if args.save_svg:
svg_dir.mkdir(parents=True, exist_ok=True)
print(f"[INFO] Font : {font_path}")
print(f"[INFO] Output : {output_dir}")
font = TTFont(str(font_path), lazy=False, fontNumber=args.font_number)
try:
cairosvg_module = load_cairosvg(args.cairo_dll_dir)
unicode_by_glyph = build_unicode_map(font)
glyph_order = list(font.getGlyphOrder())
if args.encoded_only:
glyph_names = [
glyph_name
for glyph_name in glyph_order
if glyph_name in unicode_by_glyph
]
else:
glyph_names = glyph_order
rows = []
exported_count = 0
skipped_count = 0
error_count = 0
for glyph_index, glyph_name in enumerate(glyph_names):
if glyph_name == ".notdef" and not args.include_notdef:
skipped_count += 1
continue
codepoints = unicode_by_glyph.get(glyph_name, [])
try:
path_data, bounds = get_glyph_path_and_bounds(font, glyph_name)
if not path_data or bounds is None:
if args.keep_empty:
file_name = make_output_filename(
glyph_index,
glyph_name,
codepoints,
)
output_png = output_dir / file_name
write_empty_png(output_png, args.size)
rows.append({
"glyph_index": glyph_index,
"glyph_name": glyph_name,
"unicode": " ".join(f"U+{cp:04X}" for cp in codepoints),
"output_file": file_name,
"bbox_x_min": "",
"bbox_y_min": "",
"bbox_x_max": "",
"bbox_y_max": "",
"advance_width": get_advance_width(font, glyph_name) or "",
"layout": args.layout,
"scale": "",
"status": "empty",
"error": "",
})
exported_count += 1
else:
skipped_count += 1
continue
if args.layout == "fit":
transform, scale = make_transform_fit(
bounds=bounds,
size=args.size,
padding=args.padding,
)
elif args.layout == "metrics":
transform, scale = make_transform_metrics(
font=font,
glyph_name=glyph_name,
size=args.size,
padding=args.padding,
)
else:
raise ValueError(f"Unknown layout: {args.layout}")
svg_text = build_svg(
path_data=path_data,
transform=transform,
size=args.size,
fill=args.fill,
fill_rule=args.fill_rule,
)
file_name = make_output_filename(
glyph_index,
glyph_name,
codepoints,
)
output_png = output_dir / file_name
render_svg_to_png(
cairosvg_module=cairosvg_module,
svg_text=svg_text,
output_png=output_png,
size=args.size,
oversample=args.oversample,
)
if args.save_svg:
svg_file = svg_dir / file_name.replace(".png", ".svg")
svg_file.write_text(svg_text, encoding="utf-8")
x_min, y_min, x_max, y_max = bounds
rows.append({
"glyph_index": glyph_index,
"glyph_name": glyph_name,
"unicode": " ".join(f"U+{cp:04X}" for cp in codepoints),
"output_file": file_name,
"bbox_x_min": x_min,
"bbox_y_min": y_min,
"bbox_x_max": x_max,
"bbox_y_max": y_max,
"advance_width": get_advance_width(font, glyph_name) or "",
"layout": args.layout,
"scale": f"{scale:.10f}",
"status": "exported",
"error": "",
})
exported_count += 1
if exported_count % args.progress_interval == 0:
print(
f"[INFO] exported={exported_count}, "
f"skipped={skipped_count}, "
f"errors={error_count}"
)
except Exception as exc:
error_count += 1
rows.append({
"glyph_index": glyph_index,
"glyph_name": glyph_name,
"unicode": " ".join(f"U+{cp:04X}" for cp in codepoints),
"output_file": "",
"bbox_x_min": "",
"bbox_y_min": "",
"bbox_x_max": "",
"bbox_y_max": "",
"advance_width": get_advance_width(font, glyph_name) or "",
"layout": args.layout,
"scale": "",
"status": "error",
"error": str(exc),
})
print(f"[WARN] Failed glyph={glyph_name}, index={glyph_index}: {exc}")
if not args.continue_on_error:
raise
index_csv = output_dir / "glyph_index.csv"
with index_csv.open("w", newline="", encoding="utf-8-sig") as fp:
writer = csv.DictWriter(
fp,
fieldnames=[
"glyph_index",
"glyph_name",
"unicode",
"output_file",
"bbox_x_min",
"bbox_y_min",
"bbox_x_max",
"bbox_y_max",
"advance_width",
"layout",
"scale",
"status",
"error",
],
)
writer.writeheader()
writer.writerows(rows)
print()
print("[DONE]")
print(f"Font : {font_path}")
print(f"Output : {output_dir}")
print(f"Index CSV : {index_csv}")
print(f"Exported : {exported_count}")
print(f"Skipped : {skipped_count}")
print(f"Errors : {error_count}")
print(f"Image size : {args.size}x{args.size}")
print(f"Layout : {args.layout}")
print(f"Oversample : {args.oversample}")
finally:
font.close()
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Export TTF/OTF glyph outlines to transparent PNG files. "
"The glyph outline is converted to SVG path and then rendered to PNG."
)
)
parser.add_argument(
"--font",
required=True,
help=(
"Font file path. "
'Example: "C:\\Windows\\Fonts\\SegoeIcons.ttf"'
),
)
parser.add_argument(
"--font-number",
type=int,
default=0,
help=(
"Font index for TTC/collection fonts. "
"For normal TTF files, use 0. Default: 0"
),
)
parser.add_argument(
"--out",
default="glyph_png",
help="Output directory. Default: glyph_png",
)
parser.add_argument(
"--size",
type=int,
default=256,
help="PNG canvas size. Default: 256",
)
parser.add_argument(
"--padding",
type=int,
default=16,
help="Canvas padding in pixels. Default: 16",
)
parser.add_argument(
"--oversample",
type=int,
default=4,
help=(
"Render at size*N and downsample for smoother antialiasing. "
"Default: 4"
),
)
parser.add_argument(
"--fill",
default="#000000",
help="Icon fill color. Default: #000000",
)
parser.add_argument(
"--fill-rule",
choices=["nonzero", "evenodd"],
default="nonzero",
help="SVG fill-rule. Default: nonzero",
)
parser.add_argument(
"--layout",
choices=["fit", "metrics"],
default="fit",
help=(
"fit: actual outline bbox 기준으로 중앙 배치/최대화. "
"metrics: font metrics 기준으로 원래 font cell 위치/크기 보존. "
"Default: fit"
),
)
parser.add_argument(
"--encoded-only",
action="store_true",
help="Unicode cmap에 매핑된 glyph만 export한다. 기본값은 전체 glyph order export.",
)
parser.add_argument(
"--include-notdef",
action="store_true",
help=".notdef glyph도 export한다.",
)
parser.add_argument(
"--keep-empty",
action="store_true",
help="outline이 없는 glyph도 투명 PNG로 생성한다.",
)
parser.add_argument(
"--save-svg",
action="store_true",
help="중간 SVG 파일도 _svg 디렉터리에 저장한다.",
)
parser.add_argument(
"--cairo-dll-dir",
default=None,
help=(
"Windows에서 Cairo DLL을 찾지 못할 때 GTK/Cairo/MSYS2 bin 디렉터리 지정. "
'Example: "C:\\msys64\\mingw64\\bin"'
),
)
parser.add_argument(
"--continue-on-error",
action="store_true",
default=True,
help="개별 glyph 변환 실패 시 계속 진행한다. 기본값: enabled",
)
parser.add_argument(
"--stop-on-error",
action="store_false",
dest="continue_on_error",
help="개별 glyph 변환 실패 시 즉시 중단한다.",
)
parser.add_argument(
"--progress-interval",
type=int,
default=100,
help="몇 개 glyph마다 진행률을 출력할지 지정. Default: 100",
)
args = parser.parse_args()
if args.size <= 0:
raise ValueError("--size must be positive")
if args.padding < 0:
raise ValueError("--padding must be zero or positive")
if args.padding * 2 >= args.size:
raise ValueError("--padding is too large for --size")
if args.oversample <= 0:
raise ValueError("--oversample must be positive")
if args.font_number < 0:
raise ValueError("--font-number must be zero or positive")
if args.progress_interval <= 0:
raise ValueError("--progress-interval must be positive")
return args
def main() -> None:
try:
args = parse_args()
export_glyphs(args)
except Exception as exc:
print()
print("[FAILED]")
print(str(exc))
sys.exit(1)
if __name__ == "__main__":
main()