#!/usr/bin/env python3 """Batch-convert icons/agent-motion SVG states into a color emoji-style font. Outputs: res/hermit-icons.ttf (CBDT/CBLC bitmap glyphs when fonttools works) .gen/hermit_font_map.inc (PUA codepoint table for the TUI) PUA layout matches cst_hermit_glyph.h: U+E700 + (shell * N_STATE + state) * N_FRAME + frame Requires (optional): rsvg-convert or cairosvg, pillow, fonttools. Without them, still writes hermit_font_map.inc so FONT mode can emit codepoints. """ from __future__ import annotations import struct import subprocess import sys import tempfile from pathlib import Path ROOT = Path(__file__).resolve().parents[1] ICONS = ROOT / "icons" / "agent-motion" / "icons" OUT_TTF = ROOT / "res" / "hermit-icons.ttf" OUT_INC = ROOT / ".gen" / "hermit_font_map.inc" STATES = ["idle", "question", "thinking", "coding", "success", "error"] N_FRAME = 8 PUA_BASE = 0xE700 def codepoint(shell: int, state: int, frame: int) -> int: return PUA_BASE + (shell * len(STATES) + state) * N_FRAME + frame def write_map_inc() -> None: OUT_INC.parent.mkdir(parents=True, exist_ok=True) lines = [ "/* Generated by scripts/build_hermit_font.py — do not edit. */", f"#define HERMIT_FONT_PUA_BASE_GEN 0x{PUA_BASE:X}", f"#define HERMIT_FONT_FRAMES_GEN {N_FRAME}", f"#define HERMIT_FONT_STATES_GEN {len(STATES)}", "/* state names: " + ", ".join(STATES) + " */", "", ] OUT_INC.write_text("\n".join(lines) + "\n", encoding="utf-8") print(f"wrote {OUT_INC}") def render_svg(svg: Path, png: Path, size: int) -> bool: png.parent.mkdir(parents=True, exist_ok=True) try: if subprocess.call( ["rsvg-convert", "-w", str(size), "-h", str(size), "-o", str(png), str(svg)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) == 0: return png.is_file() except FileNotFoundError: pass try: import cairosvg # type: ignore cairosvg.svg2png(url=str(svg), write_to=str(png), output_width=size, output_height=size) return png.is_file() except Exception: return False def build_ttf(png_by_cp: dict[int, Path]) -> bool: try: from fontTools.fontBuilder import FontBuilder # type: ignore from fontTools.ttLib import TTFont # type: ignore from fontTools.ttLib.tables.E_B_D_T_ import table_E_B_D_T_ # noqa: F401 from fontTools.ttLib.tables.C_B_D_T_ import table_C_B_D_T_ # noqa: F401 from fontTools.ttLib.tables.C_B_L_C_ import table_C_B_L_C_ # noqa: F401 from fontTools.ttLib.tables._g_l_y_f import Glyph # type: ignore from fontTools.pens.ttGlyphPen import TTGlyphPen # type: ignore except Exception as e: print(f"fonttools CBDT path unavailable: {e}", file=sys.stderr) return False # Minimal TTF with empty glyf + Unicode cmap to PUA; color bitmaps via nanoemoji-less stub: # Prefer shipping PNGs + cmap so terminals that map PUA→font face still get something. try: from PIL import Image # type: ignore except Exception as e: print(f"pillow required for font pack: {e}", file=sys.stderr) return False # Simplified: write a placeholder TTF that only has cmap entries; bitmaps # need full CBDT authoring. For now embed as sbix-like custom is heavy — # create a valid empty-outline font with correct cmap so install works, and # document that full color CBDT needs `nanoemoji` when available. glyph_order = [".notdef"] cmap = {} for cp, png in sorted(png_by_cp.items()): name = f"u{cp:04X}" glyph_order.append(name) cmap[cp] = name fb = FontBuilder(1024, isTTF=True) fb.setupGlyphOrder(glyph_order) fb.setupCharacterMap(cmap) pen = TTGlyphPen(None) empty = pen.glyph() glyphs = {".notdef": empty} for name in glyph_order[1:]: glyphs[name] = empty fb.setupGlyf(glyphs) fb.setupHorizontalMetrics({n: (1024, 0) for n in glyph_order}) fb.setupHorizontalHeader(ascent=1024, descent=0) fb.setupNameTable( { "familyName": "Hermit Icons", "styleName": "Regular", "uniqueFontIdentifier": "Hermit Icons", "fullName": "Hermit Icons", "psName": "HermitIcons", "version": "0.10", } ) fb.setupOS2() fb.setupPost() OUT_TTF.parent.mkdir(parents=True, exist_ok=True) fb.save(str(OUT_TTF)) print(f"wrote {OUT_TTF} (cmap + empty outlines; color CBDT via nanoemoji optional)") # Touch PNGs kept beside for tooling dump = ROOT / "res" / "hermit-icon-frames" dump.mkdir(parents=True, exist_ok=True) for cp, png in png_by_cp.items(): target = dump / f"u{cp:04X}.png" target.write_bytes(png.read_bytes()) print(f"wrote {len(png_by_cp)} frames under {dump}") return True def try_nanoemoji(svgs: list[Path]) -> bool: """If nanoemoji is installed, build a proper color font from SVGs.""" if not svgs: return False try: with tempfile.TemporaryDirectory() as td: td = Path(td) for i, s in enumerate(svgs): (td / f"{i:03d}.svg").write_bytes(s.read_bytes()) out = td / "HermitIcons.ttf" rc = subprocess.call( ["nanoemoji", f"--output_file={out}", *sorted(str(p) for p in td.glob("*.svg"))], cwd=str(td), ) if rc == 0 and out.is_file(): OUT_TTF.parent.mkdir(parents=True, exist_ok=True) OUT_TTF.write_bytes(out.read_bytes()) print(f"wrote {OUT_TTF} via nanoemoji") return True except FileNotFoundError: pass except Exception as e: print(f"nanoemoji failed: {e}", file=sys.stderr) return False def main() -> int: write_map_inc() if not ICONS.is_dir(): print(f"missing {ICONS}", file=sys.stderr) return 1 svg_list: list[Path] = [] png_by_cp: dict[int, Path] = {} with tempfile.TemporaryDirectory() as td: td = Path(td) for shell, suffix in ((0, ""), (1, "-shell")): for si, st in enumerate(STATES): svg = ICONS / f"{st}{suffix}.svg" if not svg.is_file(): print(f"skip missing {svg.name}", file=sys.stderr) continue svg_list.append(svg) # One master frame per state for now; duplicate across N_FRAME. png = td / f"{shell}_{st}.png" if not render_svg(svg, png, 64): print(f"render failed: {svg.name}", file=sys.stderr) continue for fr in range(N_FRAME): png_by_cp[codepoint(shell, si, fr)] = png if try_nanoemoji(svg_list): return 0 if png_by_cp and build_ttf(png_by_cp): return 0 print( "font binary not built (install rsvg-convert/cairosvg + pillow + fonttools " "or nanoemoji). Map .inc is ready for PUA emission.", file=sys.stderr, ) return 0 # map is enough for compile if __name__ == "__main__": sys.exit(main())