1013 lines
41 KiB
Python
1013 lines
41 KiB
Python
#!/usr/bin/env python3
|
|||
|
|
# -*- coding: utf-8 -*-
|
||
|
|
"""
|
||
|
|
md_to_bpmn.py
|
||
|
|
=============
|
||
|
|
Wandelt eine einfache Markdown-Prozessbeschreibung in eine standardkonforme
|
||
|
|
BPMN-2.0-XML-Datei um (OMG-Standard, offen dokumentiert, kein Bezug zu
|
||
|
|
internen PICTURE-UUIDs).
|
||
|
|
|
||
|
|
Wird bei einem Arbeitsschritt "Verantwortlicher: Aufgabe" geschrieben
|
||
|
|
(z. B. "S2 Task: Sachbearbeiter: Antrag prüfen"), erkennt das Skript den
|
||
|
|
Teil vor dem ersten Doppelpunkt als Verantwortlichen und erzeugt daraus
|
||
|
|
automatisch Swimlanes (Pool + Lanes). Ohne diese Angabe entsteht wie bisher
|
||
|
|
ein Diagramm ohne Lanes.
|
||
|
|
|
||
|
|
Unterstützt neben Task/Gateway/Start/Ende auch die übrigen für PICTURE
|
||
|
|
relevanten Elemente aus Elementkatalog.md (Aufgaben-Varianten, typisierte
|
||
|
|
Ereignisse, ereignisbasiertes Gateway, Datenobjekt/-speicher, Gruppe sowie
|
||
|
|
geschlossene Pools mit Nachrichtenflüssen). Siehe Anleitung.md für die
|
||
|
|
vollständige Syntax.
|
||
|
|
|
||
|
|
Aufruf:
|
||
|
|
python3 md_to_bpmn.py prozess.md ausgabe.bpmn
|
||
|
|
"""
|
||
|
|
import argparse
|
||
|
|
import math
|
||
|
|
import re
|
||
|
|
import sys
|
||
|
|
import xml.etree.ElementTree as ET
|
||
|
|
from xml.dom import minidom
|
||
|
|
from collections import defaultdict
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 1) DSL
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
SIMPLE_TYPE_ALIASES = {
|
||
|
|
'start': 'StartEvent', 'startereignis': 'StartEvent',
|
||
|
|
'ende': 'EndEvent', 'end': 'EndEvent', 'endereignis': 'EndEvent',
|
||
|
|
'zwischenereignis': 'IntermediateEvent', 'intermediate': 'IntermediateEvent',
|
||
|
|
|
||
|
|
'task': 'Task', 'aufgabe': 'Task',
|
||
|
|
'manuelle aufgabe': 'ManualTask', 'manual task': 'ManualTask', 'manualtask': 'ManualTask',
|
||
|
|
'user aufgabe': 'UserTask', 'user task': 'UserTask', 'usertask': 'UserTask',
|
||
|
|
'service aufgabe': 'ServiceTask', 'service task': 'ServiceTask', 'servicetask': 'ServiceTask',
|
||
|
|
'script aufgabe': 'ScriptTask', 'script task': 'ScriptTask', 'scripttask': 'ScriptTask',
|
||
|
|
'geschäftsregel aufgabe': 'BusinessRuleTask', 'geschaeftsregel aufgabe': 'BusinessRuleTask',
|
||
|
|
'business rule task': 'BusinessRuleTask', 'businessruletask': 'BusinessRuleTask',
|
||
|
|
'sendeaufgabe': 'SendTask', 'send task': 'SendTask', 'aufgabe sendend': 'SendTask', 'sendtask': 'SendTask',
|
||
|
|
'empfangsaufgabe': 'ReceiveTask', 'receive task': 'ReceiveTask', 'aufgabe empfangend': 'ReceiveTask',
|
||
|
|
'receivetask': 'ReceiveTask',
|
||
|
|
|
||
|
|
'gateway xor': 'XorGateway', 'xor': 'XorGateway', 'entscheidung': 'XorGateway',
|
||
|
|
'gateway or': 'OrGateway', 'gateway oder': 'OrGateway', 'or': 'OrGateway', 'oder': 'OrGateway',
|
||
|
|
'gateway and': 'AndGateway', 'gateway und': 'AndGateway', 'and': 'AndGateway',
|
||
|
|
'und': 'AndGateway', 'parallel': 'AndGateway',
|
||
|
|
'gateway ereignisbasiert': 'EventBasedGateway', 'gateway ebg': 'EventBasedGateway',
|
||
|
|
'ereignisbasierte verzweigung': 'EventBasedGateway', 'event based gateway': 'EventBasedGateway',
|
||
|
|
|
||
|
|
'hinweis': 'TextAnnotation', 'note': 'TextAnnotation', 'anmerkung': 'TextAnnotation',
|
||
|
|
'datenobjekt': 'DataObject', 'dokument': 'DataObject', 'data object': 'DataObject',
|
||
|
|
'datenspeicher': 'DataStore', 'data store': 'DataStore',
|
||
|
|
'gruppe': 'Group', 'group': 'Group',
|
||
|
|
|
||
|
|
'teilprozess': 'SubProcess', 'subprocess': 'SubProcess',
|
||
|
|
'geschlossener teilprozess': 'SubProcess',
|
||
|
|
'aufruf': 'CallActivity', 'aufrufelement': 'CallActivity', 'aufruf element': 'CallActivity',
|
||
|
|
'call activity': 'CallActivity', 'callactivity': 'CallActivity',
|
||
|
|
}
|
||
|
|
|
||
|
|
EVENT_USAGE_WORDS = {
|
||
|
|
'start': 'StartEvent', 'startereignis': 'StartEvent',
|
||
|
|
'zwischenereignis': 'IntermediateEvent', 'intermediate': 'IntermediateEvent',
|
||
|
|
'ende': 'EndEvent', 'end': 'EndEvent', 'endereignis': 'EndEvent',
|
||
|
|
}
|
||
|
|
EVENT_KIND_WORDS = {
|
||
|
|
'nachricht': 'message', 'message': 'message',
|
||
|
|
'zeit': 'timer', 'timer': 'timer',
|
||
|
|
'eskalation': 'escalation', 'escalation': 'escalation',
|
||
|
|
'bedingung': 'conditional', 'condition': 'conditional', 'conditional': 'conditional',
|
||
|
|
'link': 'link',
|
||
|
|
'fehler': 'error', 'error': 'error',
|
||
|
|
'signal': 'signal',
|
||
|
|
'terminierung': 'terminate', 'terminate': 'terminate', 'termination': 'terminate',
|
||
|
|
}
|
||
|
|
EVENT_DIRECTION_WORDS = {
|
||
|
|
'sendend': 'throw', 'send': 'throw', 'werfend': 'throw', 'throw': 'throw',
|
||
|
|
'empfangend': 'catch', 'receive': 'catch', 'fangend': 'catch', 'catch': 'catch',
|
||
|
|
}
|
||
|
|
EVENT_DEFINITION_TAG = {
|
||
|
|
'message': 'messageEventDefinition', 'timer': 'timerEventDefinition',
|
||
|
|
'escalation': 'escalationEventDefinition', 'conditional': 'conditionalEventDefinition',
|
||
|
|
'link': 'linkEventDefinition', 'error': 'errorEventDefinition',
|
||
|
|
'signal': 'signalEventDefinition', 'terminate': 'terminateEventDefinition',
|
||
|
|
}
|
||
|
|
|
||
|
|
UNCONNECTED_TYPES = {'TextAnnotation', 'DataObject', 'DataStore', 'Group'}
|
||
|
|
|
||
|
|
# Für die Verteilung mehrerer Anschlusspunkte (Modellierregel 4) muss die tatsächliche
|
||
|
|
# Kontur bekannt sein: ein Punkt auf dem rechteckigen Begrenzungsrahmen liegt bei einer
|
||
|
|
# Raute (Gateway) oder einem Kreis (Ereignis) sonst neben der sichtbaren Form.
|
||
|
|
SHAPE_KIND = {
|
||
|
|
'StartEvent': 'circle', 'EndEvent': 'circle', 'IntermediateEvent': 'circle',
|
||
|
|
'XorGateway': 'diamond', 'OrGateway': 'diamond', 'AndGateway': 'diamond',
|
||
|
|
'EventBasedGateway': 'diamond',
|
||
|
|
}
|
||
|
|
|
||
|
|
DEFAULT_SIZE = {
|
||
|
|
'StartEvent': (36, 36), 'EndEvent': (36, 36), 'IntermediateEvent': (36, 36),
|
||
|
|
'XorGateway': (50, 50), 'OrGateway': (50, 50), 'AndGateway': (50, 50), 'EventBasedGateway': (50, 50),
|
||
|
|
'Task': (100, 80), 'ManualTask': (100, 80), 'UserTask': (100, 80), 'ServiceTask': (100, 80),
|
||
|
|
'ScriptTask': (100, 80), 'BusinessRuleTask': (100, 80), 'SendTask': (100, 80), 'ReceiveTask': (100, 80),
|
||
|
|
'SubProcess': (100, 80), 'CallActivity': (100, 80),
|
||
|
|
'TextAnnotation': (160, 60), 'DataObject': (36, 50), 'DataStore': (50, 50), 'Group': (220, 140),
|
||
|
|
}
|
||
|
|
|
||
|
|
STEP_RE = re.compile(
|
||
|
|
r'^(?P<id>[A-Za-z0-9_.\-]+)\s+'
|
||
|
|
r'(?P<typewords>[A-Za-zÄÖÜäöüß]+(?:\s+[A-Za-zÄÖÜäöüß]+){0,7})'
|
||
|
|
r'\s*:\s*(?P<label>.*)$',
|
||
|
|
re.IGNORECASE,
|
||
|
|
)
|
||
|
|
ARROW_RE = re.compile(r'^\s*(?:(?P<label>.+?)\s*)?->\s*(?P<target>\S+)\s*$')
|
||
|
|
MSGFLOW_OUT_RE = re.compile(r'^\s*(?:(?P<label>.+?)\s*)?~>\s*(?P<target>.+?)\s*$')
|
||
|
|
MSGFLOW_IN_RE = re.compile(r'^\s*(?:(?P<label>.+?)\s*)?<~\s*(?P<target>.+?)\s*$')
|
||
|
|
FRONTMATTER_RE = re.compile(r'^(?P<key>[A-Za-zÄÖÜäöüß ]+):\s*(?P<value>.*)$')
|
||
|
|
|
||
|
|
UNASSIGNED_LANE = 'Nicht zugeordnet'
|
||
|
|
EXTERN_KEYS = {'extern', 'externer beteiligter', 'externe beteiligte', 'external'}
|
||
|
|
|
||
|
|
|
||
|
|
def resolve_type(typewords_raw, raw_line):
|
||
|
|
"""Ordnet die Typwörter vor dem Doppelpunkt einem internen Elementtyp zu.
|
||
|
|
|
||
|
|
Gibt (typ, event_kind, event_direction) zurück; event_kind/-direction
|
||
|
|
sind nur für Start-/Zwischen-/Endereignisse mit Typisierung gesetzt.
|
||
|
|
"""
|
||
|
|
norm = re.sub(r'\s+', ' ', typewords_raw.strip().lower())
|
||
|
|
if norm in SIMPLE_TYPE_ALIASES:
|
||
|
|
return SIMPLE_TYPE_ALIASES[norm], None, None
|
||
|
|
|
||
|
|
words = norm.split(' ')
|
||
|
|
usage = EVENT_USAGE_WORDS.get(words[0])
|
||
|
|
if usage is None:
|
||
|
|
raise ValueError(f'Unbekannter Schritt-Typ: {typewords_raw!r} (Zeile: {raw_line!r})')
|
||
|
|
|
||
|
|
kind = None
|
||
|
|
direction = None
|
||
|
|
for w in words[1:]:
|
||
|
|
if w in EVENT_KIND_WORDS:
|
||
|
|
if kind is not None:
|
||
|
|
raise ValueError(f'Mehrfache Ereignis-Typisierung in {typewords_raw!r} (Zeile: {raw_line!r})')
|
||
|
|
kind = EVENT_KIND_WORDS[w]
|
||
|
|
elif w in EVENT_DIRECTION_WORDS:
|
||
|
|
if direction is not None:
|
||
|
|
raise ValueError(f'Mehrfache Richtungsangabe in {typewords_raw!r} (Zeile: {raw_line!r})')
|
||
|
|
direction = EVENT_DIRECTION_WORDS[w]
|
||
|
|
else:
|
||
|
|
raise ValueError(f'Unbekanntes Wort {w!r} in Ereignis-Typ {typewords_raw!r} (Zeile: {raw_line!r})')
|
||
|
|
|
||
|
|
if direction is None:
|
||
|
|
direction = 'throw' if usage == 'EndEvent' else 'catch'
|
||
|
|
|
||
|
|
if usage == 'StartEvent' and direction == 'throw':
|
||
|
|
raise ValueError(
|
||
|
|
f'Startereignisse sind immer eintretend/empfangend, "sendend" ist hier nicht '
|
||
|
|
f'zulässig (Zeile: {raw_line!r})')
|
||
|
|
if usage == 'EndEvent' and direction == 'catch':
|
||
|
|
raise ValueError(
|
||
|
|
f'Endereignisse sind immer auslösend/sendend, "empfangend" ist hier nicht '
|
||
|
|
f'zulässig (Zeile: {raw_line!r})')
|
||
|
|
if kind == 'terminate' and usage != 'EndEvent':
|
||
|
|
raise ValueError(f'"Terminierung" ist nur als Endereignis zulässig (Zeile: {raw_line!r})')
|
||
|
|
if kind == 'link' and usage != 'IntermediateEvent':
|
||
|
|
raise ValueError(
|
||
|
|
f'"Link" ist nur als Zwischenereignis zulässig, paarweise sendend/empfangend '
|
||
|
|
f'(Zeile: {raw_line!r})')
|
||
|
|
|
||
|
|
return usage, kind, direction
|
||
|
|
|
||
|
|
|
||
|
|
def split_lane(label):
|
||
|
|
"""Erkennt 'Verantwortlicher: Rest' am Anfang eines Labels.
|
||
|
|
Gibt (lane_oder_None, bereinigtes_label) zurück."""
|
||
|
|
if ':' not in label:
|
||
|
|
return None, label
|
||
|
|
prefix, rest = label.split(':', 1)
|
||
|
|
prefix = prefix.strip()
|
||
|
|
rest = rest.strip()
|
||
|
|
if not rest or not prefix:
|
||
|
|
return None, label
|
||
|
|
if len(prefix) > 30 or len(prefix.split()) > 4:
|
||
|
|
return None, label
|
||
|
|
if '?' in prefix or not prefix[0].isalpha():
|
||
|
|
return None, label
|
||
|
|
return prefix, rest
|
||
|
|
|
||
|
|
|
||
|
|
class Step:
|
||
|
|
def __init__(self, sid, typ, label, order, event_kind=None, event_direction=None):
|
||
|
|
self.id = sid
|
||
|
|
self.type = typ
|
||
|
|
self.order = order
|
||
|
|
self.edges = [] # (label_or_None, target_id)
|
||
|
|
self.msg_flows = [] # (direction 'out'/'in', label_or_None, externer_name)
|
||
|
|
self.event_kind = event_kind
|
||
|
|
self.event_direction = event_direction
|
||
|
|
if typ in UNCONNECTED_TYPES:
|
||
|
|
self.lane = None
|
||
|
|
self.label = label.strip()
|
||
|
|
else:
|
||
|
|
lane, clean = split_lane(label.strip())
|
||
|
|
self.lane = lane
|
||
|
|
self.label = clean
|
||
|
|
|
||
|
|
|
||
|
|
def parse_markdown(text):
|
||
|
|
meta = {}
|
||
|
|
steps = {}
|
||
|
|
order = []
|
||
|
|
externals = []
|
||
|
|
current = None
|
||
|
|
|
||
|
|
for raw_line in text.splitlines():
|
||
|
|
stripped = raw_line.strip()
|
||
|
|
if not stripped or stripped.startswith('#'):
|
||
|
|
continue
|
||
|
|
|
||
|
|
step_m = STEP_RE.match(stripped)
|
||
|
|
if not step_m and current is not None:
|
||
|
|
if '~>' in stripped:
|
||
|
|
m = MSGFLOW_OUT_RE.match(stripped)
|
||
|
|
if m:
|
||
|
|
current.msg_flows.append(('out', m.group('label'), m.group('target').strip()))
|
||
|
|
continue
|
||
|
|
if '<~' in stripped:
|
||
|
|
m = MSGFLOW_IN_RE.match(stripped)
|
||
|
|
if m:
|
||
|
|
current.msg_flows.append(('in', m.group('label'), m.group('target').strip()))
|
||
|
|
continue
|
||
|
|
if '->' in stripped:
|
||
|
|
m = ARROW_RE.match(stripped)
|
||
|
|
if m:
|
||
|
|
current.edges.append((m.group('label'), m.group('target')))
|
||
|
|
continue
|
||
|
|
|
||
|
|
if step_m:
|
||
|
|
sid = step_m.group('id')
|
||
|
|
typ, event_kind, event_direction = resolve_type(step_m.group('typewords'), raw_line)
|
||
|
|
label = step_m.group('label')
|
||
|
|
if sid in steps:
|
||
|
|
raise ValueError(f'Doppelte Schritt-ID: {sid!r}')
|
||
|
|
st = Step(sid, typ, label, len(order), event_kind, event_direction)
|
||
|
|
steps[sid] = st
|
||
|
|
order.append(sid)
|
||
|
|
current = st
|
||
|
|
continue
|
||
|
|
|
||
|
|
fm = FRONTMATTER_RE.match(stripped)
|
||
|
|
if fm and current is None:
|
||
|
|
key = fm.group('key').strip().lower()
|
||
|
|
value = fm.group('value').strip()
|
||
|
|
if key in EXTERN_KEYS:
|
||
|
|
externals.append(value)
|
||
|
|
else:
|
||
|
|
meta[key] = value
|
||
|
|
continue
|
||
|
|
|
||
|
|
if current is None:
|
||
|
|
continue
|
||
|
|
raise ValueError(f'Zeile konnte nicht interpretiert werden: {raw_line!r}')
|
||
|
|
|
||
|
|
flow_order = [sid for sid in order if steps[sid].type not in UNCONNECTED_TYPES]
|
||
|
|
for i, sid in enumerate(flow_order):
|
||
|
|
st = steps[sid]
|
||
|
|
if not st.edges and st.type != 'EndEvent':
|
||
|
|
if i + 1 < len(flow_order):
|
||
|
|
st.edges.append((None, flow_order[i + 1]))
|
||
|
|
|
||
|
|
for st in steps.values():
|
||
|
|
for _, tgt in st.edges:
|
||
|
|
if tgt not in steps:
|
||
|
|
raise ValueError(f'Schritt {st.id!r} verweist auf unbekanntes Ziel {tgt!r}')
|
||
|
|
if st.type in UNCONNECTED_TYPES or steps[tgt].type in UNCONNECTED_TYPES:
|
||
|
|
raise ValueError(
|
||
|
|
f'Sequenzfluss {st.id!r} -> {tgt!r} nicht erlaubt: Hinweise, Datenobjekte, '
|
||
|
|
f'Datenspeicher und Gruppen sind keine Ablaufelemente (allenfalls per Notiz '
|
||
|
|
f'zuordnen, in PICTURE nach dem Import als Assoziation verbinden).')
|
||
|
|
for _, _, target in st.msg_flows:
|
||
|
|
if target not in externals:
|
||
|
|
raise ValueError(
|
||
|
|
f'Schritt {st.id!r}: unbekannter externer Beteiligter {target!r} - vorher mit '
|
||
|
|
f'"Extern: {target}" deklarieren.')
|
||
|
|
|
||
|
|
incoming_count = defaultdict(int)
|
||
|
|
for st in steps.values():
|
||
|
|
for _, tgt in st.edges:
|
||
|
|
incoming_count[tgt] += 1
|
||
|
|
for st in steps.values():
|
||
|
|
if st.type == 'EventBasedGateway' and incoming_count[st.id] > 1:
|
||
|
|
raise ValueError(
|
||
|
|
f'Ereignisbasiertes Gateway {st.id!r} hat mehrere eingehende Kanten - es darf laut '
|
||
|
|
f'Modellierregeln nicht als Zusammenführung genutzt werden.')
|
||
|
|
|
||
|
|
return meta, steps, order, externals
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 2) Layer-Berechnung (gemeinsam für beide Layout-Modi)
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
def compute_layers(steps, order):
|
||
|
|
WHITE, GRAY, BLACK = 0, 1, 2
|
||
|
|
color = {sid: WHITE for sid in order}
|
||
|
|
back_edges = set()
|
||
|
|
|
||
|
|
def dfs(u):
|
||
|
|
color[u] = GRAY
|
||
|
|
for _, v in steps[u].edges:
|
||
|
|
if color[v] == WHITE:
|
||
|
|
dfs(v)
|
||
|
|
elif color[v] == GRAY:
|
||
|
|
back_edges.add((u, v))
|
||
|
|
color[u] = BLACK
|
||
|
|
|
||
|
|
for sid in order:
|
||
|
|
if color[sid] == WHITE:
|
||
|
|
dfs(sid)
|
||
|
|
|
||
|
|
fwd_preds = {sid: [] for sid in order}
|
||
|
|
for sid in order:
|
||
|
|
for _, tgt in steps[sid].edges:
|
||
|
|
if (sid, tgt) in back_edges:
|
||
|
|
continue
|
||
|
|
fwd_preds[tgt].append(sid)
|
||
|
|
|
||
|
|
layer = {}
|
||
|
|
|
||
|
|
def longest_path(sid, visiting=None):
|
||
|
|
if sid in layer:
|
||
|
|
return layer[sid]
|
||
|
|
visiting = (visiting or set()) | {sid}
|
||
|
|
preds = fwd_preds[sid]
|
||
|
|
layer[sid] = 0 if not preds else 1 + max(longest_path(p, visiting) for p in preds)
|
||
|
|
return layer[sid]
|
||
|
|
|
||
|
|
for sid in order:
|
||
|
|
longest_path(sid)
|
||
|
|
|
||
|
|
return layer, fwd_preds, back_edges
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 2a) Layout ohne Lanes: Schichten von oben nach unten
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
COLUMN_WIDTH = 170
|
||
|
|
ROW_HEIGHT = 170
|
||
|
|
MARGIN = 80
|
||
|
|
|
||
|
|
|
||
|
|
def layout_vertical(steps, order):
|
||
|
|
layer, fwd_preds, back_edges = compute_layers(steps, order)
|
||
|
|
max_layer = max(layer.values()) if layer else 0
|
||
|
|
layers = {i: [] for i in range(max_layer + 1)}
|
||
|
|
for sid in order:
|
||
|
|
layers[layer[sid]].append(sid)
|
||
|
|
|
||
|
|
x_pos = {}
|
||
|
|
for i in sorted(layers):
|
||
|
|
nodes_in_layer = sorted(layers[i], key=lambda s: steps[s].order)
|
||
|
|
if i == 0:
|
||
|
|
for idx, sid in enumerate(nodes_in_layer):
|
||
|
|
x_pos[sid] = idx * COLUMN_WIDTH
|
||
|
|
else:
|
||
|
|
bary = {}
|
||
|
|
for sid in nodes_in_layer:
|
||
|
|
preds = fwd_preds[sid]
|
||
|
|
bary[sid] = (sum(x_pos[p] for p in preds) / len(preds)) if preds else steps[sid].order * COLUMN_WIDTH
|
||
|
|
nodes_in_layer.sort(key=lambda s: (bary[s], steps[s].order))
|
||
|
|
for idx, sid in enumerate(nodes_in_layer):
|
||
|
|
x_pos[sid] = idx * COLUMN_WIDTH
|
||
|
|
if nodes_in_layer:
|
||
|
|
mean_x = sum(x_pos[s] for s in nodes_in_layer) / len(nodes_in_layer)
|
||
|
|
for sid in nodes_in_layer:
|
||
|
|
x_pos[sid] -= mean_x
|
||
|
|
|
||
|
|
min_x = min(x_pos.values()) if x_pos else 0
|
||
|
|
shift = MARGIN - min_x
|
||
|
|
for sid in x_pos:
|
||
|
|
x_pos[sid] += shift
|
||
|
|
|
||
|
|
y_pos = {sid: MARGIN + layer[sid] * ROW_HEIGHT for sid in order}
|
||
|
|
return x_pos, y_pos, back_edges, None
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 2b) Layout mit Lanes: Schichten von links nach rechts, Lanes als Zeilen
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
LANE_COLUMN_WIDTH = 190
|
||
|
|
LANE_HEADER_WIDTH = 30
|
||
|
|
LANE_PADDING = 40
|
||
|
|
LANE_ROW_HEIGHT = 150
|
||
|
|
LANE_MIN_HEIGHT = 150
|
||
|
|
|
||
|
|
|
||
|
|
def layout_lanes(steps, order, lanes):
|
||
|
|
layer, fwd_preds, back_edges = compute_layers(steps, order)
|
||
|
|
lane_index = {name: i for i, name in enumerate(lanes)}
|
||
|
|
|
||
|
|
# je Lane: wie viele Knoten teilen sich denselben Layer? -> Zeilen je Lane
|
||
|
|
lane_layer_nodes = defaultdict(lambda: defaultdict(list))
|
||
|
|
for sid in order:
|
||
|
|
st = steps[sid]
|
||
|
|
if st.type in UNCONNECTED_TYPES:
|
||
|
|
continue
|
||
|
|
lane_layer_nodes[st.lane or UNASSIGNED_LANE][layer[sid]].append(sid)
|
||
|
|
|
||
|
|
lane_rows = {}
|
||
|
|
for lane in lanes:
|
||
|
|
buckets = lane_layer_nodes.get(lane, {})
|
||
|
|
lane_rows[lane] = max((len(v) for v in buckets.values()), default=1)
|
||
|
|
|
||
|
|
lane_height = {lane: max(LANE_MIN_HEIGHT, lane_rows[lane] * LANE_ROW_HEIGHT + LANE_PADDING)
|
||
|
|
for lane in lanes}
|
||
|
|
lane_top = {}
|
||
|
|
running = 0
|
||
|
|
for lane in lanes:
|
||
|
|
lane_top[lane] = running
|
||
|
|
running += lane_height[lane]
|
||
|
|
pool_height = running
|
||
|
|
|
||
|
|
x_pos, y_pos = {}, {}
|
||
|
|
for lane in lanes:
|
||
|
|
buckets = lane_layer_nodes.get(lane, {})
|
||
|
|
for lyr, sids in buckets.items():
|
||
|
|
sids_sorted = sorted(sids, key=lambda s: steps[s].order)
|
||
|
|
n = len(sids_sorted)
|
||
|
|
band_h = lane_height[lane]
|
||
|
|
for row, sid in enumerate(sids_sorted):
|
||
|
|
st = steps[sid]
|
||
|
|
_, h = DEFAULT_SIZE.get(st.type, (100, 80))
|
||
|
|
slot_h = band_h / n
|
||
|
|
x_pos[sid] = MARGIN + LANE_HEADER_WIDTH + lyr * LANE_COLUMN_WIDTH
|
||
|
|
y_pos[sid] = lane_top[lane] + row * slot_h + (slot_h - h) / 2
|
||
|
|
|
||
|
|
# Unverbundene Artefakte (Hinweis, Datenobjekt, Datenspeicher, Gruppe): rechts
|
||
|
|
# neben dem letzten Layer, gestapelt in der ersten Lane.
|
||
|
|
max_layer = max(layer.values()) if layer else 0
|
||
|
|
unconnected_i = 0
|
||
|
|
for sid in order:
|
||
|
|
st = steps[sid]
|
||
|
|
if st.type not in UNCONNECTED_TYPES:
|
||
|
|
continue
|
||
|
|
x_pos[sid] = MARGIN + LANE_HEADER_WIDTH + (max_layer + 1) * LANE_COLUMN_WIDTH
|
||
|
|
y_pos[sid] = lane_top[lanes[0]] + LANE_PADDING + unconnected_i * 90
|
||
|
|
unconnected_i += 1
|
||
|
|
|
||
|
|
return x_pos, y_pos, back_edges, (lane_top, lane_height, pool_height)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 3) BPMN-2.0-XML aufbauen
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
BPMN_TAG = {
|
||
|
|
'StartEvent': 'startEvent', 'EndEvent': 'endEvent',
|
||
|
|
'IntermediateEvent': 'intermediateCatchEvent', # bei "sendend" überschrieben, siehe bpmn_tag_for
|
||
|
|
'Task': 'task', 'ManualTask': 'manualTask', 'UserTask': 'userTask',
|
||
|
|
'ServiceTask': 'serviceTask', 'ScriptTask': 'scriptTask',
|
||
|
|
'BusinessRuleTask': 'businessRuleTask', 'SendTask': 'sendTask', 'ReceiveTask': 'receiveTask',
|
||
|
|
'SubProcess': 'subProcess', 'CallActivity': 'callActivity',
|
||
|
|
'XorGateway': 'exclusiveGateway', 'OrGateway': 'inclusiveGateway', 'AndGateway': 'parallelGateway',
|
||
|
|
'EventBasedGateway': 'eventBasedGateway',
|
||
|
|
}
|
||
|
|
ID_PREFIX = {
|
||
|
|
'StartEvent': 'StartEvent', 'EndEvent': 'EndEvent', 'IntermediateEvent': 'IntermediateEvent',
|
||
|
|
'Task': 'Task', 'ManualTask': 'ManualTask', 'UserTask': 'UserTask',
|
||
|
|
'ServiceTask': 'ServiceTask', 'ScriptTask': 'ScriptTask',
|
||
|
|
'BusinessRuleTask': 'BusinessRuleTask', 'SendTask': 'SendTask', 'ReceiveTask': 'ReceiveTask',
|
||
|
|
'SubProcess': 'SubProcess', 'CallActivity': 'CallActivity',
|
||
|
|
'XorGateway': 'Gateway', 'OrGateway': 'Gateway', 'AndGateway': 'Gateway', 'EventBasedGateway': 'Gateway',
|
||
|
|
'TextAnnotation': 'TextAnnotation', 'DataObject': 'DataObject', 'DataStore': 'DataStore', 'Group': 'Group',
|
||
|
|
}
|
||
|
|
|
||
|
|
NS = {
|
||
|
|
'bpmn': 'http://www.omg.org/spec/BPMN/20100524/MODEL',
|
||
|
|
'bpmndi': 'http://www.omg.org/spec/BPMN/20100524/DI',
|
||
|
|
'dc': 'http://www.omg.org/spec/DD/20100524/DC',
|
||
|
|
'di': 'http://www.omg.org/spec/DD/20100524/DI',
|
||
|
|
}
|
||
|
|
for prefix, uri in NS.items():
|
||
|
|
ET.register_namespace(prefix, uri)
|
||
|
|
|
||
|
|
|
||
|
|
def q(prefix, tag):
|
||
|
|
return f'{{{NS[prefix]}}}{tag}'
|
||
|
|
|
||
|
|
|
||
|
|
def bpmn_tag_for(st):
|
||
|
|
if st.type == 'IntermediateEvent':
|
||
|
|
return 'intermediateThrowEvent' if st.event_direction == 'throw' else 'intermediateCatchEvent'
|
||
|
|
return BPMN_TAG[st.type]
|
||
|
|
|
||
|
|
|
||
|
|
def sanitize_id(raw):
|
||
|
|
s = re.sub(r'[^A-Za-z0-9_.\-]', '_', raw)
|
||
|
|
if not s or not (s[0].isalpha() or s[0] == '_'):
|
||
|
|
s = 'n_' + s
|
||
|
|
return s
|
||
|
|
|
||
|
|
|
||
|
|
def spread_point(x, y, w, h, side, index, count, kind='rect'):
|
||
|
|
"""Verteilt mehrere Anschlusspunkte auf derselben Elementseite, statt sie alle
|
||
|
|
im Mittelpunkt zusammenlaufen zu lassen (Modellierregel: eigener Anschlusspunkt
|
||
|
|
je Kante). Bei count=1 identisch zum bisherigen Mittelpunkt-Anschluss.
|
||
|
|
|
||
|
|
`kind` legt die tatsächliche Kontur fest, auf der die Punkte liegen müssen:
|
||
|
|
'rect' (Rechteck, z. B. Task), 'circle' (Ereignis) oder 'diamond' (Gateway).
|
||
|
|
Ein Punkt auf dem bloßen Begrenzungsrahmen würde bei Kreis/Raute außerhalb
|
||
|
|
der sichtbaren Form landen und die Kante wirkt dann unverbunden."""
|
||
|
|
frac = (index + 1) / (count + 1)
|
||
|
|
cx, cy = x + w / 2, y + h / 2
|
||
|
|
|
||
|
|
if kind == 'circle':
|
||
|
|
r = min(w, h) / 2
|
||
|
|
base_angle = {'E': 0.0, 'S': 90.0, 'W': 180.0, 'N': 270.0}[side]
|
||
|
|
spread_deg = 70.0
|
||
|
|
angle = math.radians(base_angle + (frac - 0.5) * spread_deg)
|
||
|
|
return cx + r * math.cos(angle), cy + r * math.sin(angle)
|
||
|
|
|
||
|
|
if kind == 'diamond':
|
||
|
|
vertex = {'N': (cx, y), 'S': (cx, y + h), 'W': (x, cy), 'E': (x + w, cy)}[side]
|
||
|
|
neg_target, pos_target = {
|
||
|
|
'N': ((x, cy), (x + w, cy)), 'S': ((x, cy), (x + w, cy)),
|
||
|
|
'W': ((cx, y), (cx, y + h)), 'E': ((cx, y), (cx, y + h)),
|
||
|
|
}[side]
|
||
|
|
offset = frac - 0.5
|
||
|
|
t = min(abs(offset) * 2 * 0.6, 0.6)
|
||
|
|
tx, ty = pos_target if offset > 0 else neg_target
|
||
|
|
vx, vy = vertex
|
||
|
|
return vx + (tx - vx) * t, vy + (ty - vy) * t
|
||
|
|
|
||
|
|
if side == 'N':
|
||
|
|
return x + w * frac, y
|
||
|
|
if side == 'S':
|
||
|
|
return x + w * frac, y + h
|
||
|
|
if side == 'W':
|
||
|
|
return x, y + h * frac
|
||
|
|
if side == 'E':
|
||
|
|
return x + w, y + h * frac
|
||
|
|
raise ValueError(f'Unbekannte Seite: {side!r}')
|
||
|
|
|
||
|
|
|
||
|
|
GATEWAY_OUTWARD = {'N': (0, -1), 'S': (0, 1), 'E': (1, 0), 'W': (-1, 0)}
|
||
|
|
# Halbe Kantenlaenge der Raute (Standardgroesse 50x50) reicht, um deren Kontur
|
||
|
|
# in jedem Fall sicher zu verlassen, bevor die Kante abbiegt.
|
||
|
|
GATEWAY_STUB = 25
|
||
|
|
|
||
|
|
|
||
|
|
def _dedupe_consecutive(points):
|
||
|
|
out = [points[0]]
|
||
|
|
for p in points[1:]:
|
||
|
|
if p != out[-1]:
|
||
|
|
out.append(p)
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def _orthogonal_middle(ox, oy, src_side, tx, ty, tgt_side):
|
||
|
|
src_axis = 'v' if src_side in ('N', 'S') else 'h'
|
||
|
|
tgt_axis = 'v' if tgt_side in ('N', 'S') else 'h'
|
||
|
|
if src_axis == 'v' and tgt_axis == 'v':
|
||
|
|
if abs(ox - tx) < 1e-6:
|
||
|
|
return [(ox, oy), (tx, ty)]
|
||
|
|
mid_y = oy + (ty - oy) / 2
|
||
|
|
return [(ox, oy), (ox, mid_y), (tx, mid_y), (tx, ty)]
|
||
|
|
if src_axis == 'h' and tgt_axis == 'h':
|
||
|
|
if abs(oy - ty) < 1e-6:
|
||
|
|
return [(ox, oy), (tx, ty)]
|
||
|
|
mid_x = ox + (tx - ox) / 2
|
||
|
|
return [(ox, oy), (mid_x, oy), (mid_x, ty), (tx, ty)]
|
||
|
|
if src_axis == 'v':
|
||
|
|
return [(ox, oy), (ox, ty), (tx, ty)]
|
||
|
|
return [(ox, oy), (tx, oy), (tx, ty)]
|
||
|
|
|
||
|
|
|
||
|
|
def orthogonal_points(ox, oy, src_side, tx, ty, tgt_side,
|
||
|
|
src_is_gateway=False, tgt_is_gateway=False):
|
||
|
|
"""Baut einen rechtwinkligen Kantenverlauf zwischen zwei Anschlusspunkten, der
|
||
|
|
am Start gerade in Richtung src_side abgeht und am Ziel gerade aus Richtung
|
||
|
|
tgt_side ankommt - so, wie PICTURE Verbindungen an einem Gateway zeichnet.
|
||
|
|
|
||
|
|
An einer Raute (`src_is_gateway`/`tgt_is_gateway`) liegt der Anschlusspunkt
|
||
|
|
auf der Kontur, nicht am Rand einer Bounding-Box: ein direkter Kurs zum
|
||
|
|
jeweils anderen Ende kann je nach dessen Lage durchs Innere der Raute
|
||
|
|
zurücklaufen. Deshalb macht die Kante dort zuerst einen kurzen Schritt
|
||
|
|
gerade nach aussen (Modellierregel: Kante nie durchs Innere eines
|
||
|
|
Elements), bevor sie zum Ziel abbiegt - und zwar so, dass die Achse
|
||
|
|
dieses Schritts (die einzige garantiert freie Koordinate) beim nächsten
|
||
|
|
Abbiegen zunächst unverändert bleibt. Sonst würde die Kante beim
|
||
|
|
Umbiegen zum Ziel auf demselben Weg zurück durch die Raute laufen, den
|
||
|
|
sie gerade erst verlassen hat."""
|
||
|
|
sx, sy = ox, oy
|
||
|
|
if src_is_gateway:
|
||
|
|
dx, dy = GATEWAY_OUTWARD[src_side]
|
||
|
|
sx, sy = ox + dx * GATEWAY_STUB, oy + dy * GATEWAY_STUB
|
||
|
|
ex, ey = tx, ty
|
||
|
|
if tgt_is_gateway:
|
||
|
|
dx, dy = GATEWAY_OUTWARD[tgt_side]
|
||
|
|
ex, ey = tx + dx * GATEWAY_STUB, ty + dy * GATEWAY_STUB
|
||
|
|
|
||
|
|
src_axis = 'v' if src_side in ('N', 'S') else 'h'
|
||
|
|
tgt_axis = 'v' if tgt_side in ('N', 'S') else 'h'
|
||
|
|
|
||
|
|
if not src_is_gateway and not tgt_is_gateway:
|
||
|
|
points = _orthogonal_middle(ox, oy, src_side, tx, ty, tgt_side)
|
||
|
|
elif src_is_gateway and not tgt_is_gateway:
|
||
|
|
# sy/sx (die Stub-Achse) fixieren, solange die andere Koordinate zum
|
||
|
|
# Ziel hin schwenkt - erst danach auf der Stub-Achse zum Ziel.
|
||
|
|
if src_axis == 'v':
|
||
|
|
points = [(ox, oy), (sx, sy), (tx, sy), (tx, ty)]
|
||
|
|
else:
|
||
|
|
points = [(ox, oy), (sx, sy), (sx, ty), (tx, ty)]
|
||
|
|
elif tgt_is_gateway and not src_is_gateway:
|
||
|
|
# Spiegelbildlich: erst auf der Stub-Achse des Ziels ankommen, erst
|
||
|
|
# danach dorthin schwenken.
|
||
|
|
if tgt_axis == 'v':
|
||
|
|
points = [(ox, oy), (ox, ey), (ex, ey), (tx, ty)]
|
||
|
|
else:
|
||
|
|
points = [(ox, oy), (ex, oy), (ex, ey), (tx, ty)]
|
||
|
|
else:
|
||
|
|
# Beide Enden sind Rauten: von der Stub-Achse des Starts direkt auf
|
||
|
|
# die Stub-Achse des Ziels schwenken, jede nur einmal verlassen.
|
||
|
|
if src_axis == 'v':
|
||
|
|
points = [(ox, oy), (sx, sy), (ex, sy), (ex, ey), (tx, ty)]
|
||
|
|
else:
|
||
|
|
points = [(ox, oy), (sx, sy), (sx, ey), (ex, ey), (tx, ty)]
|
||
|
|
|
||
|
|
return _dedupe_consecutive(points)
|
||
|
|
|
||
|
|
|
||
|
|
GATEWAY_OUT_PREF = ['S', 'E', 'W', 'N']
|
||
|
|
GATEWAY_IN_PREF = ['N', 'W', 'E', 'S']
|
||
|
|
|
||
|
|
|
||
|
|
def assign_gateway_sides(steps, flows):
|
||
|
|
"""PICTURE-Gateways haben nur 4 feste Anschlusspunkte (N/O/S/W); jede Kante
|
||
|
|
dort verlaeuft gerade. Weist daher jeder (nicht rueckwaertigen) Kante an
|
||
|
|
einem Gateway einen eigenen Eckpunkt zu. Ab der fuenften Kante an
|
||
|
|
demselben Gateway muss ein Punkt zweimal genutzt werden - das ist eine
|
||
|
|
echte Grenze von PICTURE, keine Modellierungsfrage (siehe Einschraenkungen
|
||
|
|
in Anleitung.md)."""
|
||
|
|
touching = defaultdict(list)
|
||
|
|
for flow_id, src, tgt, label, is_back in flows:
|
||
|
|
if is_back:
|
||
|
|
continue
|
||
|
|
if SHAPE_KIND.get(steps[src].type) == 'diamond':
|
||
|
|
touching[src].append((f'{flow_id}_src', False))
|
||
|
|
if SHAPE_KIND.get(steps[tgt].type) == 'diamond':
|
||
|
|
touching[tgt].append((f'{flow_id}_tgt', True))
|
||
|
|
|
||
|
|
assigned = {}
|
||
|
|
for sid, uses in touching.items():
|
||
|
|
used_sides = set()
|
||
|
|
overflow = []
|
||
|
|
for use_id, is_incoming in uses:
|
||
|
|
pref = GATEWAY_IN_PREF if is_incoming else GATEWAY_OUT_PREF
|
||
|
|
for side in pref:
|
||
|
|
if side not in used_sides:
|
||
|
|
assigned[use_id] = side
|
||
|
|
used_sides.add(side)
|
||
|
|
break
|
||
|
|
else:
|
||
|
|
overflow.append((use_id, is_incoming))
|
||
|
|
for use_id, is_incoming in overflow:
|
||
|
|
pref = GATEWAY_IN_PREF if is_incoming else GATEWAY_OUT_PREF
|
||
|
|
assigned[use_id] = pref[0]
|
||
|
|
return assigned
|
||
|
|
|
||
|
|
|
||
|
|
def build_bpmn(meta_md, steps, order, externals):
|
||
|
|
process_name = (meta_md.get('prozess') or meta_md.get('prozessname')
|
||
|
|
or meta_md.get('name') or 'Neuer Prozess')
|
||
|
|
ext_names = list(dict.fromkeys(externals)) # Reihenfolge erhalten, Duplikate entfernen
|
||
|
|
|
||
|
|
lanes = []
|
||
|
|
seen = set()
|
||
|
|
for sid in order:
|
||
|
|
st = steps[sid]
|
||
|
|
if st.type in UNCONNECTED_TYPES:
|
||
|
|
continue
|
||
|
|
name = st.lane or None
|
||
|
|
if name and name not in seen:
|
||
|
|
seen.add(name)
|
||
|
|
lanes.append(name)
|
||
|
|
has_lanes = bool(lanes)
|
||
|
|
if has_lanes and any((steps[sid].lane is None) for sid in order if steps[sid].type not in UNCONNECTED_TYPES):
|
||
|
|
lanes.append(UNASSIGNED_LANE)
|
||
|
|
|
||
|
|
has_collaboration = has_lanes or bool(ext_names)
|
||
|
|
|
||
|
|
if has_lanes:
|
||
|
|
x_pos, y_pos, back_edges, lane_geo = layout_lanes(steps, order, lanes)
|
||
|
|
else:
|
||
|
|
x_pos, y_pos, back_edges, lane_geo = layout_vertical(steps, order)
|
||
|
|
|
||
|
|
node_ids = {}
|
||
|
|
used = set()
|
||
|
|
for sid in order:
|
||
|
|
st = steps[sid]
|
||
|
|
base = f'{ID_PREFIX.get(st.type, "Node")}_{sanitize_id(sid)}'
|
||
|
|
candidate = base
|
||
|
|
n = 1
|
||
|
|
while candidate in used:
|
||
|
|
n += 1
|
||
|
|
candidate = f'{base}_{n}'
|
||
|
|
used.add(candidate)
|
||
|
|
node_ids[sid] = candidate
|
||
|
|
|
||
|
|
definitions = ET.Element(q('bpmn', 'definitions'), {
|
||
|
|
'id': 'Definitions_1',
|
||
|
|
'targetNamespace': 'http://picture-tool.local/bpmn',
|
||
|
|
'exporter': 'md_to_bpmn.py',
|
||
|
|
})
|
||
|
|
|
||
|
|
process_id = 'Process_1'
|
||
|
|
collaboration = None
|
||
|
|
ext_ids = {}
|
||
|
|
msg_flow_records = []
|
||
|
|
if has_collaboration:
|
||
|
|
collaboration = ET.SubElement(definitions, q('bpmn', 'collaboration'), {'id': 'Collaboration_1'})
|
||
|
|
participant_id = 'Participant_1'
|
||
|
|
ET.SubElement(collaboration, q('bpmn', 'participant'), {
|
||
|
|
'id': participant_id, 'name': process_name, 'processRef': process_id,
|
||
|
|
})
|
||
|
|
for i, name in enumerate(ext_names, start=1):
|
||
|
|
eid = f'Participant_Ext_{i}'
|
||
|
|
ext_ids[name] = eid
|
||
|
|
ET.SubElement(collaboration, q('bpmn', 'participant'), {'id': eid, 'name': name})
|
||
|
|
|
||
|
|
msg_n = 0
|
||
|
|
for sid in order:
|
||
|
|
st = steps[sid]
|
||
|
|
for direction, label, target in st.msg_flows:
|
||
|
|
msg_n += 1
|
||
|
|
mfid = f'MsgFlow_{msg_n}'
|
||
|
|
if direction == 'out':
|
||
|
|
src, tgt = node_ids[sid], ext_ids[target]
|
||
|
|
else:
|
||
|
|
src, tgt = ext_ids[target], node_ids[sid]
|
||
|
|
attrs = {'id': mfid, 'sourceRef': src, 'targetRef': tgt}
|
||
|
|
if label:
|
||
|
|
attrs['name'] = label
|
||
|
|
ET.SubElement(collaboration, q('bpmn', 'messageFlow'), attrs)
|
||
|
|
msg_flow_records.append((mfid, sid, direction, target))
|
||
|
|
|
||
|
|
process = ET.SubElement(definitions, q('bpmn', 'process'), {
|
||
|
|
'id': process_id, 'name': process_name, 'isExecutable': 'false',
|
||
|
|
})
|
||
|
|
|
||
|
|
lane_ids = {}
|
||
|
|
if has_lanes:
|
||
|
|
lane_set = ET.SubElement(process, q('bpmn', 'laneSet'), {'id': 'LaneSet_1'})
|
||
|
|
for i, lane_name in enumerate(lanes, start=1):
|
||
|
|
lid = f'Lane_{i}'
|
||
|
|
lane_ids[lane_name] = lid
|
||
|
|
lane_el = ET.SubElement(lane_set, q('bpmn', 'lane'), {'id': lid, 'name': lane_name})
|
||
|
|
for sid in order:
|
||
|
|
st = steps[sid]
|
||
|
|
if st.type in UNCONNECTED_TYPES:
|
||
|
|
continue
|
||
|
|
if (st.lane or UNASSIGNED_LANE) == lane_name:
|
||
|
|
ET.SubElement(lane_el, q('bpmn', 'flowNodeRef')).text = node_ids[sid]
|
||
|
|
|
||
|
|
incoming = {sid: [] for sid in order}
|
||
|
|
outgoing = {sid: [] for sid in order}
|
||
|
|
flows = []
|
||
|
|
flow_n = 0
|
||
|
|
for sid in order:
|
||
|
|
st = steps[sid]
|
||
|
|
for label, tgt in st.edges:
|
||
|
|
flow_n += 1
|
||
|
|
flow_id = f'Flow_{flow_n}'
|
||
|
|
is_back = (sid, tgt) in back_edges
|
||
|
|
flows.append((flow_id, sid, tgt, label, is_back))
|
||
|
|
outgoing[sid].append(flow_id)
|
||
|
|
incoming[tgt].append(flow_id)
|
||
|
|
|
||
|
|
for sid in order:
|
||
|
|
st = steps[sid]
|
||
|
|
if st.type in UNCONNECTED_TYPES:
|
||
|
|
continue
|
||
|
|
tag = bpmn_tag_for(st)
|
||
|
|
el = ET.SubElement(process, q('bpmn', tag), {'id': node_ids[sid], 'name': st.label})
|
||
|
|
for fid in incoming[sid]:
|
||
|
|
ET.SubElement(el, q('bpmn', 'incoming')).text = fid
|
||
|
|
for fid in outgoing[sid]:
|
||
|
|
ET.SubElement(el, q('bpmn', 'outgoing')).text = fid
|
||
|
|
if st.event_kind:
|
||
|
|
ET.SubElement(el, q('bpmn', EVENT_DEFINITION_TAG[st.event_kind]), {'id': f'{node_ids[sid]}_ed'})
|
||
|
|
|
||
|
|
for sid in order:
|
||
|
|
st = steps[sid]
|
||
|
|
if st.type == 'TextAnnotation':
|
||
|
|
el = ET.SubElement(process, q('bpmn', 'textAnnotation'), {'id': node_ids[sid]})
|
||
|
|
ET.SubElement(el, q('bpmn', 'text')).text = st.label
|
||
|
|
elif st.type == 'DataObject':
|
||
|
|
obj_id = f'{node_ids[sid]}_obj'
|
||
|
|
ET.SubElement(process, q('bpmn', 'dataObject'), {'id': obj_id})
|
||
|
|
ET.SubElement(process, q('bpmn', 'dataObjectReference'), {
|
||
|
|
'id': node_ids[sid], 'name': st.label, 'dataObjectRef': obj_id,
|
||
|
|
})
|
||
|
|
elif st.type == 'DataStore':
|
||
|
|
ET.SubElement(process, q('bpmn', 'dataStoreReference'), {'id': node_ids[sid], 'name': st.label})
|
||
|
|
elif st.type == 'Group':
|
||
|
|
category_id = f'{node_ids[sid]}_Category'
|
||
|
|
category = ET.SubElement(definitions, q('bpmn', 'category'), {'id': category_id})
|
||
|
|
cv_id = f'{category_id}_Value'
|
||
|
|
ET.SubElement(category, q('bpmn', 'categoryValue'), {'id': cv_id, 'value': st.label})
|
||
|
|
ET.SubElement(process, q('bpmn', 'group'), {'id': node_ids[sid], 'categoryValueRef': cv_id})
|
||
|
|
|
||
|
|
for flow_id, src, tgt, label, is_back in flows:
|
||
|
|
attrs = {'id': flow_id, 'sourceRef': node_ids[src], 'targetRef': node_ids[tgt]}
|
||
|
|
if label:
|
||
|
|
attrs['name'] = label
|
||
|
|
ET.SubElement(process, q('bpmn', 'sequenceFlow'), attrs)
|
||
|
|
|
||
|
|
# --- Diagram Interchange ---
|
||
|
|
diagram = ET.SubElement(definitions, q('bpmndi', 'BPMNDiagram'), {'id': 'Diagram_1'})
|
||
|
|
plane_root = 'Collaboration_1' if has_collaboration else process_id
|
||
|
|
plane = ET.SubElement(diagram, q('bpmndi', 'BPMNPlane'), {'id': 'Plane_1', 'bpmnElement': plane_root})
|
||
|
|
|
||
|
|
def bounds_of(sid):
|
||
|
|
st = steps[sid]
|
||
|
|
w, h = DEFAULT_SIZE.get(st.type, (100, 80))
|
||
|
|
return x_pos[sid], y_pos[sid], w, h
|
||
|
|
|
||
|
|
pool_width = max((x_pos[sid] + bounds_of(sid)[2] for sid in order), default=400) + MARGIN
|
||
|
|
if has_lanes:
|
||
|
|
lane_top, lane_height, pool_height = lane_geo
|
||
|
|
else:
|
||
|
|
pool_height = max((y_pos[sid] + bounds_of(sid)[3] for sid in order), default=300) + MARGIN
|
||
|
|
|
||
|
|
ext_pos = {}
|
||
|
|
if has_collaboration:
|
||
|
|
ET.SubElement(plane, q('bpmndi', 'BPMNShape'), {
|
||
|
|
'id': 'Participant_1_di', 'bpmnElement': 'Participant_1', 'isHorizontal': 'true',
|
||
|
|
}).append(_bounds_el(0, 0, pool_width, pool_height))
|
||
|
|
if has_lanes:
|
||
|
|
for lane_name in lanes:
|
||
|
|
ET.SubElement(plane, q('bpmndi', 'BPMNShape'), {
|
||
|
|
'id': f'{lane_ids[lane_name]}_di', 'bpmnElement': lane_ids[lane_name], 'isHorizontal': 'true',
|
||
|
|
}).append(_bounds_el(0, lane_top[lane_name], pool_width, lane_height[lane_name]))
|
||
|
|
ext_top = pool_height + 60
|
||
|
|
for i, name in enumerate(ext_names):
|
||
|
|
eid = ext_ids[name]
|
||
|
|
ex_y = ext_top + i * 130
|
||
|
|
ext_pos[name] = (0, ex_y, pool_width, 100)
|
||
|
|
ET.SubElement(plane, q('bpmndi', 'BPMNShape'), {
|
||
|
|
'id': f'{eid}_di', 'bpmnElement': eid, 'isHorizontal': 'true',
|
||
|
|
}).append(_bounds_el(0, ex_y, pool_width, 100))
|
||
|
|
|
||
|
|
for sid in order:
|
||
|
|
shape = ET.SubElement(plane, q('bpmndi', 'BPMNShape'), {
|
||
|
|
'id': f'{node_ids[sid]}_di', 'bpmnElement': node_ids[sid],
|
||
|
|
})
|
||
|
|
x, y, w, h = bounds_of(sid)
|
||
|
|
shape.append(_bounds_el(x, y, w, h))
|
||
|
|
|
||
|
|
# Anschlusspunkte je (Element, Seite) zählen, damit mehrere Kanten an derselben
|
||
|
|
# Seite nicht alle im selben Punkt zusammenlaufen (Modellierregel 4).
|
||
|
|
def bounds_for(key):
|
||
|
|
kind, ident = key
|
||
|
|
return bounds_of(ident) if kind == 'n' else ext_pos[ident]
|
||
|
|
|
||
|
|
edge_sides = {}
|
||
|
|
anchor_group = defaultdict(list)
|
||
|
|
use_index = {}
|
||
|
|
|
||
|
|
def register_anchor(key, side, use_id):
|
||
|
|
lst = anchor_group[(key, side)]
|
||
|
|
use_index[use_id] = len(lst)
|
||
|
|
lst.append(use_id)
|
||
|
|
|
||
|
|
gateway_sides = assign_gateway_sides(steps, flows)
|
||
|
|
|
||
|
|
for flow_id, src, tgt, label, is_back in flows:
|
||
|
|
if has_lanes:
|
||
|
|
src_side, tgt_side = ('E', 'W') if not is_back else ('S', 'S')
|
||
|
|
elif not is_back:
|
||
|
|
src_side, tgt_side = 'S', 'N'
|
||
|
|
else:
|
||
|
|
same_side = 'W' if x_pos[src] <= x_pos[tgt] else 'E'
|
||
|
|
src_side, tgt_side = same_side, same_side
|
||
|
|
if not is_back:
|
||
|
|
# PICTURE-Gateways haben nur 4 feste Anschlusspunkte (N/O/S/W) - eigene
|
||
|
|
# Zuteilung statt der obigen Layout-Konvention, siehe assign_gateway_sides().
|
||
|
|
src_side = gateway_sides.get(f'{flow_id}_src', src_side)
|
||
|
|
tgt_side = gateway_sides.get(f'{flow_id}_tgt', tgt_side)
|
||
|
|
edge_sides[flow_id] = (src_side, tgt_side)
|
||
|
|
register_anchor(('n', src), src_side, f'{flow_id}_src')
|
||
|
|
register_anchor(('n', tgt), tgt_side, f'{flow_id}_tgt')
|
||
|
|
|
||
|
|
for mfid, sid, direction, target in msg_flow_records:
|
||
|
|
register_anchor(('n', sid), 'S', f'{mfid}_node')
|
||
|
|
register_anchor(('x', target), 'N', f'{mfid}_ext')
|
||
|
|
|
||
|
|
def shape_kind_for(key):
|
||
|
|
kind, ident = key
|
||
|
|
return SHAPE_KIND.get(steps[ident].type, 'rect') if kind == 'n' else 'rect'
|
||
|
|
|
||
|
|
def spread_anchor(key, side, use_id):
|
||
|
|
x, y, w, h = bounds_for(key)
|
||
|
|
idx = use_index[use_id]
|
||
|
|
count = len(anchor_group[(key, side)])
|
||
|
|
return spread_point(x, y, w, h, side, idx, count, shape_kind_for(key))
|
||
|
|
|
||
|
|
if has_lanes:
|
||
|
|
pool_bottom = lane_geo[2]
|
||
|
|
|
||
|
|
for flow_id, src, tgt, label, is_back in flows:
|
||
|
|
edge = ET.SubElement(plane, q('bpmndi', 'BPMNEdge'), {
|
||
|
|
'id': f'{flow_id}_di', 'bpmnElement': flow_id,
|
||
|
|
})
|
||
|
|
src_side, tgt_side = edge_sides[flow_id]
|
||
|
|
ox, oy = spread_anchor(('n', src), src_side, f'{flow_id}_src')
|
||
|
|
tx, ty = spread_anchor(('n', tgt), tgt_side, f'{flow_id}_tgt')
|
||
|
|
if not is_back:
|
||
|
|
src_is_gateway = SHAPE_KIND.get(steps[src].type) == 'diamond'
|
||
|
|
tgt_is_gateway = SHAPE_KIND.get(steps[tgt].type) == 'diamond'
|
||
|
|
points = orthogonal_points(ox, oy, src_side, tx, ty, tgt_side,
|
||
|
|
src_is_gateway, tgt_is_gateway)
|
||
|
|
elif has_lanes:
|
||
|
|
channel_y = pool_bottom + 50
|
||
|
|
points = [(ox, oy), (ox, channel_y), (tx, channel_y), (tx, ty)]
|
||
|
|
else:
|
||
|
|
offset = -60 if src_side == 'W' else 60
|
||
|
|
channel_x = (min(ox, tx) if src_side == 'W' else max(ox, tx)) + offset
|
||
|
|
points = [(ox, oy), (channel_x, oy), (channel_x, ty), (tx, ty)]
|
||
|
|
|
||
|
|
for px, py in points:
|
||
|
|
ET.SubElement(edge, q('di', 'waypoint'), {'x': f'{px:g}', 'y': f'{py:g}'})
|
||
|
|
|
||
|
|
if label:
|
||
|
|
# explizite Label-Position setzen, um Überlappungen mit Pfeilen/Nachbarlabels
|
||
|
|
# zu reduzieren: am ersten Segment, leicht seitlich versetzt
|
||
|
|
(p0x, p0y), (p1x, p1y) = points[0], points[1] if len(points) > 1 else points[0]
|
||
|
|
mx, my = (p0x + p1x) / 2, (p0y + p1y) / 2
|
||
|
|
if abs(p0y - p1y) < 1e-6: # horizontales Segment -> Label darüber
|
||
|
|
lx, ly = mx - 15, my - 20
|
||
|
|
else: # vertikales Segment -> Label seitlich
|
||
|
|
lx, ly = mx + 8, my - 8
|
||
|
|
bpmn_label = ET.SubElement(edge, q('bpmndi', 'BPMNLabel'))
|
||
|
|
bpmn_label.append(_bounds_el(lx, ly, max(30, len(label) * 6), 14))
|
||
|
|
|
||
|
|
for mfid, sid, direction, target in msg_flow_records:
|
||
|
|
edge = ET.SubElement(plane, q('bpmndi', 'BPMNEdge'), {'id': f'{mfid}_di', 'bpmnElement': mfid})
|
||
|
|
ox, oy = spread_anchor(('n', sid), 'S', f'{mfid}_node')
|
||
|
|
tx, ty = spread_anchor(('x', target), 'N', f'{mfid}_ext')
|
||
|
|
points = [(ox, oy), (tx, ty)] if direction == 'out' else [(tx, ty), (ox, oy)]
|
||
|
|
for px, py in points:
|
||
|
|
ET.SubElement(edge, q('di', 'waypoint'), {'x': f'{px:g}', 'y': f'{py:g}'})
|
||
|
|
|
||
|
|
return definitions
|
||
|
|
|
||
|
|
|
||
|
|
def _bounds_el(x, y, w, h):
|
||
|
|
el = ET.Element(q('dc', 'Bounds'), {
|
||
|
|
'x': f'{x:g}', 'y': f'{y:g}', 'width': f'{w:g}', 'height': f'{h:g}',
|
||
|
|
})
|
||
|
|
return el
|
||
|
|
|
||
|
|
|
||
|
|
def prettify(elem):
|
||
|
|
rough = ET.tostring(elem, encoding='utf-8')
|
||
|
|
parsed = minidom.parseString(rough)
|
||
|
|
return parsed.toprettyxml(indent=' ', encoding='UTF-8')
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
# Windows-Konsolen laufen häufig mit einer Legacy-Kodierung (z. B. cp1252) statt UTF-8;
|
||
|
|
# ohne dies würden Umlaute in Meldungen als Mojibake erscheinen oder das Programm abstürzen.
|
||
|
|
for stream in (sys.stdout, sys.stderr):
|
||
|
|
if hasattr(stream, 'reconfigure'):
|
||
|
|
stream.reconfigure(encoding='utf-8', errors='replace')
|
||
|
|
|
||
|
|
ap = argparse.ArgumentParser(description='Markdown-Prozessbeschreibung -> BPMN 2.0 XML')
|
||
|
|
ap.add_argument('markdown_file')
|
||
|
|
ap.add_argument('output_bpmn')
|
||
|
|
args = ap.parse_args()
|
||
|
|
|
||
|
|
with open(args.markdown_file, encoding='utf-8') as f:
|
||
|
|
md_text = f.read()
|
||
|
|
|
||
|
|
try:
|
||
|
|
meta_md, steps, order, externals = parse_markdown(md_text)
|
||
|
|
definitions = build_bpmn(meta_md, steps, order, externals)
|
||
|
|
except ValueError as exc:
|
||
|
|
print(f'Fehler beim Verarbeiten von {args.markdown_file}: {exc}', file=sys.stderr)
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
xml_bytes = prettify(definitions)
|
||
|
|
with open(args.output_bpmn, 'wb') as f:
|
||
|
|
f.write(xml_bytes)
|
||
|
|
|
||
|
|
n_edges = sum(len(s.edges) for s in steps.values())
|
||
|
|
n_msgflows = sum(len(s.msg_flows) for s in steps.values())
|
||
|
|
lanes_used = len({steps[sid].lane for sid in order if steps[sid].lane})
|
||
|
|
extra = f', {lanes_used} Lane(s)' if lanes_used else ''
|
||
|
|
if externals:
|
||
|
|
extra += f', {n_msgflows} Nachrichtenfluss(e) zu {len(set(externals))} externen Beteiligten'
|
||
|
|
print(f'OK: {len(order)} Schritte, {n_edges} Kanten{extra} -> {args.output_bpmn}')
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == '__main__':
|
||
|
|
main()
|