176 lines
8.8 KiB
Python
176 lines
8.8 KiB
Python
"""Рендерит представления из .archimate в один самодостаточный HTML со встроенным SVG.
|
||
Нужен, чтобы схемы можно было посмотреть без установки Archi."""
|
||
import sys, io, pathlib, html
|
||
import xml.etree.ElementTree as ET
|
||
|
||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
|
||
XSI = "{http://www.w3.org/2001/XMLSchema-instance}type"
|
||
src, dst = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])
|
||
root = ET.parse(src).getroot()
|
||
|
||
LAYER = { # цвета Archi по слоям
|
||
"ApplicationComponent": "#B5FFFF", "ApplicationService": "#B5FFFF",
|
||
"Node": "#C9E7B7", "SystemSoftware": "#C9E7B7",
|
||
"TechnologyService": "#C9E7B7", "Artifact": "#C9E7B7",
|
||
"Grouping": "#F4F4F4",
|
||
}
|
||
STROKE = {"Application": "#2a7f8f", "Technology": "#4a7f3a", "Grouping": "#888"}
|
||
|
||
concepts, rels = {}, {}
|
||
for folder in root.findall("folder"):
|
||
for e in folder.findall("element"):
|
||
et = (e.get(XSI) or "").split(":")[-1]
|
||
if et.endswith("Relationship"):
|
||
rels[e.get("id")] = (et[:-len("Relationship")], e.get("source"), e.get("target"))
|
||
elif et != "ArchimateDiagramModel":
|
||
concepts[e.get("id")] = (et, e.get("name") or "")
|
||
|
||
def collect(node, ox, oy, nodes, conns, notes):
|
||
b = node.find("bounds")
|
||
x, y = ox + int(b.get("x", 0)), oy + int(b.get("y", 0))
|
||
w, h = int(b.get("width", 120)), int(b.get("height", 55))
|
||
if (node.get(XSI) or "") == "archimate:Note":
|
||
notes.append((node.findtext("content") or "", x, y, w, h))
|
||
return
|
||
nodes[node.get("id")] = (node.get("archimateElement"), x, y, w, h)
|
||
for c in node.findall("sourceConnection"):
|
||
conns.append((c.get("relationship"), c.get("source"), c.get("target")))
|
||
for ch in node.findall("child"):
|
||
collect(ch, x, y, nodes, conns, notes)
|
||
|
||
def wrap(s, width, limit=4):
|
||
out, line = [], ""
|
||
for word in s.split():
|
||
if len(line) + len(word) + 1 > width and line:
|
||
out.append(line); line = word
|
||
else:
|
||
line = f"{line} {word}".strip()
|
||
if line:
|
||
out.append(line)
|
||
return out[:limit]
|
||
|
||
def edge_point(x, y, w, h, tx, ty):
|
||
"""Точка пересечения луча к (tx,ty) с границей прямоугольника."""
|
||
cx, cy = x + w / 2, y + h / 2
|
||
dx, dy = tx - cx, ty - cy
|
||
if dx == 0 and dy == 0:
|
||
return cx, cy
|
||
sx = (w / 2) / abs(dx) if dx else float("inf")
|
||
sy = (h / 2) / abs(dy) if dy else float("inf")
|
||
s = min(sx, sy)
|
||
return cx + dx * s, cy + dy * s
|
||
|
||
MARKERS = """
|
||
<defs>
|
||
<marker id="open" markerWidth="12" markerHeight="12" refX="10" refY="4" orient="auto">
|
||
<path d="M0,0 L10,4 L0,8" fill="none" stroke="#555" stroke-width="1.4"/></marker>
|
||
<marker id="hollow" markerWidth="14" markerHeight="14" refX="11" refY="5" orient="auto">
|
||
<path d="M0,0 L11,5 L0,10 Z" fill="#fff" stroke="#555" stroke-width="1.2"/></marker>
|
||
<marker id="filled" markerWidth="12" markerHeight="12" refX="10" refY="4" orient="auto">
|
||
<path d="M0,0 L10,4 L0,8 Z" fill="#555"/></marker>
|
||
<marker id="diamond" markerWidth="16" markerHeight="12" refX="1" refY="5" orient="auto">
|
||
<path d="M0,5 L6,1 L13,5 L6,9 Z" fill="#555"/></marker>
|
||
<marker id="ball" markerWidth="10" markerHeight="10" refX="2" refY="5" orient="auto">
|
||
<circle cx="4" cy="5" r="3.2" fill="#555"/></marker>
|
||
<marker id="odiamond" markerWidth="16" markerHeight="12" refX="1" refY="5" orient="auto">
|
||
<path d="M0,5 L6,1 L13,5 L6,9 Z" fill="#fff" stroke="#555" stroke-width="1.2"/></marker>
|
||
</defs>"""
|
||
|
||
STYLE = {
|
||
"Serving": ('stroke-dasharray="none"', 'marker-end="url(#open)"', ""),
|
||
"Realization": ('stroke-dasharray="6,4"', 'marker-end="url(#hollow)"', ""),
|
||
"Composition": ('stroke-dasharray="none"', "", 'marker-start="url(#diamond)"'),
|
||
"Assignment": ('stroke-dasharray="none"', 'marker-end="url(#filled)"', 'marker-start="url(#ball)"'),
|
||
"Aggregation": ('stroke-dasharray="none"', "", 'marker-start="url(#odiamond)"'),
|
||
}
|
||
|
||
parts = []
|
||
views = [e for f in root.findall("folder") for e in f.findall("element")
|
||
if (e.get(XSI) or "").endswith("ArchimateDiagramModel")]
|
||
|
||
for v in views:
|
||
nodes, conns, notes = {}, [], []
|
||
for ch in v.findall("child"):
|
||
collect(ch, 0, 0, nodes, conns, notes)
|
||
if not nodes:
|
||
continue
|
||
boxes = [(x, y, w, h) for _, x, y, w, h in nodes.values()] + \
|
||
[(x, y, w, h) for _, x, y, w, h in notes]
|
||
maxx = max(x + w for x, _, w, _ in boxes) + 40
|
||
maxy = max(y + h for _, y, _, h in boxes) + 40
|
||
|
||
svg = [f'<svg viewBox="0 0 {maxx} {maxy}" xmlns="http://www.w3.org/2000/svg" '
|
||
f'font-family="system-ui, sans-serif">', MARKERS]
|
||
|
||
# связи под блоками
|
||
for rid, so, to in conns:
|
||
if so not in nodes or to not in nodes or rid not in rels:
|
||
continue
|
||
rtype = rels[rid][0]
|
||
_, sx, sy, sw, sh = nodes[so]
|
||
_, tx, ty, tw, th = nodes[to]
|
||
x1, y1 = edge_point(sx, sy, sw, sh, tx + tw / 2, ty + th / 2)
|
||
x2, y2 = edge_point(tx, ty, tw, th, sx + sw / 2, sy + sh / 2)
|
||
dash, mend, mstart = STYLE.get(rtype, ('stroke-dasharray="none"', 'marker-end="url(#open)"', ""))
|
||
svg.append(f'<line x1="{x1:.0f}" y1="{y1:.0f}" x2="{x2:.0f}" y2="{y2:.0f}" '
|
||
f'stroke="#555" stroke-width="1.3" opacity="0.75" {dash} {mend} {mstart}/>')
|
||
|
||
# блоки: контейнеры раньше содержимого — сортируем по площади убыв.
|
||
for nid, (eref, x, y, w, h) in sorted(nodes.items(), key=lambda kv: -kv[1][3] * kv[1][4]):
|
||
etype, name = concepts.get(eref, ("", eref or "?"))
|
||
fill = LAYER.get(etype, "#EEEEEE")
|
||
stroke = (STROKE["Grouping"] if etype == "Grouping" else
|
||
STROKE["Application"] if etype.startswith("Application") else STROKE["Technology"])
|
||
big = w * h > 60000
|
||
svg.append(f'<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="4" fill="{fill}" '
|
||
f'fill-opacity="{0.35 if big else 1}" stroke="{stroke}" stroke-width="1.2"/>')
|
||
lines = wrap(name, max(12, w // 7))
|
||
if big: # подпись контейнера — сверху слева
|
||
svg.append(f'<text x="{x+10}" y="{y+20}" font-size="13" font-weight="600" '
|
||
f'fill="#1a3a1a">{html.escape(name)}</text>')
|
||
else:
|
||
fs = 11 if len(lines) > 2 else 12
|
||
y0 = y + h / 2 - (len(lines) - 1) * fs * 0.62
|
||
for i, ln in enumerate(lines):
|
||
svg.append(f'<text x="{x + w/2}" y="{y0 + i*fs*1.25:.0f}" font-size="{fs}" '
|
||
f'text-anchor="middle" dominant-baseline="middle" fill="#111">'
|
||
f'{html.escape(ln)}</text>')
|
||
|
||
# надписи — поверх всего, без заливки слоя: это не элементы модели
|
||
for content, x, y, w, h in notes:
|
||
svg.append(f'<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="3" fill="#fffbe8" '
|
||
f'stroke="#d8cfa8" stroke-width="1"/>')
|
||
for i, ln in enumerate(wrap(content, max(16, int(w / 6.2)), limit=int(h // 15))):
|
||
svg.append(f'<text x="{x+9}" y="{y + 17 + i*14}" font-size="11" fill="#4a4632">'
|
||
f'{html.escape(ln)}</text>')
|
||
svg.append("</svg>")
|
||
|
||
doc = v.find("documentation")
|
||
parts.append(f'<section><h2>{html.escape(v.get("name") or "")}</h2>'
|
||
+ (f'<p class="doc">{html.escape(doc.text)}</p>' if doc is not None and doc.text else "")
|
||
+ "".join(svg) + "</section>")
|
||
|
||
page = f"""<!doctype html>
|
||
<html lang="ru"><meta charset="utf-8">
|
||
<title>{html.escape(root.get('name') or 'Модель')}</title>
|
||
<style>
|
||
body {{ font-family: system-ui, sans-serif; margin: 0 auto; padding: 24px; max-width: 1760px;
|
||
background:#fafafa; color:#111; }}
|
||
h1 {{ font-size: 22px; }} h2 {{ font-size: 16px; margin: 32px 0 4px; }}
|
||
.doc {{ color:#555; font-size:13px; margin:0 0 10px; max-width:900px; }}
|
||
section {{ background:#fff; border:1px solid #ddd; border-radius:6px; padding:14px; margin-bottom:18px;
|
||
overflow-x:auto; }}
|
||
svg {{ width:100%; height:auto; min-width:700px; }}
|
||
.legend {{ font-size:13px; color:#444; }}
|
||
.legend code {{ background:#eee; padding:1px 5px; border-radius:3px; }}
|
||
</style>
|
||
<h1>{html.escape(root.get('name') or '')}</h1>
|
||
<p class="legend">Автогенерация из <code>{html.escape(src.name)}</code>. Зелёный — технологический слой,
|
||
голубой — прикладной. Пунктир с полым треугольником — realization, стрелка — serving,
|
||
ромб — composition, линия с шариком — assignment.</p>
|
||
{''.join(parts)}
|
||
</html>"""
|
||
|
||
dst.write_text(page, encoding="utf-8", newline="\n")
|
||
print(f"записано: {dst} ({len(parts)} представлений)")
|