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 +21 -0
- mddoco-0.1.0/PKG-INFO +163 -0
- mddoco-0.1.0/README.md +149 -0
- mddoco-0.1.0/pyproject.toml +33 -0
- mddoco-0.1.0/setup.cfg +4 -0
- mddoco-0.1.0/src/mddoco/__init__.py +1 -0
- mddoco-0.1.0/src/mddoco/cli.py +92 -0
- mddoco-0.1.0/src/mddoco/converter.py +46 -0
- mddoco-0.1.0/src/mddoco/graph.py +117 -0
- mddoco-0.1.0/src/mddoco/mermaid.py +20 -0
- mddoco-0.1.0/src/mddoco/pdf.py +33 -0
- mddoco-0.1.0/src/mddoco/renderer.py +33 -0
- mddoco-0.1.0/src/mddoco/scanner.py +21 -0
- mddoco-0.1.0/src/mddoco/themes/default.html +167 -0
- mddoco-0.1.0/src/mddoco/themes/professional.html +230 -0
- mddoco-0.1.0/src/mddoco/toc.py +15 -0
- mddoco-0.1.0/src/mddoco/writer.py +9 -0
- mddoco-0.1.0/src/mddoco.egg-info/PKG-INFO +163 -0
- mddoco-0.1.0/src/mddoco.egg-info/SOURCES.txt +21 -0
- mddoco-0.1.0/src/mddoco.egg-info/dependency_links.txt +1 -0
- mddoco-0.1.0/src/mddoco.egg-info/entry_points.txt +2 -0
- mddoco-0.1.0/src/mddoco.egg-info/requires.txt +5 -0
- mddoco-0.1.0/src/mddoco.egg-info/top_level.txt +1 -0
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 @@
|
|
|
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)
|