Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 83 additions & 2 deletions docling/backend/utils/image_resource_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
import certifi
import requests
import urllib3
from docling_core.types.doc.document import ImageRef
from docling_core.types.doc.document import ImageRef, Size
from PIL import Image, UnidentifiedImageError
from pydantic import ValidationError
from requests.utils import get_environ_proxies, select_proxy
Expand All @@ -39,6 +39,15 @@

_IPAddress = Union[ipaddress.IPv4Address, ipaddress.IPv6Address]

# The media type of a base64 `data:` URI, e.g. `image/svg+xml` in
# `data:image/svg+xml;base64,...`.
_DATA_URI_MIMETYPE_RE = re.compile(r"^data:([^;,]+)[^,]*;base64,", re.IGNORECASE)
# The root element of an SVG document and its attributes. The element is matched
# with a regular expression instead of parsed, so that malformed or hostile
# documents (entity expansions included) cannot affect the loader.
_SVG_ELEMENT_RE = re.compile(rb"<svg\b[^>]*>")
_SVG_ATTRIBUTE_RE = re.compile(rb"""\b([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')""")

# NAT64 well-known prefix (RFC 6052): 64:ff9b::/96 embeds an IPv4 address in its
# low 32 bits.
_NAT64_PREFIX = ipaddress.ip_network("64:ff9b::/96")
Expand Down Expand Up @@ -252,6 +261,70 @@ class RemoteResource:
content: bytes


def _svg_length(value: Optional[bytes]) -> Optional[float]:
"""Parse an SVG length in user units, ignoring other units."""
if value is None:
return None
match = re.fullmatch(rb"\s*([0-9]*\.?[0-9]+)\s*(?:px)?", value)
return float(match.group(1)) if match else None


def _svg_declared_size(data: bytes) -> tuple[float, float]:
"""Return the size an SVG document declares, or ``(0.0, 0.0)``.

Only the ``width``/``height`` of the root element and, when one of them is
missing, its ``viewBox`` are read. A percentage or a unit other than ``px``
declares no size in user units, so it is ignored.
"""
if (element := _SVG_ELEMENT_RE.search(data)) is None:
return 0.0, 0.0

attributes = {
name.lower(): double_quoted or single_quoted
for name, double_quoted, single_quoted in _SVG_ATTRIBUTE_RE.findall(
element.group(0)
)
}
width = _svg_length(attributes.get(b"width"))
height = _svg_length(attributes.get(b"height"))
if (view_box := attributes.get(b"viewbox")) is not None:
parts = re.split(rb"[\s,]+", view_box.strip())
if len(parts) == 4:
try:
box_width, box_height = float(parts[2]), float(parts[3])
except ValueError:
pass
else:
width = width if width is not None else box_width
height = height if height is not None else box_height
return width or 0.0, height or 0.0


def _inline_svg_image_ref(src_url: str, data: bytes) -> Optional[ImageRef]:
"""Keep the payload of an inline SVG data URI as an image resource.

Pillow cannot decode SVG, so there are no pixels to build the ref from. The
ref instead carries the declared media type, the size the document declares,
and the original data URI, which the Markdown and HTML serializers emit
as-is, so the payload survives exports and round trips. Rendering the SVG
into an image needs an SVG renderer; ``ImageRef.pil_image`` is therefore not
available for such a ref.

Returns ``None`` for any other source, and the caller keeps its warning.
"""
declared = _DATA_URI_MIMETYPE_RE.match(src_url)
if declared is None or declared.group(1).lower() != "image/svg+xml":
return None

width, height = _svg_declared_size(data)
return ImageRef(
mimetype="image/svg+xml",
dpi=72,
size=Size(width=width, height=height),
uri=src_url,
)


