Files
omnisvg-lite/renderer.py
T
Mavis 2394eff1c0 Initial commit: OmniSVG-Lite MVP before live-streaming work
- 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
2026-06-13 15:32:54 +03:00

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"]