Files
md_to_gost/md2gost/mermaid_renderer.py
T
Igor20264 1a5b35eb54
Python application / build (push) Has been cancelled
BigUpdate 0.5.0
- add Local Render Mermaid
- add page-starе для указания смещения страниц
- add Гиперссылки в документе на списки литературы
2026-09-07 09:54:33 +03:00

608 lines
20 KiB
Python

"""Local Mermaid rendering: system browser → Playwright Chromium → QuickJS (mermaidx)."""
from __future__ import annotations
import logging
import os
import subprocess
import threading
from pathlib import Path
from typing import Literal
from . import package_dir
_log = logging.getLogger(__name__)
# Pinned assets next to PlantUML schemes.
MERMAID_JS_NAME = "mermaid.min.js"
MERMAID_PAGE_NAME = "mermaid_page.html"
CACHE_KEY_PREFIX = "mmd-local-v4\n"
# QuickJS/resvg cannot paint HTML foreignObject — use SVG <text> there only.
# Browser keeps htmlLabels:true (correct box sizing for Cyrillic) + PNG via screenshot.
_MERMAID_CONFIG_QUICKJS = {
"flowchart": {"htmlLabels": False},
"themeVariables": {
"fontFamily": '"Segoe UI", "DejaVu Sans", Arial, sans-serif',
},
}
_MERMAID_INIT_QUICKJS = (
"%%{init: {"
"'flowchart': {'htmlLabels': false}, "
"'themeVariables': {'fontFamily': '\"Segoe UI\", \"DejaVu Sans\", Arial, sans-serif'}"
"}}%%\n"
)
_MERMAID_INIT_BROWSER = (
"%%{init: {"
"'flowchart': {"
"'htmlLabels': true, "
"'wrappingWidth': 320, "
"'subGraphTitleMargin': {'top': 12, 'bottom': 16}, "
"'padding': 20"
"}, "
"'themeVariables': {'fontFamily': '\"Segoe UI\", \"DejaVu Sans\", Arial, sans-serif'}"
"}}%%\n"
)
def prepare_mermaid_source(source: str, *, html_labels: bool = True) -> str:
"""
Prepend %%{init}%%.
html_labels=True — browser (correct layout for Cyrillic, PNG via screenshot).
html_labels=False — QuickJS/resvg (SVG <text> so glyphs are not dropped).
"""
text = source.lstrip("\ufeff").strip()
if text.startswith("%%{init"):
return text
return (_MERMAID_INIT_BROWSER if html_labels else _MERMAID_INIT_QUICKJS) + text
# Prefer installed browsers; bundled Chromium is optional (button / --install-chromium).
_SYSTEM_CHANNELS = ("msedge", "chrome", "chromium")
_lock = threading.RLock()
_session: "_BrowserSession | None" = None
_UNSET = object()
_cached_system_channel: str | None | object = _UNSET
def diagrams_dir() -> Path:
return Path(package_dir()) / "diagrams"
def _windows_browser_exe(channel: str) -> Path | None:
"""Best-effort path probe so GUI status does not launch browsers."""
local = Path(os.environ.get("LOCALAPPDATA") or "")
pf = Path(os.environ.get("PROGRAMFILES") or r"C:\Program Files")
pf86 = Path(os.environ.get("PROGRAMFILES(X86)") or r"C:\Program Files (x86)")
candidates: list[Path] = []
if channel == "msedge":
candidates = [
pf / "Microsoft" / "Edge" / "Application" / "msedge.exe",
pf86 / "Microsoft" / "Edge" / "Application" / "msedge.exe",
]
elif channel == "chrome":
candidates = [
pf / "Google" / "Chrome" / "Application" / "chrome.exe",
pf86 / "Google" / "Chrome" / "Application" / "chrome.exe",
local / "Google" / "Chrome" / "Application" / "chrome.exe",
]
elif channel == "chromium":
candidates = [
pf / "Chromium" / "Application" / "chrome.exe",
local / "Chromium" / "Application" / "chrome.exe",
]
for path in candidates:
if path.is_file():
return path
return None
def mermaid_assets_dir() -> Path:
return diagrams_dir()
def mermaid_page_path() -> Path:
return mermaid_assets_dir() / MERMAID_PAGE_NAME
def mermaid_js_path() -> Path:
return mermaid_assets_dir() / MERMAID_JS_NAME
def user_data_root() -> Path:
if os.name == "nt":
return Path(os.environ.get("LOCALAPPDATA") or Path.home()) / "md2gost"
return Path.home() / ".md2gost"
def playwright_browsers_dir() -> Path:
env = os.environ.get("PLAYWRIGHT_BROWSERS_PATH")
if env and env not in ("0", "1"):
return Path(env)
return user_data_root() / "ms-playwright"
def ensure_playwright_browsers_env() -> Path:
"""Point Playwright at %LOCALAPPDATA%/md2gost/ms-playwright (or ~/.md2gost)."""
root = playwright_browsers_dir()
root.mkdir(parents=True, exist_ok=True)
os.environ["PLAYWRIGHT_BROWSERS_PATH"] = str(root)
return root
def mermaidx_available() -> bool:
try:
import mermaidx # noqa: F401
return True
except ImportError:
return False
def playwright_available() -> bool:
try:
from playwright.sync_api import sync_playwright # noqa: F401
return True
except ImportError:
return False
def _playwright_driver() -> tuple[str, str] | None:
try:
from playwright._impl._driver import compute_driver_executable
node, cli = compute_driver_executable()
return str(node), str(cli)
except Exception as exc:
_log.debug("Playwright driver: %s", exc)
return None
def chromium_installed() -> bool:
"""True if Playwright Chromium binary exists under PLAYWRIGHT_BROWSERS_PATH."""
if not playwright_available():
return False
ensure_playwright_browsers_env()
try:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
exe = Path(p.chromium.executable_path)
return exe.is_file()
except Exception:
return False
def detect_system_browser_channel(*, probe_launch: bool = False) -> str | None:
"""
Return first usable Playwright channel (msedge / chrome / chromium).
By default only checks known install paths (fast, for GUI status).
Set probe_launch=True to actually launch (used once before first render).
"""
global _cached_system_channel
if _cached_system_channel is not _UNSET and not probe_launch:
return _cached_system_channel # type: ignore[return-value]
if not playwright_available():
_cached_system_channel = None
return None
# Path probe first (Windows); on other OS fall through to launch probe.
if os.name == "nt":
for channel in _SYSTEM_CHANNELS:
if _windows_browser_exe(channel) is not None:
_cached_system_channel = channel
return channel
if not probe_launch:
_cached_system_channel = None
return None
if not probe_launch and os.name == "nt":
return None
try:
from playwright.sync_api import sync_playwright
except ImportError:
_cached_system_channel = None
return None
ensure_playwright_browsers_env()
for channel in _SYSTEM_CHANNELS:
try:
with sync_playwright() as p:
browser = p.chromium.launch(channel=channel, headless=True)
browser.close()
_cached_system_channel = channel
return channel
except Exception as exc:
_log.debug("channel %s unavailable: %s", channel, exc)
_cached_system_channel = None
return None
def install_playwright_chromium() -> bool:
"""Download Playwright Chromium into user cache. Safe for frozen exe."""
if not playwright_available():
_log.warning("playwright не установлен — нельзя скачать Chromium")
return False
driver = _playwright_driver()
if not driver:
_log.warning("Playwright driver не найден")
return False
node, cli = driver
root = ensure_playwright_browsers_env()
env = os.environ.copy()
env["PLAYWRIGHT_BROWSERS_PATH"] = str(root)
cmd = [node, cli, "install", "chromium"]
_log.info("Установка Playwright Chromium → %s", root)
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=600,
check=False,
env=env,
)
except (OSError, subprocess.TimeoutExpired) as exc:
_log.warning("install chromium failed: %s", exc)
return False
if proc.returncode != 0:
_log.warning(
"playwright install chromium → %s\n%s\n%s",
proc.returncode,
proc.stdout,
proc.stderr,
)
return False
ok = chromium_installed()
if not ok:
_log.warning("Chromium скачан, но executable_path не найден")
return ok
def mermaid_engine_status() -> str:
"""Human-readable Mermaid status for the GUI."""
lines: list[str] = []
if not playwright_available():
lines.append("Mermaid browser: playwright не установлен")
else:
ch = detect_system_browser_channel()
if ch:
lines.append(f"Mermaid browser: системный ({ch})")
else:
lines.append("Mermaid browser: системный Chrome/Edge не найден")
if chromium_installed():
lines.append(f"Mermaid Chromium: есть ({playwright_browsers_dir()})")
else:
lines.append(
"Mermaid Chromium: нет. Кнопка «Скачать headless Chromium» "
"или md2gost --install-chromium"
)
if mermaidx_available():
lines.append("Mermaid QuickJS (mermaidx): готов (офлайн без браузера)")
else:
lines.append("Mermaid QuickJS: mermaidx не установлен")
page = mermaid_page_path()
js = mermaid_js_path()
if page.is_file() and js.is_file():
lines.append(f"Mermaid assets: {js.name} + {page.name}")
else:
lines.append("Mermaid assets: нет mermaid.min.js / mermaid_page.html")
return "\n".join(lines)
def svg_to_png_bytes(svg: str, *, scale: float = 1.0) -> bytes | None:
try:
from mermaidx import svg_to_png
except ImportError:
return None
try:
if scale and scale > 1:
return _svg_to_png_scaled(svg, float(scale))
return svg_to_png(svg)
except Exception as exc:
_log.warning("svg→png failed: %s", exc)
return None
def _svg_to_png_scaled(svg: str, scale: float) -> bytes:
from mermaidx import svg_to_png
if scale <= 1:
return svg_to_png(svg)
# Parse width="N" or width="Npx" from root svg; multiply for sharper raster.
import re
m = re.search(r"<svg\b[^>]*\bwidth=['\"]([\d.]+)(px)?['\"]", svg, re.I)
if m:
w = float(m.group(1)) * scale
return svg_to_png(svg, width=w)
# Fallback: resvg default size, then Pillow upsample (last resort)
png = svg_to_png(svg)
try:
from io import BytesIO
from PIL import Image
im = Image.open(BytesIO(png))
im = im.resize(
(max(1, int(im.width * scale)), max(1, int(im.height * scale))),
Image.Resampling.LANCZOS,
)
buf = BytesIO()
im.save(buf, format="PNG")
return buf.getvalue()
except Exception:
return png
def _render_quickjs(source: str, *, scale: float = 1.0) -> tuple[str, bytes] | None:
if not mermaidx_available():
return None
try:
from mermaidx import render
except ImportError:
return None
try:
prepared = prepare_mermaid_source(source, html_labels=False)
diagram = render(prepared, backend="quickjs", config=_MERMAID_CONFIG_QUICKJS)
svg = diagram.svg()
if not isinstance(svg, str) or "<svg" not in svg.lower():
return None
scale_kw = {"scale": float(scale)} if scale and scale > 1 else {}
png = diagram.png(**scale_kw)
if not isinstance(png, (bytes, bytearray)) or not png.startswith(b"\x89PNG"):
png_b = svg_to_png_bytes(svg, scale=scale)
if not png_b:
return None
png = png_b
return svg, bytes(png)
except Exception as exc:
_log.warning("mermaidx QuickJS failed: %s", exc)
return None
class _BrowserSession:
"""One Playwright browser for the whole conversion (system channel or bundled Chromium)."""
def __init__(self, *, channel: str | None, use_bundled: bool) -> None:
from playwright.sync_api import sync_playwright
ensure_playwright_browsers_env()
self._pw_cm = sync_playwright()
self._pw = self._pw_cm.__enter__()
kwargs: dict = {"headless": True}
if channel:
kwargs["channel"] = channel
elif not use_bundled:
raise RuntimeError("no browser channel")
self._browser = self._pw.chromium.launch(**kwargs)
# device_scale_factor set per render via new context when needed; default 1.
self._context = self._browser.new_context(device_scale_factor=1)
self._page = self._context.new_page()
page_file = mermaid_page_path()
js_file = mermaid_js_path()
if not page_file.is_file() or not js_file.is_file():
raise FileNotFoundError(f"Mermaid assets missing under {mermaid_assets_dir()}")
self._page.goto(page_file.resolve().as_uri(), wait_until="load")
self._page.wait_for_function("typeof window.__md2gostRender === 'function'")
self._scale = 1.0
def _ensure_scale(self, scale: float) -> None:
"""Recreate context if DPR must change for sharper screenshots."""
want = max(1.0, float(scale) if scale else 1.0)
if abs(want - self._scale) < 0.01:
return
self._context.close()
self._context = self._browser.new_context(device_scale_factor=want)
self._page = self._context.new_page()
self._page.goto(mermaid_page_path().resolve().as_uri(), wait_until="load")
self._page.wait_for_function("typeof window.__md2gostRender === 'function'")
self._scale = want
def render_svg(self, source: str) -> str:
prepared = prepare_mermaid_source(source, html_labels=True)
svg = self._page.evaluate("(code) => window.__md2gostRender(code)", prepared)
if not isinstance(svg, str) or "<svg" not in svg.lower():
raise RuntimeError("browser mermaid.render returned no SVG")
return svg
def render_png(self, source: str, *, scale: float = 1.0) -> tuple[str, bytes]:
"""
Browser: htmlLabels + screenshot at viewBox size (layout + Cyrillic + hi-res).
"""
self._ensure_scale(scale)
prepared = prepare_mermaid_source(source, html_labels=True)
svg = self._page.evaluate(
"(code) => window.__md2gostRender(code)",
prepared,
)
if not isinstance(svg, str) or "<svg" not in svg.lower():
raise RuntimeError("browser mermaid.render returned no SVG")
# Mermaid sets width="100%" — force viewBox pixel size before capture.
dims = self._page.evaluate(
"""() => {
const el = document.querySelector('#out svg');
if (!el) return null;
let w = 0, h = 0;
try {
const vb = el.viewBox && el.viewBox.baseVal;
if (vb && vb.width > 0 && vb.height > 0) {
w = vb.width;
h = vb.height;
}
} catch (e) {}
if (!(w > 0 && h > 0)) {
try {
const b = el.getBBox();
w = b.width;
h = b.height;
} catch (e) {}
}
if (!(w > 0 && h > 0)) return null;
el.setAttribute('width', String(w));
el.setAttribute('height', String(h));
el.style.width = w + 'px';
el.style.height = h + 'px';
el.style.maxWidth = 'none';
el.style.maxHeight = 'none';
return { w: Math.ceil(w), h: Math.ceil(h) };
}"""
)
if isinstance(dims, dict):
vw = max(1280, int(dims.get("w") or 0) + 64)
vh = max(720, int(dims.get("h") or 0) + 64)
self._page.set_viewport_size({"width": vw, "height": vh})
# Prefer screenshot: FO/HTML labels render correctly in a real browser.
png: bytes | None = None
loc = self._page.locator("#out svg").first
loc.wait_for(state="visible", timeout=15000)
shot = loc.screenshot(type="png", omit_background=False)
if isinstance(shot, (bytes, bytearray)) and shot.startswith(b"\x89PNG"):
png = bytes(shot)
if png is None and "foreignObject" not in svg:
png = svg_to_png_bytes(svg, scale=scale)
if not png:
raise RuntimeError("no PNG from browser path")
return svg, png
def close(self) -> None:
try:
self._browser.close()
except Exception:
pass
try:
self._pw_cm.__exit__(None, None, None)
except Exception:
pass
def reset_mermaid_session() -> None:
global _session
with _lock:
if _session is not None:
try:
_session.close()
except Exception:
pass
_session = None
def _get_session(*, prefer_system: bool = True) -> _BrowserSession | None:
global _session
with _lock:
if _session is not None:
return _session
if not playwright_available():
return None
page = mermaid_page_path()
if not page.is_file() or not mermaid_js_path().is_file():
return None
# 1) system browser
if prefer_system:
ch = detect_system_browser_channel(probe_launch=True)
if ch:
try:
_session = _BrowserSession(channel=ch, use_bundled=False)
_log.info("Mermaid: системный браузер (%s)", ch)
return _session
except Exception as exc:
_log.warning("Mermaid system browser failed: %s", exc)
_session = None
# Retry other channels once
for alt in _SYSTEM_CHANNELS:
if alt == ch:
continue
try:
_session = _BrowserSession(channel=alt, use_bundled=False)
_log.info("Mermaid: системный браузер (%s)", alt)
return _session
except Exception:
_session = None
# 2) Playwright Chromium
if chromium_installed():
try:
_session = _BrowserSession(channel=None, use_bundled=True)
_log.info("Mermaid: Playwright Chromium")
return _session
except Exception as exc:
_log.warning("Mermaid Chromium failed: %s", exc)
_session = None
return None
def _render_browser(source: str, *, scale: float = 1.0) -> tuple[str, bytes] | None:
session = _get_session(prefer_system=True)
if session is None:
return None
try:
return session.render_png(source, scale=scale)
except Exception as exc:
_log.warning("Mermaid browser render failed: %s", exc)
reset_mermaid_session()
return None
BackendName = Literal["browser", "quickjs"]
def render_mermaid_local(
source: str,
*,
scale: float = 1.0,
) -> tuple[str, bytes, BackendName] | None:
"""
Try local Mermaid backends in order: system/Chromium browser → QuickJS.
Returns (svg, png_bytes, backend) or None.
"""
got = _render_browser(source, scale=scale)
if got:
return got[0], got[1], "browser"
got = _render_quickjs(source, scale=scale)
if got:
return got[0], got[1], "quickjs"
return None
def write_mermaid_outputs(
source: str,
out_png: Path,
out_svg: Path | None,
*,
scale: float = 1.0,
) -> bool:
"""Render locally and write PNG (+ optional SVG)."""
result = render_mermaid_local(source, scale=scale)
if not result:
return False
svg, png, backend = result
out_png.write_bytes(png)
if out_svg is not None:
out_svg.write_text(svg, encoding="utf-8")
_log.info("Mermaid local ok (%s) → %s", backend, out_png.name)
return True
__all__ = [
"CACHE_KEY_PREFIX",
"chromium_installed",
"detect_system_browser_channel",
"ensure_playwright_browsers_env",
"install_playwright_chromium",
"mermaid_engine_status",
"mermaidx_available",
"playwright_available",
"playwright_browsers_dir",
"prepare_mermaid_source",
"render_mermaid_local",
"reset_mermaid_session",
"write_mermaid_outputs",
]