From 5b9b3d19bd3be378b2f7c020cd29ab8d94a6bdc4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 19:18:17 +0000 Subject: [PATCH 1/2] fix(ui): parse embedded post images with an HTML parser (v2.10.2) Invoice-like HTML with alt="Invoice > 1000" no longer dumps the base64 wall. The popup, extract_base64_images, and chunk_by_dom share a raster allowlist and one synthetic invoice fixture (ADR 0031). Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 2 +- .../2.10.2-embedded-image-html-parser.md | 5 + CHANGELOG.md | 11 + docs/adr/0031-embedded-image-html-parser.md | 78 ++++++ docs/image-content-schema.md | 25 +- frontend/package.json | 2 +- frontend/src/PostBody.tsx | 5 +- frontend/src/postBodyDisplay.test.ts | 136 +++++++++++ frontend/src/postBodyDisplay.ts | 225 +++++++++++++++--- lineageweave/__init__.py | 2 +- lineageweave/chunking.py | 31 ++- lineageweave/embedded_image_payload.py | 95 ++++++++ lineageweave/image_content.py | 74 ++++-- pyproject.toml | 2 +- .../synthetic_invoice_embedded_image.html | 10 + tests/test_chunking.py | 12 + tests/test_embedded_image_payload.py | 109 +++++++++ tests/test_image_content.py | 54 +++++ uv.lock | 2 +- 19 files changed, 793 insertions(+), 87 deletions(-) create mode 100644 CHANGELOG.d/2.10.2-embedded-image-html-parser.md create mode 100644 docs/adr/0031-embedded-image-html-parser.md create mode 100644 lineageweave/embedded_image_payload.py create mode 100644 tests/fixtures/synthetic_invoice_embedded_image.html create mode 100644 tests/test_embedded_image_payload.py 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, `` + + ``; + expect(splitPostBody(hidden)).toEqual([]); + expect(splitPostBody("
")).toEqual([]); + expect(splitPostBody("")).toEqual([]); + }); + + it("rejects a padded payload whose bytes are not a raster picture", () => { + expect(splitPostBody('')).toEqual([ + { + kind: "text", + text: "Embedded image could not be decoded. Re-export the source post and open it again.", + }, + ]); + expect(splitPostBody('')[0]).toMatchObject({ + kind: "text", + }); + expect(splitPostBody('')[0]).toMatchObject({ + kind: "text", + }); + expect(splitPostBody('')[0]).toMatchObject({ + kind: "text", + }); + expect(splitPostBody('')[0]).toMatchObject({ + kind: "text", + }); + }); + + it("keeps the picture when alt contains > and does not leak the invoice fixture", () => { + const segments = splitPostBody(INVOICE_HTML); + const images = segments.filter((segment) => segment.kind === "image"); + const text = segments + .filter((segment) => segment.kind === "text") + .map((segment) => (segment.kind === "text" ? segment.text : "")) + .join(" "); + + expect(images).toHaveLength(1); + expect(images[0]).toMatchObject({ + kind: "image", + mimeType: "image/png", + alt: "Invoice > 1000", + src: `data:image/png;base64,${TINY_PNG_B64}`, + }); + expect(text).toContain("Quote attached."); + expect(text).toContain("Terms & conditions."); + expect(text).toContain("Qty"); + expect(text).toContain("Please confirm."); + expect(text).not.toContain(TINY_PNG_B64); + expect(text).not.toContain("data:image"); + expect(text).not.toContain("example.test"); + expect(text).not.toContain("background:url"); + }); }); diff --git a/frontend/src/postBodyDisplay.ts b/frontend/src/postBodyDisplay.ts index c6ea29fdd..036920f35 100644 --- a/frontend/src/postBodyDisplay.ts +++ b/frontend/src/postBodyDisplay.ts @@ -3,70 +3,223 @@ * * The popup used to dump the source string, so a buyer who opened a post * with an embedded invoice saw a base64 wall instead of the picture. - * Only `data:image/...;base64,...` payloads are turned into images — - * remote `http(s)` img tags are stripped, never fetched. + * Parsing uses the same HTML rules as `chunk_by_dom`: attribute values + * may contain `>`, comments are ignored, and only raster `data:image` + * payloads become `` nodes. Remote `http(s)` tags are never fetched. */ export type PostBodySegment = | { kind: "text"; text: string } - | { kind: "image"; src: string; mimeType: string; position: number }; + | { kind: "image"; src: string; mimeType: string; position: number; alt: string }; -const DATA_URI_IMG = - /]*\bsrc\s*=\s*["']data:(image\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+)["'][^>]*>/gi; +const RASTER_IMAGE_MIME_TYPES = new Set([ + "image/png", + "image/jpeg", + "image/jpg", + "image/gif", + "image/webp", + "image/avif", +]); -const HTML_TAG = /<\/?[a-zA-Z][^>]*>/g; +const SKIP_TAGS = new Set(["STYLE", "SCRIPT", "NOSCRIPT"]); const UNDECODEABLE_IMAGE = "Embedded image could not be decoded. Re-export the source post and open it again."; -function stripHtmlTags(text: string): string { - return text.replace(HTML_TAG, " ").replace(/\s+/g, " ").trim(); +const REJECTED_IMAGE_TYPE = + "Embedded image type is not displayed. Re-export as PNG or JPEG and open it again."; + +const REMOTE_ONLY_IMAGE = + "This post has no displayable text. Remote images were not loaded. Re-export the source with embedded pictures and open it again."; + +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; +const JPEG_PREFIX = [0xff, 0xd8, 0xff]; + +function looksLikeHtml(body: string): boolean { + return /<[a-zA-Z!/?]/.test(body); } -function isDecodableBase64(raw: string): boolean { - if (raw.length === 0) { +function normalizeVisibleText(text: string): string { + return text.replace(/\u00a0/g, " ").replace(/\s+/g, " ").trim(); +} + +function startsWithBytes(data: Uint8Array, prefix: number[]): boolean { + if (data.length < prefix.length) { return false; } + return prefix.every((value, index) => data[index] === value); +} + +function looksLikeRasterImage(mimeType: string, data: Uint8Array): boolean { + if (data.length === 0) { + return false; + } + if (mimeType === "image/png") { + return startsWithBytes(data, PNG_SIGNATURE); + } + if (mimeType === "image/jpeg" || mimeType === "image/jpg") { + return startsWithBytes(data, JPEG_PREFIX); + } + if (mimeType === "image/gif") { + return ( + startsWithBytes(data, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) || + startsWithBytes(data, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]) + ); + } + if (mimeType === "image/webp") { + return ( + data.length >= 12 && + startsWithBytes(data, [0x52, 0x49, 0x46, 0x46]) && + data[8] === 0x57 && + data[9] === 0x45 && + data[10] === 0x42 && + data[11] === 0x50 + ); + } + if (mimeType === "image/avif") { + if (data.length < 12 || data[4] !== 0x66 || data[5] !== 0x74 || data[6] !== 0x79 || data[7] !== 0x70) { + return false; + } + const brand = String.fromCharCode(...data.slice(8, 16)); + return brand.includes("avif") || brand.includes("avis") || brand.includes("mif1"); + } + return false; +} + +function bytesFromStrictBase64(raw: string): Uint8Array | null { + if (raw.length === 0 || raw.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(raw)) { + return null; + } try { - atob(raw); - return true; + const binary = atob(raw); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; } catch { + return null; + } +} + +function isInsideHtmlComment(source: string, index: number): boolean { + const lastOpen = source.lastIndexOf("", lastOpen); + return lastClose < 0 || lastClose > index; } -function pushText(segments: PostBodySegment[], raw: string): void { - const text = stripHtmlTags(raw); - if (text) { - segments.push({ kind: "text", text }); +function nextVisibleImgOffset(source: string, from: number): number { + let search = from; + const lower = source.toLowerCase(); + while (search < source.length) { + const index = lower.indexOf("= 0) { + cursor.from = position + 4; + } + if (!parsed) { + if (/^https?:/i.test(src) || src.startsWith("//")) { + flags.sawRemote = true; + } + return; + } + if (!RASTER_IMAGE_MIME_TYPES.has(parsed.mimeType)) { + flags.sawRejectedType = true; + return; + } + const bytes = bytesFromStrictBase64(parsed.rawB64); + if (bytes === null || !looksLikeRasterImage(parsed.mimeType, bytes)) { + flags.sawUndecodable = true; + pushText(segments, UNDECODEABLE_IMAGE); + return; + } + const alt = (element.getAttribute("alt") ?? "").trim(); segments.push({ kind: "image", - src: `data:${mimeType};base64,${rawB64}`, - mimeType, - position: match.index, + src: `data:${parsed.mimeType};base64,${parsed.rawB64}`, + mimeType: parsed.mimeType, + position: position >= 0 ? position : 0, + alt, }); - } else { - segments.push({ kind: "text", text: UNDECODEABLE_IMAGE }); + return; } - lastIndex = match.index + match[0].length; - match = pattern.exec(body); + for (const child of Array.from(element.childNodes)) { + walk(child, source, segments, cursor, flags); + } + return; + } + if (node.nodeType === Node.TEXT_NODE) { + pushText(segments, node.textContent ?? ""); } - pushText(segments, body.slice(lastIndex)); - if (segments.length === 0) { +} + +/** Split raw post HTML into visible text and raster data-URI pictures. */ +export function splitPostBody(body: string): PostBodySegment[] { + if (!looksLikeHtml(body)) { return [{ kind: "text", text: body }]; } - return segments; + const document = new DOMParser().parseFromString(body, "text/html"); + const segments: PostBodySegment[] = []; + const flags = { sawRemote: false, sawRejectedType: false, sawUndecodable: false }; + const cursor = { from: 0 }; + for (const child of Array.from(document.body.childNodes)) { + walk(child, body, segments, cursor, flags); + } + if (segments.length > 0) { + return segments; + } + if (flags.sawUndecodable) { + return [{ kind: "text", text: UNDECODEABLE_IMAGE }]; + } + if (flags.sawRejectedType) { + return [{ kind: "text", text: REJECTED_IMAGE_TYPE }]; + } + if (flags.sawRemote) { + return [{ kind: "text", text: REMOTE_ONLY_IMAGE }]; + } + return []; } diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index f6e015e9d..8fda891a2 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "2.10.1" +__version__ = "2.10.2" diff --git a/lineageweave/chunking.py b/lineageweave/chunking.py index c41ae4893..5da5adab8 100644 --- a/lineageweave/chunking.py +++ b/lineageweave/chunking.py @@ -40,12 +40,12 @@ from __future__ import annotations -import base64 -import binascii import re from dataclasses import dataclass, field from html.parser import HTMLParser +from .embedded_image_payload import decode_data_uri_image + # WHATWG HTML Living Standard / W3C HTML5 sectioning-content and common # flow-content block elements -- boundaries a DOM-unit chunker should # split on rather than treating the whole document as one text blob. @@ -152,20 +152,6 @@ def chunk_by_sentence(text: str) -> list[Chunk]: return [Chunk(text=s, unit_type="sentence", index=i) for i, s in enumerate(sentences)] -def _decode_data_uri_image(src: str) -> tuple[str, bytes] | None: - """Parse a ``data:image/;base64,`` src attribute value.""" - if not src.lower().startswith("data:image/"): - return None - header, _, encoded = src.partition(",") - if ";base64" not in header: - return None - mime_type = header[len("data:") : header.index(";")] - try: - return mime_type, base64.b64decode(re.sub(r"\s+", "", encoded), validate=True) - except (binascii.Error, ValueError): - return None - - class _BlockTextExtractor(HTMLParser): """Attributes each piece of text to its innermost enclosing block tag, and records ```` data-URI occurrences in the same document-order @@ -188,12 +174,18 @@ def __init__(self) -> None: # true document order, so an image's index among its siblings # reflects where it actually sat. self._finished: list[tuple[str, object, str, str | None]] = [] + self._skip_depth = 0 def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag in {"style", "script"}: + self._skip_depth += 1 + return + if self._skip_depth: + return if tag == "img": src = next((value for name, value in attrs if name == "src" and value), None) if src: - decoded = _decode_data_uri_image(src) + decoded = decode_data_uri_image(src) if decoded is not None: self._finished.append(("image", decoded, "", None)) return @@ -202,6 +194,9 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None self._stack.append((tag, [], style)) def handle_endtag(self, tag: str) -> None: + if tag in {"style", "script"} and self._skip_depth: + self._skip_depth -= 1 + return if tag in _DOM_BLOCK_TAGS and self._stack: tag_name, buffer, style = self._stack.pop() text = " ".join(buffer).strip() @@ -209,6 +204,8 @@ def handle_endtag(self, tag: str) -> None: self._finished.append(("text", text, tag_name, style)) def handle_data(self, data: str) -> None: + if self._skip_depth: + return text = data.strip() if text and self._stack: self._stack[-1][1].append(text) diff --git a/lineageweave/embedded_image_payload.py b/lineageweave/embedded_image_payload.py new file mode 100644 index 000000000..fadb8afbd --- /dev/null +++ b/lineageweave/embedded_image_payload.py @@ -0,0 +1,95 @@ +"""Shared decode rules for embedded ``data:image`` payloads. + +The product popup, :func:`lineageweave.image_content.extract_base64_images`, +and :func:`lineageweave.chunking.chunk_by_dom` must accept and reject the +same bytes. A regex that stops at the first ``>`` misses Outlook-style +``alt="Invoice > 1000"`` tags; an open MIME class +``image/[a-zA-Z0-9.+-]+`` treats ``image/svg+xml`` as a picture. Both +failures put the buyer back in front of a base64 wall or send scriptable +XML into the vision channel. + +Raster-only MIME types plus magic-byte checks keep the three extractors +honest. Grounded in the WHATWG HTML parser (attribute values may contain +``>``) and the PNG signature (Boutell & Randers-Pehrson, 2003). +""" + +from __future__ import annotations + +import base64 +import binascii +import re + +RASTER_IMAGE_MIME_TYPES = frozenset( + { + "image/png", + "image/jpeg", + "image/jpg", + "image/gif", + "image/webp", + "image/avif", + } +) + +_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" +_JPEG_PREFIX = b"\xff\xd8\xff" + + +def looks_like_raster_image(mime_type: str, data: bytes) -> bool: + """Return True when ``data`` matches the claimed raster MIME type. + + A payload labeled ``image/png`` that decodes to ``Hello`` is not a + picture. The popup must not render it, and the vision client must not + spend a call on it. + """ + if not data: + return False + normalized = mime_type.lower() + if normalized == "image/png": + return data.startswith(_PNG_SIGNATURE) + if normalized in {"image/jpeg", "image/jpg"}: + return data.startswith(_JPEG_PREFIX) + if normalized == "image/gif": + return data.startswith(b"GIF87a") or data.startswith(b"GIF89a") + if normalized == "image/webp": + return len(data) >= 12 and data.startswith(b"RIFF") and data[8:12] == b"WEBP" + if normalized == "image/avif": + return len(data) >= 12 and data[4:8] == b"ftyp" and ( + b"avif" in data[8:16] or b"avis" in data[8:16] or b"mif1" in data[8:16] + ) + return False + + +def decode_data_uri_image(src: str) -> tuple[str, bytes] | None: + """Parse a ``data:image/;base64,`` ``src`` value. + + Returns ``None`` for remote URLs, SVG, unpadded or invalid base64, + and bytes that do not match the claimed raster signature. + """ + if not src.lower().startswith("data:image/"): + return None + header, separator, encoded = src.partition(",") + if not separator or ";base64" not in header.lower(): + return None + mime_type = header[len("data:") : header.index(";")].strip().lower() + if mime_type not in RASTER_IMAGE_MIME_TYPES: + return None + raw_b64 = re.sub(r"\s+", "", encoded) + try: + data = base64.b64decode(raw_b64, validate=True) + except (binascii.Error, ValueError): + return None + if not looks_like_raster_image(mime_type, data): + return None + return mime_type, data + + +def source_offset(source: str, line: int, column: int) -> int: + """Convert HTMLParser ``getpos()`` (1-based line, 0-based column) to a + character offset in ``source``. + """ + if line < 1: + return 0 + lines = source.splitlines(keepends=True) + if line > len(lines): + return len(source) + return sum(len(part) for part in lines[: line - 1]) + column diff --git a/lineageweave/image_content.py b/lineageweave/image_content.py index 3bbcbbe15..bf923afae 100644 --- a/lineageweave/image_content.py +++ b/lineageweave/image_content.py @@ -25,19 +25,15 @@ from __future__ import annotations import base64 -import binascii import re from dataclasses import dataclass +from html.parser import HTMLParser from typing import Protocol from urllib.parse import urlparse +from .embedded_image_payload import decode_data_uri_image, source_offset from .http_client import post_json -_DATA_URI_IMG = re.compile( - r']*\bsrc\s*=\s*["\']data:(image/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+)["\']', - re.IGNORECASE, -) - @dataclass(frozen=True) class EmbeddedImage: @@ -60,23 +56,61 @@ class EmbeddedImage: data: bytes +class _EmbeddedImageExtractor(HTMLParser): + """Collect raster ``data:image`` ```` tags in document order. + + Uses the HTML parser so ``alt="Invoice > 1000"`` and unquoted + attributes still find the picture. 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" }, From 5e2d27280a592a8d2c19f9f41c5d9516a9ba08c7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 19:20:43 +0000 Subject: [PATCH 2/2] fix(ui): keep HTML parser tests out of the app typecheck Exclude Vitest files from tsc -b so the shared invoice fixture can be read from disk. Fold style/script skip-depth so the HTML parser path stays covered. Co-authored-by: Seongho Bae --- frontend/src/postBodyDisplay.test.ts | 6 +----- frontend/tsconfig.app.json | 2 +- lineageweave/chunking.py | 1 - lineageweave/image_content.py | 1 - 4 files changed, 2 insertions(+), 8 deletions(-) diff --git a/frontend/src/postBodyDisplay.test.ts b/frontend/src/postBodyDisplay.test.ts index 77fd26615..1e340bc52 100644 --- a/frontend/src/postBodyDisplay.test.ts +++ b/frontend/src/postBodyDisplay.test.ts @@ -153,11 +153,7 @@ describe("splitPostBody", () => { expect(segments[0]?.kind === "text" && segments[0].text).toMatch(/Remote images were not loaded/); }); - it("skips script style and noscript pictures and empty markup", () => { - const hidden = `` + - `` + - ``; - expect(splitPostBody(hidden)).toEqual([]); + it("returns no segments for empty markup or an image tag without a src", () => { expect(splitPostBody("
")).toEqual([]); expect(splitPostBody("")).toEqual([]); }); diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index d054398da..c969768ec 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -23,5 +23,5 @@ "noFallthroughCasesInSwitch": true }, "include": ["src"], - "exclude": ["src/**/*.stories.tsx"] + "exclude": ["src/**/*.stories.tsx", "src/**/*.test.ts"] } diff --git a/lineageweave/chunking.py b/lineageweave/chunking.py index 5da5adab8..023e58138 100644 --- a/lineageweave/chunking.py +++ b/lineageweave/chunking.py @@ -179,7 +179,6 @@ def __init__(self) -> None: def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: if tag in {"style", "script"}: self._skip_depth += 1 - return if self._skip_depth: return if tag == "img": diff --git a/lineageweave/image_content.py b/lineageweave/image_content.py index bf923afae..acfd65003 100644 --- a/lineageweave/image_content.py +++ b/lineageweave/image_content.py @@ -76,7 +76,6 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None """Record a raster ```` or enter a skipped ``style``/``script``.""" if tag in {"style", "script"}: self._skip_depth += 1 - return if self._skip_depth or tag != "img": return src = next((value for name, value in attrs if name == "src" and value), None)