257 lines
14 KiB
Python
257 lines
14 KiB
Python
"""Рендерит представления из .archimate в один самодостаточный HTML со встроенным SVG.
|
||
Нужен, чтобы схемы можно было посмотреть без установки Archi."""
|
||
import sys, io, pathlib, html, re
|
||
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 "",
|
||
(e.findtext("documentation") or "").strip())
|
||
|
||
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 tooltip(doc, limit=260):
|
||
"""Первый абзац описания, обрезанный по границе предложения: подсказка должна
|
||
давать понять, что это за элемент, а не пересказывать весь текст."""
|
||
s = doc.split("\n\n")[0].replace("\n", " ").replace("`", "").strip()
|
||
if len(s) <= limit:
|
||
return s
|
||
cut = s[:limit]
|
||
end = max(cut.rfind(". "), cut.rfind("; "))
|
||
return (cut[:end + 1] if end > limit // 2 else cut.rstrip() + "…")
|
||
|
||
def paragraphs(doc):
|
||
return [p.replace("\n", " ").strip() for p in doc.split("\n\n") if p.strip()]
|
||
|
||
def rich(p):
|
||
"""Описания пришли из markdown-файлов: обратные кавычки там означают код."""
|
||
return re.sub(r"`([^`]+)`", r"<code>\1</code>", html.escape(p))
|
||
|
||
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, doc = 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
|
||
# описание элемента — всплывающей подсказкой: на схеме ему места нет,
|
||
# а в модели оно есть, иначе его видно только в Archi
|
||
svg.append("<g>" + (f'<title>{html.escape(tooltip(doc))}</title>' if doc else ""))
|
||
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>')
|
||
svg.append("</g>")
|
||
|
||
# надписи — поверх всего, без заливки слоя: это не элементы модели
|
||
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 id="{v.get("id")}"><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>")
|
||
|
||
toc = "".join(f'<a href="#{v.get("id")}">{html.escape(v.get("name") or "")}</a>' for v in views)
|
||
purpose = (root.findtext("purpose") or "").strip()
|
||
|
||
# ---------------------------------------------------------------- прикладной слой
|
||
# Описания компонентов лежат в модели, но на схемах их не видно: там помещается
|
||
# только имя. Поэтому — отдельным справочником, в том же порядке, что и на карте A0.
|
||
APP = "ApplicationComponent"
|
||
groups, seen = [], set()
|
||
for gid, (etype, gname, gdoc) in concepts.items():
|
||
if etype != "Grouping":
|
||
continue
|
||
members = [t for rtype, s, t in rels.values()
|
||
if rtype == "Aggregation" and s == gid and concepts.get(t, ("",))[0] == APP]
|
||
if members:
|
||
seen.update(members)
|
||
groups.append((gname, gdoc, sorted(members, key=lambda i: concepts[i][1])))
|
||
rest = sorted((i for i, c in concepts.items() if c[0] == APP and i not in seen),
|
||
key=lambda i: concepts[i][1])
|
||
if rest:
|
||
groups.append(("Вне групп", "", rest))
|
||
|
||
app_html = ""
|
||
if groups:
|
||
total = sum(len(m) for _, _, m in groups)
|
||
app_html = ('<section id="app-layer"><h2>Прикладной слой: описания компонентов</h2>'
|
||
f'<p class="doc">{total} компонентов в том же порядке, что и на карте A0. '
|
||
'Описания выведены не из манифестов контура, а из исходного кода компонентных '
|
||
'репозиториев, и потому проверяются чтением кода, а не сверкой с конфигурацией. '
|
||
'То же описание всплывает подсказкой, если задержать курсор на блоке любой схемы.</p>')
|
||
for gname, gdoc, members in groups:
|
||
app_html += f'<h3>{html.escape(gname)}</h3>'
|
||
for p in paragraphs(gdoc):
|
||
app_html += f'<p class="doc">{rich(p)}</p>'
|
||
app_html += '<dl class="apps">'
|
||
for i in members:
|
||
_, name, doc = concepts[i]
|
||
# запасное значение сборщика — путь к манифестам, а не описание
|
||
body = ("" if doc.startswith("apps/") else
|
||
"".join(f"<p>{rich(p)}</p>" for p in paragraphs(doc))) or \
|
||
f'<p class="none">описание не заполнено — см. {html.escape(doc or "манифесты")}</p>'
|
||
app_html += f'<dt>{html.escape(name)}</dt><dd>{body}</dd>'
|
||
app_html += "</dl>"
|
||
app_html += "</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: 0 0 4px; }}
|
||
h3 {{ font-size: 14px; margin: 22px 0 6px; }}
|
||
.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; scroll-margin-top:12px; }}
|
||
svg {{ width:100%; height:auto; min-width:700px; }}
|
||
.legend {{ font-size:13px; color:#444; max-width:900px; }}
|
||
.legend code {{ background:#eee; padding:1px 5px; border-radius:3px; }}
|
||
nav {{ display:flex; flex-wrap:wrap; gap:6px; margin:14px 0 22px; }}
|
||
nav a {{ font-size:12px; color:#245; text-decoration:none; background:#fff; border:1px solid #ddd;
|
||
border-radius:4px; padding:3px 8px; }}
|
||
nav a:hover {{ background:#eef4f8; }}
|
||
dl.apps {{ margin:0; }}
|
||
dl.apps dt {{ font-weight:600; font-size:13px; margin-top:14px; }}
|
||
dl.apps dd {{ margin:2px 0 0; padding-left:0; color:#333; font-size:13px; max-width:900px; }}
|
||
dl.apps dd p {{ margin:0 0 6px; }}
|
||
dl.apps .none {{ color:#999; font-style:italic; }}
|
||
code {{ background:#f0f0f0; padding:1px 4px; border-radius:3px; font-size:12px; }}
|
||
</style>
|
||
<h1>{html.escape(root.get('name') or '')}</h1>
|
||
<p class="legend">Автогенерация из <code>{html.escape(src.name)}</code>. Зелёный — технологический слой,
|
||
голубой — прикладной. Пунктир с полым треугольником — realization, стрелка — serving,
|
||
ромб — composition, линия с шариком — assignment, полый ромб — aggregation. Подробнее — в представлении
|
||
«L1. Как читать схемы». Описание элемента всплывает подсказкой при наведении на блок.</p>
|
||
{''.join(f'<p class="legend">{html.escape(p)}</p>' for p in paragraphs(purpose))}
|
||
<nav>{toc}{'<a href="#app-layer">Прикладной слой: описания</a>' if app_html else ''}</nav>
|
||
{''.join(parts)}
|
||
{app_html}
|
||
</html>"""
|
||
|
||
dst.write_text(page, encoding="utf-8", newline="\n")
|
||
print(f"записано: {dst} ({len(parts)} представлений)")
|