🐾 fix(parsers): rasterize SVG instead of failing ingest #6

Merged
r merged 1 commits from fix/svg-ingest-rasterize into main 2026-07-26 12:03:42 +00:00
Owner

The bug

"svg" was in both IMAGE_EXTENSIONS and PYMUPDF_EXTENSIONS, and the image check ran first — so every SVG reached PIL.Image.open, which cannot decode vector XML. Ingest raised UnidentifiedImageError, logged "Failed to read image file_type=svg", and counted documents_parsed_total{status="error"}.

SVG ingest has never worked. Confirmed empirically, not inferred: PIL.Image.open on a real note raises UnidentifiedImageError.

There were no SVG tests, which is why it went unnoticed.

The fix

Rasterize to PNG via PyMuPDF (already a dependency — no new system libraries).

This also unblocks the vision stage downstream: services/vision.py sends each image to a vision LLM as a data: URI, which cannot carry image/svg+xml either. So description, ocr_text, and the DEPICTS concept graph were unreachable for SVG regardless of the parse fix.

Why SVG is routed explicitly, before both extension sets

Simply removing svg from IMAGE_EXTENSIONS would let it fall through to PYMUPDF_EXTENSIONS — which parses but does not split pages. Handwritten notes from the Write app store a multi-page document as one SVG holding several <svg class="write-page"> children stacked via x/y offsets, and rendering that whole fails two different ways:

  • No root width/height (the majority of real files): MuPDF falls back to US-Letter and emits only the top-left corner — ruled lines, zero handwriting, in a perfectly valid PNG. This is the dangerous one: it looks like it worked.
  • Root sized across all pages (newer Write builds): renders as one tall strip; capped to a sane longest edge, a 5-page note squashes to ~209px wide.

Splitting per write-page element is correct for both, and since Write never rewrites existing files, both formats persist indefinitely — this isn't a migration window.

Each page becomes its own ExtractedImage with ext="png" and source_page=<index>, so every page reaches vision/OCR at a legible size.

New dependency

lxmlrecover=True is required for the real files that aren't well-formed XML, and stdlib ElementTree has no equivalent. (It does find the pages, so it's viable only if you're willing to lose those files.)

Verification

  • 26 tests pass (new test_svg_raster.py + existing test_parsers.py), plus 84 across the DB-free service suite — no regressions.
  • Parsed a real 2-page note end-to-end: 2 PNG images, correct dimensions, ink present on both pages.
  • Tests pin both silent-failure modes (non-blank pixels, page-shaped aspect ratio) against both root formats, plus the malformed-XML and unquoted-root-attribute cases.

The Django test runner couldn't create a test database in my environment (permission denied to create database), so these were run through unittest directly — none of them touch the DB.

Notes for review

  • services/svg_raster.py is a deliberate twin of daedalus's backend/daedalus/extraction/svg.py (see daedalus PR #11). The repos share no common package and a themis-style shared wheel is disproportionate for ~80 lines, so the duplication is noted in both docstrings — fixes belong in both.
  • Scoping note: my working tree had unrelated in-flight changes (UnsupportedFileTypeError in parsers.py, tasks.py, test_tasks.py, docs/deploy.md). Since UnsupportedFileTypeError lives in a file I also edited, I staged only my own hunks and verified the staged tree passes standalone in a scratch worktree — this branch does not contain or depend on that work, and it remains uncommitted locally.

🤖 Generated with Claude Code

