1a5b35eb54
Python application / build (push) Has been cancelled
- add Local Render Mermaid - add page-starе для указания смещения страниц - add Гиперссылки в документе на списки литературы
608 lines
20 KiB
Python
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",
|
|
]
|