2394eff1c0
- LM Studio client (httpx-based, OpenAI-compatible) - SVG validator (lxml, whitelist tags, no <script>/<foreignObject>/http refs) - PNG renderer (resvg-py primary, cairosvg fallback - no native cairo dep) - History (SQLite, tracks raw/validated/preview paths) - Gradio UI on 127.0.0.1:8788 with: * mode radio (icon/illustration) * n_candidates slider (default 1) * image upload for image-to-SVG * LM Studio URL/token inputs * model dropdown + refresh button - prompts/ with system_icon.txt, system_illustration.txt, few_shot_examples.txt - docs/spec.md, docs/design.md - 122 unit/integration tests passing
128 lines
4.1 KiB
Python
128 lines
4.1 KiB
Python
"""PNG-рендер валидных SVG.
|
|
|
|
Бэкенды (по убыванию предпочтения):
|
|
1. resvg-py — self-contained Rust-движок, не требует нативных зависимостей.
|
|
Это дефолт на Windows, где cairo.dll часто отсутствует в глобальном Python.
|
|
2. cairosvg — fallback, если cairo.dll уже установлена (типично для conda).
|
|
|
|
Если ничего не работает, `render_png()` возвращает `None` и пишет WARNING.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Tuple
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
# Ленивая инициализация бэкендов.
|
|
_resvg_module = None
|
|
_resvg_error: str | None = None
|
|
_cairosvg_module = None
|
|
_cairosvg_error: str | None = None
|
|
|
|
|
|
def _get_resvg():
|
|
global _resvg_module, _resvg_error
|
|
if _resvg_module is not None:
|
|
return _resvg_module
|
|
if _resvg_error is not None:
|
|
return None
|
|
try:
|
|
import resvg_py # type: ignore
|
|
_resvg_module = resvg_py
|
|
log.info("renderer: using resvg-py")
|
|
return _resvg_module
|
|
except Exception as exc: # noqa: BLE001
|
|
_resvg_error = f"{type(exc).__name__}: {exc}"
|
|
log.warning("resvg-py недоступен: %s", _resvg_error)
|
|
return None
|
|
|
|
|
|
def _get_cairosvg():
|
|
global _cairosvg_module, _cairosvg_error
|
|
if _cairosvg_module is not None:
|
|
return _cairosvg_module
|
|
if _cairosvg_error is not None:
|
|
return None
|
|
try:
|
|
import cairosvg # type: ignore
|
|
_cairosvg_module = cairosvg
|
|
log.info("renderer: using cairosvg (fallback)")
|
|
return _cairosvg_module
|
|
except Exception as exc: # noqa: BLE001
|
|
_cairosvg_error = f"{type(exc).__name__}: {exc}"
|
|
log.warning("cairosvg недоступен: %s", _cairosvg_error)
|
|
return None
|
|
|
|
|
|
def _ensure_xmlns(svg_text: str) -> str:
|
|
"""Добавляет xmlns, если модель его не поставила (рендереры без него падают)."""
|
|
head_end = svg_text.find(">")
|
|
if head_end < 0:
|
|
return svg_text
|
|
head = svg_text[:head_end]
|
|
if "xmlns=" in head:
|
|
return svg_text
|
|
return svg_text.replace("<svg", '<svg xmlns="http://www.w3.org/2000/svg"', 1)
|
|
|
|
|
|
def render_png(svg_text: str, size: Tuple[int, int] = (512, 512)) -> bytes | None:
|
|
"""Рендерит SVG в PNG-байты. Возвращает None если ни один бэкенд не сработал.
|
|
|
|
Args:
|
|
svg_text: валидный SVG-текст.
|
|
size: целевой размер. resvg/cairopng сохраняют пропорции по viewBox.
|
|
|
|
Returns:
|
|
PNG-байты или None.
|
|
"""
|
|
if not svg_text or not svg_text.strip():
|
|
log.warning("render_png: пустой svg_text")
|
|
return None
|
|
|
|
output_width = size[0]
|
|
svg_str = _ensure_xmlns(svg_text)
|
|
|
|
# Бэкенд 1: resvg-py
|
|
resvg = _get_resvg()
|
|
if resvg is not None:
|
|
try:
|
|
return resvg.svg_to_bytes(svg_string=svg_str)
|
|
except Exception as exc: # noqa: BLE001
|
|
log.warning("resvg упал (%s); пробую cairosvg", exc)
|
|
|
|
# Бэкенд 2: cairosvg
|
|
cairosvg = _get_cairosvg()
|
|
if cairosvg is not None:
|
|
try:
|
|
return cairosvg.svg2png(
|
|
bytestring=svg_str.encode("utf-8"),
|
|
output_width=output_width,
|
|
background_color="white",
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
log.warning("cairosvg упал (%s); превью пропущено", exc)
|
|
|
|
log.warning("render_png: ни один бэкенд не сработал")
|
|
return None
|
|
|
|
|
|
def save_png(
|
|
png_bytes: bytes,
|
|
*,
|
|
previews_dir: str | Path,
|
|
record_id: int,
|
|
candidate_index: int,
|
|
) -> Path:
|
|
previews_dir = Path(previews_dir)
|
|
previews_dir.mkdir(parents=True, exist_ok=True)
|
|
path = previews_dir / f"{record_id}_{candidate_index}.png"
|
|
path.write_bytes(png_bytes)
|
|
return path
|
|
|
|
|
|
__all__ = ["render_png", "save_png"]
|