Skip to content

mddoco - Generate html and pdf from markdown

mddoco is a Python module to generate HTML and PDF documents from Markdown. It was inspired by typst with the same idea, but I wanted something simpler, and not have to learn a new language. With markdown being such a widely used format, I thought it's best to stick to that.

First things first

Install it by simply running a pip command - that's it! It's a python module, and a Python CLI.

$ pip install mddoco

1 - Render some markdown documents

Single file - point mddoco at a .md file and you get a single HTML document back. By default it lands in the current directory, with the file name derived from the input.

$ mddoco README.md

Want a PDF instead? Add --format pdf. The first time you do this, mddoco downloads a copy of Chromium (via Playwright) to do the rendering - about 150 MB, once. HTML output needs none of that.

$ mddoco README.md --format pdf

Folder with files - give it a directory and it finds every *.md (and *.md.j2) file recursively, sorts them, and stitches them together into one document. This is where the tool earns its keep - I keep a folder of numbered markdown files and let mddoco assemble them into a single report.

$ mddoco ./docs --title "My Project" --toc --output ./out

A few options worth knowing:

Option What it does
--title Title shown at the top of the page
--toc / --no-toc Generate a table of contents
--toc-depth N How deep the TOC goes (1-6, default 3)
--theme NAME Pick a look - default, professional, dark, academic, and wide variants of each
--output PATH Where to write the file

Note that we sort by file name, and ignore any .md file starting with underscore. This is to allow you the use of modules and common files by naming them with an underscore. So a folder like this:

docs/
  _macros.j2          ← imported, never rendered on its own
  01_introduction.md
  02_detail.md
  03_conclusion.md

...renders in numeric order, with _macros.j2 available for import but left out of the output.

2 - Enhanced content

Mermaid

Write a normal fenced mermaid block in your markdown and mddoco picks it up automatically. It only pulls in the Mermaid.js library when a diagram is actually present, so plain documents stay lean.

```mermaid
graph TD
    A[Start] --> B[End]
```

Code

Fenced code blocks are syntax highlighted out of the box - just tag the block with a language.

```python
def hello(name: str) -> str:
    return f"Hello, {name}"
```

Charts

This is the bit I like. mddoco has a native ```graph block - drop in a small chunk of JSON and it renders a chart, no external service, no JavaScript charting library to wire up. The only required field is data.

```graph
{
  "data": {
    "x": ["Jan", "Feb", "Mar"],
    "Sales": [100, 150, 120]
  }
}
```

Every key other than x becomes a series. Left alone, each series is a blue line chart. You can override the lot - titles, bar vs line, colours, orientation, axis bounds:

```graph
{
  "title": "Sales vs Target",
  "data": {
    "x": ["Jan", "Feb", "Mar", "Apr", "May"],
    "Sales":  [85, 92, 78, 96, 110],
    "Target": [90, 90, 90, 90, 90]
  },
  "series": [
    { "label": "Sales",  "type": "bar",  "colour": "#3498db" },
    { "label": "Target", "type": "line", "colour": "#e74c3c" }
  ]
}
```

3 - Jinja

Files ending in .md.j2 are treated as Jinja2 templates that produce markdown. They sort alongside your regular .md files and go through the same pipeline - so you can mix static prose and generated content in the same document.

The clever part is the data. Drop a *.json or *.csv file into the input directory and its file name (without the extension) becomes a variable in the template. project.json is available as {{ project }}, team.csv as {{ team }}.

docs/
  01_intro.md
  02_summary.md.j2    ← Jinja2 template
  project.json        ← available as {{ project }}
  team.csv            ← available as {{ team }}

JSON is loaded as-is. CSV comes through as a list of row dicts, so you can loop over it:

{% for person in team %}
- {{ person.name }} ({{ person.role }})
{% endfor %}

A nice touch: a CSV cell containing ; is automatically split into a list (python;flask;sql becomes ["python", "flask", "sql"]), unless you quote the field.

Shared macros live in a file starting with _ so they are imported but never rendered on their own. Use {% import %} or {% from ... import %} - not {% include %}, which injects output but not callable macros.

{% import "_macros.j2" as macros %}
{{ macros.badge(person.status) }}

Where this really pays off is something like a security findings report: keep the findings in a CSV, group and count them in the template, then feed the totals straight into a ```graph block for a chart - all from one folder and one command.

Why should I use it?

You could use something like pandoc to achieve the same result. I found pandoc to be extremely bloated, and difficult to configure. When you flick between MacOS, Linux and Windows (like I normally do), the different libraries and modules required are just insane. I needed something simple, and mddoco solves that problem.

Other bits