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..1513fdb6bf 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 @@ -369,9 +370,13 @@ 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) - return ImageRef.from_pil(img, dpi=int(img.info.get("dpi", (72,))[0])) + img.load() + return ImageRef.from_pil( + normalize_image_for_png(img), + dpi=int(img.info.get("dpi", (72,))[0]), + ) except ( Image.DecompressionBombError, requests.RequestException, @@ -379,6 +384,7 @@ def create_image_ref( ValidationError, UnidentifiedImageError, OperationNotAllowed, + OSError, TypeError, ValueError, ) as e: 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_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 53f338fa4a..4fedff7e86 100644 --- a/tests/test_image_resource_loader.py +++ b/tests/test_image_resource_loader.py @@ -359,3 +359,22 @@ 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) + + +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