git/orchard/main
tools/ansi_to_png.py
78 lines3 KB last change raw · history
#!/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())