Report
This commit is contained in:
182
Report/render_uml_png.py
Normal file
182
Report/render_uml_png.py
Normal file
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Render PlantUML diagrams (.puml) to high-resolution PNG files via Kroki.
|
||||
|
||||
Why this script:
|
||||
- local `plantuml` may require X11 in some environments;
|
||||
- this script uses Kroki HTTP API and can raise output quality via `skinparam dpi`.
|
||||
|
||||
Examples:
|
||||
python3 Report/render_uml_png.py
|
||||
python3 Report/render_uml_png.py --dpi 360 --glob "*.puml"
|
||||
python3 Report/render_uml_png.py --input-dir Report/uml --timeout 120
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Render .puml diagrams to PNG with configurable DPI."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input-dir",
|
||||
default="Report/uml",
|
||||
help="Directory with .puml files (default: Report/uml)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--glob",
|
||||
default="*.puml",
|
||||
help="Glob pattern inside input-dir (default: *.puml)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dpi",
|
||||
type=int,
|
||||
default=300,
|
||||
help="Output DPI via PlantUML skinparam (default: 300)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=int,
|
||||
default=120,
|
||||
help="HTTP timeout in seconds (default: 120)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--kroki-url",
|
||||
default="https://kroki.io/plantuml/png",
|
||||
help="Kroki PlantUML PNG endpoint",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-dpi",
|
||||
type=int,
|
||||
default=96,
|
||||
help="Lower bound for auto DPI fallback (default: 96)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-dim",
|
||||
type=int,
|
||||
default=3900,
|
||||
help=(
|
||||
"Target max PNG dimension during auto-fit. "
|
||||
"Keep below backend hard limits (default: 3900)"
|
||||
),
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def iter_puml_files(input_dir: Path, pattern: str) -> Iterable[Path]:
|
||||
return sorted(p for p in input_dir.glob(pattern) if p.is_file())
|
||||
|
||||
|
||||
def inject_or_replace_skinparam(plantuml_text: str, key: str, value: int) -> str:
|
||||
"""
|
||||
Ensure diagram has the requested `skinparam <key>`.
|
||||
- If skinparam exists, replace it.
|
||||
- Otherwise insert after @startuml line.
|
||||
"""
|
||||
line_value = f"skinparam {key} {value}"
|
||||
pattern = rf"(?im)^\s*skinparam\s+{re.escape(key)}\s+\d+\s*$"
|
||||
|
||||
if re.search(pattern, plantuml_text):
|
||||
return re.sub(pattern, line_value, plantuml_text)
|
||||
|
||||
lines = plantuml_text.splitlines()
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip().lower().startswith("@startuml"):
|
||||
lines.insert(i + 1, line_value)
|
||||
return "\n".join(lines) + ("\n" if plantuml_text.endswith("\n") else "")
|
||||
|
||||
# Fallback: if @startuml is missing, prepend anyway.
|
||||
return line_value + "\n" + plantuml_text
|
||||
|
||||
|
||||
def render_one(
|
||||
puml_file: Path,
|
||||
kroki_url: str,
|
||||
dpi: int,
|
||||
min_dpi: int,
|
||||
max_dim: int,
|
||||
timeout: int,
|
||||
) -> Path:
|
||||
source = puml_file.read_text(encoding="utf-8")
|
||||
source = inject_or_replace_skinparam(source, "padding", 24)
|
||||
|
||||
current_dpi = dpi
|
||||
png_bytes: bytes | None = None
|
||||
|
||||
while True:
|
||||
payload = inject_or_replace_skinparam(source, "dpi", current_dpi)
|
||||
response = requests.post(
|
||||
kroki_url,
|
||||
data=payload.encode("utf-8"),
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
candidate = response.content
|
||||
|
||||
with Image.open(io.BytesIO(candidate)) as im:
|
||||
width, height = im.size
|
||||
|
||||
# Kroki/PlantUML PNG responses may hit hard canvas limits.
|
||||
# If we are close to that ceiling, lower DPI and retry.
|
||||
if (width > max_dim or height > max_dim) and current_dpi > min_dpi:
|
||||
next_dpi = max(min_dpi, int(current_dpi * 0.82))
|
||||
if next_dpi == current_dpi:
|
||||
next_dpi = current_dpi - 1
|
||||
current_dpi = max(min_dpi, next_dpi)
|
||||
continue
|
||||
|
||||
png_bytes = candidate
|
||||
break
|
||||
|
||||
if png_bytes is None:
|
||||
raise RuntimeError(f"Failed to render diagram: {puml_file}")
|
||||
|
||||
out_file = puml_file.with_suffix(".png")
|
||||
out_file.write_bytes(png_bytes)
|
||||
return out_file
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
|
||||
input_dir = Path(args.input_dir).resolve()
|
||||
if not input_dir.exists():
|
||||
raise FileNotFoundError(f"Input directory not found: {input_dir}")
|
||||
|
||||
files = list(iter_puml_files(input_dir, args.glob))
|
||||
if not files:
|
||||
print(f"No files matched in {input_dir} with pattern {args.glob}")
|
||||
return 0
|
||||
|
||||
print(f"Rendering {len(files)} diagrams from: {input_dir}")
|
||||
print(f"DPI: {args.dpi}")
|
||||
|
||||
success = 0
|
||||
for puml in files:
|
||||
out = render_one(
|
||||
puml_file=puml,
|
||||
kroki_url=args.kroki_url,
|
||||
dpi=args.dpi,
|
||||
min_dpi=args.min_dpi,
|
||||
max_dim=args.max_dim,
|
||||
timeout=args.timeout,
|
||||
)
|
||||
print(f"created {out}")
|
||||
success += 1
|
||||
|
||||
print(f"Done: {success}/{len(files)} PNG files generated.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user