"""SVG text extraction — the words a vector drawing carries (phase 181).
Pure functions, no I/O — fully unit-testable. Stdlib only (the
:mod:`app.rag.md_images` precedent, phase 125).
An ``.svg`` joins the image family (LOCKED A1, phase 181): its BYTES are
served to the browser so every citation surface paints the graphic
instead of XML. But OpenAI-compatible vision endpoints reject
``image/svg+xml``, and adding a rasteriser dependency is out of scope —
so the vector branch never calls the describer. The text that makes the
document **retrievable** comes from the file itself:
* ``
``, ```` and ```` (LOCKED D2) — collected in
DOCUMENT order by LOCAL name (namespace-agnostic: ```` and
``{http://www.w3.org/2000/svg}title`` both count, declared ``svg:``
prefixes included), whitespace-normalised, de-duplicated
case-insensitively keeping the FIRST occurrence, joined with ``\\n``.
```` children are joined WITH a space so ``ab``
reads "a b", not "ab". XML entity references inside the text
(``&``) are decoded by the parser — the correct direction, the
renderer re-escapes on display.
* a textless diagram (pure ````s — extremely common) still gets
honest, searchable text (LOCKED A2): one structural line built ONLY
from facts the file states, never invented prose.
Guards, applied BEFORE any parse (untrusted markup, the sibling of
LOCKED A3):
* empty / whitespace-only input → ``None``;
* more than :data:`MAX_SVG_BYTES` → ``None`` (memory guard);
* a ```` elements embedding a
raster inside the SVG (their ``href`` is not text), and
``aria-label``/``role`` attributes — ````/```` are the
SVG-spec carriers of words and the fallback line covers the rest.
"""
from __future__ import annotations
import math
import re
from dataclasses import dataclass
from xml.etree import ElementTree
#: Hard input cap for one SVG (phase 181, LOCKED A3's sibling) — bytes
#: over this are refused without a parse attempt, so a hostile or
#: accidentally gigantic file can never be the memory event of a sync.
MAX_SVG_BYTES: int = 2 * 1024 * 1024
#: Cap on the EXTRACTED text (phase 181). It binds on the result, never
#: silently mid-word: an over-cap extraction is cut at the last newline
#: or period before the cap (a hard cut only when the window holds no
#: boundary at all).
MAX_SVG_TEXT_CHARS: int = 4000
#: A document type declaration in the raw bytes (lower-cased marker).
#: Its presence — anywhere — refuses the input (billion-laughs /
#: external-entity class).
_DOCTYPE_MARKER = b"``) do not weld their neighbours together.
_SPACED_TEXT_NAMES = frozenset({"text"})
#: Local names that are structure, not drawing: never counted as shapes
#: in the fallback line (:func:`_shape_clause`).
_NON_SHAPE_NAMES = frozenset({"svg", "title", "desc"})
#: How many element kinds the fallback line names (LOCKED D3: "the
#: three most common element local names with counts").
_TOP_SHAPE_NAMES = 3
#: ``viewBox`` components: four numbers separated by whitespace and/or
#: commas (the SVG-spec grammar, which allows either).
_VIEWBOX_SPLIT_RE = re.compile(r"[\s,]+")
#: The multiplication sign used between a shape name and its count, and
#: between the viewBox width and height — the app's own typography.
_TIMES = "×"
@dataclass(frozen=True)
class SvgText:
"""The searchable text of one SVG document (phase 181, LOCKED A1/A2).
* ``text`` — what gets stored as the document's ``content`` (and
``summary``, per the phase-122 image convention);
* ``structural`` — ``True`` when the document carried no words and
``text`` is the structural fallback line, so the importer can log
and count it separately from a genuinely worded diagram.
"""
text: str
structural: bool
def local_name(tag: object) -> str:
"""The namespace-agnostic name of an element tag.
``{http://www.w3.org/2000/svg}title`` and ``title`` both give
``"title"`` — ElementTree expands every declared prefix (including
````) into Clark notation, so stripping the ``{uri}`` is
all "namespace-agnostic" needs. A non-string tag (comment /
processing-instruction marker) has no name → ``""``.
"""
if not isinstance(tag, str):
return ""
return tag.rsplit("}", 1)[-1]
def extract_svg_text(data: bytes, *, stem: str = "") -> SvgText | None:
"""Turn raw SVG bytes into the document's searchable text.
Returns ``None`` when the input is unusable or unsafe: empty /
whitespace-only, larger than :data:`MAX_SVG_BYTES`, carrying a
````/````/```` words (``structural=False``), or —
when the file carries no words at all — the structural line built
from the stem, the shape census and the ``viewBox``
(``structural=True``, LOCKED A2).
"""
if not data or not data.strip():
return None
if len(data) > MAX_SVG_BYTES:
return None
lowered = data.lower()
if _DOCTYPE_MARKER in lowered or _ENTITY_MARKER in lowered:
return None
try:
root = ElementTree.fromstring(data)
except ElementTree.ParseError:
return None
items = _dedupe(_walk_text(root))
if items:
return SvgText(text=_cap_text("\n".join(items)), structural=False)
return SvgText(text=_structural_line(root, stem), structural=True)
def _walk_text(root: ElementTree.Element) -> list[str]:
"""Every worded ``title``/``desc``/``text`` block, in document order.
Items are whitespace-normalised; a block that normalises to nothing
(an empty ````) is dropped so the fallback can still
describe the file.
"""
items: list[str] = []
for el in root.iter():
name = local_name(el.tag)
if name in _BLOCK_TEXT_NAMES:
raw = "".join(el.itertext())
elif name in _SPACED_TEXT_NAMES:
raw = " ".join(el.itertext())
else:
continue
normalised = " ".join(raw.split())
if normalised:
items.append(normalised)
return items
def _dedupe(items: list[str]) -> list[str]:
"""Drop repeats case-insensitively, keeping the FIRST occurrence.
A ```` mirrored in a ```` (exporters do this) is one
sentence in the index, not two; the first position in document
order is the one kept.
"""
seen: set[str] = set()
unique: list[str] = []
for item in items:
key = item.casefold()
if key in seen:
continue
seen.add(key)
unique.append(item)
return unique
def _cap_text(text: str) -> str:
"""Cap *text* at :data:`MAX_SVG_TEXT_CHARS` without cutting a word.
An over-cap text is cut at the LAST newline or period before the cap
(whichever comes later) so the stored text ends on a boundary; only
a window with no boundary at all (one colossal run of characters)
is hard-cut.
"""
if len(text) <= MAX_SVG_TEXT_CHARS:
return text
window = text[:MAX_SVG_TEXT_CHARS]
cut = max(window.rfind("\n"), window.rfind("."))
if cut < 0:
return window
return text[: cut + 1].rstrip()
def _structural_line(root: ElementTree.Element, stem: str) -> str:
"""The LOCKED A2 fallback: one line of facts, nothing invented.
``Vector diagram "" — shapes (), viewBox
×`` — each fact clause is omitted when the file does not
state it, and the stem clause disappears entirely when empty (no
empty quotes). With no facts and no stem the line is just
``Vector diagram``.
"""
head = f'Vector diagram "{stem}"' if stem else "Vector diagram"
facts = [c for c in (_shape_clause(root), _viewbox_clause(root)) if c]
if not facts:
return head
return f"{head} — {', '.join(facts)}"
def _shape_clause(root: ElementTree.Element) -> str:
"""`` shapes ()`` — the drawing's element census.
*N* counts every descendant element except ``svg``, ``title`` and
``desc`` (structure, not drawing). The kinds are the
:data:`_TOP_SHAPE_NAMES` most common local names, ordered by count
(document order breaks ties) and annotated with their count — the
count is left off a kind that appears exactly once, which is what
the total already says. ``""`` when there are no shapes at all, so
the clause is omitted rather than claiming "0 shapes".
"""
counts: dict[str, int] = {}
first_seen: dict[str, int] = {}
total = 0
for index, el in enumerate(root.iter()):
if el is root:
continue
name = local_name(el.tag)
if not name or name in _NON_SHAPE_NAMES:
continue
counts[name] = counts.get(name, 0) + 1
first_seen.setdefault(name, index)
total += 1
if not total:
return ""
top = sorted(counts, key=lambda name: (-counts[name], first_seen[name]))
listing = ", ".join(
f"{name}{_TIMES}{counts[name]}" if counts[name] > 1 else name
for name in top[:_TOP_SHAPE_NAMES]
)
noun = "shape" if total == 1 else "shapes"
return f"{total} {noun} ({listing})"
def _viewbox_clause(root: ElementTree.Element) -> str:
"""``viewBox ×`` from the root's ``viewBox``, or ``""``.
The spec grammar is four whitespace-and/or-comma separated numbers;
the last two are the width and height. Unparseable (wrong count,
non-numeric, non-finite) → ``""`` — the clause is omitted rather
than reporting a size the file does not state. Integral values
print without a trailing ``.0``.
"""
raw = (root.get("viewBox") or "").strip()
if not raw:
return ""
parts = [part for part in _VIEWBOX_SPLIT_RE.split(raw) if part]
if len(parts) != 4:
return ""
try:
_min_x, _min_y, width, height = (float(part) for part in parts)
except ValueError:
return ""
if not (math.isfinite(width) and math.isfinite(height)):
return ""
return f"viewBox {_format_number(width)}{_TIMES}{_format_number(height)}"
def _format_number(value: float) -> str:
"""``8`` for 8.0, ``8.5`` for 8.5 — no trailing ``.0`` in a size."""
return str(int(value)) if value.is_integer() else str(value)