From f060f68e140214fc35b05fbba7068d11c570e72a Mon Sep 17 00:00:00 2001 From: LudoBroche Date: Wed, 8 Jul 2026 18:45:37 +0200 Subject: [PATCH 1/2] Issue #17 - Added elk_converter helper and svg_task_group --- .gitignore | 5 + examples/convert_ewoks_to_elk_graph.py | 21 ++++ src/ewoksdraw/__init__.py | 29 +++-- src/ewoksdraw/config/constants.py | 7 ++ src/ewoksdraw/layout/__init__.py | 1 + src/ewoksdraw/layout/elk_converter.py | 68 +++++++++++ src/ewoksdraw/svg/__init__.py | 2 + src/ewoksdraw/svg/svg_task_group.py | 44 ++++++++ src/ewoksdraw/tests/test_elk_converter.py | 131 ++++++++++++++++++++++ src/ewoksdraw/tests/test_graph_to_svg.py | 3 +- 10 files changed, 294 insertions(+), 17 deletions(-) create mode 100644 examples/convert_ewoks_to_elk_graph.py create mode 100644 src/ewoksdraw/layout/__init__.py create mode 100644 src/ewoksdraw/layout/elk_converter.py create mode 100644 src/ewoksdraw/svg/svg_task_group.py create mode 100644 src/ewoksdraw/tests/test_elk_converter.py diff --git a/.gitignore b/.gitignore index 7973ac4..7b380cf 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,8 @@ __pycache__/ .eggs/ /doc/_generated .svg +# pixi environments +.pixi/* +!.pixi/config.toml +pixi.toml +pixi.lock diff --git a/examples/convert_ewoks_to_elk_graph.py b/examples/convert_ewoks_to_elk_graph.py new file mode 100644 index 0000000..1bcc628 --- /dev/null +++ b/examples/convert_ewoks_to_elk_graph.py @@ -0,0 +1,21 @@ +from pprint import pprint + +from ewokscore import load_graph +from ewokscore.tests.examples.graphs import get_graph +from ewoksdraw import build_svg_task_group +from ewoksdraw.layout.elk_converter import ElkGraph +from ewoksdraw.layout.elk_converter import convert_ewoks_to_elk_graph +from ewoksdraw.svg.svg_task_group import SvgTaskGroup +from ewoksdraw.svg.svg_task_group import TaskSizes + +graph_description, _ = get_graph("acyclic1") +ewoks_graph = load_graph(graph_description) + +svg_task_group: SvgTaskGroup = build_svg_task_group(ewoks_graph) +task_sizes: TaskSizes = svg_task_group.extract_task_sizes() +elk_graph: ElkGraph = convert_ewoks_to_elk_graph(ewoks_graph, task_sizes) + +pprint(dict(ewoks_graph.graph.nodes(data=True))) +pprint(list(ewoks_graph.graph.edges(data=True))) +pprint(task_sizes) +pprint(elk_graph) diff --git a/src/ewoksdraw/__init__.py b/src/ewoksdraw/__init__.py index f699db1..6067d59 100644 --- a/src/ewoksdraw/__init__.py +++ b/src/ewoksdraw/__init__.py @@ -6,34 +6,31 @@ from .svg import SvgCanvas from .svg import SvgTask +from .svg import SvgTaskGroup GAP = 10 -DEFAULT_HEIGHT = 500 -def graph_to_svg(graph: TaskGraph, output_path: str | Path): - svg_tasks = [] - width = GAP +def build_svg_task_group(graph: TaskGraph) -> SvgTaskGroup: + svg_tasks = {} for node_id, node_attrs in graph.graph.nodes.items(): node_inputs = _get_all_node_inputs(node_id, node_attrs) node_outputs = _get_all_task_output_names( node_attrs["task_type"], node_attrs["task_identifier"] ) - svg_task = SvgTask( + svg_tasks[node_id] = SvgTask( task_name=node_id, input_names=[n.name for n in node_inputs], output_names=node_outputs, ) - svg_tasks.append(svg_task) - svg_task.translate(x=width, y=GAP) - width += svg_task.width + GAP - - if len(svg_tasks) > 0: - height = max([svg_task.height for svg_task in svg_tasks]) - else: - height = DEFAULT_HEIGHT - canvas = SvgCanvas(width=width, height=height + 2 * GAP) + return SvgTaskGroup(svg_tasks) + + +def graph_to_svg(graph: TaskGraph, output_path: str | Path): + task_group = build_svg_task_group(graph) + task_group.arrange_horizontally(GAP) + + canvas = SvgCanvas(width=task_group.width, height=task_group.height + 2 * GAP) canvas.add_background() - for svg_task in svg_tasks: - canvas.add_element(svg_task) + canvas.add_element(task_group) canvas.draw(output_path) diff --git a/src/ewoksdraw/config/constants.py b/src/ewoksdraw/config/constants.py index 43869bd..7aa9abe 100644 --- a/src/ewoksdraw/config/constants.py +++ b/src/ewoksdraw/config/constants.py @@ -10,3 +10,10 @@ IO_ANCHOR_TEXT_MARGIN = 10 IO_TOP_MARGIN = 5 IO_INTER_IO_MARGIN = 3 + +ELK_ALGORITHM = "layered" +ELK_DIRECTION = "RIGHT" +ELK_SPACING_TASKS = 60 +ELK_SPACING_TASK_LAYERS = 80 +ELK_SPACING_LINK = 20 +ELK_ROUTING_MODE = "CONSERVATIVE_SOFT" diff --git a/src/ewoksdraw/layout/__init__.py b/src/ewoksdraw/layout/__init__.py new file mode 100644 index 0000000..dfc042d --- /dev/null +++ b/src/ewoksdraw/layout/__init__.py @@ -0,0 +1 @@ +from .elk_converter import convert_ewoks_to_elk_graph # noqa: F401 diff --git a/src/ewoksdraw/layout/elk_converter.py b/src/ewoksdraw/layout/elk_converter.py new file mode 100644 index 0000000..f9d6c28 --- /dev/null +++ b/src/ewoksdraw/layout/elk_converter.py @@ -0,0 +1,68 @@ +from typing import Any + +from ewokscore.graph import TaskGraph + +from ..config.constants import ELK_ALGORITHM +from ..config.constants import ELK_DIRECTION +from ..config.constants import ELK_ROUTING_MODE +from ..config.constants import ELK_SPACING_LINK +from ..config.constants import ELK_SPACING_TASK_LAYERS +from ..config.constants import ELK_SPACING_TASKS +from ..svg import TaskSizes + +ElkGraph = dict[str, Any] + +LAYOUT_OPTIONS = { + "org.eclipse.elk.algorithm": ELK_ALGORITHM, + "org.eclipse.elk.direction": ELK_DIRECTION, + "org.eclipse.elk.spacing.nodeNode": ELK_SPACING_TASKS, + "org.eclipse.elk.layered.spacing.nodeNodeBetweenLayers": ELK_SPACING_TASK_LAYERS, + "org.eclipse.elk.spacing.edgeEdge": ELK_SPACING_LINK, + "org.eclipse.elk.edgeRouting": "SPLINES", + "elk.layered.edgeRouting.splines.mode": ELK_ROUTING_MODE, +} + + +def convert_ewoks_to_elk_graph( + ewoks_graph: TaskGraph, task_sizes: TaskSizes +) -> ElkGraph: + """Convert an Ewoks task graph into an ELK layout graph. + + :param ewoks_graph: the task graph to convert, e.g. from ``ewokscore.load_graph``. + :param task_sizes: ``(width, height)`` per task in ``ewoks_graph``, and no + other task id, e.g. ``{"task1": (39.56, 55.0), ...}``. + :returns: an ELK graph, i.e. + ``{"id": str, "layoutOptions": dict, + "children": [{"id", "width", "height"}, ...], + "edges": [{"id", "sources": [str], "targets": [str]}, ...]}``. + """ + node_ids = set(ewoks_graph.graph.nodes) + if node_ids != task_sizes.keys(): + raise ValueError( + f"task_sizes {sorted(task_sizes)} do not match ewoks_graph task ids " + f"{sorted(node_ids)}" + ) + + children = [] + for task_id in ewoks_graph.graph.nodes: + width, height = task_sizes[task_id] + children.append({"id": task_id, "width": width, "height": height}) + + edges = [] + for source, target, link_attrs in ewoks_graph.graph.edges(data=True): + n_mappings = len(link_attrs.get("data_mapping") or []) or 1 + for index in range(n_mappings): + edges.append( + { + "id": f"edge_{source}_{target}_{index}", + "sources": [source], + "targets": [target], + } + ) + + return { + "id": "root", + "layoutOptions": LAYOUT_OPTIONS, + "children": children, + "edges": edges, + } diff --git a/src/ewoksdraw/svg/__init__.py b/src/ewoksdraw/svg/__init__.py index 7b90d0e..48d2409 100644 --- a/src/ewoksdraw/svg/__init__.py +++ b/src/ewoksdraw/svg/__init__.py @@ -5,6 +5,8 @@ from .svg_task import SvgTask # noqa: F401 from .svg_task_anchor_link import SvgTaskAnchorLink # noqa: F401 from .svg_task_box import SvgTaskBox # noqa: F401 +from .svg_task_group import SvgTaskGroup # noqa: F401 +from .svg_task_group import TaskSizes # noqa: F401 from .svg_task_io import SvgTaskIO # noqa: F401 from .svg_task_title import SvgTaskTitle # noqa: F401 from .svg_text import SvgText # noqa: F401 diff --git a/src/ewoksdraw/svg/svg_task_group.py b/src/ewoksdraw/svg/svg_task_group.py new file mode 100644 index 0000000..d4674a2 --- /dev/null +++ b/src/ewoksdraw/svg/svg_task_group.py @@ -0,0 +1,44 @@ +from .svg_group import SvgGroup +from .svg_task import SvgTask + +TaskSizes = dict[str, tuple[float, float]] + + +class SvgTaskGroup(SvgGroup): + """ + Represents a positioned collection of SvgTask elements. + """ + + def __init__(self, svg_tasks: dict[str, SvgTask]): + super().__init__() + self._svg_tasks = svg_tasks + self._width = 0.0 + self.add_elements(svg_tasks.values()) + + def arrange_horizontally(self, gap: float) -> None: + """ + Lays out the tasks left to right, each separated by `gap`. + + :param gap: The spacing before, between, and after the tasks. + """ + x = gap + for svg_task in self._svg_tasks.values(): + svg_task.translate(x=x, y=gap) + x += svg_task.width + gap + self._width = x + + @property + def width(self) -> float: + return self._width + + @property + def height(self) -> float: + if not self._svg_tasks: + return 0.0 + return max(svg_task.height for svg_task in self._svg_tasks.values()) + + def extract_task_sizes(self) -> TaskSizes: + return { + task_id: (svg_task.width, svg_task.height) + for task_id, svg_task in self._svg_tasks.items() + } diff --git a/src/ewoksdraw/tests/test_elk_converter.py b/src/ewoksdraw/tests/test_elk_converter.py new file mode 100644 index 0000000..09f298c --- /dev/null +++ b/src/ewoksdraw/tests/test_elk_converter.py @@ -0,0 +1,131 @@ +import pytest +from ewokscore import load_graph +from ewokscore.tests.examples.graphs import get_graph +from ewokscore.tests.examples.graphs import graph_names + +from ewoksdraw.layout.elk_converter import LAYOUT_OPTIONS +from ewoksdraw.layout.elk_converter import convert_ewoks_to_elk_graph + +_TASK_TYPE = "ewokscore.tests.examples.tasks.sumtask.SumTask" + + +def _node(node_id: str) -> dict: + return {"id": node_id, "task_type": "class", "task_identifier": _TASK_TYPE} + + +def _task_sizes(graph) -> dict[str, tuple[float, float]]: + return { + node_id: (10.0 * i, 20.0 * i) + for i, node_id in enumerate(graph.graph.nodes, start=1) + } + + +def test_top_level_structure(): + graph_description, _ = get_graph("acyclic1") + graph = load_graph(graph_description) + + elk_graph = convert_ewoks_to_elk_graph(graph, _task_sizes(graph)) + + assert elk_graph["id"] == "root" + assert elk_graph["layoutOptions"] == LAYOUT_OPTIONS + assert "children" in elk_graph + assert "edges" in elk_graph + + +def test_children_match_task_sizes(): + graph_description, _ = get_graph("acyclic1") + graph = load_graph(graph_description) + task_sizes = _task_sizes(graph) + + elk_graph = convert_ewoks_to_elk_graph(graph, task_sizes) + + assert len(elk_graph["children"]) == len(task_sizes) + for child in elk_graph["children"]: + width, height = task_sizes[child["id"]] + assert child["width"] == width + assert child["height"] == height + + +def test_link_without_data_mapping_produces_one_elk_edge(): + graph_description = { + "graph": {"id": "g", "label": "g", "schema_version": "1.1"}, + "nodes": [_node("a"), _node("b")], + "links": [{"source": "a", "target": "b"}], + } + graph = load_graph(graph_description) + + elk_graph = convert_ewoks_to_elk_graph(graph, {"a": (1.0, 1.0), "b": (1.0, 1.0)}) + + assert elk_graph["edges"] == [ + {"id": "edge_a_b_0", "sources": ["a"], "targets": ["b"]} + ] + + +def test_link_with_multiple_data_mappings_produces_one_elk_edge_per_mapping(): + graph_description = { + "graph": {"id": "g", "label": "g", "schema_version": "1.1"}, + "nodes": [_node("a"), _node("b")], + "links": [ + { + "source": "a", + "target": "b", + "data_mapping": [ + {"source_output": "result", "target_input": "a"}, + {"source_output": "result", "target_input": "b"}, + ], + } + ], + } + graph = load_graph(graph_description) + + elk_graph = convert_ewoks_to_elk_graph(graph, {"a": (1.0, 1.0), "b": (1.0, 1.0)}) + + assert elk_graph["edges"] == [ + {"id": "edge_a_b_0", "sources": ["a"], "targets": ["b"]}, + {"id": "edge_a_b_1", "sources": ["a"], "targets": ["b"]}, + ] + + +def test_graph_without_link(): + graph_description, _ = get_graph("empty") + graph = load_graph(graph_description) + + elk_graph = convert_ewoks_to_elk_graph(graph, {}) + + assert elk_graph["children"] == [] + assert elk_graph["edges"] == [] + + +@pytest.mark.parametrize("graph_name", graph_names()) +def test_children_and_edges_count_across_example_graphs(graph_name): + graph_description, _ = get_graph(graph_name) + graph = load_graph(graph_description) + task_sizes = _task_sizes(graph) + + elk_graph = convert_ewoks_to_elk_graph(graph, task_sizes) + + assert len(elk_graph["children"]) == graph.graph.number_of_nodes() + + expected_edge_count = sum( + len(link_attrs.get("data_mapping") or []) or 1 + for _, _, link_attrs in graph.graph.edges(data=True) + ) + assert len(elk_graph["edges"]) == expected_edge_count + + +def test_task_sizes_missing_a_node_raises(): + graph_description, _ = get_graph("acyclic1") + graph = load_graph(graph_description) + + with pytest.raises(ValueError): + convert_ewoks_to_elk_graph(graph, {"task1": (10.0, 20.0)}) + + +def test_task_sizes_with_extra_task_id_raises(): + graph_description, _ = get_graph("acyclic1") + graph = load_graph(graph_description) + task_sizes = _task_sizes(graph) + task_sizes["not_a_node"] = (1.0, 1.0) + + with pytest.raises(ValueError): + convert_ewoks_to_elk_graph(graph, task_sizes) diff --git a/src/ewoksdraw/tests/test_graph_to_svg.py b/src/ewoksdraw/tests/test_graph_to_svg.py index 99be1c3..05139f4 100644 --- a/src/ewoksdraw/tests/test_graph_to_svg.py +++ b/src/ewoksdraw/tests/test_graph_to_svg.py @@ -12,7 +12,8 @@ def _get_svg_groups(output_path: Path): tree = ElementTree.parse(output_path) root = tree.getroot() - return [child for child in root if child.tag.endswith("g")] + task_group = next(child for child in root if child.tag.endswith("g")) + return [child for child in task_group if child.tag.endswith("g")] @pytest.mark.parametrize("graph_name", graph_names()) From d5a32e853c649ae09dc263f3f203c5d02f060001 Mon Sep 17 00:00:00 2001 From: LudoBroche Date: Wed, 8 Jul 2026 19:41:13 +0200 Subject: [PATCH 2/2] Issue #17 - Adding pyelk graph validation --- pyproject.toml | 1 + src/ewoksdraw/tests/test_elk_converter.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 90aaf87..c53ec27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "ewokscore<5", "reportlab", "xmltodict", + "pyelk @ git+https://github.com/LudoBroche/pyelk.git@ewoksdraw-modifications", ] [project.urls] diff --git a/src/ewoksdraw/tests/test_elk_converter.py b/src/ewoksdraw/tests/test_elk_converter.py index 09f298c..8e05c1d 100644 --- a/src/ewoksdraw/tests/test_elk_converter.py +++ b/src/ewoksdraw/tests/test_elk_converter.py @@ -2,6 +2,7 @@ from ewokscore import load_graph from ewokscore.tests.examples.graphs import get_graph from ewokscore.tests.examples.graphs import graph_names +from pyelk.graph import validate_graph from ewoksdraw.layout.elk_converter import LAYOUT_OPTIONS from ewoksdraw.layout.elk_converter import convert_ewoks_to_elk_graph @@ -129,3 +130,16 @@ def test_task_sizes_with_extra_task_id_raises(): with pytest.raises(ValueError): convert_ewoks_to_elk_graph(graph, task_sizes) + + +@pytest.mark.parametrize("graph_name", graph_names()) +def test_output_is_a_valid_pyelk_graph(graph_name): + """Check graph validation from pyelk""" + + graph_description, _ = get_graph(graph_name) + graph = load_graph(graph_description) + task_sizes = _task_sizes(graph) + + elk_graph = convert_ewoks_to_elk_graph(graph, task_sizes) + + validate_graph(elk_graph)