"""Local Mermaid diagram rendering via the official Mermaid CLI (``mmdc``).
Uses ``npx -y @mermaid-js/mermaid-cli`` so no global install is required. The
rendered PNG is cached in the XDG cache directory keyed by MD5 of the source
**plus** the resolved body font (so re-skinning the template invalidates the
cache).
Requires Node.js ≥16 on ``PATH``. Raises :class:`RuntimeError` on failure so
the caller can decide whether to fall back to the original code block.
Box-overflow fix
----------------
Headless mmdc cannot measure HTML <foreignObject> text reliably, which is the
usual cause of "the words don't fit inside the box". The shipped
``mermaid-config.json`` disables ``htmlLabels`` (forcing native SVG text that
auto-sizes) and bumps node padding. On top of that, this module reads the
active DOCX template's body font (``Normal`` style) and substitutes it into
the mermaid theme so diagrams visually match the surrounding prose.
Author
------
[Warith HARCHAOUI](https://linkedin.com/in/warith-harchaoui/)
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import zipfile
from importlib import resources
import os_helper as osh
from ..cache import cache_dir
def _template_body_font(template_docx: str) -> str | None:
"""Return the ``Normal`` paragraph style's ASCII font from a DOCX template.
Falls back to ``docDefaults`` ``rPrDefault`` if ``Normal`` does not declare
its own ``<w:rFonts/>``. Returns ``None`` if the template cannot be read.
"""
# A .docx is a zip; the paragraph styles live in word/styles.xml. Any read
# failure (missing file, not a zip) just means "no font info" → None.
try:
with zipfile.ZipFile(template_docx) as z:
styles = z.read("word/styles.xml").decode("utf-8")
except (FileNotFoundError, KeyError, zipfile.BadZipFile):
return None
# Prefer the ``Normal`` paragraph style's own font: that's the body text
# font diagrams should visually match. We isolate the whole <w:style> block
# first, then pull the ascii font out of its <w:rFonts>.
normal = re.search(
r'<w:style[^>]*w:styleId="Normal"[^>]*>.*?</w:style>',
styles,
re.DOTALL,
)
if normal:
m = re.search(r'<w:rFonts[^/]*w:ascii="([^"]+)"', normal.group(0))
if m:
return m.group(1)
# Normal didn't declare its own font → inherit the document-wide default.
m = re.search(
r"<w:docDefaults>.*?<w:rFonts[^/]*w:ascii=\"([^\"]+)\"",
styles,
re.DOTALL,
)
return m.group(1) if m else None
def _load_base_mermaid_config() -> dict | None:
"""Return the bundled ``mermaid-config.json`` as a dict, or ``None``."""
try:
with resources.files("md2star.data").joinpath("mermaid-config.json").open(
"r", encoding="utf-8"
) as f:
return json.load(f)
except (FileNotFoundError, ModuleNotFoundError, json.JSONDecodeError, OSError):
return None
def _build_mermaid_config(font_family: str | None) -> tuple[str, str] | None:
"""Return ``(config_path, hash_key)`` for the mermaid config to pass to ``mmdc``.
The bundled ``mermaid-config.json`` is loaded, the body font is spliced
into ``themeVariables.fontFamily``, and the merged config is written to a
hash-keyed file in the XDG cache. The hash also feeds the render cache
key (see :func:`render_mermaid_local`) so edits to the bundled palette —
not just the font — invalidate previously cached PNGs.
Returns ``None`` if no config could be assembled.
"""
cfg = _load_base_mermaid_config()
if cfg is None:
return None
# Splice the template's body font into the theme, with a web-safe fallback
# chain so mmdc still renders if that exact font isn't on the box.
if font_family:
theme_vars = cfg.setdefault("themeVariables", {})
theme_vars["fontFamily"] = f"{font_family}, Arial, Helvetica, sans-serif"
# Key the config file (and, upstream, the render cache) on the MD5 of the
# *merged* config with sorted keys — so any palette edit, not just the
# font, deterministically produces a new key and invalidates stale PNGs.
key = osh.hash_string(json.dumps(cfg, sort_keys=True), 12)
resolved_path = str(cache_dir("mermaid") / f"config_{key}.json")
# Write once per unique config; identical configs reuse the same file.
if not os.path.exists(resolved_path):
with open(resolved_path, "w", encoding="utf-8") as f:
json.dump(cfg, f)
return resolved_path, key
def _resolve_template_docx() -> str | None:
"""Locate the active DOCX template so we can read its body font.
The historical path was ``~/.pandoc/template.docx`` (placed there by the
legacy ``make install``). After the pyproject restructure the template is
shipped inside the wheel, so we fall back to the package data copy.
"""
legacy = os.path.join(os.path.expanduser("~"), ".pandoc", "template.docx")
if os.path.exists(legacy):
return legacy
try:
return str(resources.files("md2star.data").joinpath("template.docx"))
except (FileNotFoundError, ModuleNotFoundError):
return None
[docs]
def render_mermaid_local(content: str, out_dir: str) -> str: # noqa: ARG001
"""Render *content* (Mermaid markup) to a PNG and return its absolute path.
*out_dir* is accepted for backwards compatibility but the cache lives in
``$XDG_CACHE_HOME/md2star/mermaid/`` so renders are shared across runs.
"""
template_docx = _resolve_template_docx()
body_font = _template_body_font(template_docx) if template_docx else None
built = _build_mermaid_config(body_font)
config_path, config_key = built if built else (None, "")
# Cache key folds in the body font *and* the resolved config hash, so
# both template-font changes and palette edits invalidate stale renders.
cache_key = osh.hash_string(content + "|" + (body_font or "") + "|" + config_key)
mermaid_dir = cache_dir("mermaid")
filepath = str(mermaid_dir / f"{cache_key}.png")
# Cache hit: an identical diagram (same source + font + config) was already
# rendered, so skip the expensive npx/Chromium spin-up entirely.
if os.path.exists(filepath):
return filepath
# mmdc reads from a file, not stdin — stage the markup next to the output.
tmp_input = str(mermaid_dir / f"{cache_key}.mmd")
with open(tmp_input, "w", encoding="utf-8") as f:
f.write(content)
# ``npx -y`` fetches the pinned mermaid-cli on demand, so users don't need a
# global install. transparent background blends into any page; --scale 2
# renders at 2× for crisp diagrams in print/PDF.
cmd = [
"npx", "-y", "@mermaid-js/mermaid-cli",
"-i", tmp_input, "-o", filepath,
"-b", "transparent", "--scale", "2",
]
if config_path:
cmd.extend(["-c", config_path])
# Pass --no-sandbox to the bundled Chromium. Ubuntu 24.04 and other
# recent distros restrict unprivileged user namespaces via AppArmor,
# which breaks Puppeteer's default sandbox. Our input is a static
# ``.mmd`` file we just wrote ourselves, so disabling the sandbox is
# the standard CI workaround (see https://pptr.dev/troubleshooting).
try:
puppeteer_cfg = str(
resources.files("md2star.data").joinpath("puppeteer-config.json")
)
if os.path.exists(puppeteer_cfg):
cmd.extend(["-p", puppeteer_cfg])
except (FileNotFoundError, ModuleNotFoundError):
pass
# 60s cap: a hung headless browser shouldn't stall the whole conversion.
result = subprocess.run(cmd, capture_output=True, timeout=60)
# The .mmd was only scratch input — best-effort cleanup, ignore if gone.
try:
os.remove(tmp_input)
except OSError:
pass
# Treat "non-zero exit" OR "no PNG produced" as failure; raising lets the
# caller keep the original code fence instead of losing the diagram.
if result.returncode != 0 or not os.path.exists(filepath):
stderr = result.stderr.decode(errors="replace").strip()
raise RuntimeError(
f"mmdc failed (exit {result.returncode}): {stderr}\n"
f"Ensure Node.js ≥16 is installed: https://nodejs.org/"
)
return filepath