#!/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())