scrybe-plugin-docx 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: scrybe-plugin-docx
3
+ Version: 0.4.0
4
+ Summary: Scrybe plugin — render Markdown to Word (.docx) with embedded Mermaid PNGs
5
+ License: AGPL-3.0-or-later
6
+ Project-URL: Homepage, https://github.com/hartsock/scrybe
7
+ Project-URL: Issues, https://github.com/hartsock/scrybe/issues/3
8
+ Requires-Python: >=3.9
9
+ Requires-Dist: python-docx>=1.1
10
+ Requires-Dist: mistune>=3.0
11
+ Requires-Dist: Pillow>=10.0
12
+ Requires-Dist: scrybe-mermaid>=0.2
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=8; extra == "dev"
15
+ Requires-Dist: pytest-mock>=3; extra == "dev"
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "scrybe-plugin-docx"
7
+ version = "0.4.0"
8
+ description = "Scrybe plugin — render Markdown to Word (.docx) with embedded Mermaid PNGs"
9
+ license = {text = "AGPL-3.0-or-later"}
10
+ requires-python = ">=3.9"
11
+ dependencies = [
12
+ "python-docx>=1.1",
13
+ "mistune>=3.0",
14
+ "Pillow>=10.0",
15
+ # Embeds the Mermaid source into rendered diagram PNGs (iTXt), so the
16
+ # PNGs in the .docx round-trip back to source via `scrybe_mermaid.extract`.
17
+ "scrybe-mermaid>=0.2",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ dev = ["pytest>=8", "pytest-mock>=3"]
22
+
23
+ [project.scripts]
24
+ scrybe-docx = "scrybe_plugin_docx.main:main"
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/hartsock/scrybe"
28
+ Issues = "https://github.com/hartsock/scrybe/issues/3"
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["."]
32
+ include = ["scrybe_plugin_docx*"]
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright 2026 Shawn Hartsock and contributors
3
+
4
+ from .renderer import MarkdownToDocx
5
+
6
+ __all__ = ["MarkdownToDocx"]
@@ -0,0 +1,65 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright 2026 Shawn Hartsock and contributors
3
+
4
+ """scrybe-docx — render Markdown to a Word (.docx) file.
5
+
6
+ Usage:
7
+ scrybe-docx [INPUT] -o OUTPUT [--no-diagrams]
8
+ cat file.md | scrybe-docx -o output.docx
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from .renderer import MarkdownToDocx
18
+
19
+
20
+ def main(argv: list[str] | None = None) -> int:
21
+ parser = argparse.ArgumentParser(
22
+ prog="scrybe-docx",
23
+ description="Render a Markdown file to Word (.docx) with embedded Mermaid diagrams.",
24
+ )
25
+ parser.add_argument(
26
+ "input",
27
+ nargs="?",
28
+ type=Path,
29
+ metavar="FILE",
30
+ help="Input Markdown file (default: stdin).",
31
+ )
32
+ parser.add_argument(
33
+ "-o",
34
+ "--output",
35
+ type=Path,
36
+ default=Path("output.docx"),
37
+ metavar="OUTPUT",
38
+ help="Output .docx path (default: output.docx).",
39
+ )
40
+ parser.add_argument(
41
+ "--no-diagrams",
42
+ action="store_true",
43
+ help="Skip Mermaid rendering; keep fenced blocks as monospace text.",
44
+ )
45
+
46
+ args = parser.parse_args(argv)
47
+
48
+ if args.input:
49
+ source = args.input.read_text(encoding="utf-8")
50
+ else:
51
+ source = sys.stdin.read()
52
+
53
+ try:
54
+ doc = MarkdownToDocx(source, render_diagrams=not args.no_diagrams).build()
55
+ except ImportError as exc:
56
+ print(f"error: missing dependency — {exc}", file=sys.stderr)
57
+ print(" pip install scrybe-plugin-docx", file=sys.stderr)
58
+ return 1
59
+
60
+ doc.save(args.output)
61
+ return 0
62
+
63
+
64
+ if __name__ == "__main__":
65
+ raise SystemExit(main())
@@ -0,0 +1,37 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright 2026 Shawn Hartsock and contributors
3
+
4
+ import shutil
5
+ import subprocess
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+
10
+ class MermaidUnavailable(Exception):
11
+ pass
12
+
13
+
14
+ def render_mermaid_to_png(source: str) -> bytes:
15
+ """Render a Mermaid diagram source string to PNG bytes via the mmdc CLI."""
16
+ mmdc = shutil.which("mmdc")
17
+ if mmdc is None:
18
+ raise MermaidUnavailable(
19
+ "mmdc not found on PATH. Install @mermaid-js/mermaid-cli "
20
+ "(`npm install -g @mermaid-js/mermaid-cli`) or use --no-diagrams."
21
+ )
22
+
23
+ with tempfile.TemporaryDirectory() as tmpdir:
24
+ src = Path(tmpdir) / "diagram.mmd"
25
+ out = Path(tmpdir) / "diagram.png"
26
+ src.write_text(source, encoding="utf-8")
27
+
28
+ result = subprocess.run(
29
+ [mmdc, "-i", str(src), "-o", str(out)],
30
+ capture_output=True,
31
+ timeout=60,
32
+ )
33
+ if result.returncode != 0:
34
+ raise MermaidUnavailable(
35
+ f"mmdc exited {result.returncode}: {result.stderr.decode(errors='replace')}"
36
+ )
37
+ return out.read_bytes()
@@ -0,0 +1,253 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright 2026 Shawn Hartsock and contributors
3
+
4
+ """MarkdownToDocx — walk a mistune 3.x AST and emit a python-docx Document."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import io
9
+ import os
10
+ from typing import TYPE_CHECKING
11
+
12
+ from docx import Document
13
+ from docx.shared import Inches, Pt
14
+
15
+ from .mermaid import MermaidUnavailable, render_mermaid_to_png
16
+
17
+ if TYPE_CHECKING:
18
+ from docx.text.paragraph import Paragraph
19
+
20
+
21
+ class MarkdownToDocx:
22
+ """Convert a Markdown string to a python-docx Document.
23
+
24
+ Args:
25
+ source: Markdown source text.
26
+ render_diagrams: When True (default), Mermaid fenced blocks are
27
+ rendered to PNG via mmdc and embedded as images. When False
28
+ (or when mmdc is unavailable), the fenced block is kept as a
29
+ monospace code block.
30
+ """
31
+
32
+ def __init__(self, source: str, *, render_diagrams: bool = True) -> None:
33
+ self.source = source
34
+ self.render_diagrams = render_diagrams
35
+
36
+ def build(self) -> Document:
37
+ import mistune # deferred so the module can be imported for type-checking
38
+
39
+ doc = Document()
40
+ md = mistune.create_markdown(renderer="ast", plugins=["table"])
41
+ tokens = md(self.source) or []
42
+ _BlockVisitor(doc, render_diagrams=self.render_diagrams).visit_all(tokens)
43
+ return doc
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Internal AST visitors
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ class _BlockVisitor:
52
+ def __init__(self, doc: Document, *, render_diagrams: bool) -> None:
53
+ self.doc = doc
54
+ self.render_diagrams = render_diagrams
55
+
56
+ def visit_all(self, tokens: list[dict]) -> None:
57
+ for token in tokens:
58
+ self._visit(token)
59
+
60
+ def _visit(self, token: dict) -> None:
61
+ t = token.get("type", "")
62
+ handler = getattr(self, f"_on_{t}", None)
63
+ if handler:
64
+ handler(token)
65
+
66
+ # --- block handlers ---
67
+
68
+ def _on_heading(self, token: dict) -> None:
69
+ level = token.get("attrs", {}).get("level", 1)
70
+ text = _inline_text(token.get("children", []))
71
+ self.doc.add_heading(text, level=level)
72
+
73
+ def _on_paragraph(self, token: dict) -> None:
74
+ children = token.get("children", [])
75
+ # A lone image in a paragraph gets its own picture paragraph.
76
+ if len(children) == 1 and children[0].get("type") == "image":
77
+ self._embed_image(children[0])
78
+ return
79
+ p = self.doc.add_paragraph()
80
+ _InlineVisitor(p).visit_all(children)
81
+
82
+ def _on_block_code(self, token: dict) -> None:
83
+ info = (token.get("attrs") or {}).get("info") or ""
84
+ lang = info.split()[0] if info else ""
85
+ # mistune's block_code raw includes a trailing newline; strip it so
86
+ # the rendered paragraph text matches the source faithfully.
87
+ code = token.get("raw", "").rstrip("\n")
88
+
89
+ if lang == "mermaid" and self.render_diagrams:
90
+ self._render_mermaid(code)
91
+ return
92
+
93
+ p = self.doc.add_paragraph()
94
+ run = p.add_run(code)
95
+ run.font.name = "Courier New"
96
+ run.font.size = Pt(9)
97
+
98
+ def _on_list(self, token: dict) -> None:
99
+ ordered = (token.get("attrs") or {}).get("ordered", False)
100
+ for item in token.get("children", []):
101
+ self._on_list_item(item, ordered=ordered)
102
+
103
+ def _on_list_item(self, token: dict, *, ordered: bool = False) -> None:
104
+ style = "List Number" if ordered else "List Bullet"
105
+ p = self.doc.add_paragraph(style=style)
106
+ for child in token.get("children", []):
107
+ ct = child.get("type", "")
108
+ if ct in ("paragraph", "block_text"):
109
+ # mistune emits `block_text` for list-item content in tight
110
+ # lists and `paragraph` in loose lists; both wrap inline runs.
111
+ _InlineVisitor(p).visit_all(child.get("children", []))
112
+ else:
113
+ # Nested block inside list item — recurse.
114
+ self._visit(child)
115
+
116
+ def _on_block_quote(self, token: dict) -> None:
117
+ self.visit_all(token.get("children", []))
118
+
119
+ def _on_table(self, token: dict) -> None:
120
+ children = token.get("children", [])
121
+ head_tok = next((c for c in children if c.get("type") == "table_head"), None)
122
+ body_tok = next((c for c in children if c.get("type") == "table_body"), None)
123
+
124
+ # mistune's table plugin puts table_cells directly inside table_head
125
+ # (no intermediate row wrapper) but wraps body cells in table_row.
126
+ # Normalize so head and body have the same shape: list[row] where
127
+ # row.children == list[cell].
128
+ head_rows = [head_tok] if head_tok else []
129
+ body_rows = (body_tok or {}).get("children", [])
130
+ all_rows = head_rows + body_rows
131
+ if not all_rows:
132
+ return
133
+
134
+ num_cols = len(all_rows[0].get("children", []))
135
+ if num_cols == 0:
136
+ return
137
+
138
+ table = self.doc.add_table(rows=len(all_rows), cols=num_cols)
139
+ table.style = "Table Grid"
140
+
141
+ for row_idx, row_tok in enumerate(all_rows):
142
+ for col_idx, cell_tok in enumerate(row_tok.get("children", [])):
143
+ if col_idx < num_cols:
144
+ table.cell(row_idx, col_idx).text = _inline_text(
145
+ cell_tok.get("children", [])
146
+ )
147
+
148
+ def _on_thematic_break(self, token: dict) -> None: # noqa: ARG002
149
+ self.doc.add_paragraph("─" * 40)
150
+
151
+ def _on_blank_line(self, token: dict) -> None: # noqa: ARG002
152
+ pass
153
+
154
+ # --- helpers ---
155
+
156
+ def _embed_image(self, token: dict) -> None:
157
+ url = (token.get("attrs") or {}).get("url", "")
158
+ alt = (token.get("attrs") or {}).get("alt", url)
159
+ if url and os.path.exists(url):
160
+ try:
161
+ self.doc.add_picture(url, width=Inches(5.5))
162
+ return
163
+ except Exception:
164
+ pass
165
+ self.doc.add_paragraph(f"[Image: {alt}]")
166
+
167
+ def _render_mermaid(self, source: str) -> None:
168
+ try:
169
+ png = render_mermaid_to_png(source)
170
+ png = _embed_mermaid_source(png, source)
171
+ self.doc.add_picture(io.BytesIO(png), width=Inches(5.5))
172
+ except MermaidUnavailable:
173
+ # Fall back to monospace block.
174
+ p = self.doc.add_paragraph()
175
+ run = p.add_run(source)
176
+ run.font.name = "Courier New"
177
+ run.font.size = Pt(9)
178
+
179
+
180
+ class _InlineVisitor:
181
+ """Adds formatted runs to an existing paragraph."""
182
+
183
+ def __init__(self, paragraph: Paragraph) -> None:
184
+ self.p = paragraph
185
+
186
+ def visit_all(self, tokens: list[dict]) -> None:
187
+ for token in tokens:
188
+ self._visit(token, bold=False, italic=False)
189
+
190
+ def _visit(self, token: dict, *, bold: bool, italic: bool) -> None:
191
+ t = token.get("type", "")
192
+ if t in ("text", "raw"):
193
+ self._run(token.get("raw", ""), bold=bold, italic=italic)
194
+ elif t == "strong":
195
+ for child in token.get("children", []):
196
+ self._visit(child, bold=True, italic=italic)
197
+ elif t == "emphasis":
198
+ for child in token.get("children", []):
199
+ self._visit(child, bold=bold, italic=True)
200
+ elif t == "codespan":
201
+ run = self._run(token.get("raw", ""), bold=bold, italic=italic)
202
+ run.font.name = "Courier New"
203
+ elif t == "link":
204
+ # Render link text; URL is lost (Word hyperlinks require COM/OPC manipulation).
205
+ for child in token.get("children", []):
206
+ self._visit(child, bold=bold, italic=italic)
207
+ elif t in ("softline", "softbreak", "linebreak"):
208
+ self._run(" ", bold=False, italic=False)
209
+ elif t == "image":
210
+ alt = (token.get("attrs") or {}).get("alt", "")
211
+ self._run(f"[{alt}]", bold=False, italic=False)
212
+
213
+ def _run(self, text: str, *, bold: bool, italic: bool):
214
+ run = self.p.add_run(text)
215
+ run.bold = bold
216
+ run.italic = italic
217
+ return run
218
+
219
+
220
+ def _embed_mermaid_source(png: bytes, source: str) -> bytes:
221
+ """Embed the Mermaid source into the PNG's iTXt metadata, round-trippable
222
+ via `scrybe_mermaid.extract`. Falls back to the unmodified PNG if the
223
+ `scrybe_mermaid` binding (or its embed step) is unavailable, so export
224
+ never fails just because the codec is missing.
225
+ """
226
+ try:
227
+ import scrybe_mermaid
228
+
229
+ return scrybe_mermaid.embed(png, source)
230
+ except Exception:
231
+ return png
232
+
233
+
234
+ def _inline_text(tokens: list[dict]) -> str:
235
+ """Extract plain text from inline tokens (no formatting).
236
+
237
+ mistune 3.x emits inline text as `{"type": "text", "raw": "..."}`.
238
+ Earlier versions used `"type": "raw"` — accept both for resilience.
239
+ """
240
+ parts: list[str] = []
241
+ for tok in tokens:
242
+ t = tok.get("type", "")
243
+ if t in ("text", "raw"):
244
+ parts.append(tok.get("raw", ""))
245
+ elif t in ("strong", "emphasis", "link"):
246
+ parts.append(_inline_text(tok.get("children", [])))
247
+ elif t == "codespan":
248
+ parts.append(tok.get("raw", ""))
249
+ elif t in ("softline", "softbreak", "linebreak"):
250
+ parts.append(" ")
251
+ elif t == "image":
252
+ parts.append((tok.get("attrs") or {}).get("alt", ""))
253
+ return "".join(parts)
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: scrybe-plugin-docx
3
+ Version: 0.4.0
4
+ Summary: Scrybe plugin — render Markdown to Word (.docx) with embedded Mermaid PNGs
5
+ License: AGPL-3.0-or-later
6
+ Project-URL: Homepage, https://github.com/hartsock/scrybe
7
+ Project-URL: Issues, https://github.com/hartsock/scrybe/issues/3
8
+ Requires-Python: >=3.9
9
+ Requires-Dist: python-docx>=1.1
10
+ Requires-Dist: mistune>=3.0
11
+ Requires-Dist: Pillow>=10.0
12
+ Requires-Dist: scrybe-mermaid>=0.2
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=8; extra == "dev"
15
+ Requires-Dist: pytest-mock>=3; extra == "dev"
@@ -0,0 +1,12 @@
1
+ pyproject.toml
2
+ scrybe_plugin_docx/__init__.py
3
+ scrybe_plugin_docx/main.py
4
+ scrybe_plugin_docx/mermaid.py
5
+ scrybe_plugin_docx/renderer.py
6
+ scrybe_plugin_docx.egg-info/PKG-INFO
7
+ scrybe_plugin_docx.egg-info/SOURCES.txt
8
+ scrybe_plugin_docx.egg-info/dependency_links.txt
9
+ scrybe_plugin_docx.egg-info/entry_points.txt
10
+ scrybe_plugin_docx.egg-info/requires.txt
11
+ scrybe_plugin_docx.egg-info/top_level.txt
12
+ tests/test_renderer.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ scrybe-docx = scrybe_plugin_docx.main:main
@@ -0,0 +1,8 @@
1
+ python-docx>=1.1
2
+ mistune>=3.0
3
+ Pillow>=10.0
4
+ scrybe-mermaid>=0.2
5
+
6
+ [dev]
7
+ pytest>=8
8
+ pytest-mock>=3
@@ -0,0 +1 @@
1
+ scrybe_plugin_docx
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,188 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright 2026 Shawn Hartsock and contributors
3
+
4
+ """Unit tests for scrybe_plugin_docx.renderer.
5
+
6
+ Requires python-docx and mistune. Tests are skipped when deps are absent.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import pytest
12
+
13
+ docx = pytest.importorskip("docx", reason="python-docx not installed")
14
+ mistune = pytest.importorskip("mistune", reason="mistune not installed")
15
+
16
+ from scrybe_plugin_docx.renderer import MarkdownToDocx, _inline_text # noqa: E402
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # _inline_text helper
20
+ # ---------------------------------------------------------------------------
21
+
22
+
23
+ def test_inline_text_plain():
24
+ tokens = [{"type": "raw", "raw": "hello world"}]
25
+ assert _inline_text(tokens) == "hello world"
26
+
27
+
28
+ def test_inline_text_nested_strong():
29
+ tokens = [
30
+ {"type": "raw", "raw": "foo "},
31
+ {"type": "strong", "children": [{"type": "raw", "raw": "bar"}]},
32
+ ]
33
+ assert _inline_text(tokens) == "foo bar"
34
+
35
+
36
+ def test_inline_text_softline():
37
+ tokens = [
38
+ {"type": "raw", "raw": "a"},
39
+ {"type": "softline"},
40
+ {"type": "raw", "raw": "b"},
41
+ ]
42
+ assert _inline_text(tokens) == "a b"
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # MarkdownToDocx.build()
47
+ # ---------------------------------------------------------------------------
48
+
49
+
50
+ def _build(md: str, **kwargs):
51
+ return MarkdownToDocx(md, **kwargs).build()
52
+
53
+
54
+ def test_heading_level1():
55
+ doc = _build("# Hello")
56
+ paras = [p for p in doc.paragraphs if p.text.strip()]
57
+ assert paras[0].text == "Hello"
58
+ assert paras[0].style.name.startswith("Heading")
59
+
60
+
61
+ def test_heading_levels():
62
+ doc = _build("# H1\n\n## H2\n\n### H3")
63
+ headings = [p for p in doc.paragraphs if p.text.strip()]
64
+ assert len(headings) == 3
65
+ assert headings[0].style.name == "Heading 1"
66
+ assert headings[1].style.name == "Heading 2"
67
+ assert headings[2].style.name == "Heading 3"
68
+
69
+
70
+ def test_paragraph_plain():
71
+ doc = _build("Hello world.")
72
+ paras = [p for p in doc.paragraphs if p.text.strip()]
73
+ assert paras[0].text == "Hello world."
74
+
75
+
76
+ def test_paragraph_bold_italic():
77
+ doc = _build("**bold** and *italic*")
78
+ p = next(p for p in doc.paragraphs if p.text.strip())
79
+ runs = [r for r in p.runs if r.text.strip()]
80
+ bold_runs = [r for r in runs if r.bold]
81
+ italic_runs = [r for r in runs if r.italic]
82
+ assert any(r.text == "bold" for r in bold_runs)
83
+ assert any(r.text == "italic" for r in italic_runs)
84
+
85
+
86
+ def test_bullet_list():
87
+ doc = _build("- apple\n- banana\n- cherry")
88
+ list_paras = [p for p in doc.paragraphs if p.style.name == "List Bullet"]
89
+ assert len(list_paras) == 3
90
+ texts = [p.text for p in list_paras]
91
+ assert "apple" in texts
92
+ assert "cherry" in texts
93
+
94
+
95
+ def test_ordered_list():
96
+ doc = _build("1. first\n2. second")
97
+ list_paras = [p for p in doc.paragraphs if p.style.name == "List Number"]
98
+ assert len(list_paras) == 2
99
+
100
+
101
+ def test_code_block_monospace():
102
+ doc = _build("```python\nprint('hi')\n```")
103
+ paras = [p for p in doc.paragraphs if p.text.strip()]
104
+ assert any(p.text == "print('hi')" for p in paras)
105
+ code_para = next(p for p in paras if p.text == "print('hi')")
106
+ assert code_para.runs[0].font.name == "Courier New"
107
+
108
+
109
+ def test_table():
110
+ md = "| A | B |\n|---|---|\n| 1 | 2 |"
111
+ doc = _build(md)
112
+ assert len(doc.tables) == 1
113
+ table = doc.tables[0]
114
+ assert table.cell(0, 0).text.strip() == "A"
115
+ assert table.cell(1, 1).text.strip() == "2"
116
+
117
+
118
+ def test_mermaid_no_diagrams_fallback():
119
+ md = "```mermaid\ngraph TD; A-->B\n```"
120
+ doc = _build(md, render_diagrams=False)
121
+ paras = [p for p in doc.paragraphs if p.text.strip()]
122
+ assert any("A-->B" in p.text for p in paras)
123
+
124
+
125
+ def test_mermaid_mmdc_unavailable_falls_back(monkeypatch):
126
+ """When mmdc is not on PATH, mermaid block falls back to monospace text."""
127
+ import scrybe_plugin_docx.mermaid as m
128
+ import scrybe_plugin_docx.renderer as r
129
+
130
+ def _raise(_):
131
+ raise m.MermaidUnavailable("no mmdc")
132
+
133
+ # renderer.py imports `render_mermaid_to_png` by value, so patch the name
134
+ # bound in the renderer module (patching the mermaid module's attribute
135
+ # would not affect the already-imported reference).
136
+ monkeypatch.setattr(r, "render_mermaid_to_png", _raise)
137
+
138
+ md = "```mermaid\ngraph TD; A-->B\n```"
139
+ doc = _build(md, render_diagrams=True)
140
+ paras = [p for p in doc.paragraphs if p.text.strip()]
141
+ assert any("A-->B" in p.text for p in paras)
142
+
143
+
144
+ def test_empty_input():
145
+ doc = _build("")
146
+ # Should produce a Document without errors.
147
+ assert doc is not None
148
+
149
+
150
+ def test_thematic_break():
151
+ doc = _build("above\n\n---\n\nbelow")
152
+ texts = [p.text for p in doc.paragraphs if p.text.strip()]
153
+ assert any("─" in t for t in texts)
154
+
155
+
156
+ def test_inline_code():
157
+ doc = _build("use `config.toml` here")
158
+ p = next(p for p in doc.paragraphs if p.text.strip())
159
+ runs = {r.text: r for r in p.runs if r.text.strip()}
160
+ assert "config.toml" in runs
161
+ assert runs["config.toml"].font.name == "Courier New"
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # CLI
166
+ # ---------------------------------------------------------------------------
167
+
168
+
169
+ def test_cli_stdin(tmp_path, monkeypatch):
170
+ import io, sys
171
+ from scrybe_plugin_docx.main import main
172
+
173
+ out = tmp_path / "out.docx"
174
+ monkeypatch.setattr(sys, "stdin", io.StringIO("# Title\n\nBody text."))
175
+ rc = main(["-o", str(out)])
176
+ assert rc == 0
177
+ assert out.exists()
178
+
179
+
180
+ def test_cli_file_input(tmp_path):
181
+ from scrybe_plugin_docx.main import main
182
+
183
+ src = tmp_path / "input.md"
184
+ src.write_text("# Hello\n\nWorld.", encoding="utf-8")
185
+ out = tmp_path / "out.docx"
186
+ rc = main([str(src), "-o", str(out)])
187
+ assert rc == 0
188
+ assert out.exists()