Files
Projektwissen/scripts/vorschau.py
T
DanielandClaude Opus 5.5 b341a658bc Bootstrap lokal einbetten und Stil der AG anlegen (#27)
- werkzeuge/bootstrap/: Bootstrap 5.3.8 (CSS, JS-Bundle) und Bootstrap
  Icons 1.13.1 (CSS, woff2/woff) byte-gleich aus den npm-Paketen,
  Integrität geprüft; README mit Version, Quelle, Integrität, Lizenz
  und Aktualisierung; Lizenzen (MIT) daneben; .gitattributes: binary,
  die README bleibt Text mit Diff.
- werkzeuge/stil/ag_prozesse.css: eigene kleine Stilschicht (ag-*):
  Seite in voller Höhe, dunkle Navigationsleiste, hellgrauer
  Hintergrund, Karten mit farbigem Rand, Tabellenziffern, Druck.
  Vorbild VLN_SN/EA-Bilanz, nichts übernommen.
- Vorschau: Kopfzeile als dunkle Navigationsleiste mit Icon-Knöpfen;
  JavaScript, IDs und Zeichenfläche (Wasserzeichen) unverändert.
- scripts/vorschau.py bettet Bootstrap, Icons und Stil ein und setzt die
  Icon-Schrift als data:-URI ein (_schriften_einbetten); ein nicht
  einbettbarer Schriftverweis bricht laut ab.
- Tests: drei neue in test_vorschau.py; der Aufbau von
  test_bibliothek_mit_script_ende_wird_abgelehnt kopiert jetzt ganz
  werkzeuge/ (Erwartung unverändert).
- AGENTS.md: eingebettete Fremdbibliotheken sind bpmn-js und Bootstrap.
- Tests: python -m pytest -q -> 256 bestanden, 1 übersprungen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:11:29 +02:00

154 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Zeigt eine BPMN-Datei oder eine Arbeitsschritt-.md als Diagramm im Browser – offline, ohne PICTURE.
Grundlage ist werkzeuge/vorschau/vorschau.html mit der lokal eingebetteten Bibliothek bpmn-js (bpmn.io), gestaltet
mit Bootstrap und dem Stil der AG (werkzeuge/bootstrap/, werkzeuge/stil/). Das Skript erzeugt daraus eine einzelne,
in sich geschlossene HTML-Datei (Bibliotheken, Stile, Icon-Schrift und Diagramm eingebettet) und öffnet sie im
Standardbrowser. Es wird nichts übertragen; die Seite lädt nichts aus dem Netz.
Aufrufe (aus dem Workspace-Root):
python scripts/vorschau.py Prozessbibliothek/antrag-pruefen/antrag-pruefen.bpmn
python scripts/vorschau.py entwuerfe/antrag-pruefen.md # .md wird vorher umgewandelt
python scripts/vorschau.py <datei> --ausgabe vorschau.html --nicht-oeffnen
python scripts/vorschau.py --stdin < datei.bpmn > vorschau.html # Filter, z. B. als Gitea-Renderer
Ohne Python: werkzeuge/vorschau/vorschau.html direkt im Browser öffnen und eine .bpmn hineinziehen.
Exit-Code: 0 = in Ordnung, 1 = Datei fehlt oder lässt sich nicht umwandeln.
"""
from __future__ import annotations
import argparse
import base64
import json
import re
import sys
import tempfile
import webbrowser
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
VORSCHAU = ROOT / "werkzeuge" / "vorschau"
VORLAGE = VORSCHAU / "vorschau.html"
SKILL = ROOT / ".agents" / "skills" / "sn-bpmn"
# Stellen in vorschau.html, die beim Einbetten ersetzt werden. Ein Test prüft, dass es sie gibt.
EINBETTUNGEN = {
'<link rel="stylesheet" href="../bootstrap/bootstrap.min.css">': ("style", "../bootstrap/bootstrap.min.css"),
'<link rel="stylesheet" href="../bootstrap/icons/bootstrap-icons.min.css">':
("style", "../bootstrap/icons/bootstrap-icons.min.css"),
'<link rel="stylesheet" href="../stil/ag_prozesse.css">': ("style", "../stil/ag_prozesse.css"),
'<link rel="stylesheet" href="bpmn-js/diagram-js.css">': ("style", "bpmn-js/diagram-js.css"),
'<link rel="stylesheet" href="bpmn-js/bpmn-js.css">': ("style", "bpmn-js/bpmn-js.css"),
'<script src="bpmn-js/bpmn-navigated-viewer.production.min.js"></script>':
("script", "bpmn-js/bpmn-navigated-viewer.production.min.js"),
}
PLATZHALTER_DIAGRAMM = "const EINGEBETTET = null;"
PLATZHALTER_NAME = "const DATEINAME = null;"
# Sequenzen, die einen eingebetteten <script>- bzw. <style>-Block vorzeitig beenden oder den HTML-Parser in
# den Sonderzustand für "<!--" im Skript bringen würden. Die Bibliothek wird unverändert eingebettet und deshalb
# nur geprüft; nach einem Update von bpmn-js schlägt baue_html() sonst laut fehl, statt eine kaputte Seite zu liefern.
VERBOTEN = {"script": ("</script", "<script"), "style": ("</style",)}
# Die Icon-CSS verweist relativ auf ihre Schrift (url("fonts/bootstrap-icons.woff2?…")). In der erzeugten Einzeldatei
# gäbe es diese Datei nicht: die woff2-Schrift wird als data:-URI eingesetzt, der woff-Rückfall entfällt (#27).
SCHRIFT = re.compile(r'url\("fonts/(?P<datei>[^"?]+)(?:\?[^"]*)?"\)\s*format\("(?P<format>[\w-]+)"\)')
def _schriften_einbetten(inhalt: str, ordner: Path) -> str:
"""Ersetzt in einer CSS die relativen Schriftverweise: woff2 als data:-URI, alle übrigen Formate fallen weg."""
def ersetze(treffer: re.Match) -> str:
if treffer["format"] != "woff2":
return ""
daten = base64.b64encode((ordner / "fonts" / treffer["datei"]).read_bytes()).decode("ascii")
return f'url("data:font/woff2;base64,{daten}") format("woff2")'
ergebnis = SCHRIFT.sub(ersetze, inhalt)
ergebnis = re.sub(r',\s*(?=[;}])', '', ergebnis) # Komma vor dem entfallenen Rückfall
if 'url("fonts/' in ergebnis:
raise RuntimeError("Icon-CSS enthält einen Schriftverweis, der sich nicht einbetten lässt")
return ergebnis
def _js_wert(text: str | None) -> str:
"""Als JavaScript-Literal, sicher innerhalb von <script>: jedes '<' wird als \\u003c geschrieben."""
return json.dumps(text, ensure_ascii=False).replace("<", "\\u003c")
def baue_html(bpmn_xml: str, name: str | None = None) -> str:
"""Eigenständige Vorschau-Seite mit eingebetteter Bibliothek und eingebettetem Diagramm."""
html = VORLAGE.read_text(encoding="utf-8")
for fundstelle, (art, datei) in EINBETTUNGEN.items():
if fundstelle not in html:
raise RuntimeError(f"{VORLAGE.name}: '{fundstelle}' fehlt – Vorlage und scripts/vorschau.py passen nicht zusammen")
inhalt = (VORSCHAU / datei).read_text(encoding="utf-8")
if 'url("fonts/' in inhalt:
inhalt = _schriften_einbetten(inhalt, (VORSCHAU / datei).parent)
for sequenz in VERBOTEN[art]:
if sequenz in inhalt.lower():
raise RuntimeError(f"{datei} enthält '{sequenz}' und kann nicht sicher eingebettet werden")
html = html.replace(fundstelle, f"<{art}>\n{inhalt}\n</{art}>")
for platzhalter in (PLATZHALTER_DIAGRAMM, PLATZHALTER_NAME):
if platzhalter not in html:
raise RuntimeError(f"{VORLAGE.name}: Platzhalter '{platzhalter}' fehlt")
html = html.replace(PLATZHALTER_DIAGRAMM, f"const EINGEBETTET = {_js_wert(bpmn_xml)};")
return html.replace(PLATZHALTER_NAME, f"const DATEINAME = {_js_wert(name)};")
def lies_diagramm(pfad: Path) -> str:
"""BPMN-XML aus einer .bpmn lesen oder aus einer Arbeitsschritt-.md erzeugen."""
if pfad.suffix.lower() == ".md":
sys.path.insert(0, str(SKILL))
import md_to_bpmn # noqa: PLC0415 (liegt im Skill-Ordner, nicht als Paket installiert)
meta, steps, order, externals = md_to_bpmn.parse_markdown(pfad.read_text(encoding="utf-8"))
return md_to_bpmn.prettify(md_to_bpmn.build_bpmn(meta, steps, order, externals)).decode("utf-8")
return pfad.read_text(encoding="utf-8")
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("datei", nargs="?", help=".bpmn oder Arbeitsschritt-.md")
ap.add_argument("--ausgabe", help="HTML-Datei (Standard: temporäre Datei)")
ap.add_argument("--nicht-oeffnen", action="store_true", help="nur erzeugen, keinen Browser öffnen")
ap.add_argument("--stdin", action="store_true", help="BPMN-XML von stdin lesen, HTML nach stdout schreiben")
args = ap.parse_args()
# Windows-Konsolen und Pipes laufen oft mit cp1252; Meldungen mit Umlauten wären sonst verstümmelt.
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if args.stdin:
xml = sys.stdin.buffer.read().decode("utf-8")
sys.stdout.buffer.write(baue_html(xml).encode("utf-8"))
return 0
if not args.datei:
ap.error("Datei angeben oder --stdin verwenden")
pfad = Path(args.datei)
if not pfad.is_file():
print(f"Datei nicht gefunden: {pfad}")
return 1
try:
xml = lies_diagramm(pfad)
except ValueError as fehler:
print(f"{pfad} lässt sich nicht umwandeln: {fehler}")
return 1
if args.ausgabe:
ziel = Path(args.ausgabe)
else:
ziel = Path(tempfile.gettempdir()) / "bpmn-vorschau" / f"{pfad.stem}.html"
ziel.parent.mkdir(exist_ok=True)
ziel.write_text(baue_html(xml, pfad.name), encoding="utf-8")
print(f"Vorschau: {ziel}")
if not args.nicht_oeffnen:
webbrowser.open(ziel.resolve().as_uri())
return 0
if __name__ == "__main__":
raise SystemExit(main())