"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.
264 lines
9.1 KiB
Python
264 lines
9.1 KiB
Python
"""SVG rasterization for the ingest pipeline.
|
|
|
|
The vision stage sends each extracted image to a vision LLM as a ``data:`` URI,
|
|
which cannot carry ``image/svg+xml`` — so an SVG has to become raster before it
|
|
can be described, OCR'd, or embedded. PyMuPDF (already a dependency for PDF
|
|
parsing) renders SVG natively, so this needs no cairo/rsvg system libraries.
|
|
|
|
The non-obvious part is page splitting. Handwritten notes from the Write
|
|
(Stylus Labs) app are a single SVG document holding one or more
|
|
``<svg class="write-page">`` children stacked vertically via x/y offsets. Two
|
|
root formats exist in the wild and *both* must be split per page:
|
|
|
|
- Older files carry no width/height on the root ``<svg>``. Rendering the
|
|
document as-is makes PyMuPDF fall back to US-Letter and emit the top-left
|
|
corner only — ruled lines and no handwriting, in a perfectly valid PNG.
|
|
- Newer files (Write commit eeab021) do carry root width/height, spanning the
|
|
full stacked extent. Rendering those as-is yields one very tall strip; capped
|
|
to a sane longest edge, a 5-page note squashes to ~209px wide, well past
|
|
illegible.
|
|
|
|
Write never rewrites existing files, so the old format is permanent rather than
|
|
a migration window. Page geometry lives on the ``write-page`` element and is
|
|
identical across both formats, so splitting ignores the root dimensions
|
|
entirely and needs no format detection.
|
|
|
|
.. note::
|
|
Daedalus carries a twin of this module at
|
|
``backend/daedalus/extraction/svg.py``, which returns base64 for direct
|
|
chat attachment. The two are deliberately duplicated rather than shared —
|
|
the repos ship no common package — so fixes belong in both.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import logging
|
|
import re
|
|
|
|
from lxml import etree
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SVG_NS = "http://www.w3.org/2000/svg"
|
|
XLINK_NS = "http://www.w3.org/1999/xlink"
|
|
|
|
_SVG = f"{{{SVG_NS}}}"
|
|
_PAGE_CLASS = "write-page"
|
|
|
|
# Write documents render on a grey backdrop; pages themselves are transparent,
|
|
# so each rendered page needs an explicit white underlay or strokes land on
|
|
# black in the flattened PNG.
|
|
_PAGE_BACKGROUND = "#ffffff"
|
|
|
|
#: Longest edge of a rendered page, in pixels. Enough for a vision model to
|
|
#: read handwriting without spending tokens on unusable resolution.
|
|
DEFAULT_TARGET_PX = 1568
|
|
|
|
#: Cap on pages rendered from one document.
|
|
DEFAULT_MAX_PAGES = 20
|
|
|
|
# Unquoted attribute value in the root tag, e.g. Write's `width=auto`. Matches
|
|
# only bare alphabetic values so numeric or already-quoted attributes are left
|
|
# alone.
|
|
_UNQUOTED_ATTR = re.compile(rb"(\s[-\w:]+)=([A-Za-z][-\w]*)(?=[\s>])")
|
|
_ROOT_TAG = re.compile(rb"<svg[^>]*>")
|
|
|
|
|
|
class SvgRenderError(Exception):
|
|
"""Raised when an SVG cannot be parsed or contains no renderable page."""
|
|
|
|
|
|
def _repair_root_tag(data: bytes) -> bytes:
|
|
"""Quote unquoted attribute values in the root ``<svg>`` tag.
|
|
|
|
Some Write files emit ``width=auto height=auto``, which is not valid XML.
|
|
A malformation *inside* the root tag defeats ``recover=True`` differently
|
|
from one in the body: rather than dropping a subtree, libxml2 abandons the
|
|
whole document and yields a bare root, so every page becomes invisible.
|
|
Quoting the values first recovers the full tree.
|
|
"""
|
|
match = _ROOT_TAG.search(data)
|
|
if not match:
|
|
return data
|
|
repaired = _UNQUOTED_ATTR.sub(rb'\1="\2"', match.group(0))
|
|
if repaired == match.group(0):
|
|
return data
|
|
return data[: match.start()] + repaired + data[match.end() :]
|
|
|
|
|
|
def _parser() -> etree.XMLParser:
|
|
"""Build the hardened parser used for all untrusted SVG input.
|
|
|
|
``resolve_entities=False`` blocks XXE — ingest content is untrusted.
|
|
``huge_tree`` is required because handwriting path data runs to megabytes.
|
|
``recover`` salvages the handful of Write files that emit unescaped
|
|
attribute content and are not well-formed XML.
|
|
"""
|
|
return etree.XMLParser(
|
|
huge_tree=True,
|
|
resolve_entities=False,
|
|
no_network=True,
|
|
recover=True,
|
|
)
|
|
|
|
|
|
def _dimension(element: etree._Element, name: str) -> float | None:
|
|
"""Read a CSS-pixel dimension attribute, tolerating a ``px`` suffix."""
|
|
raw = element.get(name)
|
|
if not raw:
|
|
return None
|
|
try:
|
|
return float(raw.strip().removesuffix("px"))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _viewbox_size(element: etree._Element) -> tuple[float, float] | None:
|
|
"""Derive width/height from a viewBox extent."""
|
|
raw = element.get("viewBox")
|
|
if not raw:
|
|
return None
|
|
parts = raw.replace(",", " ").split()
|
|
if len(parts) != 4:
|
|
return None
|
|
try:
|
|
width, height = float(parts[2]), float(parts[3])
|
|
except ValueError:
|
|
return None
|
|
return (width, height) if width > 0 and height > 0 else None
|
|
|
|
|
|
def _element_size(element: etree._Element) -> tuple[float, float] | None:
|
|
"""Resolve an element's rendered size from width/height, else viewBox."""
|
|
width = _dimension(element, "width")
|
|
height = _dimension(element, "height")
|
|
if width and height:
|
|
return width, height
|
|
return _viewbox_size(element)
|
|
|
|
|
|
def _standalone_page(
|
|
page: etree._Element,
|
|
defs: etree._Element | None,
|
|
width: float,
|
|
height: float,
|
|
) -> bytes:
|
|
"""Wrap one ``write-page`` element as its own renderable SVG document.
|
|
|
|
The page is repositioned to the origin (its x/y place it within the stacked
|
|
parent) and given an explicit viewBox so the renderer has an unambiguous
|
|
size. ``defs`` is copied in because pen and ruling definitions live on the
|
|
root and are referenced by page content.
|
|
"""
|
|
root = etree.Element(f"{_SVG}svg", nsmap={None: SVG_NS, "xlink": XLINK_NS})
|
|
root.set("width", str(width))
|
|
root.set("height", str(height))
|
|
root.set("viewBox", f"0 0 {width} {height}")
|
|
|
|
background = etree.SubElement(root, f"{_SVG}rect")
|
|
background.set("width", "100%")
|
|
background.set("height", "100%")
|
|
background.set("fill", _PAGE_BACKGROUND)
|
|
|
|
if defs is not None:
|
|
root.append(copy.deepcopy(defs))
|
|
|
|
element = copy.deepcopy(page)
|
|
for positional in ("x", "y"):
|
|
element.attrib.pop(positional, None)
|
|
element.set("viewBox", f"0 0 {width} {height}")
|
|
root.append(element)
|
|
|
|
return etree.tostring(root)
|
|
|
|
|
|
def split_svg_pages(data: bytes) -> list[bytes]:
|
|
"""Split an SVG into one standalone document per renderable page.
|
|
|
|
Write multi-page notes yield one document per ``write-page`` child. Any
|
|
other SVG (a diagram, a logo) yields a single document — the input itself,
|
|
which the renderer sizes from its own width/height or viewBox.
|
|
|
|
:param data: Raw SVG bytes.
|
|
:returns: One or more standalone SVG documents, in page order.
|
|
:raises SvgRenderError: If the SVG cannot be parsed or has no usable size.
|
|
"""
|
|
try:
|
|
root = etree.fromstring(_repair_root_tag(data), _parser())
|
|
except etree.XMLSyntaxError as exc:
|
|
raise SvgRenderError(f"Could not parse SVG: {exc}") from exc
|
|
|
|
if root is None:
|
|
raise SvgRenderError("Could not parse SVG: no root element.")
|
|
|
|
defs = root.find(f"{_SVG}defs")
|
|
|
|
pages: list[bytes] = []
|
|
for element in root.iter(f"{_SVG}svg"):
|
|
if element is root:
|
|
continue
|
|
if _PAGE_CLASS not in (element.get("class") or "").split():
|
|
continue
|
|
size = _element_size(element)
|
|
if not size:
|
|
continue
|
|
pages.append(_standalone_page(element, defs, *size))
|
|
|
|
if pages:
|
|
return pages
|
|
|
|
# Generic SVG: render as-is. It must still be sizeable, or the renderer
|
|
# would silently substitute a default page box.
|
|
if not _element_size(root):
|
|
raise SvgRenderError(
|
|
"SVG has no width/height or viewBox, so its size is undefined."
|
|
)
|
|
return [data]
|
|
|
|
|
|
def render_svg_pages(
|
|
data: bytes,
|
|
max_pages: int = DEFAULT_MAX_PAGES,
|
|
target_px: int = DEFAULT_TARGET_PX,
|
|
) -> list[tuple[bytes, int, int]]:
|
|
"""Rasterize an SVG to one PNG per page.
|
|
|
|
:param data: Raw SVG bytes.
|
|
:param max_pages: Cap on pages rendered.
|
|
:param target_px: Longest edge of each rendered page, in pixels.
|
|
:returns: One ``(png_bytes, width, height)`` tuple per page, in page order.
|
|
:raises SvgRenderError: If nothing could be rendered.
|
|
"""
|
|
import fitz
|
|
|
|
pages = split_svg_pages(data)
|
|
|
|
rendered: list[tuple[bytes, int, int]] = []
|
|
for index, page_svg in enumerate(pages[:max_pages]):
|
|
try:
|
|
with fitz.open(stream=page_svg, filetype="svg") as document:
|
|
page = document[0]
|
|
longest = max(page.rect.width, page.rect.height)
|
|
zoom = target_px / longest if longest else 1.0
|
|
pixmap = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom))
|
|
rendered.append(
|
|
(pixmap.tobytes("png"), pixmap.width, pixmap.height)
|
|
)
|
|
except Exception as exc:
|
|
raise SvgRenderError(
|
|
f"Could not render SVG page {index + 1}: {exc}"
|
|
) from exc
|
|
|
|
if not rendered:
|
|
raise SvgRenderError("No renderable pages in SVG.")
|
|
|
|
logger.info(
|
|
"Rasterized SVG pages_rendered=%d total_pages=%d target_px=%d",
|
|
len(rendered),
|
|
len(pages),
|
|
target_px,
|
|
)
|
|
|
|
return rendered
|