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/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)