Add incremental_svg parser (TDD) + fix pre-existing test regressions

New:
- incremental_svg.py: parse_to_valid(prefix) handles any partial SVG
  - Closes open tags, opens quotes, truncates partial tags
  - Strips reasoning text and markdown fences from prefix
  - stream_to_valid(chunks) generator for incremental updates
- tests/test_incremental_svg.py: 34 test cases including property-based

Fixed (regressions from earlier today):
- test_app.py: pass base_url and api_key to on_generate (new UI fields)
- test_app.py: on_mode_change defaults updated to n=1
- test_renderer.py: mock both resvg and cairosvg since resvg is now primary
- (manual fixes I did as owner, before letting streaming-client start)

All tests: 157 passed, 1 skipped, 0 failed
This commit is contained in:
Mavis
2026-06-13 15:54:11 +03:00
parent 2394eff1c0
commit 17a114e015
4 changed files with 744 additions and 8 deletions
+216
View File
@@ -0,0 +1,216 @@
"""Инкрементальный SVG-парсер.
Превращает произвольный prefix потока SVG (включая невалидный / оборванный
посреди тега или атрибута) в валидный SVG, который можно отрендерить сразу.
Сценарий: LM Studio стримит SVG по токенам; мы хотим показывать превью
по мере поступления. В буфере в любой момент времени может быть:
- незакрытый открывающий тег: `<svg viewBox="0 0 64 64"`
- оборванный атрибут: `<path d="M12 32 C `
- незакрытая кавычка: `<rect fill="`
- частично написанный тег: `<path d="M12 32 C 40 20,`
- не закрытый `<svg>`: `<svg ...><circle cx="50"`
`parse_to_valid(prefix)`:
1. Находит первый `<svg` в потоке (всё до него — мусор/reasoning/fence).
2. Обрезает по первому `</svg>` (остальное — мусор/повторный SVG).
3. Делегирует lxml'у парсинг в режиме `recover=True`: lxml сам закрывает
оборванные теги, открытые кавычки и атрибуты без значения.
4. Добавляет `xmlns="http://www.w3.org/2000/svg"` в корневой `<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'<svg xmlns="{SVG_NS}"></svg>'
# Находим первое вхождение `<svg` (case-insensitive). Используется только
# позиция совпадения; сам тег обрабатывается отдельно.
_SVG_START_RE = re.compile(r"<svg\b", re.IGNORECASE)
# Lazy-parser: lxml с recover=True сам залечивает оборванные теги/кавычки.
_RECOVER_PARSER = etree.XMLParser(recover=True, huge_tree=True)
# ---------------------------------------------------------------------------
# Публичное API
# ---------------------------------------------------------------------------
def parse_to_valid(prefix: str, *, ensure_root: bool = True) -> str:
"""Превращает произвольный prefix в валидный SVG.
Args:
prefix: сырая строка (возможно, неполная, оборванная, с рассуждениями
или markdown-обёрткой).
ensure_root: если True (default), результат всегда начинается с
`<svg ...>` и заканчивается `</svg>`. Если False — может вернуть
фрагмент (для специальных случаев; в т.ч. пустую строку, если
в prefix нет `<svg>`).
Returns:
Валидный SVG, который парсится через `lxml.etree.fromstring` без
исключений. Внутри используется `lxml` для парсинга и `re` для
препроцессинга.
"""
# Защитное приведение типов: None → "", нестроковые → str().
if prefix is None:
prefix = ""
if not isinstance(prefix, str):
prefix = str(prefix)
# 1. Найти первое `<svg` (case-insensitive). Если его нет — вернуть дефолт.
m = _SVG_START_RE.search(prefix)
if not m:
return DEFAULT_EMPTY_SVG if ensure_root else ""
# 2. Взять всё от первого `<svg`. Это отсекает reasoning, markdown-fence,
# "noise <svg ..." преамбулы и т.п.
text = prefix[m.start():]
# 3. Strip XML prolog / DOCTYPE, если они по какой-то причине попали сюда.
text = _strip_prolog(text)
# 4. Обрезать по первому `</svg>` (схлопывает trailing garbage, повторные
# SVG-блоки, невалидный хвост после валидного SVG).
first_close = text.find("</svg")
if first_close >= 0:
text = text[: first_close + len("</svg>")]
# 5. Попробовать распарсить в режиме recover. lxml сам залечит:
# - необструганный `<svg ...` (закроет как self-closing);
# - необструганный `<circle ...` (допишет `/>`);
# - открытую кавычку (`<rect fill="` → `<rect fill=""/>`);
# - атрибут `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 — `<svg>`. Если нет (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(["<svg", ' viewBox="0 0 64 64"', ">", "</svg>"]))
['<svg xmlns="..."></svg>',
'<svg xmlns="..." viewBox="0 0 64 64"></svg>',
'<svg xmlns="..." viewBox="0 0 64 64"></svg>',
'<svg xmlns="..." viewBox="0 0 64 64"></svg>']
"""
buffer = ""
for chunk in chunks:
buffer += chunk
yield parse_to_valid(buffer)
# ---------------------------------------------------------------------------
# Внутренние хелперы
# ---------------------------------------------------------------------------
def _strip_prolog(text: str) -> str:
"""Удаляет XML-декларацию `<?xml ...?>` и `<!DOCTYPE ...>` (если они есть)."""
text = re.sub(r"<\?xml[^>]*\?>", "", text)
text = re.sub(r"<!DOCTYPE[^>]*>", "", 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 в `<svg xmlns="...">...</svg>`."""
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",
]