okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: gallery of every screen state (registerShots) drawn as per-screen contact sheets4db295e

main
6.6 KiB190 linesraw
1"""Draw a screen's ANSI text as a PNG, so a person or a model can look at it.
2
3TUI tests write what a view draws to $ALBEDO_SHOT_DIR/<name>.ans (see shot in
4cli/internal/tui/shot_test.go). This turns every .ans in a directory, or the
5files named, into a .svg and a .png beside it. Needs rsvg-convert.
6
7    ALBEDO_SHOT_DIR=/tmp/shots go -C cli test ./internal/tui -run TestShotGallery
8    python3 test/manual/tui_shot.py /tmp/shots
9
10For a directory it also makes contact sheets per screen, sheet-<screen>-<page>.png,
11six states to a sheet so a model can read them (images over 2000px a side are
12refused). The screen is the file name up to the first "-". Screens register
13their states with registerShots in the tests.
14"""
15
16import html
17import re
18import subprocess
19import sys
20import unicodedata
21from pathlib import Path
22
23BG, FG = "#1e1e2e", "#cdd6f4"
24SHEET_TILES = 6  # two columns of three: a sheet stays under a model's 2000px edge limit
25FONTS = ["/System/Library/Fonts/Menlo.ttc", "/System/Library/Fonts/Monaco.ttf"]
26BASIC = {
27    30: "#45475a", 31: "#f38ba8", 32: "#a6e3a1", 33: "#f9e2af",
28    34: "#89b4fa", 35: "#cba6f7", 36: "#94e2d5", 37: "#bac2de",
29    90: "#585b70", 91: "#f38ba8", 92: "#a6e3a1", 93: "#f9e2af",
30    94: "#89b4fa", 95: "#cba6f7", 96: "#94e2d5", 97: "#a6adc8",
31}  # fmt: skip
32TOKEN = re.compile(
33    r"\x1b\[([0-9;:]*)m|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b_.*?\x1b\\|(.)", re.S
34)
35
36Style = dict[str, str | bool | None]
37
38
39def xterm(n: int) -> str:
40    if n < 8:
41        return BASIC[30 + n]
42    if n < 16:
43        return BASIC[82 + n]
44    if n < 232:
45        n -= 16
46        level = lambda v: 0 if v == 0 else 55 + 40 * v  # noqa: E731
47        return "#%02x%02x%02x" % (level(n // 36), level(n // 6 % 6), level(n % 6))
48    v = 8 + 10 * (n - 232)
49    return "#%02x%02x%02x" % (v, v, v)
50
51
52def fresh() -> Style:
53    return {
54        "fg": None,
55        "bg": None,
56        "bold": False,
57        "faint": False,
58        "under": False,
59        "reverse": False,
60    }
61
62
63def apply(style: Style, params: list[int]) -> None:
64    i = 0
65    while i < len(params):
66        p = params[i]
67        if p == 0:
68            style.update(fresh())
69        elif p in (1, 2, 4, 7):
70            style[{1: "bold", 2: "faint", 4: "under", 7: "reverse"}[p]] = True
71        elif p == 22:
72            style.update(bold=False, faint=False)
73        elif p == 24:
74            style["under"] = False
75        elif p == 27:
76            style["reverse"] = False
77        elif p == 39:
78            style["fg"] = None
79        elif p == 49:
80            style["bg"] = None
81        elif p in (38, 48):
82            key = "fg" if p == 38 else "bg"
83            if params[i + 1] == 2:
84                style[key] = "#%02x%02x%02x" % tuple(params[i + 2 : i + 5])
85                i += 4
86            elif params[i + 1] == 5:
87                style[key] = xterm(params[i + 2])
88                i += 2
89        elif 30 <= p <= 37 or 90 <= p <= 97:
90            style["fg"] = BASIC[p]
91        elif 40 <= p <= 47 or 100 <= p <= 107:
92            style["bg"] = BASIC[p - 10]
93        i += 1
94
95
96def width_of(char: str) -> int:
97    if unicodedata.combining(char):
98        return 0
99    return 2 if unicodedata.east_asian_width(char) in "WF" else 1
100
101
102def to_svg(text: str, cw: int = 9, ch: int = 19, pad: int = 18, size: int = 15) -> str:
103    cells: list[tuple[int, int, str, Style, int]] = []
104    lines = text.rstrip("\n").split("\n")
105    columns = 0
106    for y, line in enumerate(lines):
107        style, x = fresh(), 0
108        for match in TOKEN.finditer(line):
109            if match.group(2) is not None:
110                w = width_of(match.group(2))
111                cells.append((x, y, match.group(2), dict(style), w))
112                x += w
113            elif match.group(1) is not None:
114                apply(
115                    style,
116                    [
117                        int(p) if p else 0
118                        for p in re.split("[;:]", match.group(1) or "0")
119                    ],
120                )
121        columns = max(columns, x)
122    out = [
123        f'<svg xmlns="http://www.w3.org/2000/svg" width="{pad * 2 + columns * cw}" height="{pad * 2 + len(lines) * ch}">',
124        f'<rect width="100%" height="100%" rx="10" fill="{BG}"/>',
125        f'<g font-family="Menlo" font-size="{size}" xml:space="preserve">',
126    ]
127    for x, y, char, style, w in cells:
128        fg, bg = style["fg"] or FG, style["bg"]
129        if style["reverse"]:
130            fg, bg = bg or BG, fg
131        if bg:
132            out.append(
133                f'<rect x="{pad + x * cw}" y="{pad + y * ch}" width="{cw * w + 0.5}" height="{ch + 0.5}" fill="{bg}"/>'
134            )
135        if char == " ":
136            continue
137        extra = (' font-weight="bold"' if style["bold"] else "") + (
138            ' opacity="0.6"' if style["faint"] else ""
139        )
140        extra += ' text-decoration="underline"' if style["under"] else ""
141        out.append(
142            f'<text x="{pad + x * cw + cw * w / 2}" y="{pad + y * ch + ch * 0.75}" text-anchor="middle" fill="{fg}"{extra}>{html.escape(char)}</text>'
143        )
144    out.append("</g></svg>")
145    return "\n".join(out)
146
147
148def main(args: list[str]) -> int:
149    sources = [
150        p
151        for a in args
152        for p in (sorted(Path(a).glob("*.ans")) if Path(a).is_dir() else [Path(a)])
153    ]
154    if not sources:
155        print(__doc__, file=sys.stderr)
156        return 2
157    sheets: dict[tuple[Path, str], list[Path]] = {}
158    for source in sources:
159        svg, png = source.with_suffix(".svg"), source.with_suffix(".png")
160        if not png.exists() or png.stat().st_mtime < source.stat().st_mtime:
161            svg.write_text(to_svg(source.read_text(encoding="utf-8")), encoding="utf-8")
162            subprocess.run(["rsvg-convert", str(svg), "-o", str(png)], check=True)
163        screen = re.split("-", source.stem, maxsplit=1)[0]
164        sheets.setdefault((source.parent, screen), []).append(png)
165    for (folder, screen), pngs in sheets.items():
166        font = next((f for f in FONTS if Path(f).exists()), None)
167        montage = ["magick", "montage", "-pointsize", "16", "-fill", "#cdd6f4"]
168        montage += ["-font", font, "-label", "%t"] if font else ["-label", ""]
169        montage += [
170            "-background",
171            "#11111b",
172            "-tile",
173            "2x3",
174            "-geometry",
175            "970x>+12+12",
176            "-depth",
177            "8",
178        ]
179        for page, start in enumerate(range(0, len(pngs), SHEET_TILES), 1):
180            sheet = folder / f"sheet-{screen}-{page}.png"
181            subprocess.run(
182                [*montage, *map(str, pngs[start : start + SHEET_TILES]), str(sheet)],
183                check=True,
184            )
185            print(sheet)
186    return 0
187
188
189if __name__ == "__main__":
190    sys.exit(main(sys.argv[1:]))