diff --git a/incremental_svg.py b/incremental_svg.py new file mode 100644 index 0000000..d19a8fc --- /dev/null +++ b/incremental_svg.py @@ -0,0 +1,216 @@ +"""Инкрементальный SVG-парсер. + +Превращает произвольный prefix потока SVG (включая невалидный / оборванный +посреди тега или атрибута) в валидный SVG, который можно отрендерить сразу. + +Сценарий: LM Studio стримит SVG по токенам; мы хотим показывать превью +по мере поступления. В буфере в любой момент времени может быть: + - незакрытый открывающий тег: `` (остальное — мусор/повторный SVG). + 3. Делегирует lxml'у парсинг в режиме `recover=True`: lxml сам закрывает + оборванные теги, открытые кавычки и атрибуты без значения. + 4. Добавляет `xmlns="http://www.w3.org/2000/svg"` в корневой ``, + если его нет. + 5. Гарантирует, что результат парсится `lxml.etree.fromstring()` без ошибок. + +Использование: + + from incremental_svg import parse_to_valid, stream_to_valid + + # Один снапшот + svg = parse_to_valid(raw_prefix) + + # Стрим: получаем валидный SVG после каждого нового чанка + for snapshot in stream_to_valid(token_chunks): + render_to_png(snapshot) +""" +from __future__ import annotations + +import re +from typing import Iterable, Iterator + +from lxml import etree + + +# --------------------------------------------------------------------------- +# Константы +# --------------------------------------------------------------------------- + +SVG_NS = "http://www.w3.org/2000/svg" + +# Дефолтный пустой SVG (xmlns обязателен — без него некоторые рендереры падают). +DEFAULT_EMPTY_SVG = f'' + +# Находим первое вхождение ` str: + """Превращает произвольный prefix в валидный SVG. + + Args: + prefix: сырая строка (возможно, неполная, оборванная, с рассуждениями + или markdown-обёрткой). + ensure_root: если True (default), результат всегда начинается с + `` и заканчивается ``. Если False — может вернуть + фрагмент (для специальных случаев; в т.ч. пустую строку, если + в prefix нет ``). + + Returns: + Валидный SVG, который парсится через `lxml.etree.fromstring` без + исключений. Внутри используется `lxml` для парсинга и `re` для + препроцессинга. + """ + # Защитное приведение типов: None → "", нестроковые → str(). + if prefix is None: + prefix = "" + if not isinstance(prefix, str): + prefix = str(prefix) + + # 1. Найти первое `` (схлопывает trailing garbage, повторные + # SVG-блоки, невалидный хвост после валидного SVG). + first_close = text.find("= 0: + text = text[: first_close + len("")] + + # 5. Попробовать распарсить в режиме recover. lxml сам залечит: + # - необструганный ``); + # - открытую кавычку (``); + # - атрибут `attr=` без значения (отбросит или сделает `attr=""`); + # - вложенные необструганные теги (закроет все уровни). + root = _safe_parse(text) + + if root is None: + # Совсем сломанный ввод (recover не справился). Возвращаем дефолт. + return DEFAULT_EMPTY_SVG if ensure_root else "" + + if not ensure_root: + return etree.tostring(root, encoding="unicode") + + # 6. Добавить xmlns, если отсутствует. + root = _ensure_xmlns(root) + + # 7. Убедиться, что root — ``. Если нет (recover иногда берёт первым + # ребёнком при сложных преамбулах) — заворачиваем. + if _local(root.tag) != "svg": + root = _wrap_in_svg(root) + root = _ensure_xmlns(root) + + return etree.tostring(root, encoding="unicode") + + +def stream_to_valid(chunks: Iterable[str]) -> Iterator[str]: + """Принимает Iterable стримовых чанков, возвращает Iterator валидных + SVG-snapshots после каждого нового чанка. + + Каждый snapshot — валидный SVG, пригодный для рендера. + + Example: + >>> list(stream_to_valid(["", ""])) + ['', + '', + '', + ''] + """ + buffer = "" + for chunk in chunks: + buffer += chunk + yield parse_to_valid(buffer) + + +# --------------------------------------------------------------------------- +# Внутренние хелперы +# --------------------------------------------------------------------------- + + +def _strip_prolog(text: str) -> str: + """Удаляет XML-декларацию `` и `` (если они есть).""" + text = re.sub(r"<\?xml[^>]*\?>", "", text) + text = re.sub(r"]*>", "", text) + return text + + +def _safe_parse(text: str) -> etree._Element | None: + """Парсит text в режиме recover. Возвращает root или None при полном фейле. + + recover=True сам закрывает необструганные теги/кавычки/атрибуты. На совсем + битом вводе (например, просто `<`) может вернуть None или выбросить — мы + ловим оба случая. + """ + try: + return etree.fromstring(text.encode("utf-8"), parser=_RECOVER_PARSER) + except etree.XMLSyntaxError: + return None + except Exception: # noqa: BLE001 + return None + + +def _ensure_xmlns(root: etree._Element) -> etree._Element: + """Гарантирует, что у root есть `xmlns="http://www.w3.org/2000/svg"`. + + Если xmlns есть — возвращает root как есть. Иначе создаёт новый root с + тем же тегом, атрибутами и детьми, но с добавленным xmlns. + """ + if root.get("xmlns"): + return root + 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) + return new_root + + +def _wrap_in_svg(root: etree._Element) -> etree._Element: + """Заворачивает произвольный root в `...`.""" + new_root = etree.Element("svg", 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) + return new_root + + +def _local(tag: object) -> str: + """Снимает namespace-префикс: `{ns}local` → `local`. Терпимо к non-str.""" + s = str(tag) + if "}" in s: + return s.split("}", 1)[1] + return s + + +__all__ = [ + "parse_to_valid", + "stream_to_valid", + "SVG_NS", + "DEFAULT_EMPTY_SVG", +] diff --git a/tests/test_app.py b/tests/test_app.py index ea1863f..8a03d16 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -72,6 +72,8 @@ def _call_on_generate(**overrides: Any) -> Any: image=None, palette="", model="test-model", + base_url="http://127.0.0.1:1234/v1", + api_key="lm-studio", ) defaults.update(overrides) # Патчим chat() так, чтобы on_generate не уходил в сеть и не упал уже @@ -140,6 +142,8 @@ def test_on_generate_does_not_call_chat_on_precheck_fail(): image=None, palette="", model="x", + base_url="http://127.0.0.1:1234/v1", + api_key="lm-studio", ) assert mock_chat.call_count == 0, ( "chat() был вызван, хотя pre-check должен был остановить поток" @@ -158,6 +162,8 @@ def test_on_generate_does_not_call_chat_on_bad_mode(): image=None, palette="", model="x", + base_url="http://127.0.0.1:1234/v1", + api_key="lm-studio", ) assert mock_chat.call_count == 0 @@ -190,6 +196,8 @@ def test_on_generate_with_valid_input_does_not_typeerror(): image=None, palette="", model="test", + base_url="http://127.0.0.1:1234/v1", + api_key="lm-studio", ) except TypeError as exc: pytest.fail(f"on_generate упал с TypeError на валидном входе: {exc}") @@ -230,11 +238,11 @@ def test_on_mode_change_returns_icon_default_n(): update = on_mode_change("icon") # gr.update — это dict-like объект, у него есть .value - assert update["value"] == 4 + assert update["value"] == 1 def test_on_mode_change_returns_illustration_default_n(): from app import on_mode_change update = on_mode_change("illustration") - assert update["value"] == 2 + assert update["value"] == 1 diff --git a/tests/test_incremental_svg.py b/tests/test_incremental_svg.py new file mode 100644 index 0000000..801d8de --- /dev/null +++ b/tests/test_incremental_svg.py @@ -0,0 +1,504 @@ +"""Юнит-тесты для incremental_svg.py — incremental SVG parser. + +Покрывают контракт `parse_to_valid(prefix)`: +- на ЛЮБОМ prefix'е возвращает валидный SVG, который парсится lxml без ошибок; +- закрывает открытые теги, атрибуты, кавычки; +- обрывает частичные теги (внутри path-d); +- вырезает reasoning / markdown fence / хвостовой мусор. + +Запуск: + python -m pytest tests/test_incremental_svg.py -v +""" +from __future__ import annotations + +import random +import re +import string +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import pytest # noqa: E402 +from lxml import etree # noqa: E402 + +from incremental_svg import parse_to_valid, stream_to_valid # noqa: E402 + + +# --------------------------------------------------------------------------- +# Базовый полный валидный SVG, от которого будем брать префиксы +# --------------------------------------------------------------------------- + +VALID_SVG = ( + '' + '' + '' + "" +) + +# SVG со вложенными открытыми тегами (для теста вложенности) +NESTED_SVG = ( + '' + "" + '' + "" + "" +) + +# Простая утилита — фиксирует, что результат парсится +def _assert_valid_svg(svg_text: str) -> etree._Element: + """Парсит svg_text, возвращает root. Падает в pytest, если XML невалидный.""" + try: + root = etree.fromstring(svg_text.encode("utf-8")) + except etree.XMLSyntaxError as exc: + pytest.fail(f"parse_to_valid вернул невалидный XML: {exc}\n{svg_text!r}") + return root + + +def _has_xmlns_in_root(svg_text: str) -> bool: + """Проверяет, что xmlns либо в , либо подразумевается через сериализацию.""" + head_end = svg_text.find(">") + if head_end < 0: + return False + return "xmlns" in svg_text[:head_end] + + +def _local_tag(tag) -> str: + """Снимает namespace: `{ns}local` → `local`.""" + s = str(tag) + if "}" in s: + return s.split("}", 1)[1] + return s + + +# --------------------------------------------------------------------------- +# 1. Базовые позитивные кейсы +# --------------------------------------------------------------------------- + + +def test_empty_string_returns_valid_empty_svg(): + """Пустая строка → валидный пустой SVG (с корневым ).""" + result = parse_to_valid("") + root = _assert_valid_svg(result) + assert root.tag.endswith("svg"), f"root должен быть svg, получен {root.tag}" + assert len(root) == 0 # пустой + + +def test_whitespace_only_returns_valid_empty_svg(): + """Строка из пробелов/переносов → валидный пустой SVG.""" + result = parse_to_valid(" \n \t ") + root = _assert_valid_svg(result) + assert root.tag.endswith("svg") + + +def test_complete_valid_svg_returned_intact(): + """Полный валидный SVG → возвращается без изменений (и остаётся валидным).""" + result = parse_to_valid(VALID_SVG) + root = _assert_valid_svg(result) + # viewBox сохранился + assert root.get("viewBox") == "0 0 64 64" + # дети на месте + assert len(root) == 2 + + +def test_complete_valid_svg_with_nested_open_tags(): + """Полный валидный SVG с ... внутри → возвращается как есть.""" + result = parse_to_valid(NESTED_SVG) + root = _assert_valid_svg(result) + assert root.get("viewBox") == "0 0 64 64" + # root → g → circle + g = list(root) + assert len(g) == 1 + assert g[0].tag.endswith("g") + assert len(list(g[0])) == 1 + + +# --------------------------------------------------------------------------- +# 2. Обрезанные открывающие теги +# --------------------------------------------------------------------------- + + +def test_svg_tag_without_closing_bracket(): + """Только ``) → валидный пустой SVG.""" + result = parse_to_valid('` → валидный SVG.""" + result = parse_to_valid("`) → валидный SVG.""" + result = parse_to_valid('`) → закрывает и атрибут, и тег.""" + prefix = '` → не закрывается повторно.""" + prefix = '' + result = parse_to_valid(prefix) + root = _assert_valid_svg(result) + paths = [e for e in root.iter() if e.tag.endswith("path")] + assert len(paths) == 1 + + +def test_nested_open_tags_both_closed(): + """Вложенные `` (если открыт) → оба закрыты.""" + prefix = ', внутри — + gs = [e for e in root.iter() if _local_tag(e.tag) == "g"] + assert len(gs) >= 1 + circles_in_g = [c for g in gs for c in g.iter() if _local_tag(c.tag) == "circle"] + assert len(circles_in_g) == 1 + + +def test_multiple_open_tags_all_closed(): + """Несколько вложенных открытых тегов — все закрываются.""" + prefix = '` подряд — берём только первый валидный.""" + second = ( + '' + '' + "" + ) + prefix = VALID_SVG + "\n" + second + result = parse_to_valid(prefix) + root = _assert_valid_svg(result) + # Должен быть первый (viewBox 0 0 64 64) + assert root.get("viewBox") == "0 0 64 64" + + +def test_svg_without_xmlns_gets_xmlns_added(): + """Если в `` нет xmlns, добавляется.""" + prefix = '' + result = parse_to_valid(prefix) + # xmlns должен появиться в head + assert _has_xmlns_in_root(result), f"xmlns не добавлен: {result!r}" + root = _assert_valid_svg(result) + assert root.get("viewBox") == "0 0 64 64" + + +def test_svg_with_existing_xmlns_preserved(): + """Если xmlns уже есть, не дублируется.""" + result = parse_to_valid(VALID_SVG) + head = result.split(">", 1)[0] + # ровно одно вхождение xmlns= + assert head.count("xmlns=") == 1 + assert 'xmlns="http://www.w3.org/2000/svg"' in head + + +def test_unclosed_svg_at_end(): + """``) → добавляется закрывающий.""" + prefix = '"), f"нет закрывающего : {result!r}" + root = _assert_valid_svg(result) + assert root.tag.endswith("svg") + + +# --------------------------------------------------------------------------- +# 7. Property-based: 100 рандомных префиксов +# --------------------------------------------------------------------------- + + +def test_property_based_random_prefixes(): + """100 случайных префиксов валидного SVG → все парсятся lxml. + + Берём случайный срез `VALID_SVG` (от 0 символов до всей длины) и для каждого + вызываем `parse_to_valid`. Результат должен быть валидным XML. + """ + rng = random.Random(42) # детерминированный seed для воспроизводимости + failures: list[tuple[str, str, str]] = [] + + for _ in range(100): + cut = rng.randint(0, len(VALID_SVG)) + prefix = VALID_SVG[:cut] + # иногда добавляем "хвост" (мусор после валидной части) + if rng.random() < 0.3: + tail = "".join(rng.choices(string.ascii_letters + " \n<>", k=rng.randint(1, 20))) + prefix = prefix + tail + # иногда добавляем reasoning перед svg + if rng.random() < 0.2: + prefix = "Thinking Process: blah blah\n" + prefix + try: + result = parse_to_valid(prefix) + except Exception as exc: # noqa: BLE001 + failures.append((prefix, f"exception: {exc}", "")) + continue + try: + etree.fromstring(result.encode("utf-8")) + except etree.XMLSyntaxError as exc: + failures.append((prefix, str(exc), result)) + + if failures: + msg = "\n".join( + f" prefix={p!r}\n err={err}\n out={out[:200]!r}" + for p, err, out in failures[:5] + ) + pytest.fail( + f"{len(failures)} из 100 рандомных префиксов дали невалидный XML:\n{msg}" + ) + + +# --------------------------------------------------------------------------- +# 8. stream_to_valid (опциональный хелпер) +# --------------------------------------------------------------------------- + + +def test_stream_to_valid_yields_snapshots(): + """stream_to_valid принимает Iterable стримовых чанков и возвращает Iterator + валидных SVG-snapshots.""" + chunks = [ + "", + '", + "", + ] + snapshots = list(stream_to_valid(chunks)) + # хотя бы для каждого чанка должен быть валидный snapshot + assert len(snapshots) == len(chunks) + for snap in snapshots: + # Каждый snapshot — валидный SVG + root = etree.fromstring(snap.encode("utf-8")) + assert root.tag.endswith("svg") + + +def test_stream_to_valid_last_chunk_is_complete(): + """После последнего полного чанка stream_to_valid должен вернуть полный SVG.""" + chunks = ["", ""] + snapshots = list(stream_to_valid(chunks)) + final = snapshots[-1] + root = etree.fromstring(final.encode("utf-8")) + assert root.get("viewBox") == "0 0 64 64" + + +# --------------------------------------------------------------------------- +# 9. Невалидные / edge-case входы +# --------------------------------------------------------------------------- + + +def test_input_is_none_treated_as_empty(): + """`None` обрабатывается как пустая строка → валидный пустой SVG.""" + # parse_to_valid явно требует str, но проверим, что если кто-то передал None + # (защитное программирование), мы не падаем. + try: + result = parse_to_valid(None) # type: ignore[arg-type] + except (TypeError, AttributeError): + # если тип строго str — TypeError допустим + pytest.skip("parse_to_valid строго требует str (None → TypeError)") + root = _assert_valid_svg(result) + assert root.tag.endswith("svg") + + +def test_ensure_root_false_returns_fragment(): + """`ensure_root=False` → результат может быть фрагментом (без обёртки в ). + + Если в prefix уже есть валидный ..., ensure_root=False + должен вернуть его (или его фрагмент) без добавления лишнего root. + """ + result = parse_to_valid(VALID_SVG, ensure_root=False) + root = _assert_valid_svg(result) + # Должен быть валидным + assert root.tag.endswith("svg") + + +def test_unicode_in_svg_text(): + """Unicode в комментариях и тексте — не ломает.""" + prefix = ( + '' + '' + 'Привет, мир!' + "" + ) + result = parse_to_valid(prefix) + root = _assert_valid_svg(result) + assert root.get("viewBox") == "0 0 64 64" + + +def test_svg_with_doctype_stripped(): + """Если в prefix есть DOCTYPE-преамбула, она отбрасывается/чистится.""" + prefix = ( + '' + '' + + VALID_SVG + ) + result = parse_to_valid(prefix) + root = _assert_valid_svg(result) + assert root.get("viewBox") == "0 0 64 64" + + +def test_path_d_with_numbers_only_no_letter(): + """`' + "" + "" + '' + ', , , — всё отсутствует + ) + result = parse_to_valid(prefix) + root = _assert_valid_svg(result) + assert root.tag.endswith("svg") diff --git a/tests/test_renderer.py b/tests/test_renderer.py index dff226d..6e082ed 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -121,16 +121,24 @@ def test_render_png_invalid_svg_returns_none_or_raises_cairo(): assert result is None # либо cairo нет, либо cairo бросил и мы вернули None -def test_render_png_does_not_propagate_cairo_exceptions(): - """Даже если cairosvg бросает — render_png() возвращает None, а не raise.""" - fake_cairosvg = type("Fake", (), {})() - # Создаём фейк-модуль, у которого svg2png бросает +def test_render_png_does_not_propagate_renderer_exceptions(): + """Даже если все бэкенды бросают — render_png() возвращает None, а не raise. + + Сейчас renderer использует resvg-py как первый бэкенд, cairosvg — fallback. + Мокаем ОБА, чтобы render_png дошёл до конца и вернул None без exception. + """ + class FakeRenderer: + @staticmethod + def svg_to_bytes(**kwargs): + raise RuntimeError("simulated resvg failure") + class FakeCairo: @staticmethod def svg2png(**kwargs): raise RuntimeError("simulated cairo failure") - fake = FakeCairo() - with patch("renderer._get_cairosvg", return_value=fake): + + with patch("renderer._get_resvg", return_value=FakeRenderer()), \ + patch("renderer._get_cairosvg", return_value=FakeCairo()): result = render_png(VALID_ICON_SVG, size=(64, 64)) assert result is None