feat: WP-0005 — diagram renderer integration (Mermaid, Graphviz, PlantUML)

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 12:16:09 +00:00
parent 0ff234b93f
commit 5564747060
6 changed files with 615 additions and 99 deletions

View File

@@ -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