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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c484596702
commit
59e7224297
Executable
+236
@@ -0,0 +1,236 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user