Source code for pyArchimate.readers._arisamlreader_helpers

"""Private helpers extracted from arisAMLreader to reduce cognitive complexity (S3776)."""

import ctypes
import platform
import sys
from typing import Any, cast

try:
    from ..constants import ARIS_TYPE_MAP as ARIS_type_map  # noqa: N811  # alias matches public API export name
    from ..enums import ArchiType, TextAlignment
    from ..exceptions import ArchimateConceptTypeError, ArchimateRelationshipError
    from ..helpers.logging import log
    from ..model import Model
    from ..relationship import get_default_rel_type
    from ..view import Node, Point, View
except ImportError:
    sys.path.insert(0, "..")
    from pyArchimate import (  # type: ignore[no-redef,attr-defined]  # noqa: E401
        ArchimateConceptTypeError,
        ArchimateRelationshipError,
        ArchiType,
        ARIS_type_map,
        Model,
        Node,
        Point,
        TextAlignment,
        View,
        get_default_rel_type,
        log,
    )


_ATTRDEF_TYPE = "AttrDef.Type"
_POS_X = "Pos.X"
_POS_Y = "Pos.Y"
_NOT_A_VIEW = "'view' is not an instance of class 'View'"


_FONT_SEARCH_PATHS = [
    "DejaVuSans.ttf",  # Linux (DejaVu installed)
    "/System/Library/Fonts/Helvetica.ttc",  # macOS system font
    "/Library/Fonts/Arial.ttf",  # macOS optional font
]


