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: