Skip to content
Open
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
4 changes: 3 additions & 1 deletion docling/backend/html_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 8 additions & 2 deletions docling/backend/utils/image_resource_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -369,16 +370,21 @@ 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]),
)
Comment thread
ryanmeowy marked this conversation as resolved.
except (
Image.DecompressionBombError,
requests.RequestException,
urllib3.exceptions.HTTPError,
ValidationError,
UnidentifiedImageError,
OperationNotAllowed,
OSError,
TypeError,
ValueError,
) as e:
Expand Down
34 changes: 34 additions & 0 deletions tests/test_backend_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('<html><body><img src="cmyk.jpg"></body></html>')

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"<html><body><p>Initial content with some <strong>bold text</strong></p>"
Expand Down
28 changes: 28 additions & 0 deletions tests/test_backend_mhtml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "",
Expand Down Expand Up @@ -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: <image1@example>\r\n"
f"Content-Transfer-Encoding: base64\r\n\r\n{jpeg}\r\n"
)
html = '<html><body><img src="cid:image1@example"></body></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 = (
Expand Down
19 changes: 19 additions & 0 deletions tests/test_image_resource_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading