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
This commit is contained in:
Mavis
2026-06-13 15:32:54 +03:00
commit 2394eff1c0
21 changed files with 5116 additions and 0 deletions
+228
View File
@@ -0,0 +1,228 @@
"""Валидация SVG-ответов модели.
Делает две вещи:
1) `extract_svg()` — вытаскивает <svg>...</svg> из произвольного текста
(модель иногда отвечает с пояснениями или code-fence).
2) `validate(svg_text, mode=...)` — строгая проверка по правилам дизайна.
3) `validate_svg(svg_text) -> (ok, reason, cleaned_svg)` — обёртка для UI,
описанная в задаче: всегда возвращает кортеж, не бросает исключений.
Правила (соответствуют §5 design.md и §6 spec.md):
- well-formed XML (lxml);
- ровно один корневой <svg>;
- наличие `viewBox` (4 числа);
- теги — только из ALLOWED_TAGS;
- <script>, <foreignObject>, <image> — запрещены;
- http://, https:// — запрещены в href/xlink:href и в `url(...)`;
- on* event-handler атрибуты — запрещены (доп. защита по задаче);
- размер файла <= лимита mode (32/256 KB).
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from lxml import etree
log = logging.getLogger(__name__)
SVG_NS = "http://www.w3.org/2000/svg"
ALLOWED_TAGS: set[str] = {
"svg",
"g",
"defs",
"symbol",
"use",
"path",
"rect",
"circle",
"ellipse",
"line",
"polygon",
"polyline",
"linearGradient",
"radialGradient",
"stop",
"text",
"tspan",
"textPath",
"title",
"desc",
"filter",
"feGaussianBlur",
"feOffset",
"feMerge",
"feMergeNode",
"feColorMatrix",
"feBlend",
"feFlood",
"feComposite",
"clipPath",
"mask",
"pattern",
}
DISALLOWED_TAGS: set[str] = {"script", "foreignObject", "image", "iframe", "style"}
MAX_BYTES: dict[str, int] = {
"icon": 32 * 1024,
"illustration": 256 * 1024,
}
class ValidatorError(Exception):
"""Ошибка валидации с машино-читаемым кодом и человеческим пояснением."""
def __init__(self, code: str, detail: str = ""):
super().__init__(f"{code}: {detail}" if detail else code)
self.code = code
self.detail = detail
_SVG_RE = re.compile(
r"<svg\b[^>]*>.*?</svg\s*>",
re.IGNORECASE | re.DOTALL,
)
def extract_svg(text: str) -> str:
"""Достаёт первое вхождение <svg>...</svg> из произвольного ответа модели."""
if not text or "<svg" not in text.lower():
raise ValidatorError("not_svg", "в ответе нет <svg>...</svg>")
match = _SVG_RE.search(text)
if not match:
raise ValidatorError("not_svg", "не удалось выделить <svg>...</svg> regex'ом")
return match.group(0).strip()
def _local(tag: object) -> str:
"""Снимает namespace: {ns}local -> local. Терпимо к non-str (cyfunction)."""
s = str(tag)
if "}" in s:
return s.split("}", 1)[1]
return s
def _iter_real_elements(root: etree._Element):
"""Итератор только по Element-узлам (пропускает комменты и PI)."""
for el in root.iter():
# lxml._Element и etree._Element имеют tag как str; cyfunction — это
# callable у комментариев/PI. Отфильтруем через isinstance.
if isinstance(getattr(el, "tag", None), str):
yield el
def _check_viewbox(root: etree._Element) -> None:
vb = root.get("viewBox")
if not vb:
raise ValidatorError("missing_viewbox", "атрибут viewBox отсутствует")
try:
parts = [float(x) for x in vb.split()]
except ValueError as exc:
raise ValidatorError("bad_viewbox", f"viewBox='{vb}'") from exc
if len(parts) != 4:
raise ValidatorError("bad_viewbox", f"viewBox='{vb}' (нужно 4 числа)")
def _check_tags_and_attrs(root: etree._Element) -> None:
for el in _iter_real_elements(root):
local = _local(el.tag)
if local in DISALLOWED_TAGS:
raise ValidatorError("disallowed_tag", local)
if local not in ALLOWED_TAGS:
raise ValidatorError("unknown_tag", local)
for attr, val in el.attrib.items():
if not val:
continue
attr_local = _local(attr).lower()
if attr_local.startswith("on"):
raise ValidatorError("event_handler", f"{attr_local}={val[:80]}")
if "http://" in val or "https://" in val:
if attr_local.endswith("href") or "url(" in val.lower():
raise ValidatorError("external_ref", f"{attr_local}={val[:80]}")
def _ensure_size(svg_text: str, mode: str) -> None:
limit = MAX_BYTES.get(mode)
if limit is None:
return
if len(svg_text.encode("utf-8")) > limit:
raise ValidatorError(
"too_large",
f"{len(svg_text.encode('utf-8'))} байт > {limit} байт (mode={mode})",
)
def _serialize_clean(root: etree._Element) -> str:
if not root.get("xmlns"):
new_root = etree.Element(_local(root.tag), nsmap={None: SVG_NS})
for k, v in root.attrib.items():
new_root.set(_local(k), v)
for child in root:
new_root.append(child)
root = new_root
xml_bytes = etree.tostring(
root, pretty_print=False, xml_declaration=False, encoding="utf-8"
)
return xml_bytes.decode("utf-8")
def validate(svg_text: str, *, mode: str = "icon") -> etree._Element:
"""Строгая валидация по правилам дизайна. Бросает ValidatorError."""
svg_text = extract_svg(svg_text)
try:
root = etree.fromstring(svg_text.encode("utf-8"))
except etree.XMLSyntaxError as exc:
raise ValidatorError("malformed_xml", str(exc)) from exc
if _local(root.tag) != "svg":
raise ValidatorError("not_svg", root.tag)
_check_viewbox(root)
_check_tags_and_attrs(root)
_ensure_size(svg_text, mode)
return root
@dataclass(frozen=True)
class ValidationResult:
ok: bool
reason: str
cleaned_svg: str
def validate_svg(svg_text: str, *, mode: str = "icon") -> tuple[bool, str, str]:
"""Удобная обёртка над `validate()`: всегда возвращает кортеж.
Returns:
Кортеж `(ok, reason, cleaned_svg)`.
"""
if not svg_text or not svg_text.strip():
return False, "empty_input", ""
try:
root = validate(svg_text, mode=mode)
except ValidatorError as exc:
log.info("validate_svg: reject (%s)", exc.code)
return False, exc.code, ""
except Exception as exc: # noqa: BLE001
log.exception("validate_svg: неожиданная ошибка")
return False, f"internal:{type(exc).__name__}", ""
return True, "", _serialize_clean(root)
__all__ = [
"ALLOWED_TAGS",
"DISALLOWED_TAGS",
"MAX_BYTES",
"SVG_NS",
"ValidationResult",
"ValidatorError",
"extract_svg",
"validate",
"validate_svg",
]