2026-09-23 13:32:14 +02:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""Zeigt eine BPMN-Datei oder eine Arbeitsschritt-.md als Diagramm im Browser – offline, ohne PICTURE.
|
|
|
|
|
|
|
2026-09-25 20:11:29 +02:00
|
|
|
|
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.
|
2026-09-23 13:32:14 +02:00
|
|
|
|
|
|
|
|
|
|
Aufrufe (aus dem Workspace-Root):
|
2026-09-25 09:40:52 +02:00
|
|
|
|
python scripts/vorschau.py Prozessbibliothek/antrag-pruefen/antrag-pruefen.bpmn
|
2026-09-23 13:32:14 +02:00
|
|
|
|
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
|
2026-09-25 20:11:29 +02:00
|
|
|
|
import base64
|
2026-09-23 13:32:14 +02:00
|
|
|
|
import json
|
2026-09-25 20:11:29 +02:00
|
|
|
|
import re
|
2026-09-23 13:32:14 +02:00
|
|
|
|
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 = {
|
2026-09-25 20:11:29 +02:00
|
|
|
|
'<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"),
|
2026-09-23 13:32:14 +02:00
|
|
|
|
'<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",)}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-25 20:11:29 +02:00
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-23 13:32:14 +02:00
|
|
|
|
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")
|
2026-09-25 20:11:29 +02:00
|
|
|
|
if 'url("fonts/' in inhalt:
|
|
|
|
|
|
inhalt = _schriften_einbetten(inhalt, (VORSCHAU / datei).parent)
|
2026-09-23 13:32:14 +02:00
|
|
|
|
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())
|