819 lines
27 KiB
Python
819 lines
27 KiB
Python
"""Render UML / Mermaid / scheme fenced blocks to PNG (+ optional SVG)."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import logging
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Literal
|
||
|
||
import requests
|
||
|
||
from . import package_dir
|
||
from .diagram_schemes import (
|
||
configure_schemes,
|
||
ensure_user_schemes,
|
||
is_diagram_lang,
|
||
prepare_with_schemes,
|
||
)
|
||
|
||
# Back-compat: static set used by older tests; runtime uses is_diagram_lang().
|
||
DIAGRAM_LANGS = frozenset(
|
||
{
|
||
"uml",
|
||
"plantuml",
|
||
"c4",
|
||
"c4context",
|
||
"c4component",
|
||
"usecase",
|
||
"mermaid",
|
||
"mmd",
|
||
"idef0",
|
||
"dfd",
|
||
}
|
||
)
|
||
# Native Pillow renderers — never jar / Kroki.
|
||
LOCAL_ONLY_TYPES = frozenset({"idef0", "dfd"})
|
||
# Local hi-res path uses a scale-specific cache key.
|
||
SCALED_LOCAL_TYPES = frozenset({"mermaid", "idef0", "dfd"})
|
||
FallbackMode = Literal["local", "remote", "off"]
|
||
DiagramFormat = Literal["png", "svg"]
|
||
|
||
DEFAULT_KROKI_URL = "http://localhost:8000"
|
||
REMOTE_KROKI_URL = "https://kroki.io"
|
||
|
||
# Sharper raster when Word shrinks the figure to page width (display size stays ×1).
|
||
DEFAULT_DIAGRAM_SCALE = 2.0
|
||
# Avoid PlantUML clipping large scaled diagrams (default limit is 4096).
|
||
PLANTUML_LIMIT_SIZE = "8192"
|
||
|
||
# Pinned official jar (GPL). Bundled at exe build; otherwise downloaded once to user cache.
|
||
PLANTUML_VERSION = "1.2025.4"
|
||
PLANTUML_JAR_URL = (
|
||
f"https://github.com/plantuml/plantuml/releases/download/"
|
||
f"v{PLANTUML_VERSION}/plantuml-{PLANTUML_VERSION}.jar"
|
||
)
|
||
# Part of the cache key: a new jar draws the same source differently.
|
||
PLANTUML_CACHE_PREFIX = f"plantuml-{PLANTUML_VERSION}\n"
|
||
|
||
# One JVM per chunk; keeps the command line far below the Windows 32k limit.
|
||
PLANTUML_BATCH_CHUNK = 60
|
||
|
||
_log = logging.getLogger(__name__)
|
||
|
||
|
||
@dataclass
|
||
class DiagramConfig:
|
||
plantuml_jar: str | None = None
|
||
kroki_url: str | None = None
|
||
fallback: FallbackMode = "remote"
|
||
cache_dir: str | None = None
|
||
schemes_path: str | None = None
|
||
diagram_format: DiagramFormat = "png"
|
||
diagram_scale: float = DEFAULT_DIAGRAM_SCALE
|
||
|
||
|
||
@dataclass
|
||
class DiagramRenderResult:
|
||
png_path: str
|
||
svg_path: str | None = None
|
||
# PlantUML render scale; Image divides display size by this to keep ×1 layout.
|
||
pixel_scale: float = 1.0
|
||
|
||
|
||
_CONFIG = DiagramConfig()
|
||
|
||
|
||
def configure_diagrams(
|
||
plantuml_jar: str | None = None,
|
||
kroki_url: str | None = None,
|
||
fallback: FallbackMode = "remote",
|
||
cache_dir: str | None = None,
|
||
schemes_path: str | None = None,
|
||
md_dir: str | None = None,
|
||
diagram_format: DiagramFormat = "png",
|
||
diagram_scale: float | None = None,
|
||
) -> None:
|
||
global _CONFIG
|
||
ensure_user_schemes()
|
||
configure_schemes(schemes_path=schemes_path, md_dir=md_dir)
|
||
reset_diagram_batch()
|
||
try:
|
||
from .mermaid_renderer import ensure_playwright_browsers_env, reset_mermaid_session
|
||
|
||
ensure_playwright_browsers_env()
|
||
reset_mermaid_session()
|
||
except Exception:
|
||
pass
|
||
fmt: DiagramFormat = "svg" if diagram_format == "svg" else "png"
|
||
scale = DEFAULT_DIAGRAM_SCALE if diagram_scale is None else float(diagram_scale)
|
||
if scale < 1:
|
||
scale = 1.0
|
||
_CONFIG = DiagramConfig(
|
||
plantuml_jar=resolve_plantuml_jar(plantuml_jar or None, download=False),
|
||
kroki_url=kroki_url or os.environ.get("KROKI_URL"),
|
||
fallback=fallback,
|
||
cache_dir=cache_dir,
|
||
schemes_path=schemes_path,
|
||
diagram_format=fmt,
|
||
diagram_scale=scale,
|
||
)
|
||
|
||
|
||
def diagrams_dir() -> Path:
|
||
return Path(package_dir()) / "diagrams"
|
||
|
||
|
||
def prepare_source(lang: str, source: str) -> tuple[str, str]:
|
||
"""Return (prepared_source, kroki_diagram_type)."""
|
||
return prepare_with_schemes(lang, source)
|
||
|
||
|
||
def _cache_root(cache_dir: str | None = None) -> Path:
|
||
root = cache_dir or _CONFIG.cache_dir
|
||
if not root:
|
||
wd = os.environ.get("WORKING_DIR", ".")
|
||
root = os.path.join(wd, ".md2gost-cache")
|
||
path = Path(root)
|
||
path.mkdir(parents=True, exist_ok=True)
|
||
return path
|
||
|
||
|
||
def _cache_digest(source: str, *, cache_prefix: str = "") -> str:
|
||
payload = (cache_prefix + source) if cache_prefix else source
|
||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:24]
|
||
|
||
|
||
def _cache_paths(
|
||
source: str,
|
||
cache_dir: str | None = None,
|
||
*,
|
||
cache_prefix: str = "",
|
||
) -> tuple[Path, Path]:
|
||
root = _cache_root(cache_dir)
|
||
digest = _cache_digest(source, cache_prefix=cache_prefix)
|
||
return root / f"{digest}.png", root / f"{digest}.svg"
|
||
|
||
|
||
def _java_available() -> bool:
|
||
return shutil.which("java") is not None
|
||
|
||
|
||
def vendor_plantuml_path() -> Path:
|
||
return Path(package_dir()) / "vendor" / "plantuml.jar"
|
||
|
||
|
||
def cached_plantuml_path() -> Path:
|
||
if os.name == "nt":
|
||
root = Path(os.environ.get("LOCALAPPDATA") or Path.home()) / "md2gost"
|
||
else:
|
||
root = Path.home() / ".md2gost"
|
||
return root / "plantuml.jar"
|
||
|
||
|
||
def iter_plantuml_candidates(explicit: str | None = None) -> list[Path]:
|
||
paths: list[Path] = []
|
||
if explicit:
|
||
paths.append(Path(explicit))
|
||
env = os.environ.get("PLANTUML_JAR")
|
||
if env:
|
||
paths.append(Path(env))
|
||
paths.append(vendor_plantuml_path())
|
||
if getattr(sys, "frozen", False):
|
||
mei = getattr(sys, "_MEIPASS", None)
|
||
if mei:
|
||
paths.append(Path(mei) / "md2gost" / "vendor" / "plantuml.jar")
|
||
paths.append(Path(mei) / "vendor" / "plantuml.jar")
|
||
paths.append(Path(sys.executable).resolve().parent / "plantuml.jar")
|
||
paths.append(cached_plantuml_path())
|
||
seen: set[str] = set()
|
||
out: list[Path] = []
|
||
for path in paths:
|
||
key = str(path)
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
out.append(path)
|
||
return out
|
||
|
||
|
||
def resolve_plantuml_jar(explicit: str | None = None, *, download: bool = False) -> str | None:
|
||
"""Find a plantuml.jar: explicit path, env, bundled vendor, user cache; optionally download."""
|
||
for path in iter_plantuml_candidates(explicit):
|
||
if path.is_file() and path.stat().st_size > 1000:
|
||
return str(path)
|
||
if download:
|
||
dest = cached_plantuml_path()
|
||
if fetch_plantuml_jar(dest):
|
||
return str(dest)
|
||
return None
|
||
|
||
|
||
def fetch_plantuml_jar(dest: Path, url: str = PLANTUML_JAR_URL) -> bool:
|
||
"""Download official plantuml.jar to dest. Returns True on success."""
|
||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp = dest.with_suffix(".jar.part")
|
||
try:
|
||
resp = requests.get(url, timeout=120, stream=True)
|
||
resp.raise_for_status()
|
||
size = 0
|
||
with open(tmp, "wb") as fh:
|
||
for chunk in resp.iter_content(chunk_size=65536):
|
||
if chunk:
|
||
fh.write(chunk)
|
||
size += len(chunk)
|
||
if size < 1000:
|
||
tmp.unlink(missing_ok=True)
|
||
return False
|
||
tmp.replace(dest)
|
||
_log.info("PlantUML jar: %s", dest)
|
||
return True
|
||
except Exception as exc:
|
||
_log.warning("Не удалось скачать plantuml.jar: %s", exc)
|
||
tmp.unlink(missing_ok=True)
|
||
return False
|
||
|
||
|
||
def diagram_engine_status(explicit_jar: str | None = None) -> str:
|
||
"""Human-readable status for the GUI."""
|
||
java = shutil.which("java")
|
||
jar = resolve_plantuml_jar(explicit_jar, download=False)
|
||
if java:
|
||
java_line = f"Java: есть ({java})"
|
||
else:
|
||
java_line = (
|
||
"Java: не найдена. Локальный PlantUML не запустится — "
|
||
"картинки пойдут через интернет (kroki.io)."
|
||
)
|
||
if jar:
|
||
jar_line = f"PlantUML: {jar}"
|
||
else:
|
||
jar_line = (
|
||
"PlantUML: файла нет. Нажмите «Скачать PlantUML» (нужна Java) "
|
||
"или оставьте как есть — картинки нарисует интернет (kroki.io)."
|
||
)
|
||
lines = java_line + "\n" + jar_line
|
||
try:
|
||
from .mermaid_renderer import mermaid_engine_status
|
||
|
||
lines = lines + "\n" + mermaid_engine_status()
|
||
except Exception as exc:
|
||
lines = lines + f"\nMermaid: статус недоступен ({exc})"
|
||
lines = lines + "\nIDEF0: локальный рендер (Pillow), оградка ```idef0"
|
||
try:
|
||
from .dfd import dfd_engine_status
|
||
|
||
lines = lines + "\n" + dfd_engine_status()
|
||
except Exception as exc:
|
||
lines = lines + f"\nDFD: статус недоступен ({exc})"
|
||
return lines
|
||
|
||
|
||
def _ensure_plantuml_png_scale(source: str, scale: float = DEFAULT_DIAGRAM_SCALE) -> str:
|
||
"""Inject `scale N` into PlantUML source unless the author already set scale/dpi."""
|
||
if scale <= 1:
|
||
return source
|
||
if re.search(r"(?im)^\s*scale\b", source):
|
||
return source
|
||
if re.search(r"(?im)^\s*skinparam\s+dpi\b", source):
|
||
return source
|
||
lines = source.splitlines()
|
||
if not lines:
|
||
return source
|
||
insert_at = 0
|
||
if lines[0].strip().lower().startswith("@start"):
|
||
insert_at = 1
|
||
scale_line = f"scale {scale:g}"
|
||
lines.insert(insert_at, scale_line)
|
||
return "\n".join(lines) + ("\n" if source.endswith("\n") else "")
|
||
|
||
|
||
def _is_png(data: bytes) -> bool:
|
||
return data.startswith(b"\x89PNG")
|
||
|
||
|
||
def _is_svg(data: bytes) -> bool:
|
||
head = data.lstrip()[:200].lower()
|
||
return head.startswith(b"<svg") or head.startswith(b"<?xml") or b"<svg" in head
|
||
|
||
|
||
@dataclass
|
||
class _PumlJob:
|
||
"""One PlantUML source and where its rendered file must land."""
|
||
|
||
source: str
|
||
out_path: Path
|
||
|
||
|
||
def _pick_produced(out_dir: Path, fmt: str) -> Path | None:
|
||
"""Output name follows `@startuml <name>`, so take what the jar actually wrote."""
|
||
if not out_dir.is_dir():
|
||
return None
|
||
files = [
|
||
p for p in out_dir.iterdir()
|
||
if p.is_file() and p.suffix.lower() == f".{fmt}"
|
||
]
|
||
if not files:
|
||
return None
|
||
# `newpage` adds name_001, name_002…; the shortest name is the first page.
|
||
files.sort(key=lambda p: (len(p.name), p.name))
|
||
return files[0]
|
||
|
||
|
||
def _plantuml_run_chunk(
|
||
chunk: list[_PumlJob],
|
||
jar: str,
|
||
fmt: Literal["png", "svg"],
|
||
done: list[bool],
|
||
offset: int,
|
||
) -> None:
|
||
with tempfile.TemporaryDirectory(prefix="md2gost-puml-") as tmp:
|
||
root = Path(tmp)
|
||
sources: list[str] = []
|
||
for i, job in enumerate(chunk):
|
||
sub = root / f"{i:04d}"
|
||
sub.mkdir()
|
||
src = sub / "src.puml"
|
||
src.write_text(job.source, encoding="utf-8")
|
||
sources.append(str(src))
|
||
cmd = [
|
||
"java",
|
||
f"-Dplantuml.include.path={diagrams_dir().resolve()}",
|
||
f"-DPLANTUML_LIMIT_SIZE={PLANTUML_LIMIT_SIZE}",
|
||
"-jar", jar,
|
||
f"-t{fmt}",
|
||
"-charset", "UTF-8",
|
||
# Relative -o resolves against each input's own folder, so two sources
|
||
# naming themselves `@startuml Name` cannot collide on one output file.
|
||
"-o", "out",
|
||
*sources,
|
||
]
|
||
timeout = min(600, 120 + 30 * (len(chunk) - 1))
|
||
try:
|
||
proc = subprocess.run(
|
||
cmd, capture_output=True, text=True, timeout=timeout, check=False,
|
||
)
|
||
except (OSError, subprocess.TimeoutExpired) as e:
|
||
_log.warning("PlantUML jar failed: %s", e)
|
||
return
|
||
if proc.returncode != 0:
|
||
_log.warning("PlantUML jar error: %s", proc.stderr or proc.stdout)
|
||
return
|
||
for i, job in enumerate(chunk):
|
||
produced = _pick_produced(root / f"{i:04d}" / "out", fmt)
|
||
if produced is None:
|
||
_log.warning("PlantUML jar: %s не создан", fmt)
|
||
continue
|
||
data = produced.read_bytes()
|
||
if fmt == "png" and not _is_png(data):
|
||
continue
|
||
if fmt == "svg" and not _is_svg(data):
|
||
continue
|
||
job.out_path.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copyfile(produced, job.out_path)
|
||
done[offset + i] = True
|
||
|
||
|
||
def _plantuml_run(
|
||
jobs: list[_PumlJob],
|
||
jar: str,
|
||
fmt: Literal["png", "svg"],
|
||
) -> list[bool]:
|
||
"""Render every job in one JVM per chunk. Returns per-job success flags."""
|
||
if not jobs:
|
||
return []
|
||
if not jar or not os.path.isfile(jar) or not _java_available():
|
||
return [False] * len(jobs)
|
||
done = [False] * len(jobs)
|
||
for start in range(0, len(jobs), PLANTUML_BATCH_CHUNK):
|
||
_plantuml_run_chunk(
|
||
jobs[start:start + PLANTUML_BATCH_CHUNK], jar, fmt, done, start,
|
||
)
|
||
return done
|
||
|
||
|
||
def _render_plantuml_jar_one(
|
||
source: str,
|
||
out_path: Path,
|
||
jar: str,
|
||
fmt: Literal["png", "svg"],
|
||
) -> bool:
|
||
done = _plantuml_run([_PumlJob(source, Path(out_path))], jar, fmt)
|
||
return bool(done and done[0])
|
||
|
||
|
||
def _render_plantuml_jar(
|
||
source: str,
|
||
out_png: Path,
|
||
jar: str,
|
||
*,
|
||
out_svg: Path | None = None,
|
||
) -> bool:
|
||
"""Render PNG; optionally also SVG from the same jar."""
|
||
if not _render_plantuml_jar_one(source, out_png, jar, "png"):
|
||
return False
|
||
if out_svg is not None:
|
||
if not _render_plantuml_jar_one(source, out_svg, jar, "svg"):
|
||
_log.warning("PlantUML jar: PNG ok, SVG failed")
|
||
return True
|
||
|
||
|
||
def _plantuml_scaled(prepared: str, scale: float) -> tuple[str, float]:
|
||
"""Return (source, pixel_scale); pixel_scale stays 1 when the author set scale."""
|
||
if scale <= 1:
|
||
return prepared, 1.0
|
||
boosted = _ensure_plantuml_png_scale(prepared, scale)
|
||
return boosted, (scale if boosted != prepared else 1.0)
|
||
|
||
|
||
# Fences seen while parsing, so the whole document can share one JVM.
|
||
_PENDING: list[tuple[str, str]] = []
|
||
_PENDING_SEEN: set[tuple[str, str]] = set()
|
||
_BATCH_FLUSHED = False
|
||
|
||
|
||
def register_pending_diagram(lang: str, source: str) -> None:
|
||
"""Announce a diagram fence found during parsing (see _flush_plantuml_batch)."""
|
||
key = (lang or "", source or "")
|
||
if key in _PENDING_SEEN:
|
||
return
|
||
_PENDING_SEEN.add(key)
|
||
_PENDING.append(key)
|
||
|
||
|
||
def reset_diagram_batch() -> None:
|
||
global _BATCH_FLUSHED
|
||
_PENDING.clear()
|
||
_PENDING_SEEN.clear()
|
||
_BATCH_FLUSHED = False
|
||
|
||
|
||
def _flush_plantuml_batch() -> None:
|
||
"""
|
||
Pre-pass before the first diagram renders: draw every cache-missing PlantUML
|
||
source of the document in one JVM instead of one (two in svg mode) per diagram.
|
||
Anything that fails here simply stays a cache miss and takes the normal path.
|
||
"""
|
||
global _BATCH_FLUSHED
|
||
if _BATCH_FLUSHED:
|
||
return
|
||
_BATCH_FLUSHED = True
|
||
pending = list(_PENDING)
|
||
_PENDING.clear()
|
||
_PENDING_SEEN.clear()
|
||
if len(pending) < 2:
|
||
return
|
||
jar = resolve_plantuml_jar(_CONFIG.plantuml_jar or None, download=False)
|
||
if not jar or not _java_available():
|
||
return
|
||
|
||
want_svg = _CONFIG.diagram_format == "svg"
|
||
scale = _CONFIG.diagram_scale
|
||
png_jobs: list[_PumlJob] = []
|
||
svg_jobs: list[_PumlJob] = []
|
||
seen: set[str] = set()
|
||
for lang, source in pending:
|
||
try:
|
||
prepared, kroki_type = prepare_source(lang, source)
|
||
except Exception:
|
||
continue
|
||
if kroki_type != "plantuml":
|
||
continue
|
||
prepared, _ = _plantuml_scaled(prepared, scale)
|
||
out_png, out_svg = _cache_paths(prepared, cache_prefix=PLANTUML_CACHE_PREFIX)
|
||
if str(out_png) in seen:
|
||
continue
|
||
seen.add(str(out_png))
|
||
if not (out_png.is_file() and out_png.stat().st_size > 0):
|
||
png_jobs.append(_PumlJob(prepared, out_png))
|
||
if want_svg and not (out_svg.is_file() and out_svg.stat().st_size > 0):
|
||
svg_jobs.append(_PumlJob(prepared, out_svg))
|
||
|
||
if not png_jobs and not svg_jobs:
|
||
return
|
||
_log.info(
|
||
"PlantUML: пакетный рендер %d диаграмм одной JVM",
|
||
max(len(png_jobs), len(svg_jobs)),
|
||
)
|
||
if png_jobs:
|
||
_plantuml_run(png_jobs, jar, "png")
|
||
if svg_jobs:
|
||
_plantuml_run(svg_jobs, jar, "svg")
|
||
|
||
|
||
def shutdown_diagrams() -> None:
|
||
"""Release per-conversion resources: headless browser and the batch queue."""
|
||
reset_diagram_batch()
|
||
try:
|
||
from .mermaid_renderer import reset_mermaid_session
|
||
|
||
reset_mermaid_session()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _render_kroki(
|
||
source: str,
|
||
diagram_type: str,
|
||
base_url: str,
|
||
out_path: Path,
|
||
fmt: Literal["png", "svg"] = "png",
|
||
) -> bool:
|
||
url = base_url.rstrip("/") + f"/{diagram_type}/{fmt}"
|
||
try:
|
||
resp = requests.post(
|
||
url,
|
||
data=source.encode("utf-8"),
|
||
headers={"Content-Type": "text/plain"},
|
||
timeout=60,
|
||
)
|
||
if resp.status_code != 200:
|
||
_log.warning("Kroki %s → HTTP %s", url, resp.status_code)
|
||
return False
|
||
data = resp.content
|
||
if fmt == "png" and not _is_png(data):
|
||
_log.warning("Kroki %s → not PNG", url)
|
||
return False
|
||
if fmt == "svg" and not _is_svg(data):
|
||
_log.warning("Kroki %s → not SVG", url)
|
||
return False
|
||
out_path.write_bytes(data)
|
||
return True
|
||
except requests.RequestException as e:
|
||
_log.warning("Kroki request failed (%s): %s", url, e)
|
||
return False
|
||
|
||
|
||
def _render_kroki_pair(
|
||
source: str,
|
||
diagram_type: str,
|
||
base_url: str,
|
||
out_png: Path,
|
||
out_svg: Path | None,
|
||
) -> bool:
|
||
if not _render_kroki(source, diagram_type, base_url, out_png, "png"):
|
||
return False
|
||
if out_svg is not None:
|
||
if not _render_kroki(source, diagram_type, base_url, out_svg, "svg"):
|
||
_log.warning("Kroki: PNG ok, SVG failed (%s)", base_url)
|
||
return True
|
||
|
||
|
||
def render_diagram(
|
||
lang: str,
|
||
source: str,
|
||
*,
|
||
plantuml_jar: str | None = None,
|
||
kroki_url: str | None = None,
|
||
fallback: FallbackMode | None = None,
|
||
cache_dir: str | None = None,
|
||
diagram_format: DiagramFormat | None = None,
|
||
diagram_scale: float | None = None,
|
||
) -> DiagramRenderResult:
|
||
"""Render diagram to PNG (+ optional SVG). Raises RuntimeError if all backends fail."""
|
||
# Batch only the plain document path; explicit overrides would not match _CONFIG.
|
||
if not any(
|
||
arg is not None
|
||
for arg in (
|
||
plantuml_jar, kroki_url, fallback, cache_dir, diagram_format, diagram_scale,
|
||
)
|
||
):
|
||
try:
|
||
_flush_plantuml_batch()
|
||
except Exception as exc:
|
||
_log.warning("PlantUML: пакетный рендер не удался (%s)", exc)
|
||
|
||
prepared, kroki_type = prepare_source(lang, source)
|
||
scale = _CONFIG.diagram_scale if diagram_scale is None else float(diagram_scale)
|
||
if scale < 1:
|
||
scale = 1.0
|
||
# pixel_scale: only when we injected scale (author override → treat as 1 for display).
|
||
pixel_scale = 1.0
|
||
if kroki_type == "plantuml":
|
||
prepared, pixel_scale = _plantuml_scaled(prepared, scale)
|
||
# Mermaid: pixel_scale set only after successful local hi-res render (not Kroki).
|
||
|
||
cache_prefix = ""
|
||
if kroki_type == "plantuml":
|
||
cache_prefix = PLANTUML_CACHE_PREFIX
|
||
elif kroki_type == "mermaid":
|
||
try:
|
||
from .mermaid_renderer import CACHE_KEY_PREFIX
|
||
|
||
cache_prefix = CACHE_KEY_PREFIX
|
||
except Exception:
|
||
cache_prefix = "mmd-local-v4\n"
|
||
elif kroki_type == "idef0":
|
||
from .idef0 import CACHE_KEY_PREFIX as IDEF0_CACHE_PREFIX
|
||
|
||
cache_prefix = IDEF0_CACHE_PREFIX
|
||
elif kroki_type == "dfd":
|
||
from .dfd import CACHE_KEY_PREFIX as DFD_CACHE_PREFIX
|
||
|
||
cache_prefix = DFD_CACHE_PREFIX
|
||
|
||
# Local hi-res uses a scale-specific cache key; Kroki uses unscaled key.
|
||
local_prefix = cache_prefix
|
||
if kroki_type in SCALED_LOCAL_TYPES and scale > 1:
|
||
local_prefix = f"{cache_prefix}scale={scale:g}\n"
|
||
|
||
out_png, out_svg_path = _cache_paths(
|
||
prepared, cache_dir,
|
||
cache_prefix=local_prefix if kroki_type in SCALED_LOCAL_TYPES else cache_prefix,
|
||
)
|
||
# Kroki / remote paths (mermaid): never share the scaled local key.
|
||
kroki_png, kroki_svg_path = out_png, out_svg_path
|
||
if kroki_type == "mermaid":
|
||
kroki_png, kroki_svg_path = _cache_paths(
|
||
prepared, cache_dir, cache_prefix=cache_prefix,
|
||
)
|
||
|
||
fmt: DiagramFormat = (
|
||
diagram_format
|
||
if diagram_format is not None
|
||
else _CONFIG.diagram_format
|
||
)
|
||
want_svg = fmt == "svg"
|
||
svg_target = out_svg_path if want_svg else None
|
||
kroki_svg_target = kroki_svg_path if want_svg else None
|
||
|
||
if out_png.is_file() and out_png.stat().st_size > 0:
|
||
has_svg = out_svg_path.is_file() and out_svg_path.stat().st_size > 0
|
||
if not want_svg or has_svg:
|
||
hit_scale = pixel_scale
|
||
if kroki_type in SCALED_LOCAL_TYPES and scale > 1 and local_prefix != cache_prefix:
|
||
hit_scale = scale
|
||
return DiagramRenderResult(
|
||
png_path=str(out_png),
|
||
svg_path=str(out_svg_path) if (want_svg and has_svg) else None,
|
||
pixel_scale=hit_scale,
|
||
)
|
||
# PNG cached but SVG missing in svg mode — fall through to fill SVG.
|
||
|
||
# Mermaid: also accept unscaled Kroki cache hit
|
||
if (
|
||
kroki_type == "mermaid"
|
||
and kroki_png != out_png
|
||
and kroki_png.is_file()
|
||
and kroki_png.stat().st_size > 0
|
||
):
|
||
has_svg = kroki_svg_path.is_file() and kroki_svg_path.stat().st_size > 0
|
||
if not want_svg or has_svg:
|
||
return DiagramRenderResult(
|
||
png_path=str(kroki_png),
|
||
svg_path=str(kroki_svg_path) if (want_svg and has_svg) else None,
|
||
pixel_scale=1.0,
|
||
)
|
||
|
||
jar = plantuml_jar if plantuml_jar is not None else _CONFIG.plantuml_jar
|
||
jar = resolve_plantuml_jar(jar or None, download=False)
|
||
local_kroki = kroki_url if kroki_url is not None else _CONFIG.kroki_url
|
||
local_kroki = local_kroki or os.environ.get("KROKI_URL") or DEFAULT_KROKI_URL
|
||
mode: FallbackMode = fallback if fallback is not None else _CONFIG.fallback
|
||
|
||
def _result(
|
||
png: Path | None = None,
|
||
svg_path: Path | None = None,
|
||
*,
|
||
pscale: float | None = None,
|
||
) -> DiagramRenderResult:
|
||
use_png = png or out_png
|
||
use_svg_path = svg_path if svg_path is not None else out_svg_path
|
||
svg = None
|
||
if want_svg and use_svg_path.is_file() and use_svg_path.stat().st_size > 0:
|
||
svg = str(use_svg_path)
|
||
return DiagramRenderResult(
|
||
png_path=str(use_png),
|
||
svg_path=svg,
|
||
pixel_scale=pixel_scale if pscale is None else pscale,
|
||
)
|
||
|
||
# 1) PlantUML jar — only for plantuml type
|
||
if kroki_type == "plantuml":
|
||
if _render_plantuml_jar(prepared, out_png, jar or "", out_svg=svg_target):
|
||
return _result()
|
||
|
||
# 1a) Native Pillow (IDEF0 / DFD) — never Kroki / PlantUML
|
||
if kroki_type in LOCAL_ONLY_TYPES:
|
||
if kroki_type == "idef0":
|
||
from .idef0 import Idef0ParseError, write_idef0_outputs
|
||
|
||
try:
|
||
if write_idef0_outputs(prepared, out_png, svg_target, scale=scale):
|
||
return _result(pscale=scale if scale > 1 else 1.0)
|
||
except Idef0ParseError as exc:
|
||
raise RuntimeError(str(exc)) from exc
|
||
raise RuntimeError("Не удалось отрендерить IDEF0-диаграмму")
|
||
|
||
if kroki_type == "dfd":
|
||
from .dfd import DfdParseError, write_dfd_outputs
|
||
|
||
try:
|
||
if write_dfd_outputs(prepared, out_png, svg_target, scale=scale):
|
||
return _result(pscale=scale if scale > 1 else 1.0)
|
||
except DfdParseError as exc:
|
||
raise RuntimeError(str(exc)) from exc
|
||
raise RuntimeError("Не удалось отрендерить DFD-диаграмму")
|
||
|
||
raise RuntimeError(f"Неизвестный локальный тип диаграммы: {kroki_type}")
|
||
|
||
# 1b) Mermaid local: system browser → Playwright Chromium → QuickJS
|
||
if kroki_type == "mermaid":
|
||
try:
|
||
from .mermaid_renderer import write_mermaid_outputs
|
||
|
||
if write_mermaid_outputs(
|
||
prepared, out_png, svg_target, scale=scale,
|
||
):
|
||
return _result(pscale=scale if scale > 1 else 1.0)
|
||
except Exception as exc:
|
||
_log.warning("Mermaid local render failed: %s", exc)
|
||
|
||
# 2) Local Kroki
|
||
if _render_kroki_pair(prepared, kroki_type, local_kroki, kroki_png, kroki_svg_target):
|
||
if kroki_type == "mermaid":
|
||
return _result(kroki_png, kroki_svg_path, pscale=1.0)
|
||
return _result(kroki_png, kroki_svg_path)
|
||
|
||
def _png_fallback_or_raise(message: str) -> DiagramRenderResult:
|
||
for candidate in (out_png, kroki_png):
|
||
if candidate.is_file() and candidate.stat().st_size > 0:
|
||
_log.warning("Диаграмма: SVG недоступен, вставляю только PNG")
|
||
pscale = pixel_scale
|
||
if (
|
||
kroki_type == "mermaid"
|
||
and candidate == out_png
|
||
and scale > 1
|
||
and local_prefix != cache_prefix
|
||
):
|
||
pscale = scale
|
||
elif kroki_type == "mermaid" and candidate == kroki_png:
|
||
pscale = 1.0
|
||
return DiagramRenderResult(
|
||
png_path=str(candidate), svg_path=None, pixel_scale=pscale,
|
||
)
|
||
raise RuntimeError(message)
|
||
|
||
if mode == "off":
|
||
if kroki_type == "plantuml":
|
||
return _png_fallback_or_raise(
|
||
"Не удалось отрендерить диаграмму локально "
|
||
"(задайте --plantuml-jar или KROKI_URL; remote fallback отключён)"
|
||
)
|
||
return _png_fallback_or_raise(
|
||
"Не удалось отрендерить диаграмму локально "
|
||
"(браузер / QuickJS / --kroki-url; remote fallback отключён)"
|
||
)
|
||
if mode == "local":
|
||
if kroki_type == "plantuml":
|
||
return _png_fallback_or_raise(
|
||
"Локальный рендер диаграммы недоступен "
|
||
"(Java+plantuml.jar или локальный Kroki)"
|
||
)
|
||
return _png_fallback_or_raise(
|
||
"Локальный рендер Mermaid недоступен "
|
||
f"(браузер / Chromium / QuickJS / Kroki; тип {kroki_type})"
|
||
)
|
||
|
||
# 3) Remote fallback
|
||
_log.warning(
|
||
"Диаграмма: локальный рендер недоступен, использую remote %s",
|
||
REMOTE_KROKI_URL,
|
||
)
|
||
if _render_kroki_pair(prepared, kroki_type, REMOTE_KROKI_URL, kroki_png, kroki_svg_target):
|
||
if kroki_type == "mermaid":
|
||
return _result(kroki_png, kroki_svg_path, pscale=1.0)
|
||
return _result(kroki_png, kroki_svg_path)
|
||
|
||
return _png_fallback_or_raise(
|
||
"Не удалось отрендерить диаграмму (PlantUML/Mermaid/Kroki)"
|
||
)
|
||
|
||
|
||
# Re-export for callers / tests
|
||
__all__ = [
|
||
"DIAGRAM_LANGS",
|
||
"DiagramRenderResult",
|
||
"configure_diagrams",
|
||
"diagram_engine_status",
|
||
"diagrams_dir",
|
||
"fetch_plantuml_jar",
|
||
"is_diagram_lang",
|
||
"prepare_source",
|
||
"register_pending_diagram",
|
||
"render_diagram",
|
||
"reset_diagram_batch",
|
||
"resolve_plantuml_jar",
|
||
"shutdown_diagrams",
|
||
"PLANTUML_CACHE_PREFIX",
|
||
"PLANTUML_JAR_URL",
|
||
"PLANTUML_VERSION",
|
||
]
|