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/__init__.py +18 -0
- mdmint/__main__.py +6 -0
- mdmint/admonitions.py +70 -0
- mdmint/assets.py +84 -0
- mdmint/backends.py +244 -0
- mdmint/cli.py +164 -0
- mdmint/core.py +425 -0
- mdmint/docxtemplate.py +1250 -0
- mdmint/mathrender.py +114 -0
- mdmint/mermaid.py +149 -0
- mdmint/mkdocs.py +310 -0
- mdmint/render.py +343 -0
- mdmint/resources/default.css +122 -0
- mdmint-0.2.0.dist-info/METADATA +196 -0
- mdmint-0.2.0.dist-info/RECORD +20 -0
- mdmint-0.2.0.dist-info/WHEEL +5 -0
- mdmint-0.2.0.dist-info/entry_points.txt +2 -0
- mdmint-0.2.0.dist-info/licenses/LICENSE +201 -0
- mdmint-0.2.0.dist-info/licenses/NOTICE +5 -0
- mdmint-0.2.0.dist-info/top_level.txt +1 -0
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,,
|