"""Рендерит представления из .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"\1", 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 = """ """ 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'', 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'') # блоки: контейнеры раньше содержимого — сортируем по площади убыв. 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("" + (f'{html.escape(tooltip(doc))}' if doc else "")) svg.append(f'') lines = wrap(name, max(12, w // 7)) if big: # подпись контейнера — сверху слева svg.append(f'{html.escape(name)}') 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'' f'{html.escape(ln)}') svg.append("") # надписи — поверх всего, без заливки слоя: это не элементы модели for content, x, y, w, h in notes: svg.append(f'') for i, ln in enumerate(wrap(content, max(16, int(w / 6.2)), limit=int(h // 15))): svg.append(f'' f'{html.escape(ln)}') svg.append("") doc = v.find("documentation") parts.append(f'

{html.escape(v.get("name") or "")}

' + (f'

{html.escape(doc.text)}

' if doc is not None and doc.text else "") + "".join(svg) + "
") toc = "".join(f'{html.escape(v.get("name") or "")}' 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 = ('

Прикладной слой: описания компонентов

' f'

{total} компонентов в том же порядке, что и на карте A0. ' 'Описания выведены не из манифестов контура, а из исходного кода компонентных ' 'репозиториев, и потому проверяются чтением кода, а не сверкой с конфигурацией. ' 'То же описание всплывает подсказкой, если задержать курсор на блоке любой схемы.

') for gname, gdoc, members in groups: app_html += f'

{html.escape(gname)}

' for p in paragraphs(gdoc): app_html += f'

{rich(p)}

' app_html += '
' for i in members: _, name, doc = concepts[i] # запасное значение сборщика — путь к манифестам, а не описание body = ("" if doc.startswith("apps/") else "".join(f"

{rich(p)}

" for p in paragraphs(doc))) or \ f'

описание не заполнено — см. {html.escape(doc or "манифесты")}

' app_html += f'
{html.escape(name)}
{body}
' app_html += "
" app_html += "
" page = f""" {html.escape(root.get('name') or 'Модель')}

{html.escape(root.get('name') or '')}

Автогенерация из {html.escape(src.name)}. Зелёный — технологический слой, голубой — прикладной. Пунктир с полым треугольником — realization, стрелка — serving, ромб — composition, линия с шариком — assignment, полый ромб — aggregation. Подробнее — в представлении «L1. Как читать схемы». Описание элемента всплывает подсказкой при наведении на блок.

{''.join(f'

{html.escape(p)}

' for p in paragraphs(purpose))} {''.join(parts)} {app_html} """ dst.write_text(page, encoding="utf-8", newline="\n") print(f"записано: {dst} ({len(parts)} представлений)")