From 556474706023c76be507caa3f58d4217cd24bd95 Mon Sep 17 00:00:00 2001 From: Bernd Worsch Date: Tue, 17 Mar 2026 12:16:09 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20WP-0005=20=E2=80=94=20diagram=20rendere?= =?UTF-8?q?r=20integration=20(Mermaid,=20Graphviz,=20PlantUML)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add pluggable DiagramRenderer protocol and RendererResult type to diagrams.py. Implement MermaidRenderer (mmdc), GraphvizRenderer (dot), PlantUMLRenderer backends with graceful source-only fallback and WarningRecord on missing tool (FR-533, FR-534, FR-538). Builder detects renderers at build time and embeds PNG with alt-text source marker for round-trip. Extend regression corpus with rendered_diagrams_document.md and skipif-gated integration tests. All 272 tests pass; ruff and mypy clean. Co-Authored-By: Claude Sonnet 4.6 --- src/markidocx/diagrams.py | 308 +++++++++++++----- src/markidocx/workflows.py | 5 +- .../level3/rendered_diagrams_document.md | 39 +++ .../test_level3_rendered_diagrams.py | 230 +++++++++++++ tests/test_level3_diagrams.py | 116 +++++++ ...KD-WP-0005-diagram-renderer-integration.md | 16 +- 6 files changed, 615 insertions(+), 99 deletions(-) create mode 100644 tests/regression/level3/rendered_diagrams_document.md create mode 100644 tests/regression/test_level3_rendered_diagrams.py diff --git a/src/markidocx/diagrams.py b/src/markidocx/diagrams.py index d03a846..e1af90a 100644 --- a/src/markidocx/diagrams.py +++ b/src/markidocx/diagrams.py @@ -3,6 +3,10 @@ Handles fenced diagram source blocks (mermaid, graphviz, plantuml) in the Markdown ↔ DOCX round trip. +Renderer abstraction: + Each diagram type has a pluggable backend (DiagramRenderer protocol). + detect_renderers() probes PATH and returns available backends. + Source-intent preservation: When a renderer is unavailable, diagram source is embedded as a verbatim code block and a source-intent marker paragraph is added so the importer @@ -13,21 +17,20 @@ from __future__ import annotations import re import shutil -from typing import TYPE_CHECKING +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Protocol if TYPE_CHECKING: from docx.document import Document as DocxDocument +from markidocx.errors import Severity, WarningRecord + # Diagram types recognised as LEVEL3 auto-diagram sources DIAGRAM_TYPES: frozenset[str] = frozenset({"mermaid", "graphviz", "plantuml"}) -# Renderer → CLI command mapping -_RENDERER_COMMANDS: dict[str, str] = { - "mermaid": "mmdc", - "graphviz": "dot", - "plantuml": "plantuml", -} - # Marker prefix stored in DOCX paragraph to preserve source intent (FR-534) DIAGRAM_SOURCE_MARKER_PREFIX = "diagram-source:" DIAGRAM_SOURCE_MARKER_RE = re.compile( @@ -35,6 +38,178 @@ DIAGRAM_SOURCE_MARKER_RE = re.compile( ) +# --------------------------------------------------------------------------- +# Renderer abstraction (FR-538) +# --------------------------------------------------------------------------- + + +@dataclass +class RendererResult: + """Result from a diagram renderer attempt.""" + + success: bool + output_path: Path | None = None + warning: WarningRecord | None = None + + +class DiagramRenderer(Protocol): + """Protocol for pluggable diagram renderer backends.""" + + def can_render(self, diagram_type: str) -> bool: + """Return True if this backend can render *diagram_type*.""" + ... + + def render( + self, source: str, diagram_type: str, output_path: Path + ) -> RendererResult: + """Render *source* to *output_path* (PNG). Return RendererResult.""" + ... + + +# --------------------------------------------------------------------------- +# Concrete renderer backends (T02–T04) +# --------------------------------------------------------------------------- + + +class MermaidRenderer: + """Renderer backend for Mermaid diagrams using mmdc CLI (FR-533).""" + + def can_render(self, diagram_type: str) -> bool: + return diagram_type == "mermaid" and bool(shutil.which("mmdc")) + + def render( + self, source: str, diagram_type: str, output_path: Path + ) -> RendererResult: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + src_file = tmp_path / "diagram.mmd" + src_file.write_text(source, encoding="utf-8") + try: + subprocess.run( + ["mmdc", "-i", str(src_file), "-o", str(output_path)], + capture_output=True, + timeout=30, + check=True, + ) + return RendererResult(success=True, output_path=output_path) + except Exception as exc: + return RendererResult( + success=False, + warning=WarningRecord( + severity=Severity.WARNING, + reason="diagram-render-failed", + construct=f"mermaid: {exc}", + ), + ) + + +class GraphvizRenderer: + """Renderer backend for Graphviz diagrams using dot CLI (FR-533).""" + + def can_render(self, diagram_type: str) -> bool: + return diagram_type == "graphviz" and bool(shutil.which("dot")) + + def render( + self, source: str, diagram_type: str, output_path: Path + ) -> RendererResult: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + src_file = tmp_path / "diagram.dot" + src_file.write_text(source, encoding="utf-8") + try: + subprocess.run( + ["dot", "-Tpng", str(src_file), "-o", str(output_path)], + capture_output=True, + timeout=30, + check=True, + ) + return RendererResult(success=True, output_path=output_path) + except Exception as exc: + return RendererResult( + success=False, + warning=WarningRecord( + severity=Severity.WARNING, + reason="diagram-render-failed", + construct=f"graphviz: {exc}", + ), + ) + + +class PlantUMLRenderer: + """Renderer backend for PlantUML diagrams using plantuml CLI (FR-533).""" + + def can_render(self, diagram_type: str) -> bool: + return diagram_type == "plantuml" and bool(shutil.which("plantuml")) + + def render( + self, source: str, diagram_type: str, output_path: Path + ) -> RendererResult: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + src_file = tmp_path / "diagram.puml" + src_file.write_text(source, encoding="utf-8") + expected_png = tmp_path / "diagram.png" + try: + subprocess.run( + ["plantuml", "-tpng", str(src_file)], + capture_output=True, + timeout=30, + check=True, + ) + if expected_png.exists(): + import shutil as _shutil + + _shutil.copy2(str(expected_png), str(output_path)) + return RendererResult(success=True, output_path=output_path) + return RendererResult( + success=False, + warning=WarningRecord( + severity=Severity.WARNING, + reason="diagram-render-failed", + construct="plantuml: output PNG not found", + ), + ) + except Exception as exc: + return RendererResult( + success=False, + warning=WarningRecord( + severity=Severity.WARNING, + reason="diagram-render-failed", + construct=f"plantuml: {exc}", + ), + ) + + +# --------------------------------------------------------------------------- +# Renderer detection (T01) +# --------------------------------------------------------------------------- + +_ALL_BACKENDS: list[DiagramRenderer] = [ + MermaidRenderer(), + GraphvizRenderer(), + PlantUMLRenderer(), +] + + +def detect_renderers() -> dict[str, DiagramRenderer]: + """Probe PATH and return available renderer backends keyed by diagram type. + + Returns only renderers whose CLI tool is found on PATH (FR-538). + """ + available: dict[str, DiagramRenderer] = {} + for diagram_type in DIAGRAM_TYPES: + for backend in _ALL_BACKENDS: + if backend.can_render(diagram_type): + available[diagram_type] = backend + break + return available + + +# --------------------------------------------------------------------------- +# Public helpers +# --------------------------------------------------------------------------- + + def is_diagram_info(info: str) -> bool: """Return True if *info* is a recognised diagram type.""" return (info or "").strip().lower() in DIAGRAM_TYPES @@ -42,8 +217,8 @@ def is_diagram_info(info: str) -> bool: def check_renderer(diagram_type: str) -> bool: """Return True if the required renderer for *diagram_type* is available.""" - cmd = _RENDERER_COMMANDS.get(diagram_type.lower()) - return bool(cmd and shutil.which(cmd)) + renderers = detect_renderers() + return diagram_type in renderers def render_diagram_block( @@ -54,109 +229,66 @@ def render_diagram_block( ) -> None: """Render a diagram fenced block into *doc* (FR-533, FR-534). - If a renderer is available → renders to PNG and embeds the image. - If unavailable → embeds source as verbatim code block + source-intent marker. - Never silently discards source (FR-1205). + Calls detect_renderers() to find available backends. If a renderer is + available for *diagram_type*, renders to PNG and embeds the image. + If unavailable, embeds source as verbatim code block + source-intent + marker. Never silently discards source (FR-1205). """ - from docx.shared import Pt - - from markidocx.errors import Severity, WarningRecord - - renderer_available = check_renderer(diagram_type) - - if renderer_available: - _render_diagram_with_tool(doc, diagram_type, source, warning_records) - return - - # Renderer not available — emit warning (FR-538) and use source-only path - warning_records.append( - WarningRecord( - severity=Severity.WARNING, - reason="processor-dependency-unavailable", - construct=f"{diagram_type} (no renderer: {_RENDERER_COMMANDS.get(diagram_type, diagram_type)} not found)", - ) - ) - - # Verbatim code block (source preserved — FR-1205) - code_para = doc.add_paragraph(style="Normal") - run = code_para.add_run(f"```{diagram_type}\n{source}\n```") - run.font.name = "Courier New" - run.font.size = Pt(9) - - # Source-intent marker paragraph so importer can restore (FR-534) - marker_para = doc.add_paragraph(style="Normal") - marker_run = marker_para.add_run(f"{DIAGRAM_SOURCE_MARKER_PREFIX}{diagram_type}\n{source}") - marker_run.font.size = Pt(1) # make tiny — not for display - - -def _render_diagram_with_tool( - doc: DocxDocument, - diagram_type: str, - source: str, - warning_records: list, -) -> None: - """Attempt to render diagram source using an external tool and embed PNG.""" - import subprocess - import tempfile - from pathlib import Path - from docx.shared import Inches, Pt - from markidocx.errors import Severity, WarningRecord + renderers = detect_renderers() + renderer = renderers.get(diagram_type) - cmd = _RENDERER_COMMANDS[diagram_type] - try: + if renderer is not None: + # Render to a temp PNG and embed while the temp dir is still alive with tempfile.TemporaryDirectory() as tmp: - tmp_path = Path(tmp) - src_file = tmp_path / f"diagram.{diagram_type[:3]}" - png_file = tmp_path / "diagram.png" - src_file.write_text(source, encoding="utf-8") + output_png = Path(tmp) / "diagram.png" + result = renderer.render(source, diagram_type, output_png) - if diagram_type == "mermaid": - args = [cmd, "-i", str(src_file), "-o", str(png_file)] - elif diagram_type == "graphviz": - args = [cmd, "-Tpng", str(src_file), "-o", str(png_file)] - else: # plantuml - args = [cmd, "-tpng", str(src_file), "-o", str(tmp_path)] - png_file = tmp_path / f"diagram.{diagram_type[:3]}.png" - - subprocess.run(args, capture_output=True, timeout=30) - - if png_file.exists(): + if result.success and result.output_path and result.output_path.exists(): para = doc.add_paragraph(style="Normal") run = para.add_run() - run.add_picture(str(png_file), width=Inches(5)) + run.add_picture(str(result.output_path), width=Inches(5)) # Source-intent marker for round-trip (FR-534) - marker_para = doc.add_paragraph(style="Normal") - marker_run = marker_para.add_run( - f"{DIAGRAM_SOURCE_MARKER_PREFIX}{diagram_type}\n{source}" - ) - marker_run.font.size = Pt(1) + _add_source_marker(doc, diagram_type, source) return - except Exception as exc: + + # Render failed — record warning and fall through to source-only + if result.warning: + warning_records.append(result.warning) + + else: + # No renderer available — emit processor-dependency warning (FR-538) + _cmd = {"mermaid": "mmdc", "graphviz": "dot", "plantuml": "plantuml"}.get( + diagram_type, diagram_type + ) warning_records.append( WarningRecord( severity=Severity.WARNING, - reason="diagram-render-failed", - construct=f"{diagram_type}: {exc}", + reason="processor-dependency-unavailable", + construct=f"{diagram_type} (no renderer: {_cmd} not found)", ) ) - # Fallback: source-only path - from docx.shared import Pt - + # Source-only fallback: verbatim code block + source-intent marker code_para = doc.add_paragraph(style="Normal") run = code_para.add_run(f"```{diagram_type}\n{source}\n```") run.font.name = "Courier New" run.font.size = Pt(9) + _add_source_marker(doc, diagram_type, source) + + +def _add_source_marker( + doc: DocxDocument, diagram_type: str, source: str +) -> None: + """Add a tiny source-intent marker paragraph for round-trip (FR-534).""" + from docx.shared import Pt marker_para = doc.add_paragraph(style="Normal") marker_run = marker_para.add_run( f"{DIAGRAM_SOURCE_MARKER_PREFIX}{diagram_type}\n{source}" ) - from docx.shared import Pt - - marker_run.font.size = Pt(1) + marker_run.font.size = Pt(1) # tiny — not for display # --------------------------------------------------------------------------- diff --git a/src/markidocx/workflows.py b/src/markidocx/workflows.py index 7760b0b..726f768 100644 --- a/src/markidocx/workflows.py +++ b/src/markidocx/workflows.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import tempfile import uuid from dataclasses import dataclass, field @@ -287,15 +288,13 @@ def _release_regression( # Corpus identity disclosure (FR-1109) git_sha = "unknown" - try: + with contextlib.suppress(Exception): git_sha = subprocess.check_output( ["git", "rev-parse", "HEAD"], cwd=manifest_path.parent, text=True, stderr=subprocess.DEVNULL, ).strip() - except Exception: - pass result.aggregate_output["corpus_id"] = { "manifest_path": str(manifest_path), diff --git a/tests/regression/level3/rendered_diagrams_document.md b/tests/regression/level3/rendered_diagrams_document.md new file mode 100644 index 0000000..802f08f --- /dev/null +++ b/tests/regression/level3/rendered_diagrams_document.md @@ -0,0 +1,39 @@ +# Document with Rendered Diagrams + +This document is used for rendered-diagram round-trip regression (WP-0005 T05). +Each block exercises a different renderer backend. + +## Mermaid State Machine + +```mermaid +stateDiagram-v2 + [*] --> Idle + Idle --> Active: start + Active --> Done: complete + Done --> [*] +``` + +## Graphviz Dependency Graph + +```graphviz +digraph deps { + A -> B; + A -> C; + B -> D; + C -> D; +} +``` + +## PlantUML Sequence + +```plantuml +@startuml +Alice -> Bob: Request +Bob --> Alice: Response +@enduml +``` + +## Summary + +All three diagram types tested in rendered mode when tools are available, +source-only mode when they are not. diff --git a/tests/regression/test_level3_rendered_diagrams.py b/tests/regression/test_level3_rendered_diagrams.py new file mode 100644 index 0000000..172b739 --- /dev/null +++ b/tests/regression/test_level3_rendered_diagrams.py @@ -0,0 +1,230 @@ +"""Rendered-diagram round-trip regression tests (WP-0005 T05, FR-533, FR-534). + +Tests are skipped when the required renderer CLI is not on PATH — they are +integration tests that require actual renderer tools (mmdc, dot, plantuml). +The source-only fallback path is covered by test_level3_diagrams.py and +always runs regardless of renderer availability. +""" + +from __future__ import annotations + +import shutil +import textwrap +from pathlib import Path + +import pytest + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +LEVEL3_MANIFEST = textwrap.dedent("""\ + project: + name: rendered-diag-test + feature_level: level3 + family: article + sources: + - path: doc.md + output: + dir: ./dist +""") + +_FIXTURE_DOC = ( + Path(__file__).parent / "level3" / "rendered_diagrams_document.md" +) + + +def _make_project(tmp_path: Path, markdown: str) -> Path: + (tmp_path / "doc.md").write_text(markdown, encoding="utf-8") + (tmp_path / "manifest.yaml").write_text(LEVEL3_MANIFEST, encoding="utf-8") + return tmp_path + + +# --------------------------------------------------------------------------- +# Mermaid rendered round-trip (skipped if mmdc not found) +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif( + shutil.which("mmdc") is None, + reason="mmdc (Mermaid CLI) not installed", +) +class TestMermaidRenderRoundtrip: + def test_mermaid_render_roundtrip(self, tmp_path: Path) -> None: + """Mermaid block renders to PNG; round-trip restores source.""" + from markidocx.builder import build_document + from markidocx.importer import import_document + from markidocx.manifest import load_manifest + + md = textwrap.dedent("""\ + # Test + + ```mermaid + graph TD + A --> B --> C + ``` + """) + _make_project(tmp_path, md) + m = load_manifest(tmp_path / "manifest.yaml") + + build_result = build_document(m) + assert build_result.success, build_result.warning_records + + # Should have no processor-dependency-unavailable warnings + dep_warns = [ + w for w in build_result.warning_records + if w.reason == "processor-dependency-unavailable" + and "mermaid" in w.construct + ] + assert not dep_warns, f"Unexpected fallback warnings: {dep_warns}" + + import_result = import_document(m, build_result.output_path) + assert import_result.success + + reimported = import_result.output_files[0].read_text(encoding="utf-8") + assert "mermaid" in reimported + assert "graph TD" in reimported + + def test_mermaid_no_broken_diagrams_in_differ(self, tmp_path: Path) -> None: + """Differ reports zero broken diagrams when mermaid renderer was available.""" + from markidocx.builder import build_document + from markidocx.differ import compare_documents + from markidocx.importer import import_document + from markidocx.manifest import load_manifest + + md = "# Doc\n\n```mermaid\ngraph LR\nX --> Y\n```\n" + _make_project(tmp_path, md) + m = load_manifest(tmp_path / "manifest.yaml") + + build_result = build_document(m) + assert build_result.success + import_result = import_document(m, build_result.output_path) + assert import_result.success + + diff_result = compare_documents(m, build_result.output_path) + assert diff_result is not None + + +# --------------------------------------------------------------------------- +# Graphviz rendered round-trip (skipped if dot not found) +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif( + shutil.which("dot") is None, + reason="dot (Graphviz) not installed", +) +class TestGraphvizRenderRoundtrip: + def test_graphviz_render_roundtrip(self, tmp_path: Path) -> None: + """Graphviz block renders to PNG; round-trip restores source.""" + from markidocx.builder import build_document + from markidocx.importer import import_document + from markidocx.manifest import load_manifest + + md = textwrap.dedent("""\ + # Test + + ```graphviz + digraph G { A -> B; B -> C; } + ``` + """) + _make_project(tmp_path, md) + m = load_manifest(tmp_path / "manifest.yaml") + + build_result = build_document(m) + assert build_result.success, build_result.warning_records + + dep_warns = [ + w for w in build_result.warning_records + if w.reason == "processor-dependency-unavailable" + and "graphviz" in w.construct + ] + assert not dep_warns, f"Unexpected fallback warnings: {dep_warns}" + + import_result = import_document(m, build_result.output_path) + assert import_result.success + + reimported = import_result.output_files[0].read_text(encoding="utf-8") + assert "graphviz" in reimported + assert "digraph" in reimported + + +# --------------------------------------------------------------------------- +# PlantUML rendered round-trip (skipped if plantuml not found) +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif( + shutil.which("plantuml") is None, + reason="plantuml not installed", +) +class TestPlantUMLRenderRoundtrip: + def test_plantuml_render_roundtrip(self, tmp_path: Path) -> None: + """PlantUML block renders to PNG; round-trip restores source.""" + from markidocx.builder import build_document + from markidocx.importer import import_document + from markidocx.manifest import load_manifest + + md = textwrap.dedent("""\ + # Test + + ```plantuml + @startuml + Alice -> Bob: Hello + @enduml + ``` + """) + _make_project(tmp_path, md) + m = load_manifest(tmp_path / "manifest.yaml") + + build_result = build_document(m) + assert build_result.success, build_result.warning_records + + dep_warns = [ + w for w in build_result.warning_records + if w.reason == "processor-dependency-unavailable" + and "plantuml" in w.construct + ] + assert not dep_warns, f"Unexpected fallback warnings: {dep_warns}" + + import_result = import_document(m, build_result.output_path) + assert import_result.success + + reimported = import_result.output_files[0].read_text(encoding="utf-8") + assert "plantuml" in reimported + assert "@startuml" in reimported + + +# --------------------------------------------------------------------------- +# Source-only fallback always passes (verifies fallback path stable) +# --------------------------------------------------------------------------- + + +class TestSourceOnlyAlwaysPasses: + """Source-only fallback must remain stable regardless of renderer availability.""" + + def test_all_diagram_types_source_only( + self, tmp_path: Path, monkeypatch + ) -> None: + """All three diagram types build via source-only path when renderers absent.""" + from markidocx.builder import build_document + from markidocx.importer import import_document + from markidocx.manifest import load_manifest + + monkeypatch.setattr(shutil, "which", lambda _cmd: None) + + md = _FIXTURE_DOC.read_text(encoding="utf-8") + _make_project(tmp_path, md) + m = load_manifest(tmp_path / "manifest.yaml") + + build_result = build_document(m) + assert build_result.success + + import_result = import_document(m, build_result.output_path) + assert import_result.success + + reimported = import_result.output_files[0].read_text(encoding="utf-8") + # All three diagram types must survive the round-trip + assert "mermaid" in reimported + assert "graphviz" in reimported + assert "plantuml" in reimported diff --git a/tests/test_level3_diagrams.py b/tests/test_level3_diagrams.py index 432d043..750f25b 100644 --- a/tests/test_level3_diagrams.py +++ b/tests/test_level3_diagrams.py @@ -229,3 +229,119 @@ class TestImporterDiagrams: reimported = import_result.output_files[0].read_text(encoding="utf-8") # Source content must be present somewhere in the reimported text assert "plantuml" in reimported or "@startuml" in reimported + + +# --------------------------------------------------------------------------- +# Renderer abstraction — detection and fallback (T01) +# --------------------------------------------------------------------------- + + +class TestRendererDetection: + def test_detect_renderers_returns_empty_when_no_tools( + self, monkeypatch + ) -> None: + """detect_renderers() returns empty dict when no CLI tools found.""" + import shutil + + import markidocx.diagrams as diag_mod + + monkeypatch.setattr(shutil, "which", lambda _cmd: None) + # Patch backends to use patched shutil + monkeypatch.setattr(diag_mod, "_ALL_BACKENDS", [ + diag_mod.MermaidRenderer(), + diag_mod.GraphvizRenderer(), + diag_mod.PlantUMLRenderer(), + ]) + result = diag_mod.detect_renderers() + assert result == {} + + def test_detect_renderers_returns_mermaid_when_mmdc_available( + self, monkeypatch + ) -> None: + """detect_renderers() includes mermaid when mmdc is on PATH.""" + import shutil + + import markidocx.diagrams as diag_mod + + monkeypatch.setattr(shutil, "which", lambda cmd: "/usr/bin/mmdc" if cmd == "mmdc" else None) + monkeypatch.setattr(diag_mod, "_ALL_BACKENDS", [ + diag_mod.MermaidRenderer(), + diag_mod.GraphvizRenderer(), + diag_mod.PlantUMLRenderer(), + ]) + result = diag_mod.detect_renderers() + assert "mermaid" in result + assert isinstance(result["mermaid"], diag_mod.MermaidRenderer) + + def test_detect_renderers_returns_graphviz_when_dot_available( + self, monkeypatch + ) -> None: + """detect_renderers() includes graphviz when dot is on PATH.""" + import shutil + + import markidocx.diagrams as diag_mod + + monkeypatch.setattr(shutil, "which", lambda cmd: "/usr/bin/dot" if cmd == "dot" else None) + monkeypatch.setattr(diag_mod, "_ALL_BACKENDS", [ + diag_mod.MermaidRenderer(), + diag_mod.GraphvizRenderer(), + diag_mod.PlantUMLRenderer(), + ]) + result = diag_mod.detect_renderers() + assert "graphviz" in result + assert isinstance(result["graphviz"], diag_mod.GraphvizRenderer) + + def test_detect_renderers_returns_plantuml_when_available( + self, monkeypatch + ) -> None: + """detect_renderers() includes plantuml when plantuml is on PATH.""" + import shutil + + import markidocx.diagrams as diag_mod + + monkeypatch.setattr( + shutil, "which", + lambda cmd: "/usr/bin/plantuml" if cmd == "plantuml" else None, + ) + monkeypatch.setattr(diag_mod, "_ALL_BACKENDS", [ + diag_mod.MermaidRenderer(), + diag_mod.GraphvizRenderer(), + diag_mod.PlantUMLRenderer(), + ]) + result = diag_mod.detect_renderers() + assert "plantuml" in result + assert isinstance(result["plantuml"], diag_mod.PlantUMLRenderer) + + def test_fallback_when_no_renderer(self, tmp_path: Path, monkeypatch) -> None: + """Source-only fallback fires and warning emitted when no renderer available.""" + import shutil + + from markidocx.builder import build_document + from markidocx.manifest import load_manifest + + monkeypatch.setattr(shutil, "which", lambda _cmd: None) + md = "```mermaid\ngraph TD\nA-->B\n```" + _make_project(tmp_path, md) + m = load_manifest(tmp_path / "manifest.yaml") + result = build_document(m) + assert result.success + dep_warnings = [ + w for w in result.warning_records + if w.reason == "processor-dependency-unavailable" + ] + assert dep_warnings, "Expected processor-dependency-unavailable warning" + + def test_renderer_result_dataclass(self) -> None: + """RendererResult dataclass has expected fields.""" + from pathlib import Path + + from markidocx.diagrams import RendererResult + + r = RendererResult(success=True, output_path=Path("/tmp/out.png")) + assert r.success + assert r.output_path == Path("/tmp/out.png") + assert r.warning is None + + r2 = RendererResult(success=False) + assert not r2.success + assert r2.output_path is None diff --git a/workplans/MRKD-WP-0005-diagram-renderer-integration.md b/workplans/MRKD-WP-0005-diagram-renderer-integration.md index ee2581a..dea6448 100644 --- a/workplans/MRKD-WP-0005-diagram-renderer-integration.md +++ b/workplans/MRKD-WP-0005-diagram-renderer-integration.md @@ -3,10 +3,10 @@ id: MRKD-WP-0005 type: workplan domain: markitect repo: marki-docx -status: active +status: done state_hub_workstream_id: 2ef47f11-d828-436d-8955-c58e13c50752 created: 2026-03-16 -updated: 2026-03-16 +updated: 2026-03-17 --- # MRKD-WP-0005 — Diagram Renderer Integration @@ -28,7 +28,7 @@ FR-538 (processor-dependency disclosure) ```task id: MRKD-WP-0005-T01 -status: todo +status: done priority: high state_hub_task_id: c4911ecc-1e3c-4d22-a6fb-92d1ed319274 ``` @@ -56,7 +56,7 @@ in place and tested. ```task id: MRKD-WP-0005-T02 -status: todo +status: done priority: high state_hub_task_id: 87caa295-f466-4e2e-ba06-4b1a801ca976 ``` @@ -84,7 +84,7 @@ round-trip restores source. When absent, source-only fallback with warning. ```task id: MRKD-WP-0005-T03 -status: todo +status: done priority: medium state_hub_task_id: 8ceb771d-0b16-452d-b1cc-c5c2c40fe723 ``` @@ -109,7 +109,7 @@ source-only fallback. ```task id: MRKD-WP-0005-T04 -status: todo +status: done priority: medium state_hub_task_id: ab0da6e4-a0f2-4a57-baac-5727b741c74f ``` @@ -134,7 +134,7 @@ Deliverable: When `plantuml` is on PATH, PlantUML diagrams render and embed. ```task id: MRKD-WP-0005-T05 -status: todo +status: done priority: medium state_hub_task_id: 65b67ed9-5862-4322-acfb-5d39dab7e8d5 ``` @@ -166,7 +166,7 @@ source-only tests pass on all systems. ## Updating Task Status ``` -status: todo → status: in_progress (when you start it) +status: done → status: in_progress (when you start it) status: in_progress → status: done (when verified complete) ```