diff --git a/docs/modules/PageObject.rst b/docs/modules/PageObject.rst index 614606a942..67cfefb627 100644 --- a/docs/modules/PageObject.rst +++ b/docs/modules/PageObject.rst @@ -13,7 +13,6 @@ The PageObject Class .. autoclass:: pypdf._page.ImageFile :members: - :inherited-members: File :undoc-members: .. autofunction:: pypdf.mult diff --git a/pypdf/_page.py b/pypdf/_page.py index 5290e77511..5a622086b2 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -30,7 +30,7 @@ import math from collections.abc import Iterable, Iterator, Sequence from copy import deepcopy -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, field from decimal import Decimal from io import BytesIO from pathlib import Path @@ -379,6 +379,46 @@ class ImageFile: True if this image is displayed in the page content stream. """ + _stream_obj: Optional[Any] = field(default=None, repr=False, compare=False) + """ + Internal reference to the raw PDF stream dictionary, used only to derive + :attr:`width`, :attr:`height`, and :attr:`data_size` cheaply. Not part of + the public dataclass contract. + """ + + @property + def width(self) -> int: + """Image width in pixels, read from the stream header (cheap, no decode).""" + if self._stream_obj is None: + raise ValueError("No stream attached to this ImageFile; width is unavailable.") + return int(self._stream_obj.get(ImageAttributes.WIDTH)) + + @property + def height(self) -> int: + """Image height in pixels, read from the stream header (cheap, no decode).""" + if self._stream_obj is None: + raise ValueError("No stream attached to this ImageFile; height is unavailable.") + return int(self._stream_obj.get(ImageAttributes.HEIGHT)) + + @property + def data_size(self) -> int: + """ + Compressed byte length of the image stream (no decompression). + Read directly from the raw stream bytes as stored in the PDF, which + is cheap since it requires no decoding of the actual image data. + """ + if self._stream_obj is None: + raise ValueError("No stream attached to this ImageFile; data_size is unavailable.") + raw = getattr(self._stream_obj, "_data", None) + if raw is not None: + return len(raw) + # Fallback for stream-like objects without a private _data cache. + length = self._stream_obj.get("/Length") + if hasattr(length, "get_object"): + length = length.get_object() + return int(length) if length is not None else 0 + + def replace(self, new_image: Image, **kwargs: Any) -> None: """ Replace the image with a new PIL image. @@ -722,6 +762,7 @@ def _get_image( indirect_reference=xobj.indirect_reference, is_inline=False, is_displayed=is_displayed, + _stream_obj=xobj, ) # in a subobject assert xobjs is not None @@ -887,6 +928,7 @@ def _parse_images_from_content_stream(self) -> dict[str, Optional[ImageFile]]: indirect_reference=None, is_inline=True, is_displayed=True, + _stream_obj=ii["object"], ) return files diff --git a/tests/test_images.py b/tests/test_images.py index c592bf5ae5..bb660ddc07 100644 --- a/tests/test_images.py +++ b/tests/test_images.py @@ -181,6 +181,64 @@ def test_image_new_property(): reader.pages[0]._get_image(["test"], reader.pages[0]) assert list(PageObject(None, None).images) == [] +def test_image_file_width_height_data_size(): + """Cheap stream-header properties (width, height, data_size) on ImageFile.""" + reader = PdfReader(RESOURCE_ROOT / "git.pdf") + image = reader.pages[0].images["/Image9"] + + # Values are read straight from the stream header / raw bytes, matching + # what a full decode would report, but without requiring image.image. + assert image.width == 512 + assert image.height == 512 + assert image.data_size == len(image.indirect_reference.get_object()._data) + assert image.data_size > 0 + + # Inline images also expose the same cheap properties. + reader_inline = PdfReader(RESOURCE_ROOT / "reportlab-inline-image.pdf") + inline_image = reader_inline.pages[0].images["~0~"] + assert inline_image.width > 0 + assert inline_image.height > 0 + assert inline_image.data_size > 0 + + # An ImageFile with no attached stream (e.g. constructed directly, + # bypassing page.images) cannot answer these cheaply and raises. + bare = ImageFile() + with pytest.raises(ValueError, match="width is unavailable"): + _ = bare.width + with pytest.raises(ValueError, match="height is unavailable"): + _ = bare.height + with pytest.raises(ValueError, match="data_size is unavailable"): + _ = bare.data_size + + +def test_image_file_data_size_fallback_to_length(): + """ + data_size falls back to reading /Length when the stream-like object + has no private _data cache (e.g. a plain dict-like stand-in). + """ + class _StreamWithoutPrivateData(dict): + """Minimal stand-in exposing only a /Length entry, no ._data.""" + + stream = _StreamWithoutPrivateData({"/Length": 1234}) + image = ImageFile() + image._stream_obj = stream + assert image.data_size == 1234 + + # /Length as an IndirectObject-like wrapper is also unwrapped. + class _IndirectLength: + def get_object(self) -> int: + return 5678 + + stream_indirect = _StreamWithoutPrivateData({"/Length": _IndirectLength()}) + image = ImageFile() + image._stream_obj = stream_indirect + assert image.data_size == 5678 + + # Missing /Length entirely defaults to 0 rather than raising. + stream_empty = _StreamWithoutPrivateData({}) + image = ImageFile() + image._stream_obj = stream_empty + assert image.data_size == 0 @pytest.mark.enable_socket def test_get_image_keyerror_for_non_image_xobject():