Files
jared 86c7782b61
Lint / Python (flake8) (push) Successful in 45s
Lint / Notify on failure (push) Skipped
Security / Python Security (bandit) (push) Successful in 59s
Test / Python Tests (pytest) (push) Successful in 1m58s
Fix lint: make scripts/render_terminal.py flake8-clean
2026-10-03 16:58:52 -04:00

190 lines
7.2 KiB
Python

"""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 <span> 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("</span>" * open_spans)
open_spans = 0
elif c == 1:
out.append('<span style="font-weight:700">')
open_spans += 1
elif c in ANSI_COL:
out.append(f'<span style="color:{ANSI_COL[c]}">')
open_spans += 1
out.append(html.escape(line[pos:]))
out.append("</span>" * 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 = """<!doctype html><meta charset=utf-8>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
body{margin:0;background:#0b0f14;font-family:'JetBrains Mono','DejaVu Sans Mono',monospace}
#win{width:%(w)dpx;margin:0;background:#0d1117;border:1px solid #1f2a37;border-radius:10px;overflow:hidden}
#bar{height:34px;background:#161b22;display:flex;align-items:center;padding:0 12px;gap:8px;border-bottom:1px solid #1f2a37}
.dot{width:12px;height:12px;border-radius:50%%}
#ttl{margin-left:10px;color:#8b949e;font-size:13px}
pre{margin:0;padding:16px 18px;color:#c9d1d9;font-size:14px;line-height:1.42;white-space:pre-wrap;min-height:%(h)dpx}
.p{color:#ff8c1a;font-weight:700}.c{color:#e6edf3}.cur{background:#c9d1d9;color:#0d1117}
</style>
<div id=win><div id=bar><span class=dot style=background:#ff5f56></span><span class=dot style=background:#ffbd2e></span><span class=dot style=background:#27c93f></span><span id=ttl>%(t)s</span></div><pre id=t></pre></div>"""
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'<span style="color:{col}">{s}</span>'
else:
s = re.sub("(" + pat + ")", lambda m: f'<span style="color:{col}">{m.group(0)}</span>', s)
return s
async def _render(title, command, lines, png, gif, width, rules, min_h):
rules = rules or DEFAULT_RULES
prompt = '<span class=p>root@node</span><span style="color:#8b949e">:~#</span> '
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 + ("<span class=cur> </span>" if cursor else "")
)
async def shot():
el = pg.locator("#win")
return await el.screenshot()
full = (
prompt
+ "<span class=c>"
+ html.escape(command)
+ "</span>\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 + "<span class=c>" + html.escape(command[:i]) + "</span>", True)
add(await shot(), 45)
await show(prompt + "<span class=c>" + html.escape(command) + "</span>", True)
add(await shot(), 500)
head = prompt + "<span class=c>" + html.escape(command) + "</span>\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))