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.
- scrybe_plugin_docx/__init__.py +6 -0
- scrybe_plugin_docx/main.py +65 -0
- scrybe_plugin_docx/mermaid.py +37 -0
- scrybe_plugin_docx/renderer.py +253 -0
- scrybe_plugin_docx-0.4.0.dist-info/METADATA +15 -0
- scrybe_plugin_docx-0.4.0.dist-info/RECORD +9 -0
- scrybe_plugin_docx-0.4.0.dist-info/WHEEL +5 -0
- scrybe_plugin_docx-0.4.0.dist-info/entry_points.txt +2 -0
- scrybe_plugin_docx-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -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 @@
|
|
|
1
|
+
scrybe_plugin_docx
|