#!/usr/bin/env python3 """Render ANSI text on stdin to an SVG. Exists because a TUI cannot be screenshot headlessly: Textual's own export drops colour (its internal console has no colour system), and the Go TUI is just bytes on stdout. Rich can parse ANSI back into styled text, and Rich's SVG export accepts a terminal palette -- so this is the one way to see what either front end actually looks like without a terminal. go run . --print 118x30 | tools/ansi_to_png.py preview/tui.svg The palette is only bound here, for the image. Both front ends emit ANSI-16 indices and let the real terminal decide. """ from __future__ import annotations import re import sys from pathlib import Path from rich.console import Console from rich.terminal_theme import TerminalTheme from rich.text import Text # the user's kitty theme, so a capture resembles their actual terminal PALETTES = { "kitty": dict( background=(0, 0, 0), foreground=(0xF2, 0xF2, 0xF3), normal=[(0x28, 0x24, 0x2D), (0xFD, 0x46, 0x63), (0x9F, 0x8B, 0xC1), (0xAD, 0x81, 0xB1), (0xAD, 0x85, 0x9E), (0xBA, 0xAC, 0xD3), (0xCD, 0xAF, 0xD0), (0xF2, 0xF2, 0xF3)], bright=[(0x6B, 0x65, 0x74), (0xFD, 0x46, 0x63), (0x9F, 0x8B, 0xC1), (0xAD, 0x81, 0xB1), (0xAD, 0x85, 0x9E), (0xBA, 0xAC, 0xD3), (0xCD, 0xAF, 0xD0), (0xF2, 0xF2, 0xF3)]), "sagen": dict( background=(0, 0, 0), foreground=(0xDC, 0xEC, 0xE9), normal=[(0x05, 0x0B, 0x0A), (0xCC, 0x56, 0x65), (0x5B, 0xC0, 0xAE), (0xD4, 0xB0, 0x7B), (0x58, 0x8B, 0x9C), (0x9E, 0x82, 0xAA), (0x7C, 0xF5, 0xDE), (0xC7, 0xDB, 0xD8)], bright=[(0x1B, 0x26, 0x23), (0xE8, 0x6F, 0x7F), (0x69, 0xDE, 0xBB), (0xE3, 0xC8, 0x8A), (0x66, 0xAC, 0xBC), (0xB4, 0x91, 0xC2), (0x8C, 0xFC, 0xE6), (0xDC, 0xEC, 0xE9)]), } def theme(name: str) -> TerminalTheme: p = PALETTES[name] return TerminalTheme(background=p["background"], foreground=p["foreground"], normal=p["normal"], bright=p["bright"]) def main() -> int: if len(sys.argv) < 2: print(__doc__) return 2 out = Path(sys.argv[1]) palette = sys.argv[2] if len(sys.argv) > 2 else "kitty" raw = sys.stdin.read() # measure the *visible* width: counting escape bytes produced a 3965px image # for a 120-column frame ansi_re = re.compile(r"\x1b\[[0-9;]*m") width = max((len(ansi_re.sub("", line)) for line in raw.splitlines()), default=80) text = Text.from_ansi(raw.rstrip("\n")) console = Console(record=True, width=width, force_terminal=True, color_system="standard", height=400) console.print(text) out.parent.mkdir(parents=True, exist_ok=True) console.save_svg(str(out), font_aspect_ratio=0.5, theme=theme(palette)) print(f"wrote {out} ({width} cols, {len(raw.splitlines())} rows)") return 0 if __name__ == "__main__": raise SystemExit(main())