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/core.py
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""Orchestration: read Markdown inputs, render once to HTML, write the requested
|
|
2
|
+
output formats — either one file per input (default) or a single merged file.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import datetime
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import tempfile
|
|
13
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import assets, backends, render
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger("mdmint")
|
|
19
|
+
|
|
20
|
+
VALID_FORMATS = ("pdf", "docx")
|
|
21
|
+
_EXT = {"pdf": ".pdf", "docx": ".docx"}
|
|
22
|
+
|
|
23
|
+
# One rendered input: (source path, body HTML, headings).
|
|
24
|
+
_Rendered = tuple[Path, str, list[render.Heading]]
|
|
25
|
+
|
|
26
|
+
# Page break inserted between documents in a merged output.
|
|
27
|
+
_PAGE_BREAK = '\n<div class="mdmint-page-break"></div>\n'
|
|
28
|
+
|
|
29
|
+
_FRONT_MATTER = re.compile(r"\A---[ \t]*\r?\n(.*?)\r?\n(?:---|\.\.\.)[ \t]*(?:\r?\n|\Z)", re.DOTALL)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def front_matter(text: str) -> dict[str, str]:
|
|
33
|
+
"""Scalar YAML front-matter fields of a Markdown text, keys lower-cased.
|
|
34
|
+
|
|
35
|
+
These fill a DOCX template's ``{{PLACEHOLDERS}}`` (title, author, date…).
|
|
36
|
+
Malformed front matter is ignored rather than aborting the conversion.
|
|
37
|
+
"""
|
|
38
|
+
m = _FRONT_MATTER.match(text)
|
|
39
|
+
if not m:
|
|
40
|
+
return {}
|
|
41
|
+
import yaml
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
data = yaml.safe_load(m.group(1))
|
|
45
|
+
except yaml.YAMLError as exc:
|
|
46
|
+
log.warning("front matter: ignoring invalid YAML (%s)", exc)
|
|
47
|
+
return {}
|
|
48
|
+
if not isinstance(data, dict):
|
|
49
|
+
return {}
|
|
50
|
+
scalars = (str, int, float, datetime.date)
|
|
51
|
+
return {
|
|
52
|
+
str(k).lower(): str(v)
|
|
53
|
+
for k, v in data.items()
|
|
54
|
+
if isinstance(v, scalars) and not isinstance(v, bool)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _safe_stem(name: str) -> str:
|
|
59
|
+
return re.sub(r"[^\w.-]+", "_", name).strip("._") or "document"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _merged_target(out: Path | None, default_dir: Path, default_stem: str) -> tuple[Path, str]:
|
|
63
|
+
"""(directory, stem) for a single combined output, honouring ``-o``."""
|
|
64
|
+
if out is None:
|
|
65
|
+
return default_dir, default_stem
|
|
66
|
+
if _is_dir_like(out):
|
|
67
|
+
return out, default_stem
|
|
68
|
+
return out.parent, out.stem
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _make_work_dir() -> Path:
|
|
72
|
+
"""Create a scratch directory for generated/downloaded images.
|
|
73
|
+
|
|
74
|
+
Uses the system temp dir by default. If the only mermaid renderer is a snap
|
|
75
|
+
package (a common Linux install method), snap confinement cannot read ``/tmp``
|
|
76
|
+
or hidden dot-directories, so we instead create a non-hidden temp dir directly
|
|
77
|
+
under the user's home, which snap can access. The directory is always removed
|
|
78
|
+
when conversion finishes.
|
|
79
|
+
"""
|
|
80
|
+
from . import mermaid
|
|
81
|
+
|
|
82
|
+
if mermaid.is_snap_confined():
|
|
83
|
+
try:
|
|
84
|
+
return Path(tempfile.mkdtemp(prefix="mdmint-", dir=Path.home()))
|
|
85
|
+
except OSError:
|
|
86
|
+
pass
|
|
87
|
+
return Path(tempfile.mkdtemp(prefix="mdmint-"))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_dir_like(path: Path) -> bool:
|
|
91
|
+
"""True if *path* should be treated as a directory rather than a target file."""
|
|
92
|
+
if path.exists():
|
|
93
|
+
return path.is_dir()
|
|
94
|
+
s = str(path)
|
|
95
|
+
return s.endswith(("/", os.sep)) or path.suffix == ""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _write(
|
|
99
|
+
body: str,
|
|
100
|
+
out_path: Path,
|
|
101
|
+
fmt: str,
|
|
102
|
+
*,
|
|
103
|
+
title: str,
|
|
104
|
+
pdf_backend: str,
|
|
105
|
+
work_dir: Path,
|
|
106
|
+
template: Path | None = None,
|
|
107
|
+
meta: dict[str, str] | None = None,
|
|
108
|
+
styles: dict[str, str] | None = None,
|
|
109
|
+
) -> Path:
|
|
110
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
if fmt == "docx":
|
|
112
|
+
backends.to_docx(body, out_path, template=template, meta=meta, styles=styles)
|
|
113
|
+
else:
|
|
114
|
+
backends.to_pdf(body, out_path, backend=pdf_backend, title=title, work_dir=work_dir)
|
|
115
|
+
log.info("wrote %s", out_path)
|
|
116
|
+
return out_path
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class MarkdownConverter:
|
|
120
|
+
"""Convert one or more Markdown files to DOCX and/or PDF."""
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
formats: Iterable[str] = ("pdf",),
|
|
125
|
+
*,
|
|
126
|
+
pdf_backend: str = "xhtml2pdf",
|
|
127
|
+
merge: bool = False,
|
|
128
|
+
toc: bool = False,
|
|
129
|
+
template: str | os.PathLike[str] | None = None,
|
|
130
|
+
meta: Mapping[str, object] | None = None,
|
|
131
|
+
styles: Mapping[str, str] | None = None,
|
|
132
|
+
section_style: str | None = None,
|
|
133
|
+
) -> None:
|
|
134
|
+
formats = [f.lower() for f in formats]
|
|
135
|
+
bad = [f for f in formats if f not in VALID_FORMATS]
|
|
136
|
+
if bad:
|
|
137
|
+
raise ValueError(f"Unknown format(s): {bad}. Choose from {VALID_FORMATS}.")
|
|
138
|
+
if not formats:
|
|
139
|
+
raise ValueError("At least one output format is required.")
|
|
140
|
+
if pdf_backend not in ("xhtml2pdf", "weasyprint"):
|
|
141
|
+
raise ValueError(f"Unknown pdf_backend: {pdf_backend!r}.")
|
|
142
|
+
# Preserve order, drop duplicates.
|
|
143
|
+
self.formats = list(dict.fromkeys(formats))
|
|
144
|
+
self.pdf_backend = pdf_backend
|
|
145
|
+
self.merge = merge
|
|
146
|
+
self.toc = toc
|
|
147
|
+
# DOCX template (+ placeholder values and style overrides for it).
|
|
148
|
+
self.template = Path(template) if template else None
|
|
149
|
+
self.meta = {str(k).lower(): str(v) for k, v in (meta or {}).items()}
|
|
150
|
+
self.styles = dict(styles or {})
|
|
151
|
+
# mkdocs mode: "part" or "heading" (None = config's choice, else "part").
|
|
152
|
+
self.section_style = section_style
|
|
153
|
+
self._front: dict[Path, dict[str, str]] = {} # per-input front matter, set by convert()
|
|
154
|
+
|
|
155
|
+
def _check_template(self, template: Path | None) -> None:
|
|
156
|
+
if template is None:
|
|
157
|
+
return
|
|
158
|
+
if not template.is_file():
|
|
159
|
+
raise FileNotFoundError(f"DOCX template not found: {template}")
|
|
160
|
+
if template.suffix.lower() not in (".docx", ".dotx", ".docm", ".dotm"):
|
|
161
|
+
raise ValueError(f"{template}: a DOCX template must be a .docx or .dotx file")
|
|
162
|
+
if "docx" not in self.formats:
|
|
163
|
+
log.warning("--template only affects DOCX output; add -f docx to use it.")
|
|
164
|
+
|
|
165
|
+
def _doc_meta(self, base: Mapping[str, str], body: str, fallback_title: str) -> dict[str, str]:
|
|
166
|
+
"""Placeholder values for one output: *base* (front matter / mkdocs), then
|
|
167
|
+
explicit ``meta`` overrides; title falls back to the first heading."""
|
|
168
|
+
meta = dict(base)
|
|
169
|
+
meta.update(self.meta)
|
|
170
|
+
if not meta.get("title"):
|
|
171
|
+
heads = render.headings_from_html(body)
|
|
172
|
+
meta["title"] = heads[0][1] if heads else fallback_title
|
|
173
|
+
return meta
|
|
174
|
+
|
|
175
|
+
def _render_body(self, md_path: Path, work_dir: Path) -> tuple[str, list[render.Heading]]:
|
|
176
|
+
"""Render one file to ``(body, headings)`` with image srcs resolved.
|
|
177
|
+
|
|
178
|
+
No TOC is inserted here; insertion happens per-output (per file when writing
|
|
179
|
+
separately, or once consolidated when merging).
|
|
180
|
+
"""
|
|
181
|
+
try:
|
|
182
|
+
text = md_path.read_text(encoding="utf-8")
|
|
183
|
+
except UnicodeDecodeError as exc:
|
|
184
|
+
raise ValueError(
|
|
185
|
+
f"{md_path}: not valid UTF-8 text — is it really a Markdown file? "
|
|
186
|
+
f"(if a glob like '*' pulled in a binary file, narrow it to '*.md'). "
|
|
187
|
+
f"[{exc}]"
|
|
188
|
+
) from exc
|
|
189
|
+
body, headings = render.parse(text, work_dir=work_dir)
|
|
190
|
+
body = assets.resolve_images(body, base_dir=md_path.parent, work_dir=work_dir)
|
|
191
|
+
return body, headings
|
|
192
|
+
|
|
193
|
+
def convert(
|
|
194
|
+
self,
|
|
195
|
+
inputs: Iterable[str | os.PathLike[str]],
|
|
196
|
+
output: str | os.PathLike[str] | None = None,
|
|
197
|
+
) -> list[Path]:
|
|
198
|
+
paths = [Path(p) for p in inputs]
|
|
199
|
+
if not paths:
|
|
200
|
+
raise ValueError("No input files provided.")
|
|
201
|
+
missing = [p for p in paths if not p.is_file()]
|
|
202
|
+
if missing:
|
|
203
|
+
raise FileNotFoundError(
|
|
204
|
+
"Input file(s) not found: " + ", ".join(str(p) for p in missing)
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
self._check_template(self.template)
|
|
208
|
+
out = Path(output) if output is not None else None
|
|
209
|
+
work_dir = _make_work_dir()
|
|
210
|
+
written: list[Path] = []
|
|
211
|
+
try:
|
|
212
|
+
# Each entry: (path, body_html, headings).
|
|
213
|
+
rendered: list[_Rendered] = [(p, *self._render_body(p, work_dir)) for p in paths]
|
|
214
|
+
self._front = {p: self._read_front_matter(p) for p in paths}
|
|
215
|
+
|
|
216
|
+
if self.merge and len(rendered) > 1:
|
|
217
|
+
written = self._write_merged(rendered, out, work_dir)
|
|
218
|
+
else:
|
|
219
|
+
if self.merge:
|
|
220
|
+
log.info("merge requested with a single input; writing one file.")
|
|
221
|
+
written = self._write_separate(rendered, out, work_dir)
|
|
222
|
+
finally:
|
|
223
|
+
shutil.rmtree(work_dir, ignore_errors=True)
|
|
224
|
+
return written
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
def _read_front_matter(path: Path) -> dict[str, str]:
|
|
228
|
+
try:
|
|
229
|
+
return front_matter(path.read_text(encoding="utf-8"))
|
|
230
|
+
except (OSError, UnicodeDecodeError):
|
|
231
|
+
return {}
|
|
232
|
+
|
|
233
|
+
def convert_mkdocs(
|
|
234
|
+
self,
|
|
235
|
+
config: str | os.PathLike[str],
|
|
236
|
+
output: str | os.PathLike[str] | None = None,
|
|
237
|
+
) -> list[Path]:
|
|
238
|
+
"""Build one document per format from an mkdocs project's ``nav``.
|
|
239
|
+
|
|
240
|
+
Pages are stitched in nav order with sections as headings (see
|
|
241
|
+
:mod:`mdmint.mkdocs`). ``extra: mdmint:`` in the config may set ``template``,
|
|
242
|
+
``section_style``, ``styles`` and placeholder values; explicit arguments to
|
|
243
|
+
this converter win over it.
|
|
244
|
+
"""
|
|
245
|
+
from . import mkdocs
|
|
246
|
+
|
|
247
|
+
project = mkdocs.load(config)
|
|
248
|
+
template = self.template or project.options.get("template")
|
|
249
|
+
self._check_template(template)
|
|
250
|
+
section_style = self.section_style or project.options.get("section_style") or "part"
|
|
251
|
+
styles = {**(project.options.get("styles") or {}), **self.styles}
|
|
252
|
+
out = Path(output) if output is not None else None
|
|
253
|
+
|
|
254
|
+
work_dir = _make_work_dir()
|
|
255
|
+
written: list[Path] = []
|
|
256
|
+
try:
|
|
257
|
+
body = mkdocs.assemble(
|
|
258
|
+
project,
|
|
259
|
+
lambda path: self._render_body(path, work_dir)[0],
|
|
260
|
+
section_style=section_style,
|
|
261
|
+
)
|
|
262
|
+
body = render.strip_toc_markers(body)
|
|
263
|
+
if self.toc:
|
|
264
|
+
toc_html = render.build_toc(render.headings_from_html(body))
|
|
265
|
+
if toc_html:
|
|
266
|
+
body = toc_html + "\n" + body
|
|
267
|
+
meta = self._doc_meta(project.meta, body, project.site_name)
|
|
268
|
+
out_dir, stem = _merged_target(
|
|
269
|
+
out, project.config_path.parent, _safe_stem(project.site_name)
|
|
270
|
+
)
|
|
271
|
+
for fmt in self.formats:
|
|
272
|
+
written.append(
|
|
273
|
+
_write(
|
|
274
|
+
body,
|
|
275
|
+
out_dir / f"{stem}{_EXT[fmt]}",
|
|
276
|
+
fmt,
|
|
277
|
+
title=project.site_name,
|
|
278
|
+
pdf_backend=self.pdf_backend,
|
|
279
|
+
work_dir=work_dir,
|
|
280
|
+
template=template,
|
|
281
|
+
meta=meta,
|
|
282
|
+
styles=styles,
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
finally:
|
|
286
|
+
shutil.rmtree(work_dir, ignore_errors=True)
|
|
287
|
+
return written
|
|
288
|
+
|
|
289
|
+
# ---------------------------------------------------------------- writers
|
|
290
|
+
|
|
291
|
+
def _write_merged(
|
|
292
|
+
self, rendered: Sequence[_Rendered], out: Path | None, work_dir: Path
|
|
293
|
+
) -> list[Path]:
|
|
294
|
+
# A merged document gets a single consolidated TOC at the top, covering the
|
|
295
|
+
# headings of every input in order (each file's title heads its own block),
|
|
296
|
+
# rather than one TOC per file scattered through the document.
|
|
297
|
+
want_toc = self.toc or any(render.has_toc_marker(b) for _, b, _ in rendered)
|
|
298
|
+
parts: list[str] = []
|
|
299
|
+
all_headings: list[render.Heading] = []
|
|
300
|
+
for i, (_, body, headings) in enumerate(rendered):
|
|
301
|
+
body = render.strip_toc_markers(body)
|
|
302
|
+
if want_toc:
|
|
303
|
+
# Namespace anchors per file so the TOC links unambiguously even when
|
|
304
|
+
# two files share a heading title.
|
|
305
|
+
body, headings = render.prefix_anchors(body, headings, f"f{i}-")
|
|
306
|
+
all_headings.extend(headings)
|
|
307
|
+
parts.append(body)
|
|
308
|
+
merged = _PAGE_BREAK.join(parts)
|
|
309
|
+
if want_toc:
|
|
310
|
+
toc_html = render.build_toc(all_headings)
|
|
311
|
+
if toc_html:
|
|
312
|
+
merged = toc_html + "\n" + merged
|
|
313
|
+
|
|
314
|
+
out_dir, stem = _merged_target(out, rendered[0][0].parent, "merged")
|
|
315
|
+
title = stem
|
|
316
|
+
meta = self._doc_meta(self._front.get(rendered[0][0], {}), merged, stem)
|
|
317
|
+
written: list[Path] = []
|
|
318
|
+
for fmt in self.formats:
|
|
319
|
+
target = out_dir / f"{stem}{_EXT[fmt]}"
|
|
320
|
+
written.append(
|
|
321
|
+
_write(
|
|
322
|
+
merged,
|
|
323
|
+
target,
|
|
324
|
+
fmt,
|
|
325
|
+
title=title,
|
|
326
|
+
pdf_backend=self.pdf_backend,
|
|
327
|
+
work_dir=work_dir,
|
|
328
|
+
template=self.template,
|
|
329
|
+
meta=meta,
|
|
330
|
+
styles=self.styles,
|
|
331
|
+
)
|
|
332
|
+
)
|
|
333
|
+
return written
|
|
334
|
+
|
|
335
|
+
def _write_separate(
|
|
336
|
+
self, rendered: Sequence[_Rendered], out: Path | None, work_dir: Path
|
|
337
|
+
) -> list[Path]:
|
|
338
|
+
single = len(rendered) == 1 and len(self.formats) == 1
|
|
339
|
+
# Allow naming one exact output file: single input, single format, file path.
|
|
340
|
+
exact = out if single and out is not None and not _is_dir_like(out) else None
|
|
341
|
+
|
|
342
|
+
written: list[Path] = []
|
|
343
|
+
for md_path, body, headings in rendered:
|
|
344
|
+
body = render.apply_toc(body, headings, self.toc)
|
|
345
|
+
for fmt in self.formats:
|
|
346
|
+
if exact is not None:
|
|
347
|
+
target = exact
|
|
348
|
+
elif out is None:
|
|
349
|
+
target = md_path.parent / f"{md_path.stem}{_EXT[fmt]}"
|
|
350
|
+
elif _is_dir_like(out):
|
|
351
|
+
target = out / f"{md_path.stem}{_EXT[fmt]}"
|
|
352
|
+
else:
|
|
353
|
+
# File-ish path but multiple inputs/formats: use its directory.
|
|
354
|
+
target = out.parent / f"{md_path.stem}{_EXT[fmt]}"
|
|
355
|
+
meta = self._doc_meta(self._front.get(md_path, {}), body, md_path.stem)
|
|
356
|
+
written.append(
|
|
357
|
+
_write(
|
|
358
|
+
body,
|
|
359
|
+
target,
|
|
360
|
+
fmt,
|
|
361
|
+
title=md_path.stem,
|
|
362
|
+
pdf_backend=self.pdf_backend,
|
|
363
|
+
work_dir=work_dir,
|
|
364
|
+
template=self.template,
|
|
365
|
+
meta=meta,
|
|
366
|
+
styles=self.styles,
|
|
367
|
+
)
|
|
368
|
+
)
|
|
369
|
+
return written
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def convert(
|
|
373
|
+
inputs: Iterable[str | os.PathLike[str]],
|
|
374
|
+
formats: Iterable[str] = ("pdf",),
|
|
375
|
+
output: str | os.PathLike[str] | None = None,
|
|
376
|
+
*,
|
|
377
|
+
merge: bool = False,
|
|
378
|
+
pdf_backend: str = "xhtml2pdf",
|
|
379
|
+
toc: bool = False,
|
|
380
|
+
template: str | os.PathLike[str] | None = None,
|
|
381
|
+
meta: Mapping[str, object] | None = None,
|
|
382
|
+
styles: Mapping[str, str] | None = None,
|
|
383
|
+
) -> list[Path]:
|
|
384
|
+
"""Convenience wrapper around :class:`MarkdownConverter`.
|
|
385
|
+
|
|
386
|
+
Returns the list of written output paths.
|
|
387
|
+
"""
|
|
388
|
+
converter = MarkdownConverter(
|
|
389
|
+
formats,
|
|
390
|
+
pdf_backend=pdf_backend,
|
|
391
|
+
merge=merge,
|
|
392
|
+
toc=toc,
|
|
393
|
+
template=template,
|
|
394
|
+
meta=meta,
|
|
395
|
+
styles=styles,
|
|
396
|
+
)
|
|
397
|
+
return converter.convert(inputs, output=output)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def convert_mkdocs(
|
|
401
|
+
config: str | os.PathLike[str],
|
|
402
|
+
formats: Iterable[str] = ("pdf",),
|
|
403
|
+
output: str | os.PathLike[str] | None = None,
|
|
404
|
+
*,
|
|
405
|
+
pdf_backend: str = "xhtml2pdf",
|
|
406
|
+
toc: bool = False,
|
|
407
|
+
template: str | os.PathLike[str] | None = None,
|
|
408
|
+
meta: Mapping[str, object] | None = None,
|
|
409
|
+
styles: Mapping[str, str] | None = None,
|
|
410
|
+
section_style: str | None = None,
|
|
411
|
+
) -> list[Path]:
|
|
412
|
+
"""Build one document per format from an mkdocs.yml's ``nav``.
|
|
413
|
+
|
|
414
|
+
Returns the list of written output paths.
|
|
415
|
+
"""
|
|
416
|
+
converter = MarkdownConverter(
|
|
417
|
+
formats,
|
|
418
|
+
pdf_backend=pdf_backend,
|
|
419
|
+
toc=toc,
|
|
420
|
+
template=template,
|
|
421
|
+
meta=meta,
|
|
422
|
+
styles=styles,
|
|
423
|
+
section_style=section_style,
|
|
424
|
+
)
|
|
425
|
+
return converter.convert_mkdocs(config, output=output)
|