"""Draw a screen's ANSI text as a PNG, so a person or a model can look at it. TUI tests write what a view draws to $ALBEDO_SHOT_DIR/.ans (see shot in cli/internal/tui/shot_test.go). This turns every .ans in a directory, or the files named, into a .svg and a .png beside it. Needs rsvg-convert. ALBEDO_SHOT_DIR=/tmp/shots go -C cli test ./internal/tui -run TestShotGallery python3 test/manual/tui_shot.py /tmp/shots For a directory it also makes contact sheets per screen, sheet--.png, six states to a sheet so a model can read them (images over 2000px a side are refused). The screen is the file name up to the first "-". Screens register their states with registerShots in the tests. """ import html import re import subprocess import sys import unicodedata from pathlib import Path BG, FG = "#1e1e2e", "#cdd6f4" SHEET_TILES = 6 # two columns of three: a sheet stays under a model's 2000px edge limit FONTS = ["/System/Library/Fonts/Menlo.ttc", "/System/Library/Fonts/Monaco.ttf"] BASIC = { 30: "#45475a", 31: "#f38ba8", 32: "#a6e3a1", 33: "#f9e2af", 34: "#89b4fa", 35: "#cba6f7", 36: "#94e2d5", 37: "#bac2de", 90: "#585b70", 91: "#f38ba8", 92: "#a6e3a1", 93: "#f9e2af", 94: "#89b4fa", 95: "#cba6f7", 96: "#94e2d5", 97: "#a6adc8", } # fmt: skip TOKEN = re.compile( r"\x1b\[([0-9;:]*)m|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b_.*?\x1b\\|(.)", re.S ) Style = dict[str, str | bool | None] def xterm(n: int) -> str: if n < 8: return BASIC[30 + n] if n < 16: return BASIC[82 + n] if n < 232: n -= 16 level = lambda v: 0 if v == 0 else 55 + 40 * v # noqa: E731 return "#%02x%02x%02x" % (level(n // 36), level(n // 6 % 6), level(n % 6)) v = 8 + 10 * (n - 232) return "#%02x%02x%02x" % (v, v, v) def fresh() -> Style: return { "fg": None, "bg": None, "bold": False, "faint": False, "under": False, "reverse": False, } def apply(style: Style, params: list[int]) -> None: i = 0 while i < len(params): p = params[i] if p == 0: style.update(fresh()) elif p in (1, 2, 4, 7): style[{1: "bold", 2: "faint", 4: "under", 7: "reverse"}[p]] = True elif p == 22: style.update(bold=False, faint=False) elif p == 24: style["under"] = False elif p == 27: style["reverse"] = False elif p == 39: style["fg"] = None elif p == 49: style["bg"] = None elif p in (38, 48): key = "fg" if p == 38 else "bg" if params[i + 1] == 2: style[key] = "#%02x%02x%02x" % tuple(params[i + 2 : i + 5]) i += 4 elif params[i + 1] == 5: style[key] = xterm(params[i + 2]) i += 2 elif 30 <= p <= 37 or 90 <= p <= 97: style["fg"] = BASIC[p] elif 40 <= p <= 47 or 100 <= p <= 107: style["bg"] = BASIC[p - 10] i += 1 def width_of(char: str) -> int: if unicodedata.combining(char): return 0 return 2 if unicodedata.east_asian_width(char) in "WF" else 1 def to_svg(text: str, cw: int = 9, ch: int = 19, pad: int = 18, size: int = 15) -> str: cells: list[tuple[int, int, str, Style, int]] = [] lines = text.rstrip("\n").split("\n") columns = 0 for y, line in enumerate(lines): style, x = fresh(), 0 for match in TOKEN.finditer(line): if match.group(2) is not None: w = width_of(match.group(2)) cells.append((x, y, match.group(2), dict(style), w)) x += w elif match.group(1) is not None: apply( style, [ int(p) if p else 0 for p in re.split("[;:]", match.group(1) or "0") ], ) columns = max(columns, x) out = [ f'', f'', f'', ] for x, y, char, style, w in cells: fg, bg = style["fg"] or FG, style["bg"] if style["reverse"]: fg, bg = bg or BG, fg if bg: out.append( f'' ) if char == " ": continue extra = (' font-weight="bold"' if style["bold"] else "") + ( ' opacity="0.6"' if style["faint"] else "" ) extra += ' text-decoration="underline"' if style["under"] else "" out.append( f'{html.escape(char)}' ) out.append("") return "\n".join(out) def main(args: list[str]) -> int: sources = [ p for a in args for p in (sorted(Path(a).glob("*.ans")) if Path(a).is_dir() else [Path(a)]) ] if not sources: print(__doc__, file=sys.stderr) return 2 sheets: dict[tuple[Path, str], list[Path]] = {} for source in sources: svg, png = source.with_suffix(".svg"), source.with_suffix(".png") if not png.exists() or png.stat().st_mtime < source.stat().st_mtime: svg.write_text(to_svg(source.read_text(encoding="utf-8")), encoding="utf-8") subprocess.run(["rsvg-convert", str(svg), "-o", str(png)], check=True) screen = re.split("-", source.stem, maxsplit=1)[0] sheets.setdefault((source.parent, screen), []).append(png) for (folder, screen), pngs in sheets.items(): font = next((f for f in FONTS if Path(f).exists()), None) montage = ["magick", "montage", "-pointsize", "16", "-fill", "#cdd6f4"] montage += ["-font", font, "-label", "%t"] if font else ["-label", ""] montage += [ "-background", "#11111b", "-tile", "2x3", "-geometry", "970x>+12+12", "-depth", "8", ] for page, start in enumerate(range(0, len(pngs), SHEET_TILES), 1): sheet = folder / f"sheet-{screen}-{page}.png" subprocess.run( [*montage, *map(str, pngs[start : start + SHEET_TILES]), str(sheet)], check=True, ) print(sheet) return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))