Skip to content
Merged
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
3 changes: 2 additions & 1 deletion docling/.agents/skills/docling/references/python-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,8 @@ Or save with `ImageRefMode.EMBEDDED` so the JSON carries its images.

HTML image downloads are off by default. Enable them on the HTML backend
options; downloads only connect to public addresses, re-check every redirect
and stop at `max_remote_image_bytes`.
and stop at `max_remote_image_bytes`. An image larger than `max_image_pixels`
(width x height, default 89,478,485) is skipped with a warning.

```python
from docling.datamodel.backend_options import HTMLBackendOptions
Expand Down
11 changes: 10 additions & 1 deletion docling/backend/html_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
detect_code_language,
normalize_code_language,
)
from docling.utils.image_utils import check_image_pixels

_BS4_AVAILABLE: bool = False
_BS4_IMPORT_ERROR: ImportError | None = None
Expand Down Expand Up @@ -536,6 +537,7 @@ def __init__(
enable_remote_fetch=options.enable_remote_fetch,
max_image_data_base64_bytes=options.max_image_data_base64_bytes,
max_remote_image_bytes=options.max_remote_image_bytes,
max_image_pixels=options.max_image_pixels,
max_redirects=options.max_redirects,
headers=options.headers,
header_origins=self._get_header_origins(options, configured_base_path),
Expand Down Expand Up @@ -5375,11 +5377,18 @@ def _create_image_ref(self, src_url: str) -> Optional[ImageRef]:
return None
try:
image = Image.open(BytesIO(image_data))
check_image_pixels(image.size, self.options.max_image_pixels)
image.load()
return ImageRef.from_pil(
image, dpi=int(image.info.get("dpi", (72,))[0])
)
except (UnidentifiedImageError, OSError, TypeError, ValueError) as exc:
except (
Image.DecompressionBombError,
UnidentifiedImageError,
OSError,
TypeError,
ValueError,
) as exc:
warnings.warn(
f"Could not process an embedded MHTML image from {src_url}: "
f"{exc}"
Expand Down
80 changes: 43 additions & 37 deletions docling/backend/image_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import logging
import math
import threading
from collections.abc import Iterable
from io import BytesIO
from pathlib import Path
Expand All @@ -24,7 +25,7 @@
from docling.datamodel.base_models import InputFormat
from docling.datamodel.document import InputDocument
from docling.exceptions import DocumentLoadError
from docling.utils.image_utils import to_rgb
from docling.utils.image_utils import check_image_pixels, to_rgb

_log = logging.getLogger(__name__)

Expand Down Expand Up @@ -200,8 +201,10 @@ class ImageDocumentBackend(PdfDocumentBackend):
- Subclasses PdfDocumentBackend to satisfy pipeline type checks.
- Intentionally avoids calling PdfDocumentBackend.__init__ to skip
the image→PDF conversion and any pypdfium2 usage.
- Handles multi-page TIFF by extracting frames eagerly to separate
Image objects to keep thread-safety when pages process in parallel.
- Reads only the frame count when opened, so the page limits are
checked before any pixel data is decoded. Each page decodes its
frame on ``load_page`` into a separate Image object, which keeps
pages thread-safe when they process in parallel.
"""

def __init__(
Expand All @@ -221,47 +224,52 @@ def __init__(
f"Incompatible file format {self.input_format} was passed to ImageDocumentBackend."
)

# Load frames eagerly for thread-safety across pages
self._frames: list[Image.Image] = []
self._frame_dpi: list[tuple[float, float]] = []
# The source stays open until unload(); the lock serializes the seeks
# on it, because pages can load from several threads.
self._lock = threading.Lock()
self._source: Image.Image | None = None
self._frame_count = 0
try:
with Image.open(self.path_or_stream) as img: # type: ignore[arg-type]
# Handle multi-frame and single-frame images
# - multiframe formats: TIFF, GIF, ICO
# - singleframe formats: JPEG (.jpg, .jpeg), PNG (.png), BMP, WEBP (unless animated), HEIC
frame_count = getattr(img, "n_frames", 1)

if frame_count > 1:
for i in range(frame_count):
img.seek(i)
frame, dpi = _oriented_frame(img)
self._frame_dpi.append(dpi)
self._frames.append(to_rgb(frame))
else:
frame, dpi = _oriented_frame(img)
self._frame_dpi.append(dpi)
self._frames.append(to_rgb(frame))
self._source = Image.open(self.path_or_stream) # type: ignore[arg-type]
# Multi-frame formats: TIFF, GIF, ICO, animated WEBP/PNG. Other
# formats have no n_frames attribute and hold one frame.
self._frame_count = getattr(self._source, "n_frames", 1)
# The first frame is the current one after open (and after
# n_frames, which seeks back): reject a bad first frame here,
# without decoding it.
check_image_pixels(self._source.size, self.options.max_image_pixels)
_get_frame_dpi(self._source)
except Exception as e:
for frame in self._frames:
frame.close()
self._frames = []
self._frame_dpi = []
self._close_source()
raise DocumentLoadError(
f"Could not load image for document {self.file}"
f"Could not load image for document {self.file}: {e}"
) from e

def _close_source(self) -> None:
if self._source is not None:
self._source.close()
self._source = None
self._frame_count = 0

def is_valid(self) -> bool:
return len(self._frames) > 0
return self._frame_count > 0

def page_count(self) -> int:
return len(self._frames)
return self._frame_count

def load_page(self, page_no: int) -> _ImagePageBackend:
if not (0 <= page_no < len(self._frames)):
if not (0 <= page_no < self._frame_count):
raise IndexError(f"Page index out of range: {page_no}")
return _ImagePageBackend(
self._frames[page_no], page_no, self._frame_dpi[page_no]
)
with self._lock:
assert self._source is not None
if self._source.tell() != page_no:
self._source.seek(page_no)
# Frames of a multi-page TIFF have their own size, so each one is
# checked before it is decoded.
check_image_pixels(self._source.size, self.options.max_image_pixels)
frame, dpi = _oriented_frame(self._source)
image = to_rgb(frame)
return _ImagePageBackend(image, page_no, dpi)

@classmethod
def supported_formats(cls) -> set[InputFormat]:
Expand All @@ -273,8 +281,6 @@ def supports_pagination(cls) -> bool:
return True

def unload(self):
for frame in self._frames:
frame.close()
self._frames = []
self._frame_dpi = []
with self._lock:
self._close_source()
super().unload()
1 change: 1 addition & 0 deletions docling/backend/md_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,7 @@ def _get_image_loader(self) -> ImageResourceLoader:
enable_local_fetch=md_options.enable_local_fetch,
enable_remote_fetch=md_options.enable_remote_fetch,
max_image_data_base64_bytes=md_options.max_image_data_base64_bytes,
max_image_pixels=md_options.max_image_pixels,
)
return self._image_loader

Expand Down
20 changes: 18 additions & 2 deletions docling/backend/utils/image_resource_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
Turns an image source (a URI, a local file, or a remote URL) into a DoclingDocument
`ImageRef`, enforcing the relevant safety limits: a path-traversal guard when resolving
relative paths, the `enable_local_fetch` / `enable_remote_fetch` toggles, and the
base64 and remote download size caps.
relative paths, the `enable_local_fetch` / `enable_remote_fetch` toggles, the
base64 and remote download size caps, and a limit on the decoded image pixels.
"""

import base64
Expand All @@ -32,7 +32,9 @@
from pydantic import ValidationError
from requests.utils import get_environ_proxies, select_proxy

from docling.datamodel.backend_options import DEFAULT_MAX_IMAGE_PIXELS
from docling.exceptions import OperationNotAllowed
from docling.utils.image_utils import check_image_pixels

_log = logging.getLogger(__name__)

Expand Down Expand Up @@ -267,6 +269,7 @@ def __init__(
enable_remote_fetch: bool = False,
max_image_data_base64_bytes: int = 20 * 1024 * 1024,
max_remote_image_bytes: int = 20 * 1024 * 1024,
max_image_pixels: int = DEFAULT_MAX_IMAGE_PIXELS,
max_redirects: int = 5,
headers: Optional[dict[str, str]] = None,
header_origins: Iterable[str] = (),
Expand All @@ -275,6 +278,7 @@ def __init__(
self.enable_remote_fetch = enable_remote_fetch
self.max_image_data_base64_bytes = max_image_data_base64_bytes
self.max_remote_image_bytes = max_remote_image_bytes
self.max_image_pixels = max_image_pixels
self.max_redirects = max_redirects
self.headers = headers
# Only requests to these origins carry the configured `headers`.
Expand Down Expand Up @@ -348,8 +352,12 @@ def create_image_ref(
img_data = self.load_image_data(src_url, base_path)
if img_data:
img = Image.open(BytesIO(img_data))
# Image.open reads only the header: check the size before
# ImageRef.from_pil decodes the pixels.
check_image_pixels(img.size, self.max_image_pixels)
return ImageRef.from_pil(img, dpi=int(img.info.get("dpi", (72,))[0]))
except (
Image.DecompressionBombError,
requests.RequestException,
urllib3.exceptions.HTTPError,
ValidationError,
Expand Down Expand Up @@ -454,6 +462,14 @@ def load_image_data(
return self.fetch_remote(src_loc).content
elif src_loc.startswith("data:"):
encoded_data = re.sub(r"^data:image/.+;base64,", "", src_loc)
# Check the size before decoding: 4 base64 characters hold 3
# bytes, less one byte for each "=" padding character.
padding = len(encoded_data[-2:]) - len(encoded_data[-2:].rstrip("="))
decoded_size = len(encoded_data) * 3 // 4 - padding
if decoded_size > self.max_image_data_base64_bytes:
raise ValueError(
f"Decoded image exceeds size limit of {self.max_image_data_base64_bytes} bytes."
)
decoded_data = base64.b64decode(encoded_data)

if len(decoded_data) > self.max_image_data_base64_bytes:
Expand Down
27 changes: 27 additions & 0 deletions docling/datamodel/backend_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
model_validator,
)

# Default limit on the pixels of one decoded image or image frame. It is the
# default of Pillow's ``Image.MAX_IMAGE_PIXELS`` (about 268 MB once decoded to
# RGB), fixed here so that the limit does not depend on global Pillow state.
DEFAULT_MAX_IMAGE_PIXELS = 89_478_485


class BaseBackendOptions(BaseModel):
"""Common options for all declarative document backends."""
Expand Down Expand Up @@ -196,6 +201,13 @@ class HTMLBackendOptions(BaseBackendOptions):
20 * 1024 * 1024, # 20 MB
description="The maximum number of bytes for remote image downloads.",
)
max_image_pixels: PositiveInt = Field(
DEFAULT_MAX_IMAGE_PIXELS,
description=(
"The maximum number of pixels (width x height) of an embedded or "
"fetched image. Larger images are skipped with a warning."
),
)
max_redirects: Annotated[int, Field(ge=0)] = Field(
5,
description="Maximum number of HTTP redirects to follow when fetching remote resources. Set to 0 to disable redirects.",
Expand Down Expand Up @@ -236,6 +248,13 @@ class MarkdownBackendOptions(TextBackendOptions):
20 * 1024 * 1024, # 20 MB
description="The maximum number of base64 data bytes that the backend will accept.",
)
max_image_pixels: PositiveInt = Field(
DEFAULT_MAX_IMAGE_PIXELS,
description=(
"The maximum number of pixels (width x height) of an embedded or "
"fetched image. Larger images are skipped with a warning."
),
)


class JatsBackendOptions(BaseBackendOptions):
Expand Down Expand Up @@ -306,6 +325,14 @@ class PdfBackendOptions(BaseBackendOptions):
"decoding costs time and memory, so it is off by default."
),
)
max_image_pixels: PositiveInt = Field(
DEFAULT_MAX_IMAGE_PIXELS,
description=(
"The maximum number of pixels (width x height) of one page of an "
"image input, such as one frame of a multi-page TIFF. Used by the "
"image backend; an image with a larger page is rejected."
),
)


class ThreadedDoclingParseBackendOptions(PdfBackendOptions):
Expand Down
14 changes: 14 additions & 0 deletions docling/utils/image_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@
from PIL import Image


def check_image_pixels(size: tuple[int, int], max_pixels: int) -> None:
"""Raise ``ValueError`` if an image of ``size`` exceeds ``max_pixels``.
Pillow reads the dimensions from the image header, so call this after
``Image.open`` (or ``seek``) and before the pixel data is decoded.
"""
width, height = size
if width * height > max_pixels:
raise ValueError(
f"Image of {width}x{height} pixels exceeds the limit of "
f"{max_pixels} pixels."
)


def to_rgb(image: Image.Image) -> Image.Image:
"""Convert an image to RGB, flattening any transparency onto white.
Expand Down
25 changes: 24 additions & 1 deletion docs/usage/advanced_options.md
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,9 @@ The HTML backend only downloads images referenced by a page when you opt in with
`--html-image-fetch remote`). Downloads connect only to public, globally
routable addresses: every address of a host is checked, every redirect is
checked again before it is followed (up to `max_redirects`), and downloads stop
at `max_remote_image_bytes`. When `render_page=True`, the browser requests
at `max_remote_image_bytes`. An image larger than `max_image_pixels` (width x
height, default 89,478,485) is skipped with a warning, and the rest of the
document converts; the Markdown backend has the same option. When `render_page=True`, the browser requests
remote resources through the same download path, and navigating the page away
from the source document is refused.

Expand Down Expand Up @@ -620,6 +622,27 @@ converter = DocumentConverter()
result = converter.convert(source, max_num_pages=100, max_file_size=20971520)
```

For image inputs, the page count is checked against `max_num_pages` before any
page is decoded, and each page (for example each frame of a multi-page TIFF) is
decoded only when it is processed. The `max_image_pixels` backend option limits
the pixels (width x height) of one page; the default, 89,478,485, is the default
of Pillow's `Image.MAX_IMAGE_PIXELS`. A larger first page rejects the document,
and a larger later page fails that page:

```python
from docling.datamodel.backend_options import PdfBackendOptions
from docling.datamodel.base_models import InputFormat
from docling.document_converter import DocumentConverter, ImageFormatOption

converter = DocumentConverter(
format_options={
InputFormat.IMAGE: ImageFormatOption(
backend_options=PdfBackendOptions(max_image_pixels=50_000_000)
)
}
)
```

## Convert from binary PDF streams

You can convert PDFs from a binary stream instead of from the filesystem as follows:
Expand Down
Loading
Loading