File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
"""@fox 아이콘 폰트 생성기 — 글리프 path → woff2 6벌 + `_fox-ico.scss`.
입력: @fox/core/icons/glyph-paths.json (아이콘 1,512종 × 굵기 6종의 path 데이터)
출력: @fox/assets/fonts/fox-icons-{weight}.woff2
@fox/styles/_fox-ico.scss
실행: python3 @fox/tools/build-icon-font.py
왜 폰트인가
- 디자인팀 요구다. 클래스명 하나로 어디서나 쓰고, 이름이 Figma와 정확히 일치해야 한다.
클래스는 `fox-ico-House`처럼 시안의 `ico/House`를 그대로 담는다.
왜 glyph-paths.json에서 뽑나
- 이 데이터는 예전 SVG 컴포넌트(@fox/core/icons/glyphs/*.tsx)에서 그대로 옮겨 온 것이라,
구운 폰트가 지금까지 화면에 나가던 그림과 같다는 것이 데이터 수준에서 보장된다.
- 원본을 다시 받고 싶으면 phosphor-icons/core를 체크아웃해 `seed_from_phosphor()`를 쓴다.
좌표계
- Phosphor는 viewBox 0 0 256 256, Y가 아래로 자란다. 폰트는 Y가 위로 자란다.
그래서 (SCALE, 0, 0, -SCALE, 0, UPM)으로 뒤집으며 1000 upm으로 키운다.
- advance를 1em으로 두어 `font-size`가 곧 아이콘 한 변이 된다.
duotone
- 폰트 글리프 하나는 색을 하나만 갖는다. 그래서 배경겹을 별도 글리프로 넣고
CSS가 두 글자를 겹쳐 그린다(Phosphor 공식 웹배포판과 같은 방식).
전경은 0xE000부터, 배경은 0xF000부터 같은 순서로 앉힌다.
"""
import json
import os
import re
from fontTools.fontBuilder import FontBuilder
from fontTools.misc.transform import Transform
from fontTools.pens.t2CharStringPen import T2CharStringPen
from fontTools.pens.transformPen import TransformPen
from fontTools.svgLib.path import parse_path
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA = os.path.join(ROOT, "core", "icons", "glyph-paths.json")
FONT_OUT = os.path.join(ROOT, "assets", "fonts")
SCSS_OUT = os.path.join(ROOT, "styles", "_fox-ico.scss")
UPM = 1000
VIEWBOX = 256
SCALE = UPM / VIEWBOX
WEIGHTS = ["thin", "light", "regular", "bold", "fill", "duotone"]
# 사설 영역(0xE000~0xF8FF)에 앉힌다. 1,512 × 2 = 3,024자로 여유가 있다.
FG_BASE = 0xE000
BG_BASE = 0xF000
TRANSFORM = Transform(SCALE, 0, 0, -SCALE, 0, UPM)
def build_font(paths_by_icon, weight, out_path):
"""한 굵기를 CFF/woff2로 굽는다. 반환: {아이콘명: (전경 코드포인트, 배경 코드포인트|None)}."""
charstrings = {".notdef": T2CharStringPen(UPM, None).getCharString()}
cmap = {}
codepoints = {}
for index, (name, weights) in enumerate(sorted(paths_by_icon.items())):
entries = weights.get(weight)
if not entries:
continue
# duotone은 [배경, 전경] 순서다. 배경만 따로 떼어 별도 글리프로 만든다.
background = None
foreground = entries
if weight == "duotone" and len(entries) >= 2:
background = [entries[0]]
foreground = entries[1:]
pen = T2CharStringPen(UPM, None)
for entry in foreground:
parse_path(entry[0], TransformPen(pen, TRANSFORM))
charstrings[name] = pen.getCharString()
fg = FG_BASE + index
cmap[fg] = name
bg = None
if background:
pen = T2CharStringPen(UPM, None)
parse_path(background[0][0], TransformPen(pen, TRANSFORM))
charstrings[name + ".bg"] = pen.getCharString()
bg = BG_BASE + index
cmap[bg] = name + ".bg"
codepoints[name] = (fg, bg)
order = [".notdef"] + [g for g in charstrings if g != ".notdef"]
family = "Fox Icons " + weight.capitalize()
builder = FontBuilder(UPM, isTTF=False)
builder.setupGlyphOrder(order)
builder.setupCharacterMap(cmap)
builder.setupCFF(family.replace(" ", ""), {"FullName": family}, charstrings, {})
builder.setupHorizontalMetrics({glyph: (UPM, 0) for glyph in order})
builder.setupHorizontalHeader(ascent=UPM, descent=0)
builder.setupNameTable({
"familyName": family,
"styleName": "Regular",
"psName": family.replace(" ", ""),
})
builder.setupOS2(sTypoAscender=UPM, sTypoDescender=0, usWinAscent=UPM, usWinDescent=0)
builder.setupPost()
builder.font.flavor = "woff2"
os.makedirs(os.path.dirname(out_path), exist_ok=True)
builder.save(out_path)
return codepoints
FACE = """@font-face {{
font-family: "Fox Icons {title}";
src: url("../assets/fonts/fox-icons-{weight}.woff2") format("woff2");
font-weight: normal;
font-style: normal;
font-display: block;
}}"""
def write_scss(codepoints_by_weight, names):
faces = "\n\n".join(
FACE.format(title=weight.capitalize(), weight=weight) for weight in WEIGHTS
)
# regular도 수식어를 낸다 — 기본값과 같아 중복이지만, 굵기를 변수로 조립하는 호출부가
# regular만 예외 처리하지 않아도 된다.
modifiers = "\n".join(
f' &--{weight} {{ font-family: "Fox Icons {weight.capitalize()}"; }}'
for weight in WEIGHTS
)
# 이름 규칙은 아이콘 1종당 한 줄이다. `content`를 굵기마다 되풀이하지 않으려고
# 코드포인트를 커스텀 프로퍼티로 넘기고 `::before`/`::after`가 그것을 읽는다.
duotone = codepoints_by_weight["duotone"]
regular = codepoints_by_weight["regular"]
lines = []
for name in names:
fg = regular.get(name, duotone.get(name))
if not fg:
continue
entry = f'.fox-ico-{name} {{ --fox-ico: "\\{fg[0]:x}";'
bg = duotone.get(name, (None, None))[1]
if bg:
entry += f' --fox-ico-bg: "\\{bg:x}";'
lines.append(entry + " }")
body = f"""// 이 파일은 `python3 @fox/tools/build-icon-font.py`가 만든다. 손으로 고치지 않는다.
//
// FoxIcon 폰트 — 시안 `ico/*` {len(names):,}종을 woff2로 담았다.
//
// 마크업 계약:
// <i class="fox-ico fox-ico-House" aria-hidden="true"></i>
// <i class="fox-ico fox-ico--fill fox-ico-House" aria-hidden="true"></i>
//
// 클래스의 이름 부분은 Figma의 `ico/House`를 그대로 옮긴 것이라 대소문자가 시안과 같다.
// 크기는 `font-size`가 정한다 — 상자가 1em이라 `font-size: fox.icon(4)`면 한 변이 2rem이다.
// 색은 `color`를 따른다.
//
// 굵기별 @font-face가 따로라 화면이 실제로 쓰는 굵기의 파일만 내려받는다.
{faces}
.fox-ico {{
display: inline-block;
flex-shrink: 0;
inline-size: 1em;
block-size: 1em;
font-family: "Fox Icons Regular";
font-weight: normal;
font-style: normal;
font-variant: normal;
line-height: 1;
text-transform: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
{modifiers}
&::before {{
content: var(--fox-ico, "");
}}
// duotone은 배경겹을 뒤에 깔고 전경을 그 위에 겹친다 — 글리프 하나가 두 색을 못 갖는다.
&--duotone {{
position: relative;
&::before {{
content: var(--fox-ico-bg, var(--fox-ico, ""));
opacity: 0.2;
}}
&::after {{
content: var(--fox-ico, "");
position: absolute;
inset: 0;
}}
}}
}}
{chr(10).join(lines)}
"""
with open(SCSS_OUT, "w", encoding="utf-8") as handle:
handle.write(body)
def main():
with open(DATA, encoding="utf-8") as handle:
paths_by_icon = json.load(handle)
names = sorted(paths_by_icon)
codepoints_by_weight = {}
for weight in WEIGHTS:
out_path = os.path.join(FONT_OUT, f"fox-icons-{weight}.woff2")
codepoints_by_weight[weight] = build_font(paths_by_icon, weight, out_path)
size = os.path.getsize(out_path) / 1024
print(f"{weight:9s} {len(codepoints_by_weight[weight]):5d}종 {size:7.1f} KB")
write_scss(codepoints_by_weight, names)
print(f"\n{SCSS_OUT} — 이름 규칙 {len(names):,}줄")
if __name__ == "__main__":
main()