"""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. """ import asyncio, html, io, re from PIL import Image from playwright.async_api import async_playwright 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): 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(l,rules) for l 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(l,rules) for l 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))