"""Render CLI output as a styled terminal screenshot / animated GIF with Playwright + Pillow. Usage (as a module): from term_render import render render(title, command, lines, png='out.png', gif='out.gif', width=1000, rules=[(regex, css_color), ...]) `lines` is a list of plain-text output lines. No real terminal is needed, so output can be sanitized first. """ # flake8: noqa: E501 (the HTML/CSS page template below contains long literal lines) import asyncio import html import io import re from PIL import Image from playwright.async_api import async_playwright ANSI_COL = { 30: "#6e7681", 31: "#ff5c6c", 32: "#3ddc84", 33: "#ffb454", 34: "#58a6ff", 35: "#d2a8ff", 36: "#7fdbff", 37: "#e6edf3", 90: "#8b949e", 91: "#ff7b86", 92: "#56f09f", 93: "#ffd479", 94: "#79c0ff", 95: "#e2c5ff", 96: "#a5f3ff", 97: "#ffffff", } def ansi_to_html(line): """Convert ANSI SGR colour/bold sequences to tags (everything else is stripped).""" out = [] open_spans = 0 pos = 0 for m in re.finditer(r"\x1b\[([0-9;]*)m|\x1b\[[0-9;?]*[A-Za-z]", line): out.append(html.escape(line[pos : m.start()])) pos = m.end() if m.group(1) is None: continue codes = [int(c) if c else 0 for c in m.group(1).split(";")] for c in codes: if c == 0: out.append("" * open_spans) open_spans = 0 elif c == 1: out.append('') open_spans += 1 elif c in ANSI_COL: out.append(f'') open_spans += 1 out.append(html.escape(line[pos:])) out.append("" * open_spans) return "".join(out) def mask(lines, serial_cols=True, ips=True): """Sanitize output before publishing: hide drive serials and private IPs.""" res = [] for ln in lines: if ips: ln = ( re.sub(r"\b(10|192\.168|172\.(1[6-9]|2\d|3[01]))\.(\d+)\.(\d+)\.(\d+)\b", "x.x.x.x", ln) if not ln.startswith("x") else ln ) if serial_cols and re.match(r"^(\d+|m2-\d)\s+/dev/", ln): parts = re.split(r"(\s{2,})", ln) # fields alternate with whitespace; serial is the 8th field (index 14 in the split list) if len(parts) > 14: v = parts[14] parts[14] = v[:2] + "\u2022" * max(0, len(v) - 2) ln = "".join(parts) res.append(ln) return res DEFAULT_RULES = [ (r"✓|\bOK\b|\bNORMAL\b|\bPASS(ED)?\b|HEALTH_OK", "#3ddc84"), (r"⚠️?|\bWARN(ING)?\b|HEALTH_WARN|\[WARN\]", "#ffb454"), (r"\bERROR\b|\bFAIL(ED)?\b|\bCRITICAL\b|✗|HEALTH_ERR", "#ff5c6c"), (r"^=+.*=+$|^┏.*|^┃.*|^┣.*|^┗.*", "#7fdbff"), ] PAGE = """
%(t)s
""" def colorize(line, rules): if "\x1b[" in line: return ansi_to_html(line) s = html.escape(line) for pat, col in rules: if pat.startswith("^"): if re.search(pat, line): return f'{s}' else: s = re.sub("(" + pat + ")", lambda m: f'{m.group(0)}', s) return s async def _render(title, command, lines, png, gif, width, rules, min_h): rules = rules or DEFAULT_RULES prompt = 'root@node:~# ' async with async_playwright() as p: b = await p.chromium.launch() pg = await b.new_page(viewport={"width": width + 2, "height": 600}) await pg.set_content(PAGE % {"w": width, "h": min_h, "t": html.escape(title)}) await pg.wait_for_timeout(1200) async def show(body, cursor=False): await pg.evaluate( "(h)=>{document.getElementById('t').innerHTML=h}", body + (" " if cursor else "") ) async def shot(): el = pg.locator("#win") return await el.screenshot() full = ( prompt + "" + html.escape(command) + "\n" + "\n".join(colorize(ln, rules) for ln in lines) ) await show(full) if png: open(png, "wb").write(await shot()) print("png", png) if gif: frames = [] dur = [] def add(img, d): frames.append(Image.open(io.BytesIO(img)).convert("RGB")) dur.append(d) await show(prompt, True) add(await shot(), 500) for i in range(1, len(command) + 1, 3): await show(prompt + "" + html.escape(command[:i]) + "", True) add(await shot(), 45) await show(prompt + "" + html.escape(command) + "", True) add(await shot(), 500) head = prompt + "" + html.escape(command) + "\n" out = [] step = max(1, len(lines) // 20) for i in range(0, len(lines), step): out = lines[: i + step] await show(head + "\n".join(colorize(ln, rules) for ln in out)) add(await shot(), 120) await show(full) add(await shot(), 3500) w0 = frames[0].width scale = min(1.0, 780 / w0) frames = [f.resize((int(f.width * scale), int(f.height * scale)), Image.LANCZOS) for f in frames] # all frames must share size: pad to the tallest H = max(f.height for f in frames) W = frames[0].width norm = [] for f in frames: c = Image.new("RGB", (W, H), (13, 17, 23)) c.paste(f, (0, 0)) norm.append(c.quantize(colors=32, method=Image.MEDIANCUT, dither=Image.NONE)) norm[0].save(gif, save_all=True, append_images=norm[1:], duration=dur, loop=0, optimize=True) print("gif", gif, len(norm)) await b.close() def render(title, command, lines, png=None, gif=None, width=1000, rules=None, min_h=200): asyncio.run(_render(title, command, lines, png, gif, width, rules, min_h))