mddoco 2.0.1__tar.gz → 2.0.3__tar.gz
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.
- {mddoco-2.0.1/src/mddoco.egg-info → mddoco-2.0.3}/PKG-INFO +14 -4
- {mddoco-2.0.1 → mddoco-2.0.3}/README.md +9 -3
- {mddoco-2.0.1 → mddoco-2.0.3}/pyproject.toml +15 -1
- mddoco-2.0.3/src/mddoco/__init__.py +1 -0
- {mddoco-2.0.1 → mddoco-2.0.3}/src/mddoco/cli.py +16 -7
- {mddoco-2.0.1 → mddoco-2.0.3}/src/mddoco/graph.py +58 -37
- mddoco-2.0.3/src/mddoco/pdf.py +78 -0
- {mddoco-2.0.1 → mddoco-2.0.3}/src/mddoco/preprocessor.py +9 -9
- {mddoco-2.0.1 → mddoco-2.0.3/src/mddoco.egg-info}/PKG-INFO +14 -4
- {mddoco-2.0.1 → mddoco-2.0.3}/src/mddoco.egg-info/requires.txt +5 -0
- {mddoco-2.0.1 → mddoco-2.0.3}/tests/test_preprocessor.py +24 -4
- mddoco-2.0.1/src/mddoco/__init__.py +0 -1
- mddoco-2.0.1/src/mddoco/pdf.py +0 -39
- {mddoco-2.0.1 → mddoco-2.0.3}/LICENSE +0 -0
- {mddoco-2.0.1 → mddoco-2.0.3}/setup.cfg +0 -0
- {mddoco-2.0.1 → mddoco-2.0.3}/src/mddoco/converter.py +0 -0
- {mddoco-2.0.1 → mddoco-2.0.3}/src/mddoco/mermaid.py +0 -0
- {mddoco-2.0.1 → mddoco-2.0.3}/src/mddoco/renderer.py +0 -0
- {mddoco-2.0.1 → mddoco-2.0.3}/src/mddoco/scanner.py +0 -0
- {mddoco-2.0.1 → mddoco-2.0.3}/src/mddoco/themes/academic-wide.html +0 -0
- {mddoco-2.0.1 → mddoco-2.0.3}/src/mddoco/themes/academic.html +0 -0
- {mddoco-2.0.1 → mddoco-2.0.3}/src/mddoco/themes/dark-wide.html +0 -0
- {mddoco-2.0.1 → mddoco-2.0.3}/src/mddoco/themes/dark.html +0 -0
- {mddoco-2.0.1 → mddoco-2.0.3}/src/mddoco/themes/default-wide.html +0 -0
- {mddoco-2.0.1 → mddoco-2.0.3}/src/mddoco/themes/default.html +0 -0
- {mddoco-2.0.1 → mddoco-2.0.3}/src/mddoco/themes/paged-professional.html +0 -0
- {mddoco-2.0.1 → mddoco-2.0.3}/src/mddoco/themes/professional-wide.html +0 -0
- {mddoco-2.0.1 → mddoco-2.0.3}/src/mddoco/themes/professional.html +0 -0
- {mddoco-2.0.1 → mddoco-2.0.3}/src/mddoco/themes/vanilla.html +0 -0
- {mddoco-2.0.1 → mddoco-2.0.3}/src/mddoco/toc.py +0 -0
- {mddoco-2.0.1 → mddoco-2.0.3}/src/mddoco/writer.py +0 -0
- {mddoco-2.0.1 → mddoco-2.0.3}/src/mddoco.egg-info/SOURCES.txt +0 -0
- {mddoco-2.0.1 → mddoco-2.0.3}/src/mddoco.egg-info/dependency_links.txt +0 -0
- {mddoco-2.0.1 → mddoco-2.0.3}/src/mddoco.egg-info/entry_points.txt +0 -0
- {mddoco-2.0.1 → mddoco-2.0.3}/src/mddoco.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mddoco
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.3
|
|
4
4
|
Summary: Markdown to HTML/PDF document converter
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -10,6 +10,10 @@ Requires-Dist: markdown>=3.5
|
|
|
10
10
|
Requires-Dist: jinja2>=3.1
|
|
11
11
|
Requires-Dist: playwright>=1.40
|
|
12
12
|
Requires-Dist: matplotlib>=3.7
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
15
|
+
Requires-Dist: black>=24.0; extra == "dev"
|
|
16
|
+
Requires-Dist: ruff<0.17,>=0.16; extra == "dev"
|
|
13
17
|
Dynamic: license-file
|
|
14
18
|
|
|
15
19
|
# mddoco
|
|
@@ -20,17 +24,23 @@ A CLI tool that converts markdown files to a single HTML (or future PDF) documen
|
|
|
20
24
|
|
|
21
25
|
```bash
|
|
22
26
|
pip install mddoco
|
|
23
|
-
playwright install chromium
|
|
24
27
|
```
|
|
25
28
|
|
|
26
29
|
Or for development:
|
|
27
30
|
|
|
28
31
|
```bash
|
|
29
32
|
pip install -e .
|
|
30
|
-
playwright install chromium
|
|
31
33
|
```
|
|
32
34
|
|
|
33
|
-
> Playwright (Chromium) is required for PDF output only
|
|
35
|
+
> **Playwright (Chromium) is required for PDF output only.** HTML output works without it.
|
|
36
|
+
> The `playwright` Python package is installed automatically, but the Chromium
|
|
37
|
+
> browser it drives is not. The first time you render a PDF, mddoco downloads
|
|
38
|
+
> Chromium for you (~150 MB, one time). To do it ahead of time, or if the
|
|
39
|
+
> automatic download fails, run it yourself:
|
|
40
|
+
>
|
|
41
|
+
> ```bash
|
|
42
|
+
> playwright install chromium
|
|
43
|
+
> ```
|
|
34
44
|
|
|
35
45
|
## Usage
|
|
36
46
|
|
|
@@ -6,17 +6,23 @@ A CLI tool that converts markdown files to a single HTML (or future PDF) documen
|
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
pip install mddoco
|
|
9
|
-
playwright install chromium
|
|
10
9
|
```
|
|
11
10
|
|
|
12
11
|
Or for development:
|
|
13
12
|
|
|
14
13
|
```bash
|
|
15
14
|
pip install -e .
|
|
16
|
-
playwright install chromium
|
|
17
15
|
```
|
|
18
16
|
|
|
19
|
-
> Playwright (Chromium) is required for PDF output only
|
|
17
|
+
> **Playwright (Chromium) is required for PDF output only.** HTML output works without it.
|
|
18
|
+
> The `playwright` Python package is installed automatically, but the Chromium
|
|
19
|
+
> browser it drives is not. The first time you render a PDF, mddoco downloads
|
|
20
|
+
> Chromium for you (~150 MB, one time). To do it ahead of time, or if the
|
|
21
|
+
> automatic download fails, run it yourself:
|
|
22
|
+
>
|
|
23
|
+
> ```bash
|
|
24
|
+
> playwright install chromium
|
|
25
|
+
> ```
|
|
20
26
|
|
|
21
27
|
## Usage
|
|
22
28
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mddoco"
|
|
7
|
-
version = "2.0.
|
|
7
|
+
version = "2.0.3"
|
|
8
8
|
description = "Markdown to HTML/PDF document converter"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -16,6 +16,13 @@ dependencies = [
|
|
|
16
16
|
"matplotlib>=3.7",
|
|
17
17
|
]
|
|
18
18
|
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest>=8.0",
|
|
22
|
+
"black>=24.0",
|
|
23
|
+
"ruff>=0.16,<0.17",
|
|
24
|
+
]
|
|
25
|
+
|
|
19
26
|
[project.scripts]
|
|
20
27
|
mddoco = "mddoco.cli:main"
|
|
21
28
|
|
|
@@ -25,9 +32,16 @@ where = ["src"]
|
|
|
25
32
|
[tool.setuptools.package-data]
|
|
26
33
|
mddoco = ["themes/*.html"]
|
|
27
34
|
|
|
35
|
+
[tool.black]
|
|
36
|
+
line-length = 88
|
|
37
|
+
target-version = ["py310"]
|
|
38
|
+
|
|
28
39
|
[tool.ruff]
|
|
29
40
|
src = ["src"]
|
|
30
41
|
line-length = 88
|
|
31
42
|
|
|
32
43
|
[tool.ruff.lint]
|
|
33
44
|
select = ["E", "F", "I", "UP"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.0.3"
|
|
@@ -15,31 +15,40 @@ from mddoco.writer import write_output
|
|
|
15
15
|
@click.command()
|
|
16
16
|
@click.argument("input_path", type=click.Path(exists=True, path_type=Path))
|
|
17
17
|
@click.option(
|
|
18
|
-
"--output",
|
|
19
|
-
|
|
18
|
+
"--output",
|
|
19
|
+
"-o",
|
|
20
|
+
"output_path",
|
|
21
|
+
default=".",
|
|
22
|
+
show_default=True,
|
|
20
23
|
type=click.Path(path_type=Path),
|
|
21
24
|
help="Output directory, or explicit output file path.",
|
|
22
25
|
)
|
|
23
26
|
@click.option(
|
|
24
|
-
"--format",
|
|
25
|
-
|
|
27
|
+
"--format",
|
|
28
|
+
"-f",
|
|
29
|
+
"fmt",
|
|
30
|
+
default="html",
|
|
31
|
+
show_default=True,
|
|
26
32
|
type=click.Choice(["html", "pdf"], case_sensitive=False),
|
|
27
33
|
help="Output format.",
|
|
28
34
|
)
|
|
29
35
|
@click.option("--title", "-t", default=None, help="Document title.")
|
|
30
36
|
@click.option(
|
|
31
37
|
"--theme",
|
|
32
|
-
default="default",
|
|
38
|
+
default="default",
|
|
39
|
+
show_default=True,
|
|
33
40
|
help="Theme name to use for rendering.",
|
|
34
41
|
)
|
|
35
42
|
@click.option(
|
|
36
43
|
"--toc/--no-toc",
|
|
37
|
-
default=False,
|
|
44
|
+
default=False,
|
|
45
|
+
show_default=True,
|
|
38
46
|
help="Generate a table of contents.",
|
|
39
47
|
)
|
|
40
48
|
@click.option(
|
|
41
49
|
"--toc-depth",
|
|
42
|
-
default=3,
|
|
50
|
+
default=3,
|
|
51
|
+
show_default=True,
|
|
43
52
|
type=click.IntRange(1, 6),
|
|
44
53
|
help="Maximum heading depth included in the TOC.",
|
|
45
54
|
)
|
|
@@ -6,84 +6,105 @@ import re
|
|
|
6
6
|
from html import unescape
|
|
7
7
|
|
|
8
8
|
import matplotlib
|
|
9
|
-
|
|
9
|
+
|
|
10
|
+
matplotlib.use("Agg") # non-interactive backend — must be set before importing pyplot
|
|
10
11
|
import matplotlib.pyplot as plt
|
|
11
12
|
|
|
12
13
|
log = logging.getLogger(__name__)
|
|
13
14
|
|
|
14
|
-
_JSON_TO_PY = re.compile(r
|
|
15
|
-
_JSON_TO_PY_MAP = {
|
|
15
|
+
_JSON_TO_PY = re.compile(r"\b(true|false|null)\b")
|
|
16
|
+
_JSON_TO_PY_MAP = {"true": "True", "false": "False", "null": "None"}
|
|
16
17
|
|
|
17
18
|
_DEFAULT_COLOURS = [
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
"#2d6cbe",
|
|
20
|
+
"#e74c3c",
|
|
21
|
+
"#27ae60",
|
|
22
|
+
"#e67e22",
|
|
23
|
+
"#8e44ad",
|
|
24
|
+
"#16a085",
|
|
25
|
+
"#2c3e50",
|
|
26
|
+
"#d35400",
|
|
20
27
|
]
|
|
21
28
|
|
|
22
29
|
|
|
23
30
|
def graph_it(data, output=None):
|
|
24
31
|
dpi = 100
|
|
25
|
-
width_px = data.get(
|
|
26
|
-
height_px = data.get(
|
|
32
|
+
width_px = data.get("width_px", 640)
|
|
33
|
+
height_px = data.get("height_px", 480)
|
|
27
34
|
fig, ax = plt.subplots(figsize=(width_px / dpi, height_px / dpi), dpi=dpi)
|
|
28
35
|
|
|
29
|
-
orientation = data.get(
|
|
30
|
-
if orientation not in (
|
|
36
|
+
orientation = data.get("orientation", "vertical").lower()
|
|
37
|
+
if orientation not in ("horizontal", "vertical"):
|
|
31
38
|
raise ValueError(
|
|
32
39
|
f"Invalid orientation '{orientation}' — must be 'horizontal' or 'vertical'"
|
|
33
40
|
)
|
|
34
|
-
horizontal = orientation ==
|
|
41
|
+
horizontal = orientation == "horizontal"
|
|
35
42
|
|
|
36
|
-
x = data[
|
|
43
|
+
x = data["data"]["x"]
|
|
37
44
|
|
|
38
45
|
# Auto-generate series from data keys (everything except 'x') if not provided
|
|
39
|
-
series_list = data.get(
|
|
40
|
-
{'label': k} for k in data['data'] if k != 'x'
|
|
41
|
-
]
|
|
46
|
+
series_list = data.get("series") or [{"label": k} for k in data["data"] if k != "x"]
|
|
42
47
|
|
|
43
48
|
for i, series in enumerate(series_list):
|
|
44
|
-
label = series[
|
|
45
|
-
values = data[
|
|
46
|
-
series_type = series.get(
|
|
47
|
-
colour = series.get(
|
|
49
|
+
label = series["label"]
|
|
50
|
+
values = data["data"][label]
|
|
51
|
+
series_type = series.get("type", "line")
|
|
52
|
+
colour = series.get("colour", _DEFAULT_COLOURS[i % len(_DEFAULT_COLOURS)])
|
|
48
53
|
|
|
49
|
-
if series_type ==
|
|
54
|
+
if series_type == "bar":
|
|
50
55
|
colour_val = colour if isinstance(colour, list) else [colour] * len(values)
|
|
51
56
|
if horizontal:
|
|
52
57
|
ax.barh(x, values, color=colour_val, label=label)
|
|
53
58
|
else:
|
|
54
59
|
ax.bar(x, values, color=colour_val, label=label)
|
|
55
|
-
elif series_type in (
|
|
56
|
-
marker =
|
|
57
|
-
linestyle =
|
|
60
|
+
elif series_type in ("line", "line2"):
|
|
61
|
+
marker = "o" if series.get("marker", False) else "None"
|
|
62
|
+
linestyle = ":" if series_type == "line2" else "-"
|
|
58
63
|
if horizontal:
|
|
59
|
-
ax.plot(
|
|
64
|
+
ax.plot(
|
|
65
|
+
values,
|
|
66
|
+
x,
|
|
67
|
+
color=colour,
|
|
68
|
+
marker=marker,
|
|
69
|
+
linewidth=2,
|
|
70
|
+
linestyle=linestyle,
|
|
71
|
+
label=label,
|
|
72
|
+
)
|
|
60
73
|
else:
|
|
61
|
-
ax.plot(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
74
|
+
ax.plot(
|
|
75
|
+
x,
|
|
76
|
+
values,
|
|
77
|
+
color=colour,
|
|
78
|
+
marker=marker,
|
|
79
|
+
linewidth=2,
|
|
80
|
+
linestyle=linestyle,
|
|
81
|
+
label=label,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if "min" in data or "max" in data:
|
|
85
|
+
lo = data.get("min", None)
|
|
86
|
+
hi = data.get("max", None)
|
|
66
87
|
if horizontal:
|
|
67
88
|
ax.set_xlim(lo, hi)
|
|
68
89
|
else:
|
|
69
90
|
ax.set_ylim(lo, hi)
|
|
70
91
|
|
|
71
|
-
title = data.get(
|
|
92
|
+
title = data.get("title", "")
|
|
72
93
|
if title:
|
|
73
94
|
ax.set_title(title)
|
|
74
95
|
|
|
75
|
-
if data.get(
|
|
76
|
-
ax.legend(loc=
|
|
96
|
+
if data.get("show_legend", True):
|
|
97
|
+
ax.legend(loc="upper left")
|
|
77
98
|
|
|
78
|
-
ax.spines[
|
|
79
|
-
ax.spines[
|
|
99
|
+
ax.spines["top"].set_visible(False)
|
|
100
|
+
ax.spines["right"].set_visible(False)
|
|
80
101
|
|
|
81
102
|
if output:
|
|
82
|
-
plt.savefig(output, format="svg", bbox_inches=
|
|
103
|
+
plt.savefig(output, format="svg", bbox_inches="tight")
|
|
83
104
|
plt.close(fig)
|
|
84
105
|
else:
|
|
85
106
|
buf = io.StringIO()
|
|
86
|
-
plt.savefig(buf, format="svg", bbox_inches=
|
|
107
|
+
plt.savefig(buf, format="svg", bbox_inches="tight")
|
|
87
108
|
plt.close(fig)
|
|
88
109
|
return buf.getvalue()
|
|
89
110
|
|
|
@@ -93,7 +114,7 @@ _GRAPH_BLOCK = re.compile(
|
|
|
93
114
|
re.DOTALL,
|
|
94
115
|
)
|
|
95
116
|
|
|
96
|
-
_SVG_HEADER = re.compile(r
|
|
117
|
+
_SVG_HEADER = re.compile(r"^.*?(?=<svg)", re.DOTALL)
|
|
97
118
|
|
|
98
119
|
|
|
99
120
|
def _parse_source(source: str) -> dict:
|
|
@@ -113,7 +134,7 @@ def _render_graph(source: str) -> str:
|
|
|
113
134
|
"""Parse source, render via graph_it, and return an inline SVG element."""
|
|
114
135
|
data = _parse_source(source)
|
|
115
136
|
svg = graph_it(data)
|
|
116
|
-
svg = _SVG_HEADER.sub(
|
|
137
|
+
svg = _SVG_HEADER.sub("", svg) # strip XML declaration and DOCTYPE
|
|
117
138
|
return f'<div class="graph">{svg}</div>'
|
|
118
139
|
|
|
119
140
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
log = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
_INSTALL_HINT = (
|
|
10
|
+
"Chromium is required for PDF output and could not be installed "
|
|
11
|
+
"automatically. Run: playwright install chromium"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def html_to_pdf(html_content: str, dest: Path) -> None:
|
|
16
|
+
"""Render an HTML string to a PDF file using a headless Chromium browser.
|
|
17
|
+
|
|
18
|
+
Navigates via a temporary file:// URL so that external resources (CDN
|
|
19
|
+
scripts, Mermaid.js, etc.) load correctly before the page is printed.
|
|
20
|
+
|
|
21
|
+
The Chromium binary that Playwright drives is not shipped with the Python
|
|
22
|
+
package. The first time a PDF is rendered on a fresh machine it is
|
|
23
|
+
downloaded automatically; if that download fails the user is told how to
|
|
24
|
+
install it by hand.
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
from playwright.sync_api import Error as PlaywrightError
|
|
28
|
+
from playwright.sync_api import sync_playwright
|
|
29
|
+
except ImportError:
|
|
30
|
+
raise RuntimeError(
|
|
31
|
+
"Playwright is required for PDF output. "
|
|
32
|
+
"Run: pip install playwright && playwright install chromium"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
with tempfile.NamedTemporaryFile(
|
|
36
|
+
suffix=".html", delete=False, mode="w", encoding="utf-8"
|
|
37
|
+
) as f:
|
|
38
|
+
f.write(html_content)
|
|
39
|
+
tmp_path = Path(f.name)
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
try:
|
|
43
|
+
_render(sync_playwright, tmp_path, dest)
|
|
44
|
+
except PlaywrightError as exc:
|
|
45
|
+
if "Executable doesn't exist" not in str(exc):
|
|
46
|
+
raise
|
|
47
|
+
_install_chromium()
|
|
48
|
+
_render(sync_playwright, tmp_path, dest)
|
|
49
|
+
finally:
|
|
50
|
+
tmp_path.unlink(missing_ok=True)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _render(sync_playwright, source: Path, dest: Path) -> None:
|
|
54
|
+
"""Drive headless Chromium to print ``source`` to ``dest`` as A4 PDF."""
|
|
55
|
+
with sync_playwright() as p:
|
|
56
|
+
browser = p.chromium.launch()
|
|
57
|
+
page = browser.new_page()
|
|
58
|
+
page.goto(source.as_uri(), wait_until="networkidle")
|
|
59
|
+
# If Paged.js is present, wait for it to finish paginating before
|
|
60
|
+
# capturing — networkidle fires before its JS layout pass completes.
|
|
61
|
+
page.wait_for_function(
|
|
62
|
+
"typeof window.PagedPolyfill === 'undefined'"
|
|
63
|
+
" || !!document.querySelector('.pagedjs_pages')"
|
|
64
|
+
)
|
|
65
|
+
page.pdf(path=str(dest), format="A4", print_background=True)
|
|
66
|
+
browser.close()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _install_chromium() -> None:
|
|
70
|
+
"""Download the Chromium binary Playwright needs, or explain how to."""
|
|
71
|
+
log.warning("Chromium not found — downloading it now (~150 MB, one time).")
|
|
72
|
+
try:
|
|
73
|
+
subprocess.run(
|
|
74
|
+
[sys.executable, "-m", "playwright", "install", "chromium"],
|
|
75
|
+
check=True,
|
|
76
|
+
)
|
|
77
|
+
except (subprocess.CalledProcessError, OSError) as exc:
|
|
78
|
+
raise RuntimeError(_INSTALL_HINT) from exc
|
|
@@ -45,29 +45,29 @@ def _parse_csv_line(line: str) -> list[tuple[str, bool]]:
|
|
|
45
45
|
else:
|
|
46
46
|
parts.append(line[i])
|
|
47
47
|
i += 1
|
|
48
|
-
value =
|
|
49
|
-
while i < n and line[i] !=
|
|
48
|
+
value = "".join(parts)
|
|
49
|
+
while i < n and line[i] != ",":
|
|
50
50
|
i += 1
|
|
51
51
|
fields.append((value, True))
|
|
52
52
|
else:
|
|
53
53
|
start = i
|
|
54
|
-
while i < n and line[i] !=
|
|
54
|
+
while i < n and line[i] != ",":
|
|
55
55
|
i += 1
|
|
56
56
|
fields.append((line[start:i], False))
|
|
57
57
|
|
|
58
|
-
if i < n and line[i] ==
|
|
58
|
+
if i < n and line[i] == ",":
|
|
59
59
|
i += 1
|
|
60
60
|
if i >= n:
|
|
61
|
-
fields.append((
|
|
61
|
+
fields.append(("", False))
|
|
62
62
|
|
|
63
63
|
return fields
|
|
64
64
|
|
|
65
65
|
|
|
66
66
|
def _apply_semicolon_split(value: str, was_quoted: bool) -> str | list[str]:
|
|
67
67
|
"""Return value split on ';' unless the field was originally quoted."""
|
|
68
|
-
if was_quoted or
|
|
68
|
+
if was_quoted or ";" not in value:
|
|
69
69
|
return value
|
|
70
|
-
return [part.strip() for part in value.split(
|
|
70
|
+
return [part.strip() for part in value.split(";")]
|
|
71
71
|
|
|
72
72
|
|
|
73
73
|
def load_csv_context(input_path: Path) -> dict:
|
|
@@ -99,7 +99,7 @@ def load_csv_context(input_path: Path) -> dict:
|
|
|
99
99
|
if idx < len(fields):
|
|
100
100
|
value, was_quoted = fields[idx]
|
|
101
101
|
else:
|
|
102
|
-
value, was_quoted =
|
|
102
|
+
value, was_quoted = "", False
|
|
103
103
|
row[key] = _apply_semicolon_split(value, was_quoted)
|
|
104
104
|
rows.append(row)
|
|
105
105
|
|
|
@@ -114,7 +114,7 @@ def load_context(input_path: Path) -> dict:
|
|
|
114
114
|
csv_ctx = load_csv_context(input_path)
|
|
115
115
|
conflicts = set(json_ctx) & set(csv_ctx)
|
|
116
116
|
if conflicts:
|
|
117
|
-
names =
|
|
117
|
+
names = ", ".join(sorted(conflicts))
|
|
118
118
|
raise ValueError(
|
|
119
119
|
f"Context name conflict — both a .json and .csv exist for: {names}"
|
|
120
120
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mddoco
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.3
|
|
4
4
|
Summary: Markdown to HTML/PDF document converter
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -10,6 +10,10 @@ Requires-Dist: markdown>=3.5
|
|
|
10
10
|
Requires-Dist: jinja2>=3.1
|
|
11
11
|
Requires-Dist: playwright>=1.40
|
|
12
12
|
Requires-Dist: matplotlib>=3.7
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
15
|
+
Requires-Dist: black>=24.0; extra == "dev"
|
|
16
|
+
Requires-Dist: ruff<0.17,>=0.16; extra == "dev"
|
|
13
17
|
Dynamic: license-file
|
|
14
18
|
|
|
15
19
|
# mddoco
|
|
@@ -20,17 +24,23 @@ A CLI tool that converts markdown files to a single HTML (or future PDF) documen
|
|
|
20
24
|
|
|
21
25
|
```bash
|
|
22
26
|
pip install mddoco
|
|
23
|
-
playwright install chromium
|
|
24
27
|
```
|
|
25
28
|
|
|
26
29
|
Or for development:
|
|
27
30
|
|
|
28
31
|
```bash
|
|
29
32
|
pip install -e .
|
|
30
|
-
playwright install chromium
|
|
31
33
|
```
|
|
32
34
|
|
|
33
|
-
> Playwright (Chromium) is required for PDF output only
|
|
35
|
+
> **Playwright (Chromium) is required for PDF output only.** HTML output works without it.
|
|
36
|
+
> The `playwright` Python package is installed automatically, but the Chromium
|
|
37
|
+
> browser it drives is not. The first time you render a PDF, mddoco downloads
|
|
38
|
+
> Chromium for you (~150 MB, one time). To do it ahead of time, or if the
|
|
39
|
+
> automatic download fails, run it yourself:
|
|
40
|
+
>
|
|
41
|
+
> ```bash
|
|
42
|
+
> playwright install chromium
|
|
43
|
+
> ```
|
|
34
44
|
|
|
35
45
|
## Usage
|
|
36
46
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import pytest
|
|
2
1
|
from pathlib import Path
|
|
3
2
|
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
import mddoco
|
|
4
6
|
from mddoco.preprocessor import (
|
|
5
7
|
_apply_semicolon_split,
|
|
6
8
|
_parse_csv_line,
|
|
@@ -8,9 +10,16 @@ from mddoco.preprocessor import (
|
|
|
8
10
|
load_csv_context,
|
|
9
11
|
)
|
|
10
12
|
|
|
13
|
+
# --- version ---
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_version():
|
|
17
|
+
assert mddoco.__version__ == "2.0.3"
|
|
18
|
+
|
|
11
19
|
|
|
12
20
|
# --- _parse_csv_line ---
|
|
13
21
|
|
|
22
|
+
|
|
14
23
|
def test_parse_csv_line_simple():
|
|
15
24
|
assert _parse_csv_line("a,b,c") == [("a", False), ("b", False), ("c", False)]
|
|
16
25
|
|
|
@@ -37,12 +46,17 @@ def test_parse_csv_line_single_field():
|
|
|
37
46
|
|
|
38
47
|
# --- _apply_semicolon_split ---
|
|
39
48
|
|
|
49
|
+
|
|
40
50
|
def test_split_unquoted_with_semicolon():
|
|
41
51
|
assert _apply_semicolon_split("python;flask", False) == ["python", "flask"]
|
|
42
52
|
|
|
43
53
|
|
|
44
54
|
def test_split_trims_whitespace():
|
|
45
|
-
assert _apply_semicolon_split("python ; flask ; sql", False) == [
|
|
55
|
+
assert _apply_semicolon_split("python ; flask ; sql", False) == [
|
|
56
|
+
"python",
|
|
57
|
+
"flask",
|
|
58
|
+
"sql",
|
|
59
|
+
]
|
|
46
60
|
|
|
47
61
|
|
|
48
62
|
def test_no_split_when_quoted():
|
|
@@ -59,8 +73,11 @@ def test_single_element_after_split():
|
|
|
59
73
|
|
|
60
74
|
# --- load_csv_context ---
|
|
61
75
|
|
|
76
|
+
|
|
62
77
|
def test_load_csv_basic(tmp_path: Path):
|
|
63
|
-
(tmp_path / "people.csv").write_text(
|
|
78
|
+
(tmp_path / "people.csv").write_text(
|
|
79
|
+
"name,role\nAlice,Engineer\nBob,Manager\n", encoding="utf-8"
|
|
80
|
+
)
|
|
64
81
|
ctx = load_csv_context(tmp_path)
|
|
65
82
|
assert ctx == {
|
|
66
83
|
"people": [
|
|
@@ -71,7 +88,9 @@ def test_load_csv_basic(tmp_path: Path):
|
|
|
71
88
|
|
|
72
89
|
|
|
73
90
|
def test_load_csv_semicolon_becomes_list(tmp_path: Path):
|
|
74
|
-
(tmp_path / "data.csv").write_text(
|
|
91
|
+
(tmp_path / "data.csv").write_text(
|
|
92
|
+
"name,skills\nAlice,python;flask\n", encoding="utf-8"
|
|
93
|
+
)
|
|
75
94
|
ctx = load_csv_context(tmp_path)
|
|
76
95
|
assert ctx["data"][0]["skills"] == ["python", "flask"]
|
|
77
96
|
|
|
@@ -111,6 +130,7 @@ def test_load_csv_from_file_path(tmp_path: Path):
|
|
|
111
130
|
|
|
112
131
|
# --- load_context ---
|
|
113
132
|
|
|
133
|
+
|
|
114
134
|
def test_load_context_merges_json_and_csv(tmp_path: Path):
|
|
115
135
|
(tmp_path / "config.json").write_text('{"env": "prod"}', encoding="utf-8")
|
|
116
136
|
(tmp_path / "people.csv").write_text("name\nAlice\n", encoding="utf-8")
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "2.0.0"
|
mddoco-2.0.1/src/mddoco/pdf.py
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import tempfile
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
def html_to_pdf(html_content: str, dest: Path) -> None:
|
|
6
|
-
"""Render an HTML string to a PDF file using a headless Chromium browser.
|
|
7
|
-
|
|
8
|
-
Navigates via a temporary file:// URL so that external resources (CDN
|
|
9
|
-
scripts, Mermaid.js, etc.) load correctly before the page is printed.
|
|
10
|
-
"""
|
|
11
|
-
try:
|
|
12
|
-
from playwright.sync_api import sync_playwright
|
|
13
|
-
except ImportError:
|
|
14
|
-
raise RuntimeError(
|
|
15
|
-
"Playwright is required for PDF output. "
|
|
16
|
-
"Run: pip install playwright && playwright install chromium"
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
with tempfile.NamedTemporaryFile(
|
|
20
|
-
suffix=".html", delete=False, mode="w", encoding="utf-8"
|
|
21
|
-
) as f:
|
|
22
|
-
f.write(html_content)
|
|
23
|
-
tmp_path = Path(f.name)
|
|
24
|
-
|
|
25
|
-
try:
|
|
26
|
-
with sync_playwright() as p:
|
|
27
|
-
browser = p.chromium.launch()
|
|
28
|
-
page = browser.new_page()
|
|
29
|
-
page.goto(tmp_path.as_uri(), wait_until="networkidle")
|
|
30
|
-
# If Paged.js is present, wait for it to finish paginating before
|
|
31
|
-
# capturing — networkidle fires before its JS layout pass completes.
|
|
32
|
-
page.wait_for_function(
|
|
33
|
-
"typeof window.PagedPolyfill === 'undefined'"
|
|
34
|
-
" || !!document.querySelector('.pagedjs_pages')"
|
|
35
|
-
)
|
|
36
|
-
page.pdf(path=str(dest), format="A4", print_background=True)
|
|
37
|
-
browser.close()
|
|
38
|
-
finally:
|
|
39
|
-
tmp_path.unlink(missing_ok=True)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|