From 6c1c9b0a22bc879548babcae86272f40e040671d Mon Sep 17 00:00:00 2001 From: LudoBroche Date: Wed, 7 Oct 2026 16:40:10 +0200 Subject: [PATCH 1/5] Issue #33 - WIP - Adding @suport to style --- pixi.lock | 2 +- src/ewoksdraw/svg/utils.py | 35 ++++++++-- .../tests/test_inkscape_rendering.py | 64 ++++++++++++++++++ src/ewoksdraw/tests/test_svg_css_variables.py | 66 +++++++++++++++++++ 4 files changed, 160 insertions(+), 7 deletions(-) create mode 100644 src/ewoksdraw/tests/test_inkscape_rendering.py create mode 100644 src/ewoksdraw/tests/test_svg_css_variables.py diff --git a/pixi.lock b/pixi.lock index 2ae0b47..37011eb 100644 --- a/pixi.lock +++ b/pixi.lock @@ -1454,11 +1454,11 @@ packages: name: ewoksdraw requires_dist: - defusedxml + - ewokscore==5.1.0rc3 - reportlab - typing-extensions ; python_full_version < '3.11' - xmltodict - pyelk @ git+https://github.com/LudoBroche/pyelk.git@ewoksdraw-modifications - - ewokscore==5.1.0rc3 - pytest>=7 ; extra == 'test' - ewoksdraw[test] ; extra == 'dev' - ruff>=0.16.0 ; extra == 'dev' diff --git a/src/ewoksdraw/svg/utils.py b/src/ewoksdraw/svg/utils.py index db523f5..659091e 100644 --- a/src/ewoksdraw/svg/utils.py +++ b/src/ewoksdraw/svg/utils.py @@ -1,16 +1,39 @@ +import re from pathlib import Path from xml.etree.ElementTree import Element CSS_DIR = Path(__file__).parent.parent / "css_styles" -def generate_style_element(css_file_name: str) -> None | Element: - css_file_path = CSS_DIR / css_file_name - if not css_file_path.exists(): - return None +def _add_variable_fallbacks(css_content: str) -> str: + if "var(" not in css_content: + return css_content + + variables = dict( + re.findall(r"(--[\w-]+)\s*:\s*([^;]+);", (CSS_DIR / "root.css").read_text()) + ) + + def convert_rule(rule: re.Match[str]) -> str: + checks = " and ".join( + f"({name}: {value.strip()})" + for name, value in re.findall(r"([\w-]+)\s*:\s*([^;]+);", rule[2]) + if "var(" in value + ) + if not checks: + return rule[0] + fallback = re.sub( + r"var\(\s*(--[\w-]+)\s*\)", + lambda variable: variables[variable[1]].strip(), + rule[2], + ) + return f"{rule[1]}{{{fallback}}}\n@supports {checks} {{\n{rule[0]}\n}}" - with open(css_file_path, "r") as css_file: - css_content = css_file.read() + return re.sub(r"([^{}]+)\{([^{}]*)\}", convert_rule, css_content) + + +def generate_style_element(css_file_name: str) -> Element: + css_file_path = CSS_DIR / css_file_name style = Element("style") + css_content = _add_variable_fallbacks(css_file_path.read_text()) style.text = f"" return style diff --git a/src/ewoksdraw/tests/test_inkscape_rendering.py b/src/ewoksdraw/tests/test_inkscape_rendering.py new file mode 100644 index 0000000..c0577d1 --- /dev/null +++ b/src/ewoksdraw/tests/test_inkscape_rendering.py @@ -0,0 +1,64 @@ +import shutil +import subprocess +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Iterator + +import pytest +from PIL import Image + +from ewoksdraw.svg.svg_canvas import SvgCanvas +from ewoksdraw.svg.svg_task_anchor_link import SvgTaskAnchorLink + + +@pytest.fixture +def inkscape_tmp_path() -> Iterator[Path]: + # Snap applications have a private /tmp; use the project directory instead. + with TemporaryDirectory( + prefix="inkscape-test-", dir=Path(__file__).resolve().parent + ) as directory: + yield Path(directory) + + +def test_inkscape_renders_link_color(inkscape_tmp_path: Path) -> None: + inkscape = shutil.which("inkscape") + if inkscape is None: + pytest.skip("Inkscape is required for this rendering test") + + svg_path = inkscape_tmp_path / "circle.svg" + png_path = inkscape_tmp_path / "circle.png" + + canvas = SvgCanvas(width=100, height=100) + canvas.add_element(SvgTaskAnchorLink(cx=50, cy=50, radius=20)) + canvas.draw(svg_path) + + # PATH-resolved Inkscape and test-owned paths; no shell or user input. + result = subprocess.run( # noqa: S603 + [ + inkscape, + str(svg_path), + "--export-type=png", + "--export-area-page", + "--export-width=100", + f"--export-filename={png_path}", + ], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + + assert result.returncode == 0 and png_path.is_file(), ( + f"Inkscape export failed (exit status {result.returncode}): {png_path}\n" + f"stdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}" + ) + + with Image.open(png_path) as image: + pixel = image.convert("RGBA").getpixel((50, 50)) + + assert isinstance(pixel, tuple), pixel + expected = (176, 147, 255, 255) + assert all(abs(actual - wanted) <= 1 for actual, wanted in zip(pixel, expected)), ( + pixel + ) diff --git a/src/ewoksdraw/tests/test_svg_css_variables.py b/src/ewoksdraw/tests/test_svg_css_variables.py new file mode 100644 index 0000000..f071e0a --- /dev/null +++ b/src/ewoksdraw/tests/test_svg_css_variables.py @@ -0,0 +1,66 @@ +from pathlib import Path + +import pytest + +from ewoksdraw.svg import utils + + +@pytest.fixture +def css_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + (tmp_path / "root.css").write_text( + ".ewoksdraw { --link-color: #b093ff; " + "--stroke-color: #ffffff; --error: #ff0000; }" + ) + monkeypatch.setattr(utils, "CSS_DIR", tmp_path) + return tmp_path + + +def test_variable_generates_fallback_and_supports(css_dir: Path) -> None: + result = utils._add_variable_fallbacks(".anchor { fill: var(--link-color); }") + + assert " ".join(result.split()) == ( + ".anchor { fill: #b093ff; } " + "@supports (fill: var(--link-color)) { " + ".anchor { fill: var(--link-color); } }" + ) + + +def test_css_without_variables_is_unchanged(css_dir: Path) -> None: + css = ".anchor {\n fill: purple;\n stroke-width: 2;\n}\n" + + assert utils._add_variable_fallbacks(css) == css + + +def test_multiple_variable_properties_combine_support_checks(css_dir: Path) -> None: + result = utils._add_variable_fallbacks( + ".anchor { fill: var(--link-color); " + "stroke: var(--stroke-color); stroke-width: 2; }" + ) + + assert " ".join(result.split()) == ( + ".anchor { fill: #b093ff; stroke: #ffffff; stroke-width: 2; } " + "@supports (fill: var(--link-color)) and (stroke: var(--stroke-color)) { " + ".anchor { fill: var(--link-color); " + "stroke: var(--stroke-color); stroke-width: 2; } }" + ) + + +def test_import_error_selector_and_rule_order_are_preserved(css_dir: Path) -> None: + result = utils._add_variable_fallbacks( + ".task_box { stroke: var(--stroke-color); }\n" + ".task_box[data-import-error=''] { stroke: var(--error); }" + ) + + assert " ".join(result.split()) == ( + ".task_box { stroke: #ffffff; } " + "@supports (stroke: var(--stroke-color)) { " + ".task_box { stroke: var(--stroke-color); } } " + ".task_box[data-import-error=''] { stroke: #ff0000; } " + "@supports (stroke: var(--error)) { " + ".task_box[data-import-error=''] { stroke: var(--error); } }" + ) + + +def test_undefined_variable_raises_error(css_dir: Path) -> None: + with pytest.raises(KeyError, match="missing-color"): + utils._add_variable_fallbacks(".anchor { fill: var(--missing-color); }") From e9465e283870b469ab3f1cd4d53caa68a74c46c9 Mon Sep 17 00:00:00 2001 From: LudoBroche Date: Wed, 7 Oct 2026 17:59:54 +0200 Subject: [PATCH 2/5] Issue #33 - Reformat utils --- src/ewoksdraw/svg/utils.py | 60 ++++++++++--------- src/ewoksdraw/tests/test_svg_css_variables.py | 33 +++++----- 2 files changed, 47 insertions(+), 46 deletions(-) diff --git a/src/ewoksdraw/svg/utils.py b/src/ewoksdraw/svg/utils.py index 659091e..1e2e8be 100644 --- a/src/ewoksdraw/svg/utils.py +++ b/src/ewoksdraw/svg/utils.py @@ -1,39 +1,43 @@ -import re from pathlib import Path from xml.etree.ElementTree import Element CSS_DIR = Path(__file__).parent.parent / "css_styles" +ROOT_CSS_PATH = CSS_DIR / "root.css" -def _add_variable_fallbacks(css_content: str) -> str: - if "var(" not in css_content: - return css_content +def generate_style_element(css_file_name: str) -> Element: + css = (CSS_DIR / css_file_name).read_text() + css_with_fallback = _add_css_variable_fallback(css) + style = Element("style") + style.text = f"" + return style - variables = dict( - re.findall(r"(--[\w-]+)\s*:\s*([^;]+);", (CSS_DIR / "root.css").read_text()) - ) - def convert_rule(rule: re.Match[str]) -> str: - checks = " and ".join( - f"({name}: {value.strip()})" - for name, value in re.findall(r"([\w-]+)\s*:\s*([^;]+);", rule[2]) - if "var(" in value - ) - if not checks: - return rule[0] - fallback = re.sub( - r"var\(\s*(--[\w-]+)\s*\)", - lambda variable: variables[variable[1]].strip(), - rule[2], - ) - return f"{rule[1]}{{{fallback}}}\n@supports {checks} {{\n{rule[0]}\n}}" +def _add_css_variable_fallback(css: str) -> str: - return re.sub(r"([^{}]+)\{([^{}]*)\}", convert_rule, css_content) + if "var(" not in css: + return css + theme_values = _read_theme_values() + css_with_plain_values = _replace_css_variables(css, theme_values) + original_css_if_supported = f"@supports (--css: variables) {{\n{css}\n}}" + return f"{css_with_plain_values}\n{original_css_if_supported}" -def generate_style_element(css_file_name: str) -> Element: - css_file_path = CSS_DIR / css_file_name - style = Element("style") - css_content = _add_variable_fallbacks(css_file_path.read_text()) - style.text = f"" - return style + +def _read_theme_values() -> dict[str, str]: + """Read root.css into {"--link-color": "rgb(176, 147, 255)", ...}.""" + root_css = ROOT_CSS_PATH.read_text() + theme_values: dict[str, str] = {} + for line in root_css.splitlines(): + declaration = line.strip().removesuffix(";") + if declaration.startswith("--"): + name, value = declaration.split(": ") + theme_values[name] = value + return theme_values + + +def _replace_css_variables(css: str, theme_values: dict[str, str]) -> str: + """Replace each "var(--link-color)" by its value, e.g. "rgb(176, 147, 255)".""" + for name, value in theme_values.items(): + css = css.replace(f"var({name})", value) + return css diff --git a/src/ewoksdraw/tests/test_svg_css_variables.py b/src/ewoksdraw/tests/test_svg_css_variables.py index f071e0a..53d4e79 100644 --- a/src/ewoksdraw/tests/test_svg_css_variables.py +++ b/src/ewoksdraw/tests/test_svg_css_variables.py @@ -8,19 +8,22 @@ @pytest.fixture def css_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: (tmp_path / "root.css").write_text( - ".ewoksdraw { --link-color: #b093ff; " - "--stroke-color: #ffffff; --error: #ff0000; }" + ".ewoksdraw {\n" + " --link-color: #b093ff;\n" + " --stroke-color: #ffffff;\n" + " --error: #ff0000;\n" + "}\n" ) monkeypatch.setattr(utils, "CSS_DIR", tmp_path) return tmp_path def test_variable_generates_fallback_and_supports(css_dir: Path) -> None: - result = utils._add_variable_fallbacks(".anchor { fill: var(--link-color); }") + result = utils._add_css_variable_fallback(".anchor { fill: var(--link-color); }") assert " ".join(result.split()) == ( ".anchor { fill: #b093ff; } " - "@supports (fill: var(--link-color)) { " + "@supports (--css: variables) { " ".anchor { fill: var(--link-color); } }" ) @@ -28,39 +31,33 @@ def test_variable_generates_fallback_and_supports(css_dir: Path) -> None: def test_css_without_variables_is_unchanged(css_dir: Path) -> None: css = ".anchor {\n fill: purple;\n stroke-width: 2;\n}\n" - assert utils._add_variable_fallbacks(css) == css + assert utils._add_css_variable_fallback(css) == css -def test_multiple_variable_properties_combine_support_checks(css_dir: Path) -> None: - result = utils._add_variable_fallbacks( +def test_multiple_variables_are_replaced(css_dir: Path) -> None: + result = utils._add_css_variable_fallback( ".anchor { fill: var(--link-color); " "stroke: var(--stroke-color); stroke-width: 2; }" ) assert " ".join(result.split()) == ( ".anchor { fill: #b093ff; stroke: #ffffff; stroke-width: 2; } " - "@supports (fill: var(--link-color)) and (stroke: var(--stroke-color)) { " + "@supports (--css: variables) { " ".anchor { fill: var(--link-color); " "stroke: var(--stroke-color); stroke-width: 2; } }" ) -def test_import_error_selector_and_rule_order_are_preserved(css_dir: Path) -> None: - result = utils._add_variable_fallbacks( +def test_import_error_rule_stays_after_normal_rule(css_dir: Path) -> None: + result = utils._add_css_variable_fallback( ".task_box { stroke: var(--stroke-color); }\n" ".task_box[data-import-error=''] { stroke: var(--error); }" ) assert " ".join(result.split()) == ( ".task_box { stroke: #ffffff; } " - "@supports (stroke: var(--stroke-color)) { " - ".task_box { stroke: var(--stroke-color); } } " ".task_box[data-import-error=''] { stroke: #ff0000; } " - "@supports (stroke: var(--error)) { " + "@supports (--css: variables) { " + ".task_box { stroke: var(--stroke-color); } " ".task_box[data-import-error=''] { stroke: var(--error); } }" ) - - -def test_undefined_variable_raises_error(css_dir: Path) -> None: - with pytest.raises(KeyError, match="missing-color"): - utils._add_variable_fallbacks(".anchor { fill: var(--missing-color); }") From a0653c00cabcb2a6743bff0fa28158ce56cedb1e Mon Sep 17 00:00:00 2001 From: LudoBroche Date: Wed, 7 Oct 2026 18:03:26 +0200 Subject: [PATCH 3/5] Issue #33 - Fix pytest --- src/ewoksdraw/tests/test_svg_css_variables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ewoksdraw/tests/test_svg_css_variables.py b/src/ewoksdraw/tests/test_svg_css_variables.py index 53d4e79..f35ef45 100644 --- a/src/ewoksdraw/tests/test_svg_css_variables.py +++ b/src/ewoksdraw/tests/test_svg_css_variables.py @@ -14,7 +14,7 @@ def css_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: " --error: #ff0000;\n" "}\n" ) - monkeypatch.setattr(utils, "CSS_DIR", tmp_path) + monkeypatch.setattr(utils, "ROOT_CSS_PATH", tmp_path / "root.css") return tmp_path From 2593e0fbb0c8e8a28ba8f2341ca6564015e1f8be Mon Sep 17 00:00:00 2001 From: LudoBroche Date: Thu, 8 Oct 2026 11:01:28 +0200 Subject: [PATCH 4/5] Issue #33 - Mypy --- src/ewoksdraw/tests/test_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ewoksdraw/tests/test_utils.py b/src/ewoksdraw/tests/test_utils.py index d7334b5..662a949 100644 --- a/src/ewoksdraw/tests/test_utils.py +++ b/src/ewoksdraw/tests/test_utils.py @@ -4,7 +4,7 @@ from ..utils import get_task_id_from_target_id -def test_edge_source_task_id_roundtrip(): +def test_edge_source_task_id_roundtrip() -> None: task_id = "pipeline.output.normalize" output_name = "value" @@ -12,7 +12,7 @@ def test_edge_source_task_id_roundtrip(): assert get_task_id_from_source_id(source_id) == task_id -def test_edge_target_task_id_roundtrip(): +def test_edge_target_task_id_roundtrip() -> None: task_id = "pipeline.input.normalize" output_name = "value" From 71d5e37e266d4dd3e460a0406b8a56cda4f8be2a Mon Sep 17 00:00:00 2001 From: LudoBroche Date: Mon, 19 Oct 2026 16:12:55 +0200 Subject: [PATCH 5/5] Issue #33 - REmove inkscape test --- .../tests/test_inkscape_rendering.py | 64 ------------------- 1 file changed, 64 deletions(-) delete mode 100644 src/ewoksdraw/tests/test_inkscape_rendering.py diff --git a/src/ewoksdraw/tests/test_inkscape_rendering.py b/src/ewoksdraw/tests/test_inkscape_rendering.py deleted file mode 100644 index c0577d1..0000000 --- a/src/ewoksdraw/tests/test_inkscape_rendering.py +++ /dev/null @@ -1,64 +0,0 @@ -import shutil -import subprocess -from pathlib import Path -from tempfile import TemporaryDirectory -from typing import Iterator - -import pytest -from PIL import Image - -from ewoksdraw.svg.svg_canvas import SvgCanvas -from ewoksdraw.svg.svg_task_anchor_link import SvgTaskAnchorLink - - -@pytest.fixture -def inkscape_tmp_path() -> Iterator[Path]: - # Snap applications have a private /tmp; use the project directory instead. - with TemporaryDirectory( - prefix="inkscape-test-", dir=Path(__file__).resolve().parent - ) as directory: - yield Path(directory) - - -def test_inkscape_renders_link_color(inkscape_tmp_path: Path) -> None: - inkscape = shutil.which("inkscape") - if inkscape is None: - pytest.skip("Inkscape is required for this rendering test") - - svg_path = inkscape_tmp_path / "circle.svg" - png_path = inkscape_tmp_path / "circle.png" - - canvas = SvgCanvas(width=100, height=100) - canvas.add_element(SvgTaskAnchorLink(cx=50, cy=50, radius=20)) - canvas.draw(svg_path) - - # PATH-resolved Inkscape and test-owned paths; no shell or user input. - result = subprocess.run( # noqa: S603 - [ - inkscape, - str(svg_path), - "--export-type=png", - "--export-area-page", - "--export-width=100", - f"--export-filename={png_path}", - ], - check=False, - capture_output=True, - text=True, - timeout=30, - ) - - assert result.returncode == 0 and png_path.is_file(), ( - f"Inkscape export failed (exit status {result.returncode}): {png_path}\n" - f"stdout:\n{result.stdout}\n" - f"stderr:\n{result.stderr}" - ) - - with Image.open(png_path) as image: - pixel = image.convert("RGBA").getpixel((50, 50)) - - assert isinstance(pixel, tuple), pixel - expected = (176, 147, 255, 255) - assert all(abs(actual - wanted) <= 1 for actual, wanted in zip(pixel, expected)), ( - pixel - )