Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ ghpdf *.md -O -q

- GitHub-flavored markdown styling
- Syntax highlighting for code blocks
- Mermaid diagrams rendered as SVG
- Tables, task lists, footnotes, and more
- Page break support
- Optional page numbers
Expand All @@ -124,6 +125,29 @@ Insert page breaks using any of these formats:
\pagebreak
```

### Mermaid Diagrams

Fenced code blocks tagged with `mermaid` are rendered to SVG at conversion time and embedded in the PDF:

````
```mermaid
flowchart LR
A[Markdown] --> B[ghpdf] --> C[PDF]
```
````

**Renderers (in order of preference):**

1. **Local — [mermaid-cli](https://github.com/mermaid-js/mermaid-cli) (`mmdc`)**: used automatically if `mmdc` is on `$PATH`. Works offline. Install once:
```bash
npm install -g @mermaid-js/mermaid-cli
# or
brew install mermaid-cli
```
2. **Remote — [mermaid.ink](https://mermaid.ink)**: fallback when `mmdc` is not available. Requires network access.

If both fail, the block is preserved as a regular code block. Set `GHPDF_MERMAID_OFFLINE=1` to disable the network fallback (offline-only mode).

## License

MIT
100 changes: 100 additions & 0 deletions src/ghpdf/converter.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
"""Core conversion functions for ghpdf."""

import base64
import io
import os
import re
import shutil
import subprocess
import tempfile
import urllib.error
import urllib.request
from pathlib import Path

import markdown
Expand All @@ -17,6 +24,18 @@
)
PAGE_BREAK_HTML = '<div class="pagebreak"></div>'

# Mermaid fenced code blocks: ```mermaid ... ```
MERMAID_PATTERN = re.compile(
r"^```mermaid[ \t]*\n(.*?)\n```[ \t]*$",
re.MULTILINE | re.DOTALL,
)
MERMAID_INK_URL = "https://mermaid.ink/img/{}?type=png"
MERMAID_TIMEOUT = 15
MERMAID_USER_AGENT = "ghpdf/1.0 (+https://github.com/atlekbai/md2pdf)"
MERMAID_CLI_TIMEOUT = 60
MERMAID_CLI_SCALE = "3" # 3x SVG → ≥150 DPI when downscaled to A4 width
MERMAID_OFFLINE_ENV = "GHPDF_MERMAID_OFFLINE" # "1" disables network fallback

# CSS for page numbers
PAGE_NUMBERS_CSS = """
@page {
Expand All @@ -40,8 +59,89 @@ def preprocess_pagebreaks(md_content: str) -> str:
return PAGE_BREAK_PATTERN.sub(PAGE_BREAK_HTML, md_content)


def _render_mermaid_png_local(source: str) -> bytes | None:
"""Render via mermaid-cli (mmdc) to PNG. None if unavailable or failed.

PNG (rather than SVG) is used because mermaid lays out labels using HTML
metrics inside <foreignObject>, which downstream SVG rasterizers (e.g.
WeasyPrint) cannot reproduce — text would overflow and overlap.
"""
cli = shutil.which("mmdc")
if not cli:
return None
try:
with tempfile.TemporaryDirectory() as tmpdir:
input_path = Path(tmpdir) / "diagram.mmd"
output_path = Path(tmpdir) / "diagram.png"
input_path.write_text(source, encoding="utf-8")
result = subprocess.run(
[
cli,
"-i", str(input_path),
"-o", str(output_path),
"-e", "png",
"-s", MERMAID_CLI_SCALE,
"-b", "white",
"-q",
],
capture_output=True,
timeout=MERMAID_CLI_TIMEOUT,
)
if result.returncode != 0 or not output_path.exists():
return None
return output_path.read_bytes()
except (subprocess.TimeoutExpired, OSError):
return None


def _render_mermaid_png_remote(source: str) -> bytes | None:
"""Render via mermaid.ink PNG endpoint. None on failure or non-200."""
encoded = base64.urlsafe_b64encode(source.encode("utf-8")).decode("ascii")
request = urllib.request.Request(
MERMAID_INK_URL.format(encoded),
headers={"User-Agent": MERMAID_USER_AGENT},
)
try:
with urllib.request.urlopen(request, timeout=MERMAID_TIMEOUT) as resp:
if resp.status != 200:
return None
return resp.read()
except (urllib.error.URLError, TimeoutError, OSError):
return None


def _render_mermaid_png(diagram: str) -> bytes | None:
"""Render mermaid source to PNG bytes.

Order of attempts:
1. Local mermaid-cli (mmdc) if installed — works offline.
2. mermaid.ink unless GHPDF_MERMAID_OFFLINE=1.
"""
source = diagram.strip()
if not source:
return None

png = _render_mermaid_png_local(source)
if png is None and os.environ.get(MERMAID_OFFLINE_ENV) != "1":
png = _render_mermaid_png_remote(source)
return png


def preprocess_mermaid(md_content: str) -> str:
"""Replace ```mermaid blocks with rendered PNG; fall back to original on failure."""
def replace(match: re.Match) -> str:
png = _render_mermaid_png(match.group(1))
if png is None:
return match.group(0)
data_url = "data:image/png;base64," + base64.b64encode(png).decode("ascii")
return f'\n\n<div class="mermaid"><img src="{data_url}" alt="mermaid diagram"></div>\n\n'

return MERMAID_PATTERN.sub(replace, md_content)


def markdown_to_html(md_content: str) -> str:
"""Convert markdown to HTML with extensions."""
md_content = preprocess_mermaid(md_content)
md_content = preprocess_pagebreaks(md_content)

extensions = [
Expand Down
13 changes: 13 additions & 0 deletions src/ghpdf/static/github.css
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,19 @@ kbd {
vertical-align: super;
}

/* Mermaid diagrams (rendered to PNG before PDF generation) */
.mermaid {
text-align: center;
margin: 16px 0;
page-break-inside: avoid;
break-inside: avoid;
}

.mermaid img {
max-width: 100%;
height: auto;
}

/* Abbreviations */
abbr[title] {
border-bottom: 1px dotted #656d76;
Expand Down