Files
proxmox-snapshot-service/handbuch/werkstatt/shot.py
T
duffyduckandClaude Opus 5 59e7224297 Handbuch auf MkDocs-Basis, mit Bildschirmfotos aus dem Programm
26 Kapitel in vier Teilen, in der Reihenfolge, in der man sie braucht:
erst sichern, dann Dateien holen, dann ganze Maschinen wiederherstellen,
dahinter der Nachschlagteil.

Gebaut wird mit MkDocs + Material. Bewusst ohne Netzabhaengigkeiten:
keine Schriften vom CDN (font: false), Volltextsuche mit deutschem
Stemming liegt neben den Seiten. Im Notfall steht vielleicht das halbe
Netz - dann nuetzt eine Doku im Internet nichts.

  handbuch/bauen.sh              baut handbuch/site/
  handbuch/bauen.sh ansehen      Vorschau auf 127.0.0.1:8000

install.sh nimmt das gebaute Handbuch mit nach
/usr/share/doc/pvesnap/handbuch/ - falls es vorliegt. Auf dem Host selbst
wird nichts gebaut, mkdocs gehoert nicht auf einen Hypervisor.

Die 28 Bildschirmfotos sind nicht abfotografiert, sondern erzeugt: Der
echte Programmcode laeuft in einem Pseudo-Terminal gegen einen erfundenen
Proxmox-Host (Attrappen fuer pvesh, perl und rbd), pyte baut den Bildschirm
nach, heraus faellt ein SVG. Damit stimmen sie garantiert mit dem Programm
ueberein, sind reproduzierbar und enthalten keine echten Daten. Die
Werkstatt dafuer liegt unter handbuch/werkstatt/ samt LIESMICH.md.

Nebenbei: die Schlussmeldung von install.sh warb noch mit --exchange,
das mit dem eingebauten Austauschlaufwerk weggefallen ist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:39:30 +02:00

237 lines
8.4 KiB
Python
Executable File

#!/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 = [
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %.1f %.1f" '
'width="%.0f" height="%.0f" font-family="ui-monospace, SFMono-Regular, '
'Menlo, Consolas, \'DejaVu Sans Mono\', monospace" font-size="%.1f">'
% (width, height, width, height, FS),
'<rect width="%.1f" height="%.1f" rx="7" fill="%s"/>' % (width, height, BG_DEFAULT),
]
if title:
parts.append('<path d="M0 7a7 7 0 0 1 7-7h%.1fa7 7 0 0 1 7 7v%.1fH0z" '
'fill="#2b2f38"/>' % (width - 14, CHROME - 7))
for index, colour in enumerate(("#ed6a5e", "#f4bf4f", "#61c554")):
parts.append('<circle cx="%.1f" cy="15" r="5" fill="%s"/>'
% (16 + index * 17, colour))
parts.append('<text x="%.1f" y="20" fill="#9aa3b2" font-size="12" '
'text-anchor="middle">%s</text>'
% (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('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" '
'fill="%s"/>' % (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('<text x="%.1f" y="%.1f" fill="%s"%s textLength="%.1f" '
'lengthAdjust="spacing" xml:space="preserve">%s</text>'
% (PAD + x * CW, top + y * LH + FS * 0.92, fg, weight,
run_width, escape(text)))
parts.append("</svg>")
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)