mddoco 0.1.0__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-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Phil Massyn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
mddoco-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,163 @@
1
+ Metadata-Version: 2.4
2
+ Name: mddoco
3
+ Version: 0.1.0
4
+ Summary: Markdown to HTML/PDF document converter
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: click>=8.0
9
+ Requires-Dist: markdown>=3.5
10
+ Requires-Dist: jinja2>=3.1
11
+ Requires-Dist: playwright>=1.40
12
+ Requires-Dist: matplotlib>=3.7
13
+ Dynamic: license-file
14
+
15
+ # mddoco
16
+
17
+ A CLI tool that converts markdown files to a single HTML (or future PDF) document.
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ pip install mddoco
23
+ playwright install chromium
24
+ ```
25
+
26
+ Or for development:
27
+
28
+ ```bash
29
+ pip install -e .
30
+ playwright install chromium
31
+ ```
32
+
33
+ > Playwright (Chromium) is required for PDF output only. HTML output works without it.
34
+
35
+ ## Usage
36
+
37
+ ```
38
+ mddoco [OPTIONS] INPUT_PATH
39
+ ```
40
+
41
+ `INPUT_PATH` can be a **directory** (all `*.md` files are found recursively and sorted) or a **single `.md` file**.
42
+
43
+ ### Options
44
+
45
+ | Option | Short | Default | Description |
46
+ |---|---|---|---|
47
+ | `--output PATH` | `-o` | `.` | Directory to write the output file |
48
+ | `--format [html\|pdf]` | `-f` | `html` | Output format |
49
+ | `--title TEXT` | `-t` | _(none)_ | Document title shown at the top of the page |
50
+ | `--theme NAME` | | `default` | Theme to use for rendering |
51
+ | `--toc / --no-toc` | | off | Generate a table of contents |
52
+ | `--toc-depth N` | | `3` | Maximum heading depth included in the TOC (1–6) |
53
+
54
+ ### Examples
55
+
56
+ Convert all markdown files in a directory:
57
+
58
+ ```bash
59
+ mddoco ./docs
60
+ ```
61
+
62
+ Convert a single file:
63
+
64
+ ```bash
65
+ mddoco README.md
66
+ ```
67
+
68
+ With a title, TOC, and custom output directory:
69
+
70
+ ```bash
71
+ mddoco ./docs --title "My Project" --toc --output ./out
72
+ ```
73
+
74
+ ## Output
75
+
76
+ All matched markdown files are combined into a single HTML document in the output directory. The filename is derived from the input folder or file name.
77
+
78
+ ## Themes
79
+
80
+ Themes are self-contained Jinja2 HTML files with embedded CSS. The `default` theme includes:
81
+
82
+ - Clean typographic layout
83
+ - Syntax-highlighted code blocks
84
+ - Styled tables and blockquotes
85
+ - Optional document title
86
+ - Optional table of contents
87
+ - [Mermaid.js](https://mermaid.js.org) diagram support (loaded only when diagrams are present)
88
+
89
+ ### Graph diagrams
90
+
91
+ Use ` ```graph ` fenced blocks with a JSON payload. The only required field is `data`.
92
+
93
+ **Minimal example** — all defaults applied:
94
+
95
+ ````markdown
96
+ ```graph
97
+ {
98
+ "data": {
99
+ "x": ["Jan", "Feb", "Mar"],
100
+ "Sales": [100, 150, 120]
101
+ }
102
+ }
103
+ ```
104
+ ````
105
+
106
+ Series are inferred from the data keys (everything except `x`). Each series defaults to a line chart in blue.
107
+
108
+ **Full schema:**
109
+
110
+ | Field | Required | Default | Description |
111
+ |---|---|---|---|
112
+ | `data.x` | yes | — | Category labels |
113
+ | `data.<name>` | yes | — | Values for each series (one key per series) |
114
+ | `title` | no | _(none)_ | Chart title |
115
+ | `orientation` | no | `vertical` | `vertical` or `horizontal` |
116
+ | `show_legend` | no | `true` | Show the legend |
117
+ | `width_px` | no | `640` | Width in pixels |
118
+ | `height_px` | no | `480` | Height in pixels |
119
+ | `min` / `max` | no | — | Axis bounds |
120
+ | `series` | no | _(auto)_ | Override series definitions (see below) |
121
+
122
+ **Series fields** (all optional when auto-generated):
123
+
124
+ | Field | Default | Description |
125
+ |---|---|---|
126
+ | `label` | _(data key)_ | Must match a key in `data` |
127
+ | `type` | `line` | `bar`, `line` (solid), or `line2` (dotted) |
128
+ | `colour` | _(palette)_ | Colour string, or list of colours per bar |
129
+ | `marker` | `false` | Show point markers (line types only) |
130
+
131
+ **Full example:**
132
+
133
+ ````markdown
134
+ ```graph
135
+ {
136
+ "title": "Sales vs Target",
137
+ "orientation": "vertical",
138
+ "show_legend": true,
139
+ "data": {
140
+ "x": ["Jan", "Feb", "Mar", "Apr", "May"],
141
+ "Sales": [85, 92, 78, 96, 110],
142
+ "Target": [90, 90, 90, 90, 90]
143
+ },
144
+ "series": [
145
+ { "label": "Sales", "type": "bar", "colour": "#3498db" },
146
+ { "label": "Target", "type": "line", "colour": "#e74c3c", "marker": false }
147
+ ]
148
+ }
149
+ ```
150
+ ````
151
+
152
+ ### Mermaid diagrams
153
+
154
+ Use standard fenced code blocks in your markdown:
155
+
156
+ ````markdown
157
+ ```mermaid
158
+ graph TD
159
+ A[Start] --> B[End]
160
+ ```
161
+ ````
162
+
163
+ mddoco detects mermaid blocks automatically and loads the Mermaid.js CDN only when needed.
mddoco-0.1.0/README.md ADDED
@@ -0,0 +1,149 @@
1
+ # mddoco
2
+
3
+ A CLI tool that converts markdown files to a single HTML (or future PDF) document.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install mddoco
9
+ playwright install chromium
10
+ ```
11
+
12
+ Or for development:
13
+
14
+ ```bash
15
+ pip install -e .
16
+ playwright install chromium
17
+ ```
18
+
19
+ > Playwright (Chromium) is required for PDF output only. HTML output works without it.
20
+
21
+ ## Usage
22
+
23
+ ```
24
+ mddoco [OPTIONS] INPUT_PATH
25
+ ```
26
+
27
+ `INPUT_PATH` can be a **directory** (all `*.md` files are found recursively and sorted) or a **single `.md` file**.
28
+
29
+ ### Options
30
+
31
+ | Option | Short | Default | Description |
32
+ |---|---|---|---|
33
+ | `--output PATH` | `-o` | `.` | Directory to write the output file |
34
+ | `--format [html\|pdf]` | `-f` | `html` | Output format |
35
+ | `--title TEXT` | `-t` | _(none)_ | Document title shown at the top of the page |
36
+ | `--theme NAME` | | `default` | Theme to use for rendering |
37
+ | `--toc / --no-toc` | | off | Generate a table of contents |
38
+ | `--toc-depth N` | | `3` | Maximum heading depth included in the TOC (1–6) |
39
+
40
+ ### Examples
41
+
42
+ Convert all markdown files in a directory:
43
+
44
+ ```bash
45
+ mddoco ./docs
46
+ ```
47
+
48
+ Convert a single file:
49
+
50
+ ```bash
51
+ mddoco README.md
52
+ ```
53
+
54
+ With a title, TOC, and custom output directory:
55
+
56
+ ```bash
57
+ mddoco ./docs --title "My Project" --toc --output ./out
58
+ ```
59
+
60
+ ## Output
61
+
62
+ All matched markdown files are combined into a single HTML document in the output directory. The filename is derived from the input folder or file name.
63
+
64
+ ## Themes
65
+
66
+ Themes are self-contained Jinja2 HTML files with embedded CSS. The `default` theme includes:
67
+
68
+ - Clean typographic layout
69
+ - Syntax-highlighted code blocks
70
+ - Styled tables and blockquotes
71
+ - Optional document title
72
+ - Optional table of contents
73
+ - [Mermaid.js](https://mermaid.js.org) diagram support (loaded only when diagrams are present)
74
+
75
+ ### Graph diagrams
76
+
77
+ Use ` ```graph ` fenced blocks with a JSON payload. The only required field is `data`.
78
+
79
+ **Minimal example** — all defaults applied:
80
+
81
+ ````markdown
82
+ ```graph
83
+ {
84
+ "data": {
85
+ "x": ["Jan", "Feb", "Mar"],
86
+ "Sales": [100, 150, 120]
87
+ }
88
+ }
89
+ ```
90
+ ````
91
+
92
+ Series are inferred from the data keys (everything except `x`). Each series defaults to a line chart in blue.
93
+
94
+ **Full schema:**
95
+
96
+ | Field | Required | Default | Description |
97
+ |---|---|---|---|
98
+ | `data.x` | yes | — | Category labels |
99
+ | `data.<name>` | yes | — | Values for each series (one key per series) |
100
+ | `title` | no | _(none)_ | Chart title |
101
+ | `orientation` | no | `vertical` | `vertical` or `horizontal` |
102
+ | `show_legend` | no | `true` | Show the legend |
103
+ | `width_px` | no | `640` | Width in pixels |
104
+ | `height_px` | no | `480` | Height in pixels |
105
+ | `min` / `max` | no | — | Axis bounds |
106
+ | `series` | no | _(auto)_ | Override series definitions (see below) |
107
+
108
+ **Series fields** (all optional when auto-generated):
109
+
110
+ | Field | Default | Description |
111
+ |---|---|---|
112
+ | `label` | _(data key)_ | Must match a key in `data` |
113
+ | `type` | `line` | `bar`, `line` (solid), or `line2` (dotted) |
114
+ | `colour` | _(palette)_ | Colour string, or list of colours per bar |
115
+ | `marker` | `false` | Show point markers (line types only) |
116
+
117
+ **Full example:**
118
+
119
+ ````markdown
120
+ ```graph
121
+ {
122
+ "title": "Sales vs Target",
123
+ "orientation": "vertical",
124
+ "show_legend": true,
125
+ "data": {
126
+ "x": ["Jan", "Feb", "Mar", "Apr", "May"],
127
+ "Sales": [85, 92, 78, 96, 110],
128
+ "Target": [90, 90, 90, 90, 90]
129
+ },
130
+ "series": [
131
+ { "label": "Sales", "type": "bar", "colour": "#3498db" },
132
+ { "label": "Target", "type": "line", "colour": "#e74c3c", "marker": false }
133
+ ]
134
+ }
135
+ ```
136
+ ````
137
+
138
+ ### Mermaid diagrams
139
+
140
+ Use standard fenced code blocks in your markdown:
141
+
142
+ ````markdown
143
+ ```mermaid
144
+ graph TD
145
+ A[Start] --> B[End]
146
+ ```
147
+ ````
148
+
149
+ mddoco detects mermaid blocks automatically and loads the Mermaid.js CDN only when needed.
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mddoco"
7
+ version = "0.1.0"
8
+ description = "Markdown to HTML/PDF document converter"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "click>=8.0",
13
+ "markdown>=3.5",
14
+ "jinja2>=3.1",
15
+ "playwright>=1.40",
16
+ "matplotlib>=3.7",
17
+ ]
18
+
19
+ [project.scripts]
20
+ mddoco = "mddoco.cli:main"
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["src"]
24
+
25
+ [tool.setuptools.package-data]
26
+ mddoco = ["themes/*.html"]
27
+
28
+ [tool.ruff]
29
+ src = ["src"]
30
+ line-length = 88
31
+
32
+ [tool.ruff.lint]
33
+ select = ["E", "F", "I", "UP"]
mddoco-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,92 @@
1
+ from pathlib import Path
2
+
3
+ import click
4
+
5
+ from mddoco.converter import convert_files
6
+ from mddoco.pdf import html_to_pdf
7
+ from mddoco.renderer import render_html
8
+ from mddoco.scanner import find_markdown_files
9
+ from mddoco.toc import combine_toc
10
+ from mddoco.writer import write_output
11
+
12
+
13
+ @click.command()
14
+ @click.argument("input_path", type=click.Path(exists=True, path_type=Path))
15
+ @click.option(
16
+ "--output", "-o", "output_path",
17
+ default=".", show_default=True,
18
+ type=click.Path(file_okay=False, path_type=Path),
19
+ help="Directory to write the output file.",
20
+ )
21
+ @click.option(
22
+ "--format", "-f", "fmt",
23
+ default="html", show_default=True,
24
+ type=click.Choice(["html", "pdf"], case_sensitive=False),
25
+ help="Output format.",
26
+ )
27
+ @click.option("--title", "-t", default=None, help="Document title.")
28
+ @click.option(
29
+ "--theme",
30
+ default="default", show_default=True,
31
+ help="Theme name to use for rendering.",
32
+ )
33
+ @click.option(
34
+ "--toc/--no-toc",
35
+ default=False, show_default=True,
36
+ help="Generate a table of contents.",
37
+ )
38
+ @click.option(
39
+ "--toc-depth",
40
+ default=3, show_default=True,
41
+ type=click.IntRange(1, 6),
42
+ help="Maximum heading depth included in the TOC.",
43
+ )
44
+ def main(
45
+ input_path: Path,
46
+ output_path: Path,
47
+ fmt: str,
48
+ title: str | None,
49
+ theme: str,
50
+ toc: bool,
51
+ toc_depth: int,
52
+ ) -> None:
53
+ """Convert markdown files in INPUT_PATH to a single document."""
54
+ try:
55
+ md_files = find_markdown_files(input_path)
56
+ except (ValueError, FileNotFoundError) as exc:
57
+ raise click.ClickException(str(exc)) from exc
58
+
59
+ click.echo(f"Found {len(md_files)} markdown file(s).")
60
+
61
+ raw = convert_files(md_files, toc=toc, toc_depth=toc_depth)
62
+ sections = [(p, html) for p, html, _ in raw]
63
+ toc_html = combine_toc([t for _, _, t in raw]) if toc else None
64
+ has_mermaid = any('<div class="mermaid">' in html for _, html in sections)
65
+
66
+ resolved = input_path.resolve()
67
+ stem = resolved.stem if resolved.is_file() else resolved.name
68
+
69
+ try:
70
+ content = render_html(
71
+ sections,
72
+ title=title,
73
+ toc_html=toc_html or None,
74
+ theme=theme,
75
+ has_mermaid=has_mermaid,
76
+ )
77
+ except ValueError as exc:
78
+ raise click.ClickException(str(exc)) from exc
79
+
80
+ if fmt == "html":
81
+ output_file = write_output(content, output_path, f"{stem}.html")
82
+ click.echo(f"Written to {output_file}")
83
+
84
+ elif fmt == "pdf":
85
+ output_path.mkdir(parents=True, exist_ok=True)
86
+ dest = output_path / f"{stem}.pdf"
87
+ click.echo("Rendering PDF...")
88
+ try:
89
+ html_to_pdf(content, dest)
90
+ except RuntimeError as exc:
91
+ raise click.ClickException(str(exc)) from exc
92
+ click.echo(f"Written to {dest}")
@@ -0,0 +1,46 @@
1
+ from pathlib import Path
2
+
3
+ import markdown
4
+
5
+ from mddoco.graph import process_graph
6
+ from mddoco.mermaid import process_mermaid
7
+
8
+
9
+ def convert_file(
10
+ path: Path,
11
+ toc: bool = False,
12
+ toc_depth: int = 3,
13
+ ) -> tuple[str, str]:
14
+ """Convert a markdown file to an HTML fragment.
15
+
16
+ Returns (html_body, toc_html). toc_html is empty when toc=False.
17
+ """
18
+ text = path.read_text(encoding="utf-8")
19
+
20
+ extensions = ["tables", "fenced_code", "attr_list"]
21
+ extension_configs: dict = {}
22
+
23
+ if toc:
24
+ extensions.append("toc")
25
+ extension_configs["toc"] = {"toc_depth": toc_depth}
26
+
27
+ md = markdown.Markdown(extensions=extensions, extension_configs=extension_configs)
28
+ html = md.convert(text)
29
+ html, _ = process_mermaid(html)
30
+ html, _ = process_graph(html)
31
+
32
+ toc_html: str = md.toc if toc else ""
33
+ return html, toc_html
34
+
35
+
36
+ def convert_files(
37
+ paths: list[Path],
38
+ toc: bool = False,
39
+ toc_depth: int = 3,
40
+ ) -> list[tuple[Path, str, str]]:
41
+ """Convert markdown files, returning (path, html_body, toc_html) tuples."""
42
+ results = []
43
+ for p in paths:
44
+ html, toc_html = convert_file(p, toc=toc, toc_depth=toc_depth)
45
+ results.append((p, html, toc_html))
46
+ return results
@@ -0,0 +1,117 @@
1
+ import io
2
+ import json
3
+ import re
4
+ from html import escape, unescape
5
+
6
+ import matplotlib
7
+ matplotlib.use('Agg') # non-interactive backend — must be set before importing pyplot
8
+ import matplotlib.pyplot as plt
9
+
10
+ _DEFAULT_COLOURS = [
11
+ '#2d6cbe', '#e74c3c', '#27ae60', '#e67e22',
12
+ '#8e44ad', '#16a085', '#2c3e50', '#d35400',
13
+ ]
14
+
15
+
16
+ def graph_it(data, output=None):
17
+ dpi = 100
18
+ width_px = data.get('width_px', 640)
19
+ height_px = data.get('height_px', 480)
20
+ fig, ax = plt.subplots(figsize=(width_px / dpi, height_px / dpi), dpi=dpi)
21
+
22
+ orientation = data.get('orientation', 'vertical').lower()
23
+ if orientation not in ('horizontal', 'vertical'):
24
+ raise ValueError(
25
+ f"Invalid orientation '{orientation}' — must be 'horizontal' or 'vertical'"
26
+ )
27
+ horizontal = orientation == 'horizontal'
28
+
29
+ x = data['data']['x']
30
+
31
+ # Auto-generate series from data keys (everything except 'x') if not provided
32
+ series_list = data.get('series') or [
33
+ {'label': k} for k in data['data'] if k != 'x'
34
+ ]
35
+
36
+ for i, series in enumerate(series_list):
37
+ label = series['label']
38
+ values = data['data'][label]
39
+ series_type = series.get('type', 'line')
40
+ colour = series.get('colour', _DEFAULT_COLOURS[i % len(_DEFAULT_COLOURS)])
41
+
42
+ if series_type == 'bar':
43
+ colour_val = colour if isinstance(colour, list) else [colour] * len(values)
44
+ if horizontal:
45
+ ax.barh(x, values, color=colour_val, label=label)
46
+ else:
47
+ ax.bar(x, values, color=colour_val, label=label)
48
+ elif series_type in ('line', 'line2'):
49
+ marker = 'o' if series.get('marker', False) else 'None'
50
+ linestyle = ':' if series_type == 'line2' else '-'
51
+ if horizontal:
52
+ ax.plot(values, x, color=colour, marker=marker, linewidth=2, linestyle=linestyle, label=label)
53
+ else:
54
+ ax.plot(x, values, color=colour, marker=marker, linewidth=2, linestyle=linestyle, label=label)
55
+
56
+ if 'min' in data or 'max' in data:
57
+ lo = data.get('min', None)
58
+ hi = data.get('max', None)
59
+ if horizontal:
60
+ ax.set_xlim(lo, hi)
61
+ else:
62
+ ax.set_ylim(lo, hi)
63
+
64
+ title = data.get('title', '')
65
+ if title:
66
+ ax.set_title(title)
67
+
68
+ if data.get('show_legend', True):
69
+ ax.legend(loc='upper left')
70
+
71
+ ax.spines['top'].set_visible(False)
72
+ ax.spines['right'].set_visible(False)
73
+
74
+ if output:
75
+ plt.savefig(output, format="svg", bbox_inches='tight')
76
+ plt.close(fig)
77
+ else:
78
+ buf = io.StringIO()
79
+ plt.savefig(buf, format="svg", bbox_inches='tight')
80
+ plt.close(fig)
81
+ return buf.getvalue()
82
+
83
+
84
+ _GRAPH_BLOCK = re.compile(
85
+ r'<pre><code class="language-graph">(.*?)</code></pre>',
86
+ re.DOTALL,
87
+ )
88
+
89
+ _SVG_HEADER = re.compile(r'^.*?(?=<svg)', re.DOTALL)
90
+
91
+
92
+ def _render_graph(source: str) -> str:
93
+ """Parse JSON source, render via graph_it, and return an inline SVG element."""
94
+ data = json.loads(unescape(source))
95
+ svg = graph_it(data)
96
+ svg = _SVG_HEADER.sub('', svg) # strip XML declaration and DOCTYPE
97
+ return f'<div class="graph">{svg}</div>'
98
+
99
+
100
+ def process_graph(html: str) -> tuple[str, bool]:
101
+ """Replace fenced graph blocks with rendered SVG charts.
102
+
103
+ Falls back to an inline error message if the block cannot be parsed or rendered.
104
+ Returns (processed_html, found).
105
+ """
106
+ found = bool(_GRAPH_BLOCK.search(html))
107
+
108
+ def replace(m: re.Match) -> str:
109
+ try:
110
+ return _render_graph(m.group(1))
111
+ except Exception as exc:
112
+ return (
113
+ f'<p class="graph-error"><strong>Graph error:</strong> '
114
+ f'{escape(str(exc))}</p>'
115
+ )
116
+
117
+ return _GRAPH_BLOCK.sub(replace, html), found
@@ -0,0 +1,20 @@
1
+ import re
2
+
3
+ _MERMAID_BLOCK = re.compile(
4
+ r'<pre><code class="language-mermaid">(.*?)</code></pre>',
5
+ re.DOTALL,
6
+ )
7
+
8
+
9
+ def process_mermaid(html: str) -> tuple[str, bool]:
10
+ """Replace fenced mermaid blocks with Mermaid.js div elements.
11
+
12
+ Returns (processed_html, found) where found indicates whether any
13
+ mermaid blocks were present.
14
+ """
15
+ found = bool(_MERMAID_BLOCK.search(html))
16
+ processed = _MERMAID_BLOCK.sub(
17
+ lambda m: f'<div class="mermaid">{m.group(1)}</div>',
18
+ html,
19
+ )
20
+ return processed, found
@@ -0,0 +1,33 @@
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
+ page.pdf(path=str(dest), format="A4", print_background=True)
31
+ browser.close()
32
+ finally:
33
+ tmp_path.unlink(missing_ok=True)