diff --git a/README.md b/README.md
index d0c80b2..6c80d06 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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
diff --git a/src/ghpdf/converter.py b/src/ghpdf/converter.py
index 584f0d3..cea0863 100644
--- a/src/ghpdf/converter.py
+++ b/src/ghpdf/converter.py
@@ -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
@@ -17,6 +24,18 @@
)
PAGE_BREAK_HTML = '
'
+# 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 {
@@ -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 , 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\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 = [
diff --git a/src/ghpdf/static/github.css b/src/ghpdf/static/github.css
index abf63e7..1a4ff1e 100644
--- a/src/ghpdf/static/github.css
+++ b/src/ghpdf/static/github.css
@@ -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;