From 89578b788fe5632c837865f197e1eaa494c4ea36 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Sun, 27 Jul 2025 18:12:05 +0200 Subject: [PATCH] graph.yed.MapAttr2Shape: Add Class MapAttr2Shape is a class which allows processing GraphML files, such that nodes get added shape attributes understood by the yEd editor, based on the values of other node attributes. This allows to programmatically visualize node attributes. Signed-off-by: Jan Lindemann --- tools/python/jwutils/graph/Makefile | 4 + tools/python/jwutils/graph/yed/Makefile | 4 + .../python/jwutils/graph/yed/MapAttr2Shape.py | 191 ++++++++++++++++++ 3 files changed, 199 insertions(+) create mode 100644 tools/python/jwutils/graph/Makefile create mode 100644 tools/python/jwutils/graph/yed/Makefile create mode 100644 tools/python/jwutils/graph/yed/MapAttr2Shape.py diff --git a/tools/python/jwutils/graph/Makefile b/tools/python/jwutils/graph/Makefile new file mode 100644 index 0000000..59b3ac1 --- /dev/null +++ b/tools/python/jwutils/graph/Makefile @@ -0,0 +1,4 @@ +TOPDIR = ../../../.. + +include $(TOPDIR)/make/proj.mk +include $(JWBDIR)/make/py-mod.mk diff --git a/tools/python/jwutils/graph/yed/Makefile b/tools/python/jwutils/graph/yed/Makefile new file mode 100644 index 0000000..781b0c8 --- /dev/null +++ b/tools/python/jwutils/graph/yed/Makefile @@ -0,0 +1,4 @@ +TOPDIR = ../../../../.. + +include $(TOPDIR)/make/proj.mk +include $(JWBDIR)/make/py-mod.mk diff --git a/tools/python/jwutils/graph/yed/MapAttr2Shape.py b/tools/python/jwutils/graph/yed/MapAttr2Shape.py new file mode 100644 index 0000000..1962322 --- /dev/null +++ b/tools/python/jwutils/graph/yed/MapAttr2Shape.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- + +from collections.abc import Callable + +import xml.etree.ElementTree as ET + +from jwutils.log import * + +class MapAttr2Shape: # export + + def __init__(self, mappings: dict[str, str|Callable[[dict[str, str]], str]]|None=None) -> None: + self.__mappings = mappings if mappings is not None else {} + self.__shape_node_key = 'd25' + self.__ns_gml = "http://graphml.graphdrawing.org/xmlns" + self.__ns = { + # -- Standard GraphML + "": self.__ns_gml, + "xsi": "http://www.w3.org/2001/XMLSchema-instance", + "xsi:schemaLocation": "http://graphml.graphdrawing.org/xmlns http://graphml.graphdrawing.org/xmlns/1.0/graphml.xsd", + + # -- YWorks GraphML + "java": "http://www.yworks.com/xml/yfiles-common/1.0/java", + "sys": "http://www.yworks.com/xml/yfiles-common/markup/primitives/2.0", + "x": "http://www.yworks.com/xml/yfiles-common/markup/2.0", + "y": "http://www.yworks.com/xml/graphml", + "yed": "http://www.yworks.com/xml/yed/3", + } + # https://stackoverflow.com/questions/4997848/ + for name, url in self.__ns.items(): + ET.register_namespace(name, url) + + def __keys(self, root): + ret: dict[str, str] = {} + for el in root.findall('key', self.__ns): + attr_name = el.get('attr.name') + id = el.get('id') + if attr_name is not None: + ret[attr_name] = id + return ret + + def __value(self, node, key): + data = node.find(f'data[@key="{key}"]', self.__ns) + if data is None: + return None + return data.text + + def __attribs(self, node, keys): + ret: dict[str, str] = {} + for name, key in keys.items(): + val = self.__value(node, key) + if val is None: + continue + ret[name] = val + return ret + + def __add_key_nodegraphics(self, root): + # + el = ET.Element('key') + for attr, val in { + 'id': self.__shape_node_key, + 'for': 'node', + 'yfiles.type':'nodegraphics' + }.items(): + el.set(attr, val) + root.append(el) + + def __massage_node(self, node, keys: dict[str, str]) -> None: + + def __add(parent, d: dict[str, Any]): + for tag, content in d.items(): + if tag.find('y:') != -1: + ns, tag = tag.split(':') + tag = '{' + self.__ns[ns] + '}' + tag + attrib = content.get('a') or {} + el = ET.Element(tag, attrib=attrib) + text = content.get('t') + if text is not None: + el.text = text + parent.append(el) + children = content.get('c') + if children is not None: + __add(el, children) + + default_values = { + 'color': '#FFCC00', + 'text': '' + } + values = {} + + for key, default in default_values.items(): + mapping = self.__mappings.get(key) + if mapping is None: + values[key] = default + continue + try: + if isinstance(str, mapping): + values[key] = mapping + continue + except: + pass + mapped = mapping(self.__attribs(node, keys)) + values[key] = mapped or default + + color = values['color'] + text = values['text'] + has_text = 'true' if text else 'false' + + width_text = round(len(text) * 5.75, 5) if text else 0 + width_box = width_text + 10 if text else 30 + + shape = { + 'data': { + 'a': {'key': self.__shape_node_key}, + 'c': { + 'y:ShapeNode': { + 'a': {}, + 'c': { + 'y:Geometry': {'a': {'height': '30.0', 'width': str(width_box), 'x': str(-(width_box / 2)), 'y':' -15.0'}}, + 'y:Fill': {'a': {'color': color, 'transparent': 'false'}}, + 'y:BorderStyle': {'a': {'color': '#000000', 'raised': 'false', 'type': 'line', 'width': '1.0'}}, + 'y:NodeLabel': { + 'a': { + 'alignment': 'center', + 'autoSizePolicy': 'content', + 'fontFamily': 'Dialog', + 'fontSize': '12', + 'fontStyle': 'plain', + 'hasBackgroundColor': 'false', + 'hasLineColor': 'false', + 'hasText': has_text, + 'height': '18', + 'horizontalTextPosition': 'center', + 'iconTextGap': '4', + 'modelName': 'custom', + 'textColor': '#000000', + 'verticalTextPosition': 'bottom', + 'visible': 'true', + 'width': str(width_text), + 'x': '13.0', + 'y': '13.0', + }, + 'c': { + 'y:LabelModel': { + 'c': { + 'y:SmartNodeLabelModel': {'a': {'distance': '4.0'}} + }, + }, + 'y:ModelParameter': { + 'c': { + 'y:SmartNodeLabelModelParameter': { + 'a': { + 'labelRatioX':'0.0', + 'labelRatioY': '0.0', + 'nodeRatioX': '0.0', + 'nodeRatioY': '0.0', + 'offsetX': '0.0', + 'offsetY': '0.0', + 'upX': '0.0', + 'upY': '-1.0', + } + } + } + } + }, + 't': text + }, + 'y:Shape': {'a': {'type': 'rectangle'}} + } + } + } + } + } + + __add(node, shape) + + def __massage_nodes(self, root) -> None: + keys = self.__keys(root) + graph = root.find(f'graph', self.__ns) + for node in graph: + self.__massage_node(node, keys) + + def run(self, path_in: str, path_out: str) -> None: + parser = ET.XMLParser(encoding="utf-8") + tree = ET.parse(path_in, parser=parser) + root = tree.getroot() + + self.__add_key_nodegraphics(root) + self.__massage_nodes(root) + + ET.indent(tree, space=' ', level=0) + tree.write(path_out, xml_declaration=True, encoding='utf-8')