## The bug `"svg"` was in **both** `IMAGE_EXTENSIONS` and `PYMUPDF_EXTENSIONS`, and the image check ran first — so every SVG reached `PIL.Image.open`, which cannot decode vector XML. Ingest raised `UnidentifiedImageError`, logged `"Failed to read image file_type=svg"`, and counted `documents_parsed_total{status="error"}`. **SVG ingest has never worked.** Confirmed empirically, not inferred: `PIL.Image.open` on a real note raises `UnidentifiedImageError`. There were no SVG tests, which is why it went unnoticed. ## The fix Rasterize to PNG via **PyMuPDF** (already a dependency — no new system libraries). This also unblocks the **vision stage downstream**: `services/vision.py` sends each image to a vision LLM as a `data:` URI, which cannot carry `image/svg+xml` either. So `description`, `ocr_text`, and the `DEPICTS` concept graph were unreachable for SVG regardless of the parse fix. ### Why SVG is routed explicitly, before both extension sets Simply removing `svg` from `IMAGE_EXTENSIONS` would let it fall through to `PYMUPDF_EXTENSIONS` — which parses but does **not** split pages. Handwritten notes from the Write app store a multi-page document as one SVG holding several `<svg class="write-page">` children stacked via `x`/`y` offsets, and rendering that whole fails two different ways: - **No root `width`/`height`** (the majority of real files): MuPDF falls back to US-Letter and emits only the top-left corner — ruled lines, zero handwriting, in a perfectly valid PNG. This is the dangerous one: it *looks* like it worked. - **Root sized across all pages** (newer Write builds): renders as one tall strip; capped to a sane longest edge, a 5-page note squashes to ~209px wide. Splitting per `write-page` element is correct for both, and since Write never rewrites existing files, both formats persist indefinitely — this isn't a migration window. Each page becomes its own `ExtractedImage` with `ext="png"` and `source_page=<index>`, so every page reaches vision/OCR at a legible size. ## New dependency `lxml` — `recover=True` is required for the real files that aren't well-formed XML, and stdlib `ElementTree` has no equivalent. (It does find the pages, so it's viable only if you're willing to lose those files.) ## Verification - **26 tests pass** (new `test_svg_raster.py` + existing `test_parsers.py`), plus **84** across the DB-free service suite — no regressions. - Parsed a real 2-page note end-to-end: 2 PNG images, correct dimensions, ink present on both pages. - Tests pin both silent-failure modes (non-blank pixels, page-shaped aspect ratio) against *both* root formats, plus the malformed-XML and unquoted-root-attribute cases. The Django test runner couldn't create a test database in my environment (`permission denied to create database`), so these were run through `unittest` directly — none of them touch the DB. ## Notes for review - `services/svg_raster.py` is a **deliberate twin** of daedalus's `backend/daedalus/extraction/svg.py` (see daedalus PR #11). The repos share no common package and a themis-style shared wheel is disproportionate for ~80 lines, so the duplication is noted in both docstrings — **fixes belong in both**. - Scoping note: my working tree had unrelated in-flight changes (`UnsupportedFileTypeError` in `parsers.py`, `tasks.py`, `test_tasks.py`, `docs/deploy.md`). Since `UnsupportedFileTypeError` lives in a file I also edited, I staged only my own hunks and verified the staged tree passes standalone in a scratch worktree — **this branch does not contain or depend on that work**, and it remains uncommitted locally. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
r added 1 commit 2026-07-26 11:48:55 +00:00
"svg" was in both IMAGE_EXTENSIONS and PYMUPDF_EXTENSIONS, and the image
check ran first, so every SVG reached PIL.Image.open — which cannot decode
vector XML. Ingest raised UnidentifiedImageError, logged "Failed to read
image file_type=svg", and counted documents_parsed_total{status="error"}.
SVG ingest has never worked.

Rasterize to PNG instead, via PyMuPDF (already a dependency). This also
unblocks the vision stage downstream: it sends images to a vision LLM as a
data: URI, which cannot carry image/svg+xml either, so the description and
OCR fields were unreachable for SVG regardless of the parse fix.

Route SVG explicitly before both extension sets. Falling through to
PYMUPDF_EXTENSIONS would parse but not split, and a multi-page Write note
rendered as one document is either blank (no root width/height, so MuPDF
falls back to US-Letter and emits the top-left corner) or an illegible tall
strip (root sized across every stacked page). Splitting per write-page
element is correct for both formats, and since Write never rewrites
existing files both persist indefinitely.

svg_raster is a deliberate twin of daedalus's extraction/svg.py — the repos
share no common package, so the duplication is noted in both docstrings and
fixes belong in both.

lxml is a new dependency: recover=True is needed for the real files that
aren't well-formed XML, and stdlib ElementTree has no equivalent.
r merged commit 9f5df20d2b into main 2026-07-26 12:03:42 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: r/mnemosyne#6