mdmint 0.2.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.
mdmint/render.py ADDED
@@ -0,0 +1,343 @@
1
+ """Markdown → HTML body, using markdown-it-py and a handful of plugins.
2
+
3
+ This is the single intermediate representation feeding both the DOCX and PDF
4
+ writers. Mermaid fences and LaTeX math are routed through the optional renderers
5
+ (:mod:`mdmint.mermaid`, :mod:`mdmint.mathrender`) via custom render rules; the work
6
+ directory they write images into is threaded through markdown-it's ``env``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import html
12
+ import re
13
+ import unicodedata
14
+ from collections.abc import Sequence
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Any, Optional, cast
17
+
18
+ from markdown_it import MarkdownIt
19
+ from mdit_py_plugins.admon import admon_plugin
20
+ from mdit_py_plugins.anchors import anchors_plugin
21
+ from mdit_py_plugins.dollarmath import dollarmath_plugin
22
+ from mdit_py_plugins.footnote import footnote_plugin
23
+ from mdit_py_plugins.front_matter import front_matter_plugin
24
+ from mdit_py_plugins.tasklists import tasklists_plugin
25
+
26
+ from . import admonitions, mathrender, mermaid
27
+
28
+ if TYPE_CHECKING:
29
+ from markdown_it.renderer import RendererHTML
30
+ from markdown_it.rules_core import StateCore
31
+ from markdown_it.token import Token
32
+ from markdown_it.utils import EnvType, OptionsDict
33
+
34
+ # A heading as (level, text, anchor id or None).
35
+ Heading = tuple[int, str, Optional[str]]
36
+
37
+ _TOC_MARKER = re.compile(r"<p>\[\[?TOC\]\]?</p>", re.IGNORECASE)
38
+
39
+ # Trailing attr_list on a heading, python-markdown / mkdocs style:
40
+ # ``# Title {#custom-id .part}`` or ``# Title {: .appendix }``.
41
+ _HEADING_ATTRS = re.compile(r"\s*\{:?\s*((?:[#.][\w-]+\s*)+)\}\s*$")
42
+
43
+
44
+ def slugify(title: str) -> str:
45
+ """Heading id slug compatible with mkdocs' default ``toc`` slugify.
46
+
47
+ Matching mkdocs matters because docs written for it link to headings by these
48
+ ids (``page.md#2-the-maintenance-case``). Non-ASCII is folded away as mkdocs
49
+ does; a heading with nothing ASCII left keeps its Unicode word characters so it
50
+ still gets a usable id.
51
+ """
52
+ ascii_ = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii")
53
+ for value in (ascii_, title):
54
+ value = re.sub(r"[^\w\s-]", "", value).strip().lower()
55
+ value = re.sub(r"[-\s]+", "-", value)
56
+ if value:
57
+ return value
58
+ return "section"
59
+
60
+
61
+ def _heading_attrs_strip(state: StateCore) -> None:
62
+ """Core rule (before anchors): strip a trailing ``{#id .class}`` from headings.
63
+
64
+ The attrs are parked in ``token.meta`` so the slug is computed from the clean
65
+ title, then re-applied by :func:`_heading_attrs_apply` after the anchors rule
66
+ (which would otherwise overwrite a custom id).
67
+ """
68
+ tokens = state.tokens
69
+ for i, tok in enumerate(tokens):
70
+ if tok.type != "heading_open" or i + 1 >= len(tokens):
71
+ continue
72
+ inline = tokens[i + 1]
73
+ last = inline.children[-1] if inline.children else None
74
+ if last is None or last.type != "text":
75
+ continue
76
+ m = _HEADING_ATTRS.search(last.content)
77
+ if not m:
78
+ continue
79
+ last.content = last.content[: m.start()]
80
+ inline.content = _HEADING_ATTRS.sub("", inline.content)
81
+ parts = m.group(1).split()
82
+ tok.meta = dict(tok.meta or {})
83
+ tok.meta["attr_id"] = next((p[1:] for p in parts if p.startswith("#")), None)
84
+ tok.meta["attr_classes"] = [p[1:] for p in parts if p.startswith(".")]
85
+
86
+
87
+ def _heading_attrs_apply(state: StateCore) -> None:
88
+ for tok in state.tokens:
89
+ if tok.type != "heading_open" or not tok.meta:
90
+ continue
91
+ if tok.meta.get("attr_id"):
92
+ tok.attrSet("id", tok.meta["attr_id"])
93
+ if tok.meta.get("attr_classes"):
94
+ tok.attrSet("class", " ".join(tok.meta["attr_classes"]))
95
+
96
+
97
+ def _math_inline(tokens: Sequence[Token], idx: int, options: OptionsDict, env: EnvType) -> str:
98
+ return mathrender.render(tokens[idx].content, block=False, work_dir=env["work_dir"])
99
+
100
+
101
+ def _math_block(tokens: Sequence[Token], idx: int, options: OptionsDict, env: EnvType) -> str:
102
+ return mathrender.render(tokens[idx].content, block=True, work_dir=env["work_dir"])
103
+
104
+
105
+ # --- Admonitions (!!! / ??? callouts) -------------------------------------
106
+ #
107
+ # We render each admonition as a full-width single-cell table rather than the
108
+ # plugin's default <div>. A <div> with CSS looks right in PDF but htmldocx drops
109
+ # it entirely (no CSS, no box); a one-cell table is the one structure both
110
+ # writers turn into a real box. The dynamic per-type colours go inline so the PDF
111
+ # backend needs no generated stylesheet, and the accent-coloured title doubles as
112
+ # the marker the DOCX post-pass keys on (see backends._style_admonitions).
113
+
114
+
115
+ def _admonition_open(tokens: Sequence[Token], idx: int, options: OptionsDict, env: EnvType) -> str:
116
+ tok = tokens[idx]
117
+ tag = (tok.meta or {}).get("tag", "")
118
+ canon = admonitions.resolve(tag)
119
+ accent, bg = admonitions.style_for(tag)
120
+ env.setdefault("_admon_stack", []).append((accent, bg))
121
+ classes = tok.attrGet("class") or f"admonition {canon}"
122
+ return (
123
+ f'<table class="{classes}"><tbody><tr>'
124
+ f'<td class="admonition-cell" style="background-color:{bg};'
125
+ f'border-left:4px solid {accent};">'
126
+ )
127
+
128
+
129
+ def _admonition_close(tokens: Sequence[Token], idx: int, options: OptionsDict, env: EnvType) -> str:
130
+ stack = env.get("_admon_stack")
131
+ if stack:
132
+ stack.pop()
133
+ return "</td></tr></tbody></table>\n"
134
+
135
+
136
+ def _admonition_title_open(
137
+ tokens: Sequence[Token], idx: int, options: OptionsDict, env: EnvType
138
+ ) -> str:
139
+ stack = env.get("_admon_stack") or [admonitions.style_for("note")]
140
+ accent = stack[-1][0]
141
+ # The colour must live on a <span> (not the <p>): htmldocx only copies run
142
+ # colour from a span's inline style, and that coloured run is exactly what the
143
+ # DOCX post-pass uses to recognise the admonition.
144
+ return (
145
+ f'<p class="admonition-title" style="margin:0 0 0.4em 0;">'
146
+ f'<span style="color:{accent};"><strong>'
147
+ )
148
+
149
+
150
+ def _admonition_title_close(
151
+ tokens: Sequence[Token], idx: int, options: OptionsDict, env: EnvType
152
+ ) -> str:
153
+ return "</strong></span></p>\n"
154
+
155
+
156
+ def _build_md() -> MarkdownIt:
157
+ """Construct a configured MarkdownIt instance (stateless across renders)."""
158
+ md = (
159
+ MarkdownIt("commonmark", {"html": True, "linkify": True, "typographer": False})
160
+ .enable(["table", "strikethrough"])
161
+ .use(front_matter_plugin) # strip YAML front-matter from output
162
+ .use(footnote_plugin)
163
+ .use(tasklists_plugin, enabled=True)
164
+ .use(anchors_plugin, max_level=6, permalink=False, slug_func=slugify)
165
+ .use(dollarmath_plugin, double_inline=True)
166
+ .use(admon_plugin) # !!! note / ??? collapsible callouts
167
+ )
168
+ md.core.ruler.before("anchor", "heading_attrs_strip", _heading_attrs_strip)
169
+ md.core.ruler.after("anchor", "heading_attrs_apply", _heading_attrs_apply)
170
+
171
+ # Renderer rules are called as rule(tokens, idx, options, env): plain functions work.
172
+ rules: dict[str, Any] = cast("RendererHTML", md.renderer).rules
173
+
174
+ # Route mermaid fenced blocks through the optional renderer; everything else
175
+ # uses the default fence rule.
176
+ default_fence = rules["fence"]
177
+
178
+ def fence(tokens: Sequence[Token], idx: int, options: OptionsDict, env: EnvType) -> str:
179
+ token = tokens[idx]
180
+ info = token.info.strip()
181
+ lang = info.split(maxsplit=1)[0] if info else ""
182
+ if lang.lower() == "mermaid":
183
+ return mermaid.render(token.content, env["work_dir"])
184
+ return default_fence(tokens, idx, options, env)
185
+
186
+ rules["fence"] = fence
187
+
188
+ # Route math tokens through the optional renderer.
189
+ rules["math_inline"] = _math_inline
190
+ rules["math_inline_double"] = _math_block # $$...$$ used inline
191
+ rules["math_block"] = _math_block
192
+ rules["math_block_label"] = _math_block
193
+
194
+ # Route admonitions to a styled single-cell table (see helpers above).
195
+ rules["admonition_open"] = _admonition_open
196
+ rules["admonition_close"] = _admonition_close
197
+ rules["admonition_title_open"] = _admonition_title_open
198
+ rules["admonition_title_close"] = _admonition_title_close
199
+ return md
200
+
201
+
202
+ _MD = _build_md()
203
+
204
+
205
+ def _inline_text(inline_token: Token | None) -> str:
206
+ """Plain-text content of an inline token (for TOC labels)."""
207
+ if not inline_token or not inline_token.children:
208
+ return inline_token.content if inline_token else ""
209
+ return "".join(c.content for c in inline_token.children if c.type in ("text", "code_inline"))
210
+
211
+
212
+ def _extract_headings(tokens: Sequence[Token]) -> list[Heading]:
213
+ """Return (level, text, anchor_id) for every heading in the document."""
214
+ headings: list[Heading] = []
215
+ for i, tok in enumerate(tokens):
216
+ if tok.type == "heading_open":
217
+ level = int(tok.tag[1])
218
+ anchor = tok.attrGet("id")
219
+ text = _inline_text(tokens[i + 1]) if i + 1 < len(tokens) else ""
220
+ headings.append((level, text, None if anchor is None else str(anchor)))
221
+ return headings
222
+
223
+
224
+ def build_toc(headings: Sequence[Heading]) -> str:
225
+ """Build a ``<nav class="toc">`` block from a list of (level, text, anchor)."""
226
+ if not headings:
227
+ return ""
228
+ items = []
229
+ for level, text, anchor in headings:
230
+ label = html.escape(text)
231
+ link = f'<a href="#{anchor}">{label}</a>' if anchor else label
232
+ indent = (level - 1) * 1.2
233
+ items.append(f'<li style="margin-left:{indent:.1f}em">{link}</li>')
234
+ return f'<nav class="toc"><div class="toc-title">Contents</div><ul>{"".join(items)}</ul></nav>'
235
+
236
+
237
+ def has_toc_marker(body: str) -> bool:
238
+ return bool(_TOC_MARKER.search(body))
239
+
240
+
241
+ def strip_toc_markers(body: str) -> str:
242
+ """Remove any literal ``[TOC]`` markers from a rendered body."""
243
+ return _TOC_MARKER.sub("", body)
244
+
245
+
246
+ def prefix_anchors(
247
+ body: str, headings: Sequence[Heading], prefix: str
248
+ ) -> tuple[str, list[Heading]]:
249
+ """Namespace heading anchors so several documents can share one HTML page.
250
+
251
+ Returns ``(body, headings)`` with each heading's ``id`` (and the matching TOC
252
+ anchor) prefixed, so a consolidated TOC over merged files links unambiguously
253
+ even when two files use the same heading text. Non-heading ids (e.g. footnotes)
254
+ are left untouched.
255
+ """
256
+ mapping: dict[str, str] = {}
257
+ new_headings: list[Heading] = []
258
+ for level, text, anchor in headings:
259
+ if anchor:
260
+ mapping[anchor] = f"{prefix}{anchor}"
261
+ new_headings.append((level, text, mapping[anchor]))
262
+ else:
263
+ new_headings.append((level, text, anchor))
264
+ if mapping:
265
+
266
+ def repl(m: re.Match[str]) -> str:
267
+ old = m.group(2)
268
+ return f"{m.group(1)}{mapping.get(old, old)}{m.group(3)}"
269
+
270
+ body = re.sub(r'(\bid=")([^"]*)(")', repl, body)
271
+ return body, new_headings
272
+
273
+
274
+ _HEADING_TAG = re.compile(r"<(/?)h([1-6])(?=[\s>])", re.IGNORECASE)
275
+ _HEADING_HTML = re.compile(r"<h([1-6])\b([^>]*)>(.*?)</h\1>", re.IGNORECASE | re.DOTALL)
276
+ _ID_ATTR = re.compile(r'\bid="([^"]*)"')
277
+
278
+
279
+ def shift_headings(body: str, offset: int) -> str:
280
+ """Demote every heading in *body* by *offset* levels (capped at ``h6``)."""
281
+ if not offset:
282
+ return body
283
+ return _HEADING_TAG.sub(lambda m: f"<{m.group(1)}h{min(6, int(m.group(2)) + offset)}", body)
284
+
285
+
286
+ def namespace_ids(body: str, prefix: str) -> str:
287
+ """Prefix *every* ``id`` and in-page ``href="#..."`` in *body*.
288
+
289
+ Unlike :func:`prefix_anchors` (headings only), this also covers footnotes, so
290
+ several documents stitched into one keep their footnote links apart.
291
+ """
292
+ body = re.sub(
293
+ r'(\bid=")([^"]*)(")', lambda m: f"{m.group(1)}{prefix}{m.group(2)}{m.group(3)}", body
294
+ )
295
+ return re.sub(
296
+ r'(\bhref="#)([^"]+)(")', lambda m: f"{m.group(1)}{prefix}{m.group(2)}{m.group(3)}", body
297
+ )
298
+
299
+
300
+ def headings_from_html(body: str) -> list[Heading]:
301
+ """Return (level, text, anchor_id) for every heading in an HTML fragment."""
302
+ out: list[Heading] = []
303
+ for m in _HEADING_HTML.finditer(body):
304
+ idm = _ID_ATTR.search(m.group(2))
305
+ text = html.unescape(re.sub(r"<[^>]+>", "", m.group(3))).strip()
306
+ out.append((int(m.group(1)), text, idm.group(1) if idm else None))
307
+ return out
308
+
309
+
310
+ def parse(md_text: str, *, work_dir: Path) -> tuple[str, list[Heading]]:
311
+ """Render Markdown to an HTML fragment and return ``(body, headings)``.
312
+
313
+ ``headings`` is a list of (level, text, anchor) in document order. No table of
314
+ contents is inserted — use :func:`apply_toc` (single file) or :func:`build_toc`
315
+ (consolidated across merged files).
316
+ """
317
+ env: dict[str, Any] = {"work_dir": work_dir}
318
+ tokens = _MD.parse(md_text, env)
319
+ body = _MD.renderer.render(tokens, _MD.options, env)
320
+ return body, _extract_headings(tokens)
321
+
322
+
323
+ def apply_toc(body: str, headings: Sequence[Heading], toc: bool) -> str:
324
+ """Insert a single-document TOC: at a ``[TOC]`` marker if present, else at the
325
+ top when *toc* is True. A stray marker with no TOC wanted is dropped."""
326
+ toc_html = build_toc(headings)
327
+ if toc_html and _TOC_MARKER.search(body):
328
+ return _TOC_MARKER.sub(toc_html, body, count=1)
329
+ if toc and toc_html:
330
+ return toc_html + "\n" + body
331
+ if _TOC_MARKER.search(body):
332
+ return _TOC_MARKER.sub("", body)
333
+ return body
334
+
335
+
336
+ def render_body(md_text: str, *, work_dir: Path, toc: bool = False) -> str:
337
+ """Render Markdown source to an HTML fragment (no <html>/<body> wrapper).
338
+
339
+ toc: if True, insert a table of contents at the top (unless a ``[TOC]`` marker
340
+ is present, in which case it is inserted there regardless of this flag).
341
+ """
342
+ body, headings = parse(md_text, work_dir=work_dir)
343
+ return apply_toc(body, headings, toc)
@@ -0,0 +1,122 @@
1
+ /* Base styling shared by the PDF and (where supported) DOCX writers.
2
+ Kept deliberately simple: xhtml2pdf supports only a subset of CSS, so we avoid
3
+ flexbox/grid and stick to properties it understands (margins, borders, fonts,
4
+ page breaks). weasyprint honours all of it and a bit more. */
5
+
6
+ @page {
7
+ size: a4;
8
+ margin: 2cm;
9
+ }
10
+
11
+ body {
12
+ font-family: "Helvetica", "Arial", sans-serif;
13
+ font-size: 11pt;
14
+ line-height: 1.45;
15
+ color: #1a1a1a;
16
+ }
17
+
18
+ h1, h2, h3, h4, h5, h6 {
19
+ font-family: "Helvetica", "Arial", sans-serif;
20
+ color: #111;
21
+ line-height: 1.2;
22
+ margin-top: 1.1em;
23
+ margin-bottom: 0.4em;
24
+ }
25
+
26
+ h1 { font-size: 22pt; border-bottom: 2px solid #ddd; padding-bottom: 4px; }
27
+ h2 { font-size: 17pt; border-bottom: 1px solid #eee; padding-bottom: 3px; }
28
+ h3 { font-size: 14pt; }
29
+ h4 { font-size: 12pt; }
30
+
31
+ p { margin: 0.5em 0; }
32
+
33
+ a { color: #2a6db0; text-decoration: none; }
34
+
35
+ /* Inline code and code blocks */
36
+ code {
37
+ font-family: "Courier New", monospace;
38
+ font-size: 10pt;
39
+ background-color: #f4f4f4;
40
+ padding: 1px 3px;
41
+ }
42
+
43
+ pre {
44
+ font-family: "Courier New", monospace;
45
+ font-size: 9.5pt;
46
+ background-color: #f4f4f4;
47
+ border: 1px solid #e0e0e0;
48
+ padding: 8px 10px;
49
+ margin: 0.6em 0;
50
+ white-space: pre-wrap;
51
+ word-wrap: break-word;
52
+ }
53
+
54
+ pre code { background-color: transparent; padding: 0; }
55
+
56
+ /* Mermaid fallback block (when no renderer is available) */
57
+ pre.mermaid {
58
+ background-color: #fff8e6;
59
+ border: 1px dashed #d4b96a;
60
+ }
61
+
62
+ blockquote {
63
+ margin: 0.6em 0;
64
+ padding: 2px 12px;
65
+ color: #555;
66
+ border-left: 4px solid #ddd;
67
+ background-color: #fafafa;
68
+ }
69
+
70
+ /* GFM tables */
71
+ table {
72
+ border-collapse: collapse;
73
+ margin: 0.7em 0;
74
+ width: 100%;
75
+ }
76
+
77
+ th, td {
78
+ border: 1px solid #ccc;
79
+ padding: 5px 8px;
80
+ text-align: left;
81
+ }
82
+
83
+ th { background-color: #f0f0f0; font-weight: bold; }
84
+
85
+ img { max-width: 100%; }
86
+
87
+ /* Math images render inline with the text baseline */
88
+ img.math-inline { vertical-align: middle; }
89
+ img.math-block { display: block; margin: 0.6em auto; }
90
+
91
+ /* Task lists */
92
+ ul.contains-task-list { list-style-type: none; padding-left: 1em; }
93
+ li.task-list-item { list-style-type: none; }
94
+
95
+ /* Footnotes */
96
+ hr.footnotes-sep { margin-top: 2em; border: none; border-top: 1px solid #ccc; }
97
+ section.footnotes { font-size: 9.5pt; color: #555; }
98
+
99
+ /* Table of contents */
100
+ nav.toc { margin: 1em 0 1.5em 0; padding: 8px 12px; border: 1px solid #eee; background: #fafafa; }
101
+ nav.toc .toc-title { font-weight: bold; margin-bottom: 0.4em; }
102
+ nav.toc ul { margin: 0; padding-left: 1.2em; }
103
+
104
+ /* Admonitions (!!! note / ??? warning callouts).
105
+ Rendered as a full-width single-cell table; per-type colours (background and
106
+ the left accent bar) are set inline by the renderer. We only need to undo the
107
+ generic td border here so the inline accent rule is the sole visible edge. */
108
+ table.admonition { margin: 0.8em 0; }
109
+ table.admonition td.admonition-cell {
110
+ border: none; /* generic th,td border off; accent set inline */
111
+ padding: 6px 12px;
112
+ vertical-align: top;
113
+ }
114
+ .admonition-title { margin-top: 0; }
115
+
116
+ /* Page break inserted between merged documents */
117
+ .mdmint-page-break { page-break-before: always; }
118
+
119
+ /* Part dividers (top-level mkdocs nav sections, or `# Title {.part}`) and
120
+ appendix chapters (`{.appendix}` / pages under an "Annexes" section). */
121
+ h1.part { page-break-before: always; font-size: 28pt; border-bottom: none; margin-top: 30%; }
122
+ h1.appendix { page-break-before: always; }
@@ -0,0 +1,196 @@
1
+ Metadata-Version: 2.4
2
+ Name: mdmint
3
+ Version: 0.2.0
4
+ Summary: Export Markdown files to DOCX and/or PDF. Pure-Python, cross-platform, minimal dependencies.
5
+ Author-email: Antonio Olleros <antonio.olleros@meaningfuldata.eu>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/Meaningful-Data/mdmint
8
+ Keywords: markdown,docx,pdf,converter,export,mermaid,mkdocs,template
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ License-File: NOTICE
17
+ Requires-Dist: markdown-it-py>=3.0
18
+ Requires-Dist: mdit-py-plugins>=0.4
19
+ Requires-Dist: htmldocx>=0.0.6
20
+ Requires-Dist: python-docx>=1.1
21
+ Requires-Dist: xhtml2pdf>=0.2.11
22
+ Requires-Dist: svglib<1.6
23
+ Requires-Dist: PyYAML>=5.1
24
+ Provides-Extra: weasyprint
25
+ Requires-Dist: weasyprint>=60; extra == "weasyprint"
26
+ Provides-Extra: math
27
+ Requires-Dist: matplotlib>=3.5; extra == "math"
28
+ Provides-Extra: all
29
+ Requires-Dist: weasyprint>=60; extra == "all"
30
+ Requires-Dist: matplotlib>=3.5; extra == "all"
31
+ Dynamic: license-file
32
+
33
+ # mdmint
34
+
35
+ Export Markdown files to **DOCX** and/or **PDF** — pure-Python, cross-platform,
36
+ minimal dependencies.
37
+
38
+ A single Markdown → HTML pass feeds both writers. The two features that can't be
39
+ rendered statically in pure Python (mermaid diagrams and LaTeX math) use an
40
+ **optional** renderer if one is available and fall back gracefully otherwise — so
41
+ the base install stays small and works everywhere.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install mdmint
47
+ ```
48
+
49
+ The core install is pure-Python (no native libraries):
50
+ `markdown-it-py`, `mdit-py-plugins`, `htmldocx`/`python-docx`, `xhtml2pdf`.
51
+
52
+ Optional extras:
53
+
54
+ ```bash
55
+ pip install "mdmint[math]" # render LaTeX math (adds matplotlib)
56
+ pip install "mdmint[weasyprint]" # higher-fidelity PDF (needs native Pango/Cairo)
57
+ pip install "mdmint[all]" # both of the above
58
+ ```
59
+
60
+ For **mermaid diagrams**, install the mermaid CLI separately (optional):
61
+ `npm install -g @mermaid-js/mermaid-cli` (provides `mmdc`).
62
+
63
+ ## Usage
64
+
65
+ ```bash
66
+ mdmint notes.md # -> notes.pdf
67
+ mdmint notes.md -f docx,pdf -o out/ # both formats into ./out/
68
+ mdmint a.md b.md -f docx # -> a.docx and b.docx (one per input)
69
+ mdmint a.md b.md --merge -f pdf -o book.pdf # one merged PDF
70
+ mdmint report.md --toc -f pdf # insert a table of contents
71
+ mdmint report.md -f pdf --pdf-backend weasyprint
72
+ ```
73
+
74
+ | Option | Meaning |
75
+ | ------ | ------- |
76
+ | `-f, --format` | `pdf`, `docx`, or both (comma-separated). Default `pdf`. |
77
+ | `-o, --output` | Output directory, or a file path. Default: alongside each input. |
78
+ | `--merge` | With multiple inputs, produce one merged file per format. |
79
+ | `--toc` | Insert a table of contents (or honour a `[TOC]` marker). |
80
+ | `--pdf-backend` | `xhtml2pdf` (default) or `weasyprint`. |
81
+ | `-v, --verbose` | Verbose logging. |
82
+
83
+ | `--mkdocs FILE` | Build one document from an mkdocs.yml `nav` (instead of INPUTs). |
84
+ | `--section-style` | With `--mkdocs`: `part` (default) or `heading` — see below. |
85
+ | `--template FILE` | Word template (`.docx`/`.dotx`) for DOCX output. |
86
+ | `--meta KEY=VALUE` | Value for a template `{{KEY}}` placeholder (repeatable). |
87
+ | `--style ROLE=STYLE` | Use a specific template style for a role (repeatable). |
88
+
89
+ Also runnable as `python -m mdmint ...`.
90
+
91
+ ### From an mkdocs site
92
+
93
+ ```bash
94
+ mdmint --mkdocs mkdocs.yml -f docx,pdf -o out/ # -> out/<site_name>.docx/.pdf
95
+ ```
96
+
97
+ The `nav` decides which pages go in and in what order (without a `nav`, the
98
+ `docs_dir` tree is used, like mkdocs does). Sections become headings:
99
+
100
+ - `--section-style part` (default): a **top-level section** is a *part* divider
101
+ that doesn't use up a heading level, so its pages keep their `#` as chapters;
102
+ nested sections become `#`, `##`… and demote their pages to match.
103
+ - `--section-style heading`: every section is a heading one level above its pages.
104
+
105
+ Pages under a section titled *Annex(es)*, *Appendix* or *Appendices* become
106
+ appendix chapters. Links between pages (`../other/page.md#anchor`) become
107
+ in-document links; links to pages left out of the nav are kept as plain text.
108
+ Headings accept mkdocs `attr_list` syntax (`# Title {#id .part}`).
109
+
110
+ Settings can live in the mkdocs.yml itself (command-line options win):
111
+
112
+ ```yaml
113
+ extra:
114
+ mdmint:
115
+ template: templates/brand.docx # relative to mkdocs.yml
116
+ section_style: part
117
+ status: Final # any other key fills {{STATUS}} etc.
118
+ confidentiality: Internal
119
+ styles: {code: "My Code Style"} # optional role overrides
120
+ ```
121
+
122
+ ### Branded DOCX with a Word template
123
+
124
+ ```bash
125
+ mdmint report.md -f docx --template brand.docx --meta status=Draft
126
+ ```
127
+
128
+ With `--template`, DOCX output is written *into a copy of the template*: its
129
+ cover, contents page, headers/footers, page setup and styles are kept, and the
130
+ content is appended at the end (or where a paragraph reading `{{CONTENT}}` sits).
131
+ Text like `{{TITLE}}`, `{{AUTHOR}}`, `{{DATE}}` anywhere in the body, headers or
132
+ footers is filled from, in increasing priority: mkdocs.yml (`site_name` → title,
133
+ `site_description` → subtitle, `site_author` → author, `extra.mdmint`), the Markdown
134
+ front matter, and `--meta`. `date` defaults to the current month; placeholders
135
+ left without a value are blanked with a warning.
136
+
137
+ Each element uses the template's styles, picking the first that exists:
138
+
139
+ | Role (`--style` key) | Styles tried |
140
+ | --- | --- |
141
+ | headings | `Heading 1`…`Heading N` (deeper levels reuse the last) |
142
+ | `part` / `appendix` | `Part Title` / `Appendix Heading` (else `Heading 1`) |
143
+ | `code` / `inline_code` | `Code Block`, `Source Code`, `HTML Preformatted` / `Inline Code`, `Verbatim Char`, `HTML Code` |
144
+ | `quote` | `Callout`, `Quote` (blockquotes) |
145
+ | `note` / `warning` | `Callout Note` / `Callout Warning` (admonitions, `> [!NOTE]`) |
146
+ | `table` | the template's own (non-stock) table style, else `Table Grid` |
147
+ | `table_heading` / `table_text` | `Table Heading` / `Table Text` |
148
+ | `figure` / `caption` | `Figure` / `Caption` ("Figure N." from the image alt text; `Table: …` before a table gives "Table N.") |
149
+ | lists | `List Bullet`, `List Number` (real multi-level numbering, restarted per list) |
150
+ | footnotes | `Footnote Text`/`Footnote Reference` (created if missing) — real Word footnotes |
151
+
152
+ How to build a template that styles well — required styles, numbering, placeholders, and a
153
+ specimen file to test it — is in [docs/TEMPLATES.md](docs/TEMPLATES.md).
154
+
155
+ Headings are bookmarked so in-document links work, and the template's TOC field
156
+ (if any) picks the headings up when Word updates fields on open. Without
157
+ `--template`, DOCX output is unchanged (htmldocx).
158
+
159
+ ### Library API
160
+
161
+ ```python
162
+ from mdmint import convert
163
+
164
+ convert(["report.md"], formats=["pdf", "docx"], output="out/")
165
+ convert(["a.md", "b.md"], formats=["pdf"], output="book.pdf", merge=True)
166
+ ```
167
+
168
+ ## Supported Markdown
169
+
170
+ CommonMark plus: GFM tables, task lists, strikethrough, footnotes, auto table of
171
+ contents, fenced code, [admonitions](https://squidfunk.github.io/mkdocs-material/reference/admonitions/)
172
+ (`!!! note` / `??? warning` callouts), embedded images (relative paths resolved
173
+ against the source file; remote URLs downloaded), LaTeX math, and mermaid diagrams.
174
+
175
+ ## Notes & limitations
176
+
177
+ - **PDF backend.** `xhtml2pdf` (default) is pure-Python and runs anywhere, with a
178
+ pragmatic subset of CSS. `weasyprint` produces nicer output but needs native
179
+ Pango/Cairo libraries, so it is an opt-in extra.
180
+ - **Math** uses matplotlib's *mathtext* engine (a large LaTeX-math subset, no system
181
+ LaTeX required) rendered to images. Full LaTeX environments (e.g. `align`) are not
182
+ supported. Without matplotlib, math is shown as literal source with a warning.
183
+ - **Mermaid** requires the `mmdc` CLI. Without it, diagrams are shown as styled code
184
+ blocks with a warning. A snap-installed `mmdc` is supported (mdmint places its
185
+ scratch files where snap confinement can read them).
186
+ - **Admonitions** render as a colour-coded callout box (tinted background + accent
187
+ bar + coloured title) in both PDF and DOCX. Type icons are intentionally omitted:
188
+ the pure-Python PDF backend can't embed an emoji font, so colour and the title
189
+ carry the type instead. The collapsible `???`/`???+` blocks render fully expanded
190
+ (static documents have nothing to collapse into).
191
+
192
+ ## License
193
+
194
+ Licensed under the [Apache License, Version 2.0](LICENSE).
195
+
196
+ Copyright 2026 Meaningful Data.
@@ -0,0 +1,20 @@
1
+ mdmint/__init__.py,sha256=H9hgo1y0wS_guc7nOAThErRf7UE2wRIMb87eHtc3MV0,681
2
+ mdmint/__main__.py,sha256=xzZNdhekhaq6aTyyuJjNz7WULBlp61fqqAMHPevfHcs,115
3
+ mdmint/admonitions.py,sha256=nU3WLQOHAifAcJ954IqZRbw2EasQUQQCIteC0J_KxFI,2404
4
+ mdmint/assets.py,sha256=hjgRWZGXYhDyU1I94ta2lZCKkrHGrj-oZN8dKgiOLn8,3003
5
+ mdmint/backends.py,sha256=FgxEaK70Fq2omPXZn7B62DDi1Beglz7UrN-t7VeG9rc,8793
6
+ mdmint/cli.py,sha256=6St1MADS-SY8-ftFd9SLZtudwwMZQOOBIeMKZXxn1Ds,5594
7
+ mdmint/core.py,sha256=Ssai4eIV7jM4HLrHD1jos9CUVFTtcbMe0t4oSev1UDg,15953
8
+ mdmint/docxtemplate.py,sha256=9uayB86si40cb7o79RZ0-J8mccgdv1ifYNg9EIiowJ8,48288
9
+ mdmint/mathrender.py,sha256=Qth_pFfpjuXaUDJCyOlC7MjSyykFubT2F7xx7QIJ8zE,3737
10
+ mdmint/mermaid.py,sha256=u5QlcekW-txs484ouEq-ICQ-5QBN_HLu-9s91aMiypA,5294
11
+ mdmint/mkdocs.py,sha256=3BVwTjoL5BJLZHwCdgQfOpqODU1RtFU1joNSn5xBEG8,12388
12
+ mdmint/render.py,sha256=iGNYH07TIlb-7eAGqWoqcUEZnUtE-gUxo84yQqPclwg,13755
13
+ mdmint/resources/default.css,sha256=hPRWDeJgGXwPyCvQgTWwiFhrYQ2jo_GrzfVBTB6rZ_Q,3493
14
+ mdmint-0.2.0.dist-info/licenses/LICENSE,sha256=12A3kGbtJ5mxsp14iQCCFFbXNAtl4e6DxbW-uy_ovKs,11345
15
+ mdmint-0.2.0.dist-info/licenses/NOTICE,sha256=CFPdjea17oZZigq-DY98BljeDmZxcFZYjrzBTlrSbBY,128
16
+ mdmint-0.2.0.dist-info/METADATA,sha256=jppJnF-XnJqf4qIMW8zxxieA0SJrHxGqsHfLXrrxcbg,8596
17
+ mdmint-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
18
+ mdmint-0.2.0.dist-info/entry_points.txt,sha256=5TKP2Caoduad3M9a8LEX-43YuEwQ6yIKRCaixSZ5yQ0,43
19
+ mdmint-0.2.0.dist-info/top_level.txt,sha256=y0zWkYK6Y01xkXT733JQH4LJamjgl8heB_f0FqRXO7Y,7
20
+ mdmint-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+