diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 22bf0c910..0525a018c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -64,7 +64,7 @@ flowchart LR | `chunking.py` | Splits a document into meaning-identifiable units (paragraph, sentence, DOM, conversation-turn) plus embedded-image extraction, in document order | | `embedding_client.py` | Pluggable text-embedding channel (`Null` default, `OpenAiCompatible` real impl) + `chunked_max_similarity` | | `adjudication_client.py` | Pluggable LLM-judgment channel (`Null` default, `ContextualOrchestrator` real impl) | -| `image_content.py` | Pluggable vision channel: OCR + object recognition/tagging for embedded images (`Null` default, `OpenAiCompatibleVisionClient` real impl). The product popup (`frontend/src/PostBody.tsx`) renders each `data:image` payload in document order so the buyer sees the picture, not the base64 string; GET does not call the vision client. | +| `image_content.py` | Pluggable vision channel: OCR + object recognition/tagging for embedded images (`Null` default, `OpenAiCompatibleVisionClient` real impl). The product popup (`frontend/src/PostBody.tsx`) and `extract_base64_images` parse with the same HTML rules as `chunk_by_dom` (ADR 0031) so invoice-like `alt` values still show the picture; GET does not call the vision client. | | `tepp_client.py` | TEPP's published `AnalysisRunRequest` wire contract, pluggable transport | | `rankweave_client.py` | Fail-closed RankWeave ranking port (`weighted_reciprocal_rank_fuse` in-process; never invent a fused score or a theta) | | `reconstruct.py` | The pipeline: group → candidate window → score → fuse → thread | diff --git a/CHANGELOG.d/2.10.2-embedded-image-html-parser.md b/CHANGELOG.d/2.10.2-embedded-image-html-parser.md new file mode 100644 index 000000000..cb3b3f820 --- /dev/null +++ b/CHANGELOG.d/2.10.2-embedded-image-html-parser.md @@ -0,0 +1,5 @@ +# 2.10.2 Parse invoice HTML images with an HTML parser + +Opening a post whose embedded picture uses invoice-like HTML +(`alt="Invoice > 1000"`) shows the picture between the surrounding +sentences. The raw base64 string is gone (ADR 0031). diff --git a/CHANGELOG.md b/CHANGELOG.md index 85e50fc69..853632a3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.10.2] - 2026-08-17 + +### Fixed + +- Opening a post whose embedded picture uses invoice-like HTML + (`alt="Invoice > 1000"`, unquoted `width`, newlines in the base64) + now shows the picture. The raw payload no longer returns when a + remote-only or SVG tag is the whole body. Re-export as PNG or JPEG + if the type is rejected. The popup, `extract_base64_images`, and + `chunk_by_dom` share one raster allowlist (ADR 0031). + ## [2.10.1] - 2026-08-17 ### Fixed diff --git a/docs/adr/0031-embedded-image-html-parser.md b/docs/adr/0031-embedded-image-html-parser.md new file mode 100644 index 000000000..6acdfd71c --- /dev/null +++ b/docs/adr/0031-embedded-image-html-parser.md @@ -0,0 +1,78 @@ +# ADR 0031 — Embedded images use an HTML parser and a raster allowlist + +**Decision status:** Accepted +**Date:** 2026-08-17 + +## Context + +The product popup stopped dumping a well-formed +`data:image/png;base64,...` invoice as a base64 wall. The splitter and +`extract_base64_images` still used a `[^>]*` regex. Real invoice HTML +puts `>` inside `alt` or `title` *before* `src`. That shape is legal +HTML (WHATWG, n.d.) and is what `chunk_by_dom` already parses. The regex +missed the picture and put the payload back into the text node. + +The same open MIME class `image/[a-zA-Z0-9.+-]+` accepted +`image/svg+xml`. SVG-as-`` does not run script in current browsers, +but the regex also fed the vision channel. `atob` and +`b64decode(validate=True)` already disagreed on padding. + +ADR 0019 is the R&R catalog-identity decision. This decision is the +viewer/extractor parse contract. Layout clues stay as character offsets +and `chunk_position` rows — never raw HTML in the knowledge graph or in +a persisted post body. + +Persistence of OCR under the figure (Li et al., 2023; Radford et al., +2021) is still the next buyer slice. It must not land on a splitter that +fails the HTML the buyer actually opens. + +## Decision + +The popup (`splitPostBody`), `extract_base64_images`, and `chunk_by_dom` +share one decode helper (`lineageweave.embedded_image_payload`): + +1. Parse with an HTML parser (`DOMParser` in the browser, `html.parser` + in Python). Comments, ` +

