"""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 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 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"]*\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 " 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 " 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 " { 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", ]