[docs] def get_text_size(text: str, points: int, font: str) -> tuple[float, float]: """Calculate text dimensions for given font and size (platform-dependent).""" if platform.system() == "Windows": class SIZE(ctypes.Structure): """Windows API SIZE structure for text dimensions.""" _fields_ = [("cx", ctypes.c_long), ("cy", ctypes.c_long)] hdc = ctypes.windll.user32.GetDC(0) # type: ignore[attr-defined] hfont = ctypes.windll.gdi32.CreateFontA( # type: ignore[attr-defined] points, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, font ) hfont_old = ctypes.windll.gdi32.SelectObject(hdc, hfont) # type: ignore[attr-defined] size = SIZE(0, 0) ctypes.windll.gdi32.GetTextExtentPoint32A( # type: ignore[attr-defined] hdc, text, len(text), ctypes.byref(size) ) ctypes.windll.gdi32.SelectObject(hdc, hfont_old) # type: ignore[attr-defined] ctypes.windll.gdi32.DeleteObject(hfont) # type: ignore[attr-defined] return size.cx, size.cy else: from PIL import ( ImageFont, # noqa: PLC0415 # optional dependency: PIL only available at call time on non-Windows ) for path in _FONT_SEARCH_PATHS: try: fnt = ImageFont.truetype(path, points) bbox = fnt.getbbox(text) return bbox[2] - bbox[0], bbox[3] - bbox[1] except OSError: continue # Fallback: approximate with a character-count heuristic return len(text) * points * 0.6, float(points)
[docs] def id_of(_id: str) -> str: """Convert ARIS ID to normalized format.""" if "." in _id: return "id-" + _id.split(".")[1] return "id-" + _id
def _parse_aris_attrs(elem: Any) -> tuple[str | None, str | None, dict[str, str]]: name: str | None = None desc: str | None = None props: dict[str, str] = {} for attr in elem.findall("AttrDef"): key = attr.attrib[_ATTRDEF_TYPE] val = "".join(v.get("TextValue") + "\n" for v in attr.iter("PlainText")) if key == "AT_NAME": name = val elif key == "AT_DESC": desc = val else: props[key] = val return name, desc, props def _parse_objdef(o: Any, model: Model, folder: str) -> None: if "SymbolNum" not in o.attrib: return o_type = ARIS_type_map[o.attrib["SymbolNum"]] if o_type == "": return guid = o.find("GUID").text o_uuid = id_of(o.attrib["ObjDef.ID"]) o_name, o_desc, props = _parse_aris_attrs(o) props["GUID"] = guid elem = model.add(concept_type=o_type, name=o_name, desc=o_desc, uuid=o_uuid, folder=folder) for k, v in props.items(): elem.prop(k, v) def parse_elements(group: Any, root: Any, model: Model, folder: str = "") -> None: """Recursively parse and add elements from ARIS model hierarchy.""" if group is None: group = root for g in group.findall("Group"): a = g.find("AttrDef") old_folder = folder if a is not None: for n in a.iter("PlainText"): folder += "/" + n.get("TextValue") for o in g.findall("ObjDef"): _parse_objdef(o, model, folder) parse_elements(g, root, model, folder) folder = old_folder def _add_rel_with_fallback( model: Model, r_type: str, o_uuid: str, r_target: str, r_id: str, props: dict[str, str] ) -> None: try: r = model.add_relationship(rel_type=r_type, source=o_uuid, target=r_target, uuid=r_id) except ArchimateRelationshipError as exc: fallback_type = get_default_rel_type(model.elems_dict[o_uuid].type, model.elems_dict[r_target].type) log.warning(str(exc) + f" - Replacing by {fallback_type}") if fallback_type is None: return r = model.add_relationship(rel_type=fallback_type, source=o_uuid, target=r_target, uuid=r_id) if r is not None: for key, value in props.items(): r.prop(key, value) def _collect_cxn_props(rel: Any) -> dict[str, str]: props: dict[str, str] = {} for attr in rel.findall("AttrDef"): key = attr.attrib[_ATTRDEF_TYPE] val = "".join(v.get("TextValue") + "\n" for v in attr.iter("PlainText")) props[key] = val return props def _process_objdef_rels(o: Any, model: Model) -> None: o_uuid = id_of(o.attrib["ObjDef.ID"]) for rel in o.findall("CxnDef"): r_type = ARIS_type_map[rel.attrib["CxnDef.Type"]] r_id = id_of(rel.attrib["CxnDef.ID"]) r_target = id_of(rel.attrib.get("ToObjDef.IdRef")) if r_target not in model.elems_dict: continue _add_rel_with_fallback(model, r_type, o_uuid, r_target, r_id, _collect_cxn_props(rel)) def parse_relationships(groups: Any, root: Any, model: Model) -> None: """Recursively parse and add relationships from ARIS model hierarchy.""" if groups is None: groups = root for g in groups.findall("Group"): for o in g.findall("ObjDef"): _process_objdef_rels(o, model) parse_relationships(g, root, model) def parse_nodes(grp: Any, view: View | None, model: Model, scale_x: float, scale_y: float) -> None: """Parse and add visual nodes from ARIS diagram.""" if grp is None or view is None: return if not isinstance(view, View): raise ArchimateConceptTypeError(_NOT_A_VIEW) for o in grp.findall("ObjOcc"): o_type = ARIS_type_map[o.attrib["SymbolNum"]] o_id = id_of(o.attrib["ObjOcc.ID"]) o_elem_ref = model.elems_dict[id_of(o.attrib["ObjDef.IdRef"])].uuid pos = o.find("Position") size = o.find("Size") n = view.add( ref=o_elem_ref, x=int(int(pos.get(_POS_X)) * scale_x), y=int(int(pos.get(_POS_Y)) * scale_y), w=int(int(size.get("Size.dX")) * scale_x), h=int(int(size.get("Size.dY")) * scale_y), uuid=o_id, ) if o_type == "Grouping": n.fill_color = "#FFFFFF" n.opacity = 100 def _handle_embedding(conn: Any, o_id: str, model: Model) -> None: c_target = id_of(conn.get("ToObjOcc.IdRef")) try: nt = model.nodes_dict[c_target] ns = model.nodes_dict[o_id] if not (nt.x >= ns.x and nt.y >= ns.y and nt.x + nt.w <= ns.x + ns.w and nt.y + nt.h <= ns.y + ns.h): ns.move(nt) else: nt.move(ns) except ArchimateConceptTypeError as exc: log.warning(exc) except ArchimateRelationshipError as exc: log.warning(exc) except ValueError as exc: log.warning(exc) except KeyError: log.error(f"Orphan Connection with unrelated relationship {id_of(conn.attrib['CxnDef.IdRef'])} ") def _handle_regular_conn(conn: Any, o_id: str, view: View, model: Model, scale_x: float, scale_y: float) -> None: c_id = id_of(conn.attrib["CxnOcc.ID"]) c_rel_id = id_of(conn.attrib["CxnDef.IdRef"]) c_target = id_of(conn.get("ToObjOcc.IdRef")) if c_rel_id not in model.rels_dict: return c = view.add_connection(ref=c_rel_id, source=o_id, target=c_target, uuid=c_id) for i, pos in enumerate(conn.findall("Position")): if 0 < i < len(conn.findall("Position")) - 1: c.add_bendpoint(Point(int(pos.get(_POS_X)) * scale_x, int(pos.get(_POS_Y)) * scale_y)) def parse_connections(grp: Any, view: View | None, model: Model, scale_x: float, scale_y: float) -> None: """Parse and add connections between nodes in ARIS diagram.""" if grp is None or view is None: return if not isinstance(view, View): raise ValueError(_NOT_A_VIEW) for o in grp.findall("ObjOcc"): o_id = id_of(o.attrib["ObjOcc.ID"]) for conn in o.findall("CxnOcc"): if "Embedding" in conn.attrib and conn.attrib["Embedding"] == "YES": _handle_embedding(conn, o_id, model) else: _handle_regular_conn(conn, o_id, view, model, scale_x, scale_y) def parse_containers(grp: Any, view: View | None, scale_x: float, scale_y: float) -> None: """Parse and add container/grouping shapes from ARIS diagram.""" if grp is None or view is None: return if not isinstance(view, View): raise ArchimateConceptTypeError(_NOT_A_VIEW) for objs in grp.findall("GfxObj"): for o in objs.findall("RoundedRectangle"): pos = o.find("Position") size = o.find("Size") brush = o.find("Brush") if pos is not None and size is not None: n = view.add( ref=None, x=int(int(pos.get(_POS_X)) * scale_x), y=int(int(pos.get(_POS_Y)) * scale_y), w=int(int(size.get("Size.dX")) * scale_x), h=int(int(size.get("Size.dY")) * scale_y), node_type="Container", ) n.line_color = f"#{int(brush.get('Color')):0>6X}" if brush is not None else "#000000" n.fill_color = "#FFFFFF" n.opacity = 100 def parse_labels(root: Any, model: Model) -> None: """Parse and register text labels from ARIS model.""" for o in root.findall("FFTextDef"): o_id = id_of(o.attrib["FFTextDef.ID"]) if o.attrib["IsModelAttr"] == "TEXT": o_name = None for attr in o.findall("AttrDef"): key = attr.attrib[_ATTRDEF_TYPE] val = "" for v in attr.iter("PlainText"): val += v.get("TextValue") + "\n" if key == "AT_NAME": o_name = val model.labels_dict[o_id] = o_name def parse_labels_in_view(grp: Any, view: View | None, model: Model, scale_x: float, scale_y: float) -> None: """Parse and add label nodes to ARIS diagram view.""" if grp is None or view is None: return if not isinstance(view, View): raise ArchimateConceptTypeError(_NOT_A_VIEW) for objs in grp.findall("FFTextOcc"): lbl_ref = id_of(objs.attrib["FFTextDef.IdRef"]) if lbl_ref not in model.labels_dict: continue o_name = model.labels_dict[lbl_ref] o = objs.find("Position") if o is None: continue pos = o.attrib w, h = max([get_text_size(x, 9, "Segoe UI") for x in o_name.split("\n")]) try: n = view.add( ref=lbl_ref, x=max(int(float(pos.get(_POS_X, "0")) * scale_x), 0), y=max(int(float(pos.get(_POS_Y, "0")) * scale_y), 0), w=int(w) + 18, h=30 + (h * 1.5) * (o_name.count("\n") + 1), node_type="Label", label=o_name, ) n.fill_color = "#FFFFFF" n.opacity = 100 n.line_color = "#000000" n.border_type = "2" n.text_alignment = TextAlignment.Left except ValueError: log.warning(f"Node {o_name} has unknown element reference {lbl_ref} - ignoring") def _build_view(o: Any, model: Model, folder: str, scale_x: float, scale_y: float) -> None: view_id = id_of(o.attrib["Model.ID"]) o_name, o_desc, _ = _parse_aris_attrs(o) view = cast(View, model.add(concept_type=ArchiType.View, name=o_name, uuid=view_id, desc=o_desc)) view.folder = folder log.info("Parsing & adding nodes") parse_nodes(o, view, model, scale_x, scale_y) log.info("Parsing & adding conns") parse_connections(o, view, model, scale_x, scale_y) log.info("Parsing and adding container groups") parse_containers(o, view, scale_x, scale_y) log.info("Parsing and adding labels") parse_labels_in_view(o, view, model, scale_x, scale_y) def parse_views(group: Any, root: Any, model: Model, scale_x: float, scale_y: float, folder: str = "") -> None: """Recursively parse and add views from ARIS model hierarchy.""" if group is None: group = root for g in group.findall("Group"): a = g.find("AttrDef") old_folder = folder if a is not None: for n in a.iter("PlainText"): folder += "/" + n.get("TextValue") for o in g.findall("Model"): _build_view(o, model, folder, scale_x, scale_y) parse_views(g, root, model, scale_x, scale_y, folder) folder = old_folder def clean_nested_conns(model: Model) -> None: """Remove connections that reference their parent node as source.""" for c in [x for x in model.conns_dict.values() if x.source.uuid == x.parent.uuid and isinstance(x.parent, Node)]: c.delete() for c in [x for x in model.conns_dict.values() if x.target.uuid == x.parent.uuid and isinstance(x.parent, Node)]: c.delete()