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**; ⑤`` 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`.
This commit is contained in:
@@ -94,6 +94,20 @@ format table as node behavioral runs over the real module, and the
|
||||
``.doc-prose`` CSS (the 72rem reading column + ``pre-wrap`` +
|
||||
``overflow-wrap: anywhere``, tokens only) beside the ``.doc-raw``
|
||||
contract that stays locked (phase 116 A1 / phase 165 A3).
|
||||
|
||||
Phase 181 (task 04) — the SVG vector preview: ``docImageBlock`` adds
|
||||
the ``doc-image-img--vector`` modifier ONLY when
|
||||
``doc.format === "svg"`` (the base class is always kept) — a
|
||||
``viewBox``-only SVG has NO intrinsic size, so the default 300×150
|
||||
replaced-element box would sit small inside the full-width
|
||||
``.doc-image`` card — pinned as a node behavioral run of the real
|
||||
``docImageBlock`` (svg → the compound class, ``src`` from
|
||||
``image_url`` never inlined, ``alt`` = summary with the title
|
||||
fallback; png → the class string byte-identical to the base class)
|
||||
plus the ``.doc-image-img--vector`` CSS pins (``width: 100%`` +
|
||||
``height: auto`` + ``object-fit: contain`` under the base rule's
|
||||
70vh clamp, zero colour literals, the base ``.doc-image-img`` rule
|
||||
untouched).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -2102,3 +2116,180 @@ def test_doc_prose_css_wraps_in_the_reading_column() -> None:
|
||||
raw_rule = css[raw_i : css.find("}", raw_i)]
|
||||
assert "white-space: pre" in raw_rule
|
||||
assert "overflow-x: auto" in raw_rule
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 181 (task 04) — the SVG vector preview: docImageBlock's format
|
||||
# gate (base class + the doc-image-img--vector modifier, LOCKED A3:
|
||||
# bytes only ever through <img>) and the .doc-image-img--vector CSS
|
||||
# (a viewBox-only SVG has no intrinsic size — width: 100% spans the
|
||||
# .doc-image card, object-fit: contain survives the 70vh clamp,
|
||||
# palette-neutral). The chat figure (app.js) and the Sources
|
||||
# thumbnail (sources.js) are VERIFIED non-changes (design D7): both
|
||||
# key off image_url alone and their boxes pin the img
|
||||
# (.source-image-img 96px cap, .kb-doc-thumb-img fixed 48px box).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_doc_image_block_gates_the_vector_modifier_on_format() -> None:
|
||||
"""Source pins (node-free): docImageBlock selects the compound
|
||||
class on ``doc.format === "svg"`` and the bare base class
|
||||
otherwise (every current .doc-image-img rule keeps applying);
|
||||
the bytes stay an <img> src property assignment — never inlined
|
||||
(LOCKED A3), never innerHTML. The comment records the rationale
|
||||
(no-intrinsic-size / 300×150) and the legacy-TEXT honesty
|
||||
(XML-in-.doc-raw is the truth; no content sniffing)."""
|
||||
js = _read(DOCUMENT_JS)
|
||||
fn = _fn(js, "docImageBlock")
|
||||
assert 'doc.format === "svg"' in fn
|
||||
assert '"doc-image-img doc-image-img--vector"' in fn
|
||||
assert ': "doc-image-img";' in fn, "the raster path keeps the bare base class"
|
||||
assert "img.src = doc.image_url;" in fn, "the bytes URL stays a property assignment"
|
||||
assert "innerHTML" not in fn
|
||||
# The rationale + honesty comments (house style: why, not what).
|
||||
for citation in ("Phase 181", "viewBox", "300\u00d7150", "LOCKED A3", ".doc-raw"):
|
||||
assert citation in fn, f"the comment must cite {citation!r}"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_doc_image_block_svg_yields_the_vector_class_png_unchanged() -> None:
|
||||
"""Behavioral (node, the house fake DOM): the REAL docImageBlock —
|
||||
an SVG doc renders class="doc-image-img doc-image-img--vector"
|
||||
with src = image_url and alt = the summary (title fallback when
|
||||
the summary is null/blank); a PNG doc's class string is the base
|
||||
class ONLY (byte-identical to pre-phase — the phase-122/133
|
||||
raster surfaces cannot drift); the wrapper stays .doc-image."""
|
||||
js = _read(DOCUMENT_JS)
|
||||
# The docs ride in via json.dumps (the summary carries quotes and
|
||||
# an em-dash — the alt contract must survive verbatim).
|
||||
cases = {
|
||||
"svg": {
|
||||
"format": "svg",
|
||||
"image_url": "/api/documents/7/image",
|
||||
"summary": 'Vector diagram "topo" \u2014 3 shapes',
|
||||
"title": "topo",
|
||||
},
|
||||
"svgNoSummary": {
|
||||
"format": "svg",
|
||||
"image_url": "/api/documents/9/image",
|
||||
"summary": None,
|
||||
"title": "rack",
|
||||
},
|
||||
"svgBlankSummary": {
|
||||
"format": "svg",
|
||||
"image_url": "/api/documents/10/image",
|
||||
"summary": " ",
|
||||
"title": "blank",
|
||||
},
|
||||
"png": {
|
||||
"format": "png",
|
||||
"image_url": "/api/documents/8/image",
|
||||
"summary": "A photo",
|
||||
"title": "t",
|
||||
},
|
||||
}
|
||||
script = (
|
||||
_CONTENT_BRANCH_FAKE_DOM
|
||||
+ _fn(js, "docImageBlock")
|
||||
+ "\nconst cases = "
|
||||
+ json.dumps(cases)
|
||||
+ """
|
||||
;
|
||||
function imageFor(doc) {
|
||||
const wrap = docImageBlock(doc);
|
||||
const img = wrap.children[0];
|
||||
return {
|
||||
wrapClass: wrap.className, imgClass: img.className, src: img.src, alt: img.alt,
|
||||
};
|
||||
}
|
||||
const out = {};
|
||||
for (const [k, doc] of Object.entries(cases)) out[k] = imageFor(doc);
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
)
|
||||
out = json.loads(_run_node(script))
|
||||
assert out["svg"] == {
|
||||
"wrapClass": "doc-image",
|
||||
"imgClass": "doc-image-img doc-image-img--vector",
|
||||
"src": "/api/documents/7/image",
|
||||
"alt": 'Vector diagram "topo" \u2014 3 shapes',
|
||||
}
|
||||
# alt falls back to the title (the WCAG alt contract, unchanged).
|
||||
assert out["svgNoSummary"]["alt"] == "rack"
|
||||
assert out["svgBlankSummary"]["alt"] == "blank"
|
||||
assert out["svgBlankSummary"]["imgClass"] == "doc-image-img doc-image-img--vector"
|
||||
# The raster class string is byte-identical to pre-phase (base only).
|
||||
assert out["png"]["imgClass"] == "doc-image-img"
|
||||
assert out["png"]["src"] == "/api/documents/8/image"
|
||||
|
||||
|
||||
def test_doc_image_vector_css_spans_the_card_without_squashing() -> None:
|
||||
"""styles.css: ``.doc-image-img--vector`` (immediately after
|
||||
``.doc-image-img``) is ``width: 100%`` + ``height: auto`` +
|
||||
``object-fit: contain`` — a viewBox-only SVG (no intrinsic size:
|
||||
the default replaced-element box is 300\u00d7150) spans the
|
||||
full-width .doc-image card, and contain letterboxes instead of
|
||||
squashing when the base rule's max-height: 70vh clamps a tall
|
||||
drawing. Zero colour literals (palette-neutral — the monochrome
|
||||
invariant); the base .doc-image-img rule is UNTOUCHED (the
|
||||
phase-122 raster contract)."""
|
||||
css = _read(STYLES_CSS)
|
||||
base_i = css.find(".doc-image-img {")
|
||||
vec_i = css.find(".doc-image-img--vector {")
|
||||
assert -1 < base_i < vec_i, ".doc-image-img--vector must sit next to the base rule"
|
||||
# Provenance comment directly above the rule (house style).
|
||||
comment_start = css.rfind("/*", 0, vec_i)
|
||||
provenance = css[comment_start:vec_i]
|
||||
assert -1 < comment_start < vec_i
|
||||
for citation in ("phase 181", "viewBox", "300\u00d7150", "object-fit", "doc.format"):
|
||||
assert citation in provenance, f"the comment must cite {citation!r}"
|
||||
assert "no animation" in provenance
|
||||
rule = css[vec_i : css.find("}", vec_i)]
|
||||
for decl in ("width: 100%", "height: auto", "object-fit: contain"):
|
||||
assert decl in rule, f".doc-image-img--vector must carry {decl!r}"
|
||||
# Palette-neutral — zero colour literals (no new hue).
|
||||
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", rule)
|
||||
assert "rgb(" not in rule and "hsl(" not in rule and "color-mix(" not in rule
|
||||
# The untouched base rule (the raster + onerror contract).
|
||||
base_rule = css[base_i : css.find("}", base_i)]
|
||||
for decl in ("max-width: 100%", "max-height: 70vh", "height: auto"):
|
||||
assert decl in base_rule, f"the base rule must keep {decl!r}"
|
||||
|
||||
|
||||
def test_figure_and_thumbnail_key_off_image_url_alone() -> None:
|
||||
"""Design D7's verified non-changes: the chat figure
|
||||
(appendSourceImageFigure) and the Sources KB-tree thumbnail
|
||||
(docThumb) resolve their bytes from ``image_url`` alone — no
|
||||
format field on the wire, none added — and their CSS boxes pin
|
||||
the img (.source-image-img: 96px cap + object-fit: contain; the
|
||||
.kb-doc-thumb-img: the fixed 48px box), so a size-less SVG
|
||||
scales inside each cap exactly like a raster. Pins: neither
|
||||
function sniffs a format at all (the deliberate non-change,
|
||||
design D7), the phase-181 comments sit where they were made,
|
||||
and the two rules keep their box declarations."""
|
||||
app_js = _read(APP_JS)
|
||||
assert app_js.count("function appendSourceImageFigure(") == 1
|
||||
fig = _fn(app_js, "appendSourceImageFigure")
|
||||
assert "img.src = s.image_url;" in fig
|
||||
assert "format" not in fig, "no format sniffing on the chat figure (D7)"
|
||||
fn_start = app_js.index("function appendSourceImageFigure(")
|
||||
assert "phase 181" in app_js[max(0, fn_start - 2400) : fn_start].lower(), (
|
||||
"the deliberate-non-change comment sits above the figure"
|
||||
)
|
||||
sources_js = _read(SOURCES_JS)
|
||||
thumb_start = sources_js.index("function docThumb(d)")
|
||||
thumb_end = sources_js.index("\n }", thumb_start)
|
||||
thumb = sources_js[thumb_start:thumb_end]
|
||||
assert "img.src = d.image_url;" in thumb
|
||||
assert "format" not in thumb, "no format sniffing on the thumbnail (D7)"
|
||||
branch_i = sources_js.index("if (d.is_image) {")
|
||||
assert "phase 181" in sources_js[branch_i - 900 : branch_i].lower(), (
|
||||
"the verified-non-change comment sits above the is_image branch"
|
||||
)
|
||||
css = _read(STYLES_CSS)
|
||||
cap_i = css.find(".source-image-img {")
|
||||
cap = css[cap_i : css.find("}", cap_i)]
|
||||
assert "max-height: 96px" in cap and "object-fit: contain" in cap
|
||||
box_i = css.find(".kb-doc-thumb-img {")
|
||||
box = css[box_i : css.find("}", box_i)]
|
||||
assert "width: 100%" in box and "height: 100%" in box
|
||||
|
||||
Reference in New Issue
Block a user