From 6a2457591c9115fbe39c2a2525bc84ad99c633f8 Mon Sep 17 00:00:00 2001 From: Tr8ch Date: Tue, 5 May 2026 17:05:10 +0500 Subject: [PATCH] feat: add support for Mermaid diagrams rendering in PDF output --- README.md | 24 +++++++++ src/ghpdf/converter.py | 100 ++++++++++++++++++++++++++++++++++++ src/ghpdf/static/github.css | 13 +++++ 3 files changed, 137 insertions(+) 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
mermaid diagram
\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;