class ImageResourceLoader:
"""Resolve and load image resources for declarative document backends.

Expand Down Expand Up @@ -347,7 +420,15 @@ def create_image_ref(
try:
img_data = self.load_image_data(src_url, base_path)
if img_data:
img = Image.open(BytesIO(img_data))
try:
img = Image.open(BytesIO(img_data))
except UnidentifiedImageError:
# Pillow cannot decode an inline SVG. Keep its decoded
# payload as the image resource rather than losing the image
# of the picture. Any other payload keeps the warning.
if (svg_ref := _inline_svg_image_ref(src_url, img_data)) is None:
raise
return svg_ref

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocker: SVG ImageRef makes PictureItem.get_image() raise instead of returning None

Trigger: Markdown or HTML input with ![x](data:image/svg+xml;base64,...) and fetch_images=True. This is now the default path for such input.

Cause: The new ImageRef has uri=data:image/svg+xml;... and no _pil. FloatingItem.get_image() returns self.image.pil_image. For a data: URI, ImageRef.pil_image calls PILImage.open(BytesIO(decoded)). It has no try/except, so it raises UnidentifiedImageError. This happens in docling_core/types/doc/common/reference.py (the pil_image property) and in docling_core/types/doc/items/node.py (FloatingItem.get_image).

Wrong result: Before this PR the picture had image=None. get_image() then returned None for a picture with no provenance, and callers skipped it. Now two paths crash:

  • DoclingDocument._with_pictures_refs calls item.get_image(doc=self) with no guard. It runs for export_to_markdown, export_to_html and save_as_* with image_mode=ImageRefMode.REFERENCED. A document with one inline SVG can no longer be exported in this mode.
  • BaseItemAndImageEnrichmentModel.prepare_element in docling/models/base_model.py:197 calls element.get_image(conv_res.document). The picture description, classification and similar enrichment stages will crash on the SVG picture.

The docstring says pil_image is "not available" for such a ref. In fact it raises, and the new code does not guard the callers.

Test gap: The tests use only ImageRefMode.EMBEDDED and export_to_dict. Neither calls pil_image. No test covers REFERENCED mode or get_image() on the SVG picture.

Fix: Do not hand out an ImageRef that pil_image cannot open. Options:

  • Keep image=None and store the SVG payload elsewhere, for example in the picture meta.
  • Rasterize the SVG so the ref has real pixels.
  • Change docling_core so that pil_image returns None for non-raster mimetypes.

Add a regression test that exports with ImageRefMode.REFERENCED and calls get_image(doc) on the SVG pictu…

return ImageRef.from_pil(img, dpi=int(img.info.get("dpi", (72,))[0]))
except (
requests.RequestException,
Expand Down
61 changes: 61 additions & 0 deletions tests/test_backend_markdown_images.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# SPDX-FileCopyrightText: The Docling Contributors
# SPDX-License-Identifier: MIT

import base64
import warnings
from pathlib import Path

import pytest
Expand All @@ -14,6 +16,16 @@

pytestmark = pytest.mark.cross_platform

_SVG_RECT = (
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80">'
'<rect width="120" height="80" fill="#1e64c8"/></svg>'
)


def _svg_data_uri(svg: str = _SVG_RECT) -> str:
encoded = base64.b64encode(svg.encode()).decode()
return f"data:image/svg+xml;base64,{encoded}"


@pytest.mark.parametrize(
"html_block",
Expand Down Expand Up @@ -163,3 +175,52 @@ def test_converter_keeps_an_image_inside_a_markdown_table_cell() -> None:
assert "data:image/png;base64," in doc.export_to_markdown(
image_mode=ImageRefMode.EMBEDDED
)


@pytest.mark.parametrize("fetch_images", [True, False])
def test_inline_svg_data_uri_keeps_the_image(fetch_images: bool) -> None:
svg_uri = _svg_data_uri()
markdown = f"![red box]({_png_data_uri(120, 80)})\n\n![blue box]({svg_uri})\n"
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
doc = _convert_markdown(
markdown, MarkdownBackendOptions(fetch_images=fetch_images)
)

assert len(doc.pictures) == 2
if not fetch_images:
assert all(picture.image is None for picture in doc.pictures)
return

assert not [w for w in caught if "Could not process an image" in str(w.message)]
png_picture, svg_picture = doc.pictures
# A raster payload still goes through Pillow.
assert png_picture.image is not None
assert png_picture.image.mimetype == "image/png"
assert png_picture.get_image(doc).getpixel((0, 0)) == (255, 0, 0)
# Pillow cannot decode SVG, so the picture keeps the decoded payload.
assert svg_picture.image is not None
assert svg_picture.image.mimetype == "image/svg+xml"
assert svg_picture.image.size.as_tuple() == (120.0, 80.0)
assert str(svg_picture.image.uri) == svg_uri

# The payload survives an export and a round trip through the model.
assert svg_uri in doc.export_to_markdown(image_mode=ImageRefMode.EMBEDDED)
restored = DoclingDocument.model_validate(doc.export_to_dict())
assert len(restored.pictures) == 2
assert restored.pictures[1].image is not None
assert restored.pictures[1].image.mimetype == "image/svg+xml"
assert str(restored.pictures[1].image.uri) == svg_uri


def test_inline_svg_data_uri_size_comes_from_the_view_box() -> None:
svg_uri = _svg_data_uri(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 16">'
'<rect width="32" height="16" fill="#1e64c8"/></svg>'
)
doc = _convert_markdown(
f"![blue box]({svg_uri})\n", MarkdownBackendOptions(fetch_images=True)
)
assert len(doc.pictures) == 1
assert doc.pictures[0].image is not None
assert doc.pictures[0].image.size.as_tuple() == (32.0, 16.0)
Loading