#!/usr/bin/env python3 """Bildschirmfotos aus ncurses-Oberflaechen. Startet ein Programm in einem echten Pseudo-Terminal fester Groesse, spielt Tastendruecke ein, laesst pyte den Bildschirm nachbilden und schreibt das Ergebnis als SVG. Das SVG ist reiner Text - keine Schriftart eingebettet, dafuer jede Zeichenkette mit `textLength` fest vermessen. Damit sitzt das Raster in jedem Browser, egal welche Monospace-Schrift er waehlt. """ from __future__ import annotations import fcntl import os import pty import select import signal import struct import subprocess import sys import termios import time from xml.sax.saxutils import escape import pyte CW = 8.6 # Zeichenbreite in px LH = 18.0 # Zeilenhoehe in px FS = 14.5 # Schriftgroesse PAD = 14.0 # Rand um den Textbereich CHROME = 30.0 # Hoehe der Titelleiste # Tango - die Palette, die auch Debian im Terminal benutzt. Fett macht aus der # Grundfarbe die helle Variante; genau das tut ein Terminal mit A_BOLD, und # genau darauf beruht der Kommentar in curses_util.init_colors(). PALETTE = { "black": "#2e3436", "red": "#cc0000", "green": "#4e9a06", "brown": "#c4a000", "yellow": "#c4a000", "blue": "#3465a4", "magenta": "#75507b", "cyan": "#06989a", "white": "#d3d7cf", "brightblack": "#555753", "brightred": "#ef2929", "brightgreen": "#8ae234", "brightbrown": "#fce94f", "brightyellow": "#fce94f", "brightblue": "#729fcf", "brightmagenta": "#ad7fa8", "brightcyan": "#34e2e2", "brightwhite": "#eeeeec", } BG_DEFAULT = "#1b1e24" FG_DEFAULT = "#d3d7cf" BRIGHT = {"black": "brightblack", "red": "brightred", "green": "brightgreen", "brown": "brightbrown", "yellow": "brightyellow", "blue": "brightblue", "magenta": "brightmagenta", "cyan": "brightcyan", "white": "brightwhite"} def _color(name, bold, default): if name == "default": # Fett faerbt den Vordergrund nicht um, wenn er gar keine Farbe hat. return default if bold: name = BRIGHT.get(name, name) if name in PALETTE: return PALETTE[name] if len(name) == 6: try: int(name, 16) return "#" + name except ValueError: pass return default class Terminal: """Ein Programm in einem Pseudo-Terminal, dessen Bildschirm mitgelesen wird.""" def __init__(self, command, cols=100, rows=30, env=None, cwd=None): self.cols, self.rows = cols, rows self.screen = pyte.Screen(cols, rows) self.stream = pyte.ByteStream(self.screen) self.master, slave = pty.openpty() fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, cols * 8, rows * 16)) environment = dict(os.environ) environment.update({"TERM": "xterm-256color", "LINES": str(rows), "COLUMNS": str(cols), "LANG": "de_DE.UTF-8", "LC_ALL": "de_DE.UTF-8"}) environment.update(env or {}) self.process = subprocess.Popen( command, stdin=slave, stdout=slave, stderr=slave, cwd=cwd, env=environment, close_fds=True, start_new_session=True) os.close(slave) # -- lesen ------------------------------------------------------------- def _drain(self, timeout): """Alles lesen, was bis `timeout` kommt. True, wenn etwas kam.""" got = False deadline = time.monotonic() + timeout while True: rest = deadline - time.monotonic() if rest <= 0: return got ready, _, _ = select.select([self.master], [], [], rest) if not ready: return got try: data = os.read(self.master, 65536) except OSError: return got if not data: return got self.stream.feed(data) got = True def settle(self, quiet=0.35, limit=8.0): """Warten, bis eine Weile nichts mehr nachkommt.""" deadline = time.monotonic() + limit while time.monotonic() < deadline: if not self._drain(quiet): return self._drain(0.1) # -- schreiben --------------------------------------------------------- def send(self, keys, pause=0.25): for key in keys if isinstance(keys, (list, tuple)) else [keys]: os.write(self.master, key.encode("utf-8")) time.sleep(pause) self.settle() def close(self): try: self.process.send_signal(signal.SIGKILL) self.process.wait(timeout=5) except (ProcessLookupError, subprocess.TimeoutExpired, OSError): pass try: os.close(self.master) except OSError: pass # -- ausgeben ---------------------------------------------------------- def to_svg(self, title=""): return render(self.screen, self.cols, self.rows, title) def _runs(screen, y, cols): """Eine Zeile in Abschnitte gleicher Darstellung zerlegen.""" line = screen.buffer[y] out, current = [], None for x in range(cols): char = line[x] style = (char.fg, char.bg, char.bold, char.reverse) if char.reverse: style = (char.bg, char.fg, char.bold, False) if current and current[0] == style: current[1].append(char.data or " ") else: current = (style, [char.data or " "], x) out.append(current) return out def render(screen, cols, rows, title=""): width = cols * CW + 2 * PAD height = rows * LH + 2 * PAD + (CHROME if title else 0) top = PAD + (CHROME if title else 0) parts = [ '' % (width, height, width, height, FS), '' % (width, height, BG_DEFAULT), ] if title: parts.append('' % (width - 14, CHROME - 7)) for index, colour in enumerate(("#ed6a5e", "#f4bf4f", "#61c554")): parts.append('' % (16 + index * 17, colour)) parts.append('%s' % (width / 2, escape(title))) for y in range(rows): for style, chars, x in _runs(screen, y, cols): fg_name, bg_name, bold, _ = style text = "".join(chars) run_width = len(chars) * CW bg = _color(bg_name, False, None) if bg and bg != BG_DEFAULT: parts.append('' % (PAD + x * CW, top + y * LH, run_width + 0.4, LH + 0.4, bg)) if not text.strip(): continue fg = _color(fg_name, bold, FG_DEFAULT) weight = ' font-weight="bold"' if bold else "" parts.append('%s' % (PAD + x * CW, top + y * LH + FS * 0.92, fg, weight, run_width, escape(text))) parts.append("") return "\n".join(parts) def capture(target, command, keys=(), cols=100, rows=30, title="", env=None, cwd=None, pause=0.3, warmup=1.2): """Ein Programm starten, Tasten schicken, Bildschirm als SVG ablegen.""" term = Terminal(command, cols=cols, rows=rows, env=env, cwd=cwd) try: time.sleep(warmup) term.settle() term.send(list(keys), pause=pause) time.sleep(0.2) term.settle() svg = term.to_svg(title) finally: term.close() os.makedirs(os.path.dirname(target), exist_ok=True) with open(target, "w", encoding="utf-8") as handle: handle.write(svg) return svg def text_of(svg_screen): """Nur zum Nachsehen beim Bauen: der Bildschirm als reiner Text.""" return "\n".join(svg_screen.display) if __name__ == "__main__": print("Wird von scenes.py benutzt.", file=sys.stderr)