Files
ducoterra 814a400e6d
Build and Push Containers / build-and-push-app (push) Successful in 1m57s
Build and Push Containers / build-and-push-db (push) Successful in 15s
phase: 181_svg_document_preview
**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**; ⑤`![alt](/api/documents/<id>/image)` 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`.
2026-10-14 22:54:43 -04:00

294 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
(``&amp;``) 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)