v0.5.3
Example document / build (push) Has been cancelled
Python application / build (push) Has been cancelled

- Мелкие исправления увеличивающие работу конвертера
This commit is contained in:
Igor20264
2026-09-10 10:50:15 +03:00
parent 510f7e7adf
commit e6b58efeb3
95 changed files with 7165 additions and 8771 deletions
+208 -30
View File
@@ -60,6 +60,11 @@ 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__)
@@ -99,6 +104,7 @@ def configure_diagrams(
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
@@ -298,17 +304,45 @@ def _is_svg(data: bytes) -> bool:
return head.startswith(b"<svg") or head.startswith(b"<?xml") or b"<svg" in head
def _render_plantuml_jar_one(
source: str,
out_path: Path,
@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"],
) -> bool:
if not jar or not os.path.isfile(jar) or not _java_available():
return False
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "diagram.puml"
src.write_text(source, encoding="utf-8")
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()}",
@@ -316,27 +350,63 @@ def _render_plantuml_jar_one(
"-jar", jar,
f"-t{fmt}",
"-charset", "UTF-8",
"-o", tmp,
str(src),
# 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=120, check=False,
cmd, capture_output=True, text=True, timeout=timeout, check=False,
)
except (OSError, subprocess.TimeoutExpired) as e:
_log.warning("PlantUML jar failed: %s", e)
return False
produced = Path(tmp) / f"diagram.{fmt}"
if proc.returncode != 0 or not produced.is_file():
return
if proc.returncode != 0:
_log.warning("PlantUML jar error: %s", proc.stderr or proc.stdout)
return False
data = produced.read_bytes()
if fmt == "png" and not _is_png(data):
return False
if fmt == "svg" and not _is_svg(data):
return False
shutil.copyfile(produced, out_path)
return True
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(
@@ -355,6 +425,100 @@ def _render_plantuml_jar(
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,
@@ -414,22 +578,32 @@ def render_diagram(
diagram_scale: float | None = None,
) -> DiagramRenderResult:
"""Render diagram to PNG (+ optional SVG). Raises RuntimeError if all backends fail."""
ensure_user_schemes()
# 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" and scale > 1:
before = prepared
prepared = _ensure_plantuml_png_scale(prepared, scale)
if prepared != before:
pixel_scale = scale
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 == "mermaid":
if kroki_type == "plantuml":
cache_prefix = PLANTUML_CACHE_PREFIX
elif kroki_type == "mermaid":
try:
from .mermaid_renderer import CACHE_KEY_PREFIX
@@ -633,8 +807,12 @@ __all__ = [
"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",
]