Please confirm.

diff --git a/tests/test_chunking.py b/tests/test_chunking.py index 4eab1ff65..27e47c238 100644 --- a/tests/test_chunking.py +++ b/tests/test_chunking.py @@ -107,6 +107,18 @@ def test_chunk_by_dom_skips_malformed_image_data() -> None: assert [c.unit_type for c in chunks] == ["dom"] +def test_chunk_by_dom_skips_script_and_style_images() -> None: + tiny_png_b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" + html = ( + f'' + f'' + "

Visible.

" + ) + chunks = chunk_by_dom(html) + assert [c.unit_type for c in chunks] == ["dom"] + assert chunks[0].text == "Visible." + + def test_chunk_by_conversation_turn_labels_each_chunk_with_its_sender() -> None: turns = [ ConversationTurn(sender="alice@example.com", text="Can we move the meeting?"), diff --git a/tests/test_embedded_image_payload.py b/tests/test_embedded_image_payload.py new file mode 100644 index 000000000..f57566137 --- /dev/null +++ b/tests/test_embedded_image_payload.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import base64 + +from lineageweave.embedded_image_payload import ( + decode_data_uri_image, + looks_like_raster_image, + source_offset, +) + +_TINY_PNG_B64 = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" +) +_TINY_PNG = base64.b64decode(_TINY_PNG_B64) +_JPEG_BYTES = b"\xff\xd8\xff\x00" +_GIF87_BYTES = b"GIF87a" + b"\x00" * 2 +_GIF89_BYTES = b"GIF89a" + b"\x00" * 2 +_WEBP_BYTES = b"RIFF\x00\x00\x00\x00WEBP" +_AVIF_BYTES = b"\x00\x00\x00\x00ftypavif\x00\x00\x00\x00" +_AVIS_BYTES = b"\x00\x00\x00\x00ftypavis\x00\x00\x00\x00" +_MIF1_BYTES = b"\x00\x00\x00\x00ftypmif1\x00\x00\x00\x00" + + +def test_looks_like_raster_image_accepts_png_signature() -> None: + assert looks_like_raster_image("image/png", _TINY_PNG) is True + + +def test_looks_like_raster_image_rejects_ascii_labeled_as_png() -> None: + assert looks_like_raster_image("image/png", b"Hello") is False + + +def test_looks_like_raster_image_rejects_empty_payload() -> None: + assert looks_like_raster_image("image/png", b"") is False + + +def test_looks_like_raster_image_accepts_jpeg_gif_webp_avif_signatures() -> None: + assert looks_like_raster_image("image/jpeg", _JPEG_BYTES) is True + assert looks_like_raster_image("image/jpg", _JPEG_BYTES) is True + assert looks_like_raster_image("image/gif", _GIF87_BYTES) is True + assert looks_like_raster_image("image/gif", _GIF89_BYTES) is True + assert looks_like_raster_image("image/webp", _WEBP_BYTES) is True + assert looks_like_raster_image("image/avif", _AVIF_BYTES) is True + assert looks_like_raster_image("image/avif", _AVIS_BYTES) is True + assert looks_like_raster_image("image/avif", _MIF1_BYTES) is True + + +def test_looks_like_raster_image_rejects_wrong_magic_and_unknown_type() -> None: + assert looks_like_raster_image("image/jpeg", b"not-a-jpeg") is False + assert looks_like_raster_image("image/gif", b"GIF8xa") is False + assert looks_like_raster_image("image/webp", b"RIFF....NOTW") is False + assert looks_like_raster_image("image/webp", b"RIFF") is False + assert looks_like_raster_image("image/avif", b"xxxxftypxxxx") is False + assert looks_like_raster_image("image/avif", b"short") is False + assert looks_like_raster_image("image/svg+xml", _TINY_PNG) is False + + +def test_decode_data_uri_image_rejects_svg_and_remote_src() -> None: + assert decode_data_uri_image("https://example.test/invoice.png") is None + assert ( + decode_data_uri_image( + "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==" + ) + is None + ) + + +def test_decode_data_uri_image_rejects_missing_comma_or_base64_marker() -> None: + assert decode_data_uri_image("data:image/png;base64") is None + assert decode_data_uri_image(f"data:image/png,{_TINY_PNG_B64}") is None + + +def test_decode_data_uri_image_rejects_unpadded_and_wrong_magic() -> None: + assert decode_data_uri_image("data:image/png;base64,YQ") is None + assert decode_data_uri_image("data:image/png;base64,AAAA") is None + + +def test_decode_data_uri_image_accepts_newlines_inside_png_payload() -> None: + wrapped = f"data:image/png;base64,{_TINY_PNG_B64[:24]}\n{_TINY_PNG_B64[24:]}" + decoded = decode_data_uri_image(wrapped) + assert decoded == ("image/png", _TINY_PNG) + + +def test_decode_data_uri_image_accepts_jpeg_alias() -> None: + encoded = base64.b64encode(_JPEG_BYTES).decode("ascii") + assert decode_data_uri_image(f"data:image/jpg;base64,{encoded}") == ( + "image/jpg", + _JPEG_BYTES, + ) + + +def test_decode_data_uri_image_accepts_gif_webp_and_avif() -> None: + for mime_type, payload in ( + ("image/gif", _GIF89_BYTES), + ("image/webp", _WEBP_BYTES), + ("image/avif", _AVIF_BYTES), + ): + encoded = base64.b64encode(payload).decode("ascii") + assert decode_data_uri_image(f"data:{mime_type};base64,{encoded}") == ( + mime_type, + payload, + ) + + +def test_source_offset_maps_htmlparser_getpos() -> None: + source = "ab\ncd" + assert source_offset(source, 1, 0) == 0 + assert source_offset(source, 2, 1) == 4 + assert source_offset(source, 0, 0) == 0 + assert source_offset(source, 9, 0) == len(source) diff --git a/tests/test_image_content.py b/tests/test_image_content.py index 033202be6..8015c1b6d 100644 --- a/tests/test_image_content.py +++ b/tests/test_image_content.py @@ -1,9 +1,12 @@ from __future__ import annotations import base64 +from pathlib import Path import pytest +from lineageweave.chunking import chunk_by_dom +from lineageweave.embedded_image_payload import decode_data_uri_image from lineageweave.image_content import ( ImageContentClient, ImageDescriptionParseError, @@ -55,6 +58,57 @@ def test_extract_base64_images_empty_document_yields_no_images() -> None: assert extract_base64_images("

No images here.

") == [] +def test_extract_base64_images_skips_svg_and_unpadded_payloads() -> None: + svg = ( + '' + ) + assert extract_base64_images(svg) == [] + assert extract_base64_images('') == [] + assert decode_data_uri_image(f"data:image/png;base64,{_TINY_PNG_B64}") is not None + + +def test_extract_base64_images_skips_style_script_and_src_less_tags() -> None: + html = ( + f'' + f'' + "" + f'' + ) + images = extract_base64_images(html) + assert len(images) == 1 + assert images[0].data == base64.b64decode(_TINY_PNG_B64) + + +def test_invoice_fixture_is_one_visible_png_for_every_extractor() -> None: + """Outlook-style invoice HTML must not resurrect the base64 wall. + + The same file is read by the TypeScript popup splitter. All three + extractors must see one raster PNG, ignore the commented copy, the + remote URL, the SVG, and the CSS background, and keep surrounding + sentences readable. + """ + html = (Path(__file__).parent / "fixtures" / "synthetic_invoice_embedded_image.html").read_text( + encoding="utf-8" + ) + images = extract_base64_images(html) + chunks = chunk_by_dom(html) + image_chunks = [chunk for chunk in chunks if chunk.unit_type == "image"] + text = " ".join(chunk.text for chunk in chunks if chunk.unit_type == "dom") + + assert len(images) == 1 + assert len(image_chunks) == 1 + assert images[0].mime_type == "image/png" + assert images[0].data == base64.b64decode(_TINY_PNG_B64) + assert images[0].position == html.find(")
+    assert None: content = "TEXT: Quarterly Budget Report\nCAPTION: A printed report cover page.\nTAGS: document, report, text" description = _parse_description(content) diff --git a/uv.lock b/uv.lock index 6cf73a876..127875bd5 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.10.1" +version = "2.10.2" source = { virtual = "." } dependencies = [ { name = "certifi" },