**Phase 181 — final verification pass (all tasks already complete; verified + docs pass)**
- Verified: `app/rag/svg_text.py` (100 % cov), vector branch in `_index_image_file`, `_row_kind_mismatch` text→image migration, `IMAGE_MIMES[".svg"]`, sandboxed CSP on `GET /api/documents/{id}/image`, `.doc-image-img--vector` in `document.js`/`styles.css`, figure/thumbnail deliberate non-changes.
- Tests/lint (exact commands): `uv run pytest --cov=app --cov-report=term-missing` → **4265 passed, TOTAL 99 %** (exit 0) · `uv run pytest tests/e2e/test_svg_document_preview.py -v --no-cov` → **5 passed in isolation** · `uv run ruff check .` → clean · `uv run pyright` → **0 errors**.
- Regression (each run in isolation, `--no-cov`, all rc=0): `test_image_documents`, `test_chat_image_questions`, `test_answer_images`, `test_chat_answer_clusters`, `test_image_anchor_context`, `test_image_upload_feedback`, `test_markdown_inline_links_images`, `test_prose_word_wrap`, `test_pdf_support`, `test_pictorial_pdf`, `test_garbage_pdf`, `test_pdf_image_extraction`, `test_pdf_image_context`, `test_pdf_pages_in_sources`, `test_pdf_strip_additive_selfheal`, `test_sync_image_progress`.
- Completion criteria: ①SVG→`is_image`+`image_path`, no vision call, own `<title>/<desc>/<text>` — **met**; ②`image/svg+xml` + `default-src 'none'; sandbox` + `private, max-age=3600`, raster headers byte-unchanged — **met**; ③chat figure → modal `<img>`, no XML — **met**; ④`/document.html` full-width vector in `.doc-image` + text below — **met**; ⑤`` paints — **met**; ⑥raster/PDF unchanged — **met**; ⑦all gates green — **met**; ⑧commit + phase-dir move — harness-owned.
- Documentation: found and fixed README inconsistencies — stale forward reference "left to phase 181 / is phase 181's job" → completed-tense sizing note; "Updating the documents" standalone-image list missing `svg`; new **Raster only** bullet for question uploads (`.svg` → 422) documenting the `chat_images` guard; missing `svg_indexed=N` / `import: svg indexed` log contract; env-table heading `122/123/124` → `122/123/124/181`; new troubleshooting entry for an `.svg` showing raw XML. Full audit: every `Settings` field and every `.env.example` var is documented; pages/commands/scripts all exist. AGENTS.md verified accurate (phase-181 §5 clause present) — no changes needed beyond what the phase wrote.
- Notable deviation (kept from task 02): `app/api/chat_images.py` subtracts `_VECTOR_IMAGE_EXTENSIONS` from the question-image accept set — a required consequence of LOCKED A7 (`svg` in the default image set) since `_CHAT_IMAGE_FILENAME_RE` refuses to *serve* `.svg`; the regex itself is untouched, as the phase specified.
- Note for the commit: changes include `README.md`, `AGENTS.md` and `.env.example` — the phase file's `git add app/ frontend/ tests/ .env.example .agents/phases/` line omits the two markdown files; stage them too.
- Next pending phase: `182_verbatim_show_text`.
294 lines
11 KiB
Python
294 lines
11 KiB
Python
"""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:
|
||
|
||
* ``<title>``, ``<desc>`` and ``<text>`` (LOCKED D2) — collected in
|
||
DOCUMENT order by LOCAL name (namespace-agnostic: ``<title>`` 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``.
|
||
``<tspan>`` children are joined WITH a space so ``a<tspan>b</tspan>``
|
||
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 ``<path>``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 ``<!DOCTYPE`` or ``<!ENTITY`` declaration anywhere in the bytes →
|
||
``None``. Refusing the doctype outright is what makes stdlib
|
||
:mod:`xml.etree.ElementTree` safe here: no external entities, no
|
||
entity expansion (the billion-laughs class);
|
||
* malformed XML (:class:`xml.etree.ElementTree.ParseError`) → ``None``
|
||
— never re-raised, the caller's fail-soft is a skip, not a crash.
|
||
|
||
What is deliberately NOT followed: ``<image>`` elements embedding a
|
||
raster inside the SVG (their ``href`` is not text), and
|
||
``aria-label``/``role`` attributes — ``<title>``/``<desc>`` 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"<!doctype"
|
||
|
||
#: An entity declaration in the raw bytes (lower-cased marker) — same
|
||
#: refusal as :data:`_DOCTYPE_MARKER`, checked separately so a stray
|
||
#: ``<!ENTITY`` inside a stray doctype-less prolog is still caught.
|
||
_ENTITY_MARKER = b"<!entity"
|
||
|
||
#: Elements whose whole text block is one item, nested markup
|
||
#: concatenated (:meth:`ElementTree.Element.itertext`).
|
||
_BLOCK_TEXT_NAMES = frozenset({"title", "desc"})
|
||
|
||
#: Elements whose text is joined with a space so inline children
|
||
#: (``<tspan>``) 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
|
||
``<svg:title>``) 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
|
||
``<!DOCTYPE``/``<!ENTITY`` declaration, or not well-formed XML. The
|
||
caller's fail-soft (skip the document, count it) is the honest
|
||
outcome — this function never raises for bad input.
|
||
|
||
*stem* is the file name without extension: it names the document in
|
||
the structural fallback line and is never invented.
|
||
|
||
Otherwise a :class:`SvgText` whose ``text`` is the document-order
|
||
``<title>``/``<desc>``/``<text>`` 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 ``<title></title>``) 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 ``<title>`` mirrored in a ``<desc>`` (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 "<stem>" — <N> shapes (<top-3 kinds>), viewBox
|
||
<w>×<h>`` — 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:
|
||
"""``<N> shapes (<kinds>)`` — 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 <w>×<h>`` 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)
|