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 <jan@janware.com>
This commit is contained in:
Jan Lindemann 2025-07-27 18:12:05 +02:00
commit 89578b788f
3 changed files with 199 additions and 0 deletions

View file

@ -0,0 +1,4 @@
TOPDIR = ../../../../..
include $(TOPDIR)/make/proj.mk
include $(JWBDIR)/make/py-mod.mk

View file

@ -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):
# <graphml><key for="node" id="d22" yfiles.type="nodegraphics"/></graphml>
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')