scrybe-plugin-docx 0.4.0__py3-none-any.whl

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,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,9 @@
1
+ scrybe_plugin_docx/__init__.py,sha256=0ucqCxYnaNdCWb03a88CgDxaQOWqHalbU8LO9e6Du3k,162
2
+ scrybe_plugin_docx/main.py,sha256=U_zDMPlKs6VCFk2vhXwWRuJ9EvY7MVrRoUTStNrfdjo,1669
3
+ scrybe_plugin_docx/mermaid.py,sha256=vKFaEfoTFcJRhvkXYEBxVZZtS0DVcA8anR7sGqjLHTE,1145
4
+ scrybe_plugin_docx/renderer.py,sha256=sX3Q9Vk-mA0lx6pIerEUddYJL7k7dGlRmy9VmCqnhg4,9429
5
+ scrybe_plugin_docx-0.4.0.dist-info/METADATA,sha256=5zkR_rBd7zhHbTvQyvuyZdmsHvDsO7z-Tlg7oxGwGoY,551
6
+ scrybe_plugin_docx-0.4.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
7
+ scrybe_plugin_docx-0.4.0.dist-info/entry_points.txt,sha256=1zQk0h2afzURDEP2f3wr78hEST-g4XL7EyHDiwONWTo,61
8
+ scrybe_plugin_docx-0.4.0.dist-info/top_level.txt,sha256=s4hjnsr-do2xhgn1GEmg0DBK8Jj8SNFcH-XjT6EKszE,19
9
+ scrybe_plugin_docx-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ scrybe-docx = scrybe_plugin_docx.main:main
@@ -0,0 +1 @@
1
+ scrybe_plugin_docx