generated from coulomb/repo-seed
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:
@@ -3,6 +3,10 @@
|
|||||||
Handles fenced diagram source blocks (mermaid, graphviz, plantuml) in the
|
Handles fenced diagram source blocks (mermaid, graphviz, plantuml) in the
|
||||||
Markdown ↔ DOCX round trip.
|
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:
|
Source-intent preservation:
|
||||||
When a renderer is unavailable, diagram source is embedded as a verbatim
|
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
|
code block and a source-intent marker paragraph is added so the importer
|
||||||
@@ -13,21 +17,20 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import shutil
|
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:
|
if TYPE_CHECKING:
|
||||||
from docx.document import Document as DocxDocument
|
from docx.document import Document as DocxDocument
|
||||||
|
|
||||||
|
from markidocx.errors import Severity, WarningRecord
|
||||||
|
|
||||||
# Diagram types recognised as LEVEL3 auto-diagram sources
|
# Diagram types recognised as LEVEL3 auto-diagram sources
|
||||||
DIAGRAM_TYPES: frozenset[str] = frozenset({"mermaid", "graphviz", "plantuml"})
|
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)
|
# Marker prefix stored in DOCX paragraph to preserve source intent (FR-534)
|
||||||
DIAGRAM_SOURCE_MARKER_PREFIX = "diagram-source:"
|
DIAGRAM_SOURCE_MARKER_PREFIX = "diagram-source:"
|
||||||
DIAGRAM_SOURCE_MARKER_RE = re.compile(
|
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:
|
def is_diagram_info(info: str) -> bool:
|
||||||
"""Return True if *info* is a recognised diagram type."""
|
"""Return True if *info* is a recognised diagram type."""
|
||||||
return (info or "").strip().lower() in DIAGRAM_TYPES
|
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:
|
def check_renderer(diagram_type: str) -> bool:
|
||||||
"""Return True if the required renderer for *diagram_type* is available."""
|
"""Return True if the required renderer for *diagram_type* is available."""
|
||||||
cmd = _RENDERER_COMMANDS.get(diagram_type.lower())
|
renderers = detect_renderers()
|
||||||
return bool(cmd and shutil.which(cmd))
|
return diagram_type in renderers
|
||||||
|
|
||||||
|
|
||||||
def render_diagram_block(
|
def render_diagram_block(
|
||||||
@@ -54,109 +229,66 @@ def render_diagram_block(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Render a diagram fenced block into *doc* (FR-533, FR-534).
|
"""Render a diagram fenced block into *doc* (FR-533, FR-534).
|
||||||
|
|
||||||
If a renderer is available → renders to PNG and embeds the image.
|
Calls detect_renderers() to find available backends. If a renderer is
|
||||||
If unavailable → embeds source as verbatim code block + source-intent marker.
|
available for *diagram_type*, renders to PNG and embeds the image.
|
||||||
Never silently discards source (FR-1205).
|
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 docx.shared import Inches, Pt
|
||||||
|
|
||||||
from markidocx.errors import Severity, WarningRecord
|
renderers = detect_renderers()
|
||||||
|
renderer = renderers.get(diagram_type)
|
||||||
|
|
||||||
cmd = _RENDERER_COMMANDS[diagram_type]
|
if renderer is not None:
|
||||||
try:
|
# Render to a temp PNG and embed while the temp dir is still alive
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
tmp_path = Path(tmp)
|
output_png = Path(tmp) / "diagram.png"
|
||||||
src_file = tmp_path / f"diagram.{diagram_type[:3]}"
|
result = renderer.render(source, diagram_type, output_png)
|
||||||
png_file = tmp_path / "diagram.png"
|
|
||||||
src_file.write_text(source, encoding="utf-8")
|
|
||||||
|
|
||||||
if diagram_type == "mermaid":
|
if result.success and result.output_path and result.output_path.exists():
|
||||||
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():
|
|
||||||
para = doc.add_paragraph(style="Normal")
|
para = doc.add_paragraph(style="Normal")
|
||||||
run = para.add_run()
|
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)
|
# Source-intent marker for round-trip (FR-534)
|
||||||
marker_para = doc.add_paragraph(style="Normal")
|
_add_source_marker(doc, diagram_type, source)
|
||||||
marker_run = marker_para.add_run(
|
|
||||||
f"{DIAGRAM_SOURCE_MARKER_PREFIX}{diagram_type}\n{source}"
|
|
||||||
)
|
|
||||||
marker_run.font.size = Pt(1)
|
|
||||||
return
|
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(
|
warning_records.append(
|
||||||
WarningRecord(
|
WarningRecord(
|
||||||
severity=Severity.WARNING,
|
severity=Severity.WARNING,
|
||||||
reason="diagram-render-failed",
|
reason="processor-dependency-unavailable",
|
||||||
construct=f"{diagram_type}: {exc}",
|
construct=f"{diagram_type} (no renderer: {_cmd} not found)",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fallback: source-only path
|
# Source-only fallback: verbatim code block + source-intent marker
|
||||||
from docx.shared import Pt
|
|
||||||
|
|
||||||
code_para = doc.add_paragraph(style="Normal")
|
code_para = doc.add_paragraph(style="Normal")
|
||||||
run = code_para.add_run(f"```{diagram_type}\n{source}\n```")
|
run = code_para.add_run(f"```{diagram_type}\n{source}\n```")
|
||||||
run.font.name = "Courier New"
|
run.font.name = "Courier New"
|
||||||
run.font.size = Pt(9)
|
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_para = doc.add_paragraph(style="Normal")
|
||||||
marker_run = marker_para.add_run(
|
marker_run = marker_para.add_run(
|
||||||
f"{DIAGRAM_SOURCE_MARKER_PREFIX}{diagram_type}\n{source}"
|
f"{DIAGRAM_SOURCE_MARKER_PREFIX}{diagram_type}\n{source}"
|
||||||
)
|
)
|
||||||
from docx.shared import Pt
|
marker_run.font.size = Pt(1) # tiny — not for display
|
||||||
|
|
||||||
marker_run.font.size = Pt(1)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -287,15 +288,13 @@ def _release_regression(
|
|||||||
|
|
||||||
# Corpus identity disclosure (FR-1109)
|
# Corpus identity disclosure (FR-1109)
|
||||||
git_sha = "unknown"
|
git_sha = "unknown"
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
git_sha = subprocess.check_output(
|
git_sha = subprocess.check_output(
|
||||||
["git", "rev-parse", "HEAD"],
|
["git", "rev-parse", "HEAD"],
|
||||||
cwd=manifest_path.parent,
|
cwd=manifest_path.parent,
|
||||||
text=True,
|
text=True,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
).strip()
|
).strip()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
result.aggregate_output["corpus_id"] = {
|
result.aggregate_output["corpus_id"] = {
|
||||||
"manifest_path": str(manifest_path),
|
"manifest_path": str(manifest_path),
|
||||||
|
|||||||
39
tests/regression/level3/rendered_diagrams_document.md
Normal file
39
tests/regression/level3/rendered_diagrams_document.md
Normal file
@@ -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.
|
||||||
230
tests/regression/test_level3_rendered_diagrams.py
Normal file
230
tests/regression/test_level3_rendered_diagrams.py
Normal file
@@ -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
|
||||||
@@ -229,3 +229,119 @@ class TestImporterDiagrams:
|
|||||||
reimported = import_result.output_files[0].read_text(encoding="utf-8")
|
reimported = import_result.output_files[0].read_text(encoding="utf-8")
|
||||||
# Source content must be present somewhere in the reimported text
|
# Source content must be present somewhere in the reimported text
|
||||||
assert "plantuml" in reimported or "@startuml" in reimported
|
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
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ id: MRKD-WP-0005
|
|||||||
type: workplan
|
type: workplan
|
||||||
domain: markitect
|
domain: markitect
|
||||||
repo: marki-docx
|
repo: marki-docx
|
||||||
status: active
|
status: done
|
||||||
state_hub_workstream_id: 2ef47f11-d828-436d-8955-c58e13c50752
|
state_hub_workstream_id: 2ef47f11-d828-436d-8955-c58e13c50752
|
||||||
created: 2026-03-16
|
created: 2026-03-16
|
||||||
updated: 2026-03-16
|
updated: 2026-03-17
|
||||||
---
|
---
|
||||||
|
|
||||||
# MRKD-WP-0005 — Diagram Renderer Integration
|
# MRKD-WP-0005 — Diagram Renderer Integration
|
||||||
@@ -28,7 +28,7 @@ FR-538 (processor-dependency disclosure)
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: MRKD-WP-0005-T01
|
id: MRKD-WP-0005-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: c4911ecc-1e3c-4d22-a6fb-92d1ed319274
|
state_hub_task_id: c4911ecc-1e3c-4d22-a6fb-92d1ed319274
|
||||||
```
|
```
|
||||||
@@ -56,7 +56,7 @@ in place and tested.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: MRKD-WP-0005-T02
|
id: MRKD-WP-0005-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: 87caa295-f466-4e2e-ba06-4b1a801ca976
|
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
|
```task
|
||||||
id: MRKD-WP-0005-T03
|
id: MRKD-WP-0005-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: 8ceb771d-0b16-452d-b1cc-c5c2c40fe723
|
state_hub_task_id: 8ceb771d-0b16-452d-b1cc-c5c2c40fe723
|
||||||
```
|
```
|
||||||
@@ -109,7 +109,7 @@ source-only fallback.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: MRKD-WP-0005-T04
|
id: MRKD-WP-0005-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: ab0da6e4-a0f2-4a57-baac-5727b741c74f
|
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
|
```task
|
||||||
id: MRKD-WP-0005-T05
|
id: MRKD-WP-0005-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: 65b67ed9-5862-4322-acfb-5d39dab7e8d5
|
state_hub_task_id: 65b67ed9-5862-4322-acfb-5d39dab7e8d5
|
||||||
```
|
```
|
||||||
@@ -166,7 +166,7 @@ source-only tests pass on all systems.
|
|||||||
## Updating Task Status
|
## 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)
|
status: in_progress → status: done (when verified complete)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user