iac/docs/architecture/render_preview.py

257 lines
14 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.

"""Рендерит представления из .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)} представлений)")