From 5e9f4eb3e4c25dbb95fed0adb2b3a803e187d320 Mon Sep 17 00:00:00 2001 From: Ryan Yang Date: Thu, 8 Oct 2026 14:14:47 +0800 Subject: [PATCH 1/2] fix: keep pictures whose mode cannot be written as PNG ImageRef.from_pil() serializes to PNG, so images in CMYK, LAB or F mode raise OSError: cannot write mode as PNG. In the shared ImageResourceLoader.create_image_ref() that error is not in the except tuple, so it escaped and aborted the whole conversion for the HTML, Markdown, AsciiDoc and JATS backends. In the MHTML branch of html_backend the error is caught, so the picture was silently dropped instead. Normalize the image first, as the Office backends already do, and catch OSError so that a single unreadable image can never fail a conversion. Unit test for the truncated-image (OSError) path carried over from PR 4681 by @usmanmateen. Co-authored-by: usmanmateen Signed-off-by: Ryan Yang --- docling/backend/html_backend.py | 4 +- .../backend/utils/image_resource_loader.py | 7 +- tests/test_backend_image_resource_modes.py | 121 ++++++++++++++++++ tests/test_image_resource_loader.py | 21 +++ 4 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 tests/test_backend_image_resource_modes.py diff --git a/docling/backend/html_backend.py b/docling/backend/html_backend.py index 9794a8718f..8c1a2d01e4 100644 --- a/docling/backend/html_backend.py +++ b/docling/backend/html_backend.py @@ -63,6 +63,7 @@ from docling.backend.abstract_backend import ( DeclarativeDocumentBackend, ) +from docling.backend.utils.image import normalize_image_for_png from docling.backend.utils.image_resource_loader import ImageResourceLoader from docling.backend.utils.table_spans import ( MAX_COLSPAN, @@ -5380,7 +5381,8 @@ def _create_image_ref(self, src_url: str) -> Optional[ImageRef]: 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]) + normalize_image_for_png(image), + dpi=int(image.info.get("dpi", (72,))[0]), ) except ( Image.DecompressionBombError, diff --git a/docling/backend/utils/image_resource_loader.py b/docling/backend/utils/image_resource_loader.py index df8b739b71..19c34e6cd1 100644 --- a/docling/backend/utils/image_resource_loader.py +++ b/docling/backend/utils/image_resource_loader.py @@ -33,6 +33,7 @@ from pydantic import ValidationError from requests.utils import get_environ_proxies, select_proxy +from docling.backend.utils.image import normalize_image_for_png from docling.datamodel.backend_options import DEFAULT_MAX_IMAGE_PIXELS from docling.exceptions import OperationNotAllowed from docling.utils.image_utils import check_image_pixels @@ -371,7 +372,10 @@ def create_image_ref( # 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])) + return ImageRef.from_pil( + normalize_image_for_png(img), + dpi=int(img.info.get("dpi", (72,))[0]), + ) except ( Image.DecompressionBombError, requests.RequestException, @@ -379,6 +383,7 @@ def create_image_ref( ValidationError, UnidentifiedImageError, OperationNotAllowed, + OSError, TypeError, ValueError, ) as e: diff --git a/tests/test_backend_image_resource_modes.py b/tests/test_backend_image_resource_modes.py new file mode 100644 index 0000000000..cd062d8d48 --- /dev/null +++ b/tests/test_backend_image_resource_modes.py @@ -0,0 +1,121 @@ +# SPDX-FileCopyrightText: The Docling Contributors +# SPDX-License-Identifier: MIT + +import base64 +from io import BytesIO +from pathlib import Path + +import pytest +from PIL import Image + +from docling.datamodel.backend_options import ( + AsciiDocBackendOptions, + HTMLBackendOptions, + MarkdownBackendOptions, +) +from docling.datamodel.base_models import ConversionStatus, InputFormat +from docling.document_converter import ( + AsciiDocFormatOption, + DocumentConverter, + HTMLFormatOption, + MarkdownFormatOption, +) + + +def _cmyk_jpeg() -> bytes: + buffer = BytesIO() + Image.new("CMYK", (200, 100), (0, 255, 255, 0)).save(buffer, format="JPEG") + return buffer.getvalue() + + +def _assert_single_red_picture(result) -> None: + assert result.status == ConversionStatus.SUCCESS + assert len(result.document.pictures) == 1 + image_ref = result.document.pictures[0].image + assert image_ref is not None + image = image_ref.pil_image + assert image is not None + assert image.size == (200, 100) + assert image.convert("RGB").getpixel((0, 0)) == (255, 0, 0) + + +@pytest.mark.parametrize( + "suffix, content, format_name, format_option, backend_options", + [ + ( + "html", + '', + InputFormat.HTML, + HTMLFormatOption, + HTMLBackendOptions, + ), + ( + "md", + "![cmyk](cmyk.jpg)\n", + InputFormat.MD, + MarkdownFormatOption, + MarkdownBackendOptions, + ), + ( + "adoc", + "image::cmyk.jpg[]\n", + InputFormat.ASCIIDOC, + AsciiDocFormatOption, + AsciiDocBackendOptions, + ), + ], +) +def test_cmyk_picture_is_kept( + tmp_path: Path, + suffix: str, + content: str, + format_name: InputFormat, + format_option: type, + backend_options: type, +) -> None: + (tmp_path / "cmyk.jpg").write_bytes(_cmyk_jpeg()) + source = tmp_path / f"source.{suffix}" + source.write_text(content) + + options = backend_options( + enable_local_fetch=True, fetch_images=True, source_uri=str(source) + ) + converter = DocumentConverter( + allowed_formats=[format_name], + format_options={format_name: format_option(backend_options=options)}, + ) + + _assert_single_red_picture(converter.convert(source)) + + +def test_cmyk_picture_in_mhtml_is_kept(tmp_path: Path) -> None: + payload = base64.b64encode(_cmyk_jpeg()).decode("ascii") + source = tmp_path / "source.mhtml" + source.write_bytes( + ( + "MIME-Version: 1.0\r\n" + 'Content-Type: multipart/related; boundary="B"\r\n\r\n' + "--B\r\n" + "Content-Type: text/html; charset=utf-8\r\n" + "Content-ID: \r\n" + "Content-Location: https://example.com/docs/page.html\r\n\r\n" + '\r\n' + "--B\r\n" + "Content-Type: image/jpeg\r\n" + "Content-Transfer-Encoding: base64\r\n" + "Content-ID: \r\n\r\n" + f"{payload}\r\n" + "--B--\r\n" + ).encode() + ) + + _assert_single_red_picture( + DocumentConverter( + allowed_formats=[InputFormat.MHTML], + format_options={ + InputFormat.MHTML: HTMLFormatOption( + backend_options=HTMLBackendOptions(fetch_images=True) + ) + }, + ).convert(source) + ) diff --git a/tests/test_image_resource_loader.py b/tests/test_image_resource_loader.py index 53f338fa4a..2834f759af 100644 --- a/tests/test_image_resource_loader.py +++ b/tests/test_image_resource_loader.py @@ -359,3 +359,24 @@ def test_data_uri_over_size_limit_is_rejected(): assert loader.load_image_data("data:image/png;base64,AAAAAAAAAAA=", None) with pytest.raises(ValueError, match="exceeds size limit of 8 bytes"): loader.load_image_data("data:image/png;base64,AAAAAAAAAAAAAAAA", None) + + +# Carried over from #4681 (@usmanmateen): unit coverage for the OSError +# truncation path in create_image_ref. +def _jpeg_data_uri(data: bytes) -> str: + return "data:image/jpeg;base64," + base64.b64encode(data).decode() + + +def _jpeg(image: Image.Image) -> bytes: + buffer = BytesIO() + image.save(buffer, format="JPEG") + return buffer.getvalue() + + +def test_create_image_ref_skips_undecodable_image(): + # A truncated file only fails when Pillow decodes it, with an OSError. + data = _jpeg(Image.new("RGB", (200, 100), "red")) + truncated = _jpeg_data_uri(data[: len(data) // 2]) + + with pytest.warns(UserWarning, match="Could not process an image"): + assert ImageResourceLoader().create_image_ref(truncated, None) is None From 5e46d36d59f9d93a1ac2e86a6a18258756457866 Mon Sep 17 00:00:00 2001 From: Ryan Yang Date: Fri, 9 Oct 2026 08:22:38 +0800 Subject: [PATCH 2/2] test: streamline image-mode tests per review Single-source the loader coverage: keep one end-to-end HTML check and the MHTML check for the PNG-mode normalization, dropping the Markdown and AsciiDoc variants that only re-exercise the DocumentConverter plumbing, and delete the dedicated test module in favor of the existing backend suites. Call img.load() in create_image_ref for parity with the MHTML and OpenDocument paths. Drop the provenance comment; attribution is tracked in the commit trailer. Signed-off-by: Ryan Yang --- .../backend/utils/image_resource_loader.py | 3 +- tests/test_backend_html.py | 34 +++++ tests/test_backend_image_resource_modes.py | 121 ------------------ tests/test_backend_mhtml.py | 28 ++++ tests/test_image_resource_loader.py | 2 - 5 files changed, 64 insertions(+), 124 deletions(-) delete mode 100644 tests/test_backend_image_resource_modes.py diff --git a/docling/backend/utils/image_resource_loader.py b/docling/backend/utils/image_resource_loader.py index 19c34e6cd1..1513fdb6bf 100644 --- a/docling/backend/utils/image_resource_loader.py +++ b/docling/backend/utils/image_resource_loader.py @@ -370,8 +370,9 @@ def create_image_ref( 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. + # the pixels are decoded. check_image_pixels(img.size, self.max_image_pixels) + img.load() return ImageRef.from_pil( normalize_image_for_png(img), dpi=int(img.info.get("dpi", (72,))[0]), diff --git a/tests/test_backend_html.py b/tests/test_backend_html.py index 49cc7b1d61..bf3269c931 100644 --- a/tests/test_backend_html.py +++ b/tests/test_backend_html.py @@ -1521,6 +1521,40 @@ def test_local_file_uri_source_resolves_images(tmp_path: Path): assert pictures[0].get_image(doc) is not None +def test_cmyk_picture_is_kept(tmp_path: Path): + # Images in modes that cannot be written as PNG (e.g. CMYK) must still + # make it through the conversion as normalized RGB pictures. + buffer = BytesIO() + Image.new("CMYK", (200, 100), (0, 255, 255, 0)).save(buffer, format="JPEG") + (tmp_path / "cmyk.jpg").write_bytes(buffer.getvalue()) + + source = tmp_path / "cmyk.html" + source.write_text('') + + backend_options = HTMLBackendOptions( + enable_local_fetch=True, fetch_images=True, source_uri=str(source) + ) + converter = DocumentConverter( + allowed_formats=[InputFormat.HTML], + format_options={ + InputFormat.HTML: HTMLFormatOption(backend_options=backend_options) + }, + ) + result = converter.convert(source) + + assert result.status == ConversionStatus.SUCCESS + num_pic = 0 + for element, _ in result.document.iterate_items(): + if isinstance(element, PictureItem): + assert element.image + image = element.image.pil_image + assert image is not None + assert image.size == (200, 100) + assert image.convert("RGB").getpixel((0, 0)) == (255, 0, 0) + num_pic += 1 + assert num_pic == 1, "The CMYK picture was not kept in the converted file" + + def test_html_furniture(): raw_html = ( b"

Initial content with some bold text

" diff --git a/tests/test_backend_image_resource_modes.py b/tests/test_backend_image_resource_modes.py deleted file mode 100644 index cd062d8d48..0000000000 --- a/tests/test_backend_image_resource_modes.py +++ /dev/null @@ -1,121 +0,0 @@ -# SPDX-FileCopyrightText: The Docling Contributors -# SPDX-License-Identifier: MIT - -import base64 -from io import BytesIO -from pathlib import Path - -import pytest -from PIL import Image - -from docling.datamodel.backend_options import ( - AsciiDocBackendOptions, - HTMLBackendOptions, - MarkdownBackendOptions, -) -from docling.datamodel.base_models import ConversionStatus, InputFormat -from docling.document_converter import ( - AsciiDocFormatOption, - DocumentConverter, - HTMLFormatOption, - MarkdownFormatOption, -) - - -def _cmyk_jpeg() -> bytes: - buffer = BytesIO() - Image.new("CMYK", (200, 100), (0, 255, 255, 0)).save(buffer, format="JPEG") - return buffer.getvalue() - - -def _assert_single_red_picture(result) -> None: - assert result.status == ConversionStatus.SUCCESS - assert len(result.document.pictures) == 1 - image_ref = result.document.pictures[0].image - assert image_ref is not None - image = image_ref.pil_image - assert image is not None - assert image.size == (200, 100) - assert image.convert("RGB").getpixel((0, 0)) == (255, 0, 0) - - -@pytest.mark.parametrize( - "suffix, content, format_name, format_option, backend_options", - [ - ( - "html", - '', - InputFormat.HTML, - HTMLFormatOption, - HTMLBackendOptions, - ), - ( - "md", - "![cmyk](cmyk.jpg)\n", - InputFormat.MD, - MarkdownFormatOption, - MarkdownBackendOptions, - ), - ( - "adoc", - "image::cmyk.jpg[]\n", - InputFormat.ASCIIDOC, - AsciiDocFormatOption, - AsciiDocBackendOptions, - ), - ], -) -def test_cmyk_picture_is_kept( - tmp_path: Path, - suffix: str, - content: str, - format_name: InputFormat, - format_option: type, - backend_options: type, -) -> None: - (tmp_path / "cmyk.jpg").write_bytes(_cmyk_jpeg()) - source = tmp_path / f"source.{suffix}" - source.write_text(content) - - options = backend_options( - enable_local_fetch=True, fetch_images=True, source_uri=str(source) - ) - converter = DocumentConverter( - allowed_formats=[format_name], - format_options={format_name: format_option(backend_options=options)}, - ) - - _assert_single_red_picture(converter.convert(source)) - - -def test_cmyk_picture_in_mhtml_is_kept(tmp_path: Path) -> None: - payload = base64.b64encode(_cmyk_jpeg()).decode("ascii") - source = tmp_path / "source.mhtml" - source.write_bytes( - ( - "MIME-Version: 1.0\r\n" - 'Content-Type: multipart/related; boundary="B"\r\n\r\n' - "--B\r\n" - "Content-Type: text/html; charset=utf-8\r\n" - "Content-ID: \r\n" - "Content-Location: https://example.com/docs/page.html\r\n\r\n" - '\r\n' - "--B\r\n" - "Content-Type: image/jpeg\r\n" - "Content-Transfer-Encoding: base64\r\n" - "Content-ID: \r\n\r\n" - f"{payload}\r\n" - "--B--\r\n" - ).encode() - ) - - _assert_single_red_picture( - DocumentConverter( - allowed_formats=[InputFormat.MHTML], - format_options={ - InputFormat.MHTML: HTMLFormatOption( - backend_options=HTMLBackendOptions(fetch_images=True) - ) - }, - ).convert(source) - ) diff --git a/tests/test_backend_mhtml.py b/tests/test_backend_mhtml.py index 84a0ed292d..46fea93903 100644 --- a/tests/test_backend_mhtml.py +++ b/tests/test_backend_mhtml.py @@ -39,6 +39,12 @@ def _blue_png() -> bytes: return buffer.getvalue() +def _cmyk_jpeg() -> bytes: + buffer = BytesIO() + Image.new("CMYK", (200, 100), (0, 255, 255, 0)).save(buffer, format="JPEG") + return buffer.getvalue() + + def _archive( html: str, extra_parts: str = "", @@ -231,6 +237,28 @@ def test_embedded_relative_location_resolves_with_file_root(): assert doc.pictures[0].image is not None +def test_cmyk_picture_in_mhtml_is_kept(): + jpeg = base64.b64encode(_cmyk_jpeg()).decode() + parts = ( + "--B\r\nContent-Type: image/jpeg\r\n" + "Content-ID: \r\n" + f"Content-Transfer-Encoding: base64\r\n\r\n{jpeg}\r\n" + ) + html = '' + + doc = _convert_stream( + _archive(html, parts), options=HTMLBackendOptions(fetch_images=True) + ) + + assert len(doc.pictures) == 1 + image_ref = doc.pictures[0].image + assert image_ref is not None + image = image_ref.pil_image + assert image is not None + assert image.size == (200, 100) + assert image.convert("RGB").getpixel((0, 0)) == (255, 0, 0) + + def test_default_fetch_images_leaves_archive_image_as_placeholder(): png = base64.b64encode(_red_png()).decode() parts = ( diff --git a/tests/test_image_resource_loader.py b/tests/test_image_resource_loader.py index 2834f759af..4fedff7e86 100644 --- a/tests/test_image_resource_loader.py +++ b/tests/test_image_resource_loader.py @@ -361,8 +361,6 @@ def test_data_uri_over_size_limit_is_rejected(): loader.load_image_data("data:image/png;base64,AAAAAAAAAAAAAAAA", None) -# Carried over from #4681 (@usmanmateen): unit coverage for the OSError -# truncation path in create_image_ref. def _jpeg_data_uri(data: bytes) -> str: return "data:image/jpeg;base64," + base64.b64encode(data).decode()