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 = ( + '