Skip to content
Open
1 change: 0 additions & 1 deletion docs/modules/PageObject.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ The PageObject Class

.. autoclass:: pypdf._page.ImageFile
:members:
:inherited-members: File
Comment thread
itisar-345 marked this conversation as resolved.
:undoc-members:

.. autofunction:: pypdf.mult
44 changes: 43 additions & 1 deletion pypdf/_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -370,6 +370,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
Comment thread
itisar-345 marked this conversation as resolved.
:attr:`width`, :attr:`height`, and :attr:`data_size` cheaply. Not part of
the public dataclass contract.
"""

@property
Comment thread
itisar-345 marked this conversation as resolved.
def width(self) -> int:
"""Image width in pixels, read from the stream header (cheap, no decode)."""
Comment thread
itisar-345 marked this conversation as resolved.
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))
Comment thread
itisar-345 marked this conversation as resolved.

@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)
Comment thread
itisar-345 marked this conversation as resolved.
if raw is not None:
return len(raw)
# Fallback for stream-like objects without a private _data cache.
length = self._stream_obj.get("/Length")
Comment thread
itisar-345 marked this conversation as resolved.
if hasattr(length, "get_object"):
length = length.get_object()
return int(length) if length is not None else 0
Comment thread
itisar-345 marked this conversation as resolved.


def replace(self, new_image: Image, **kwargs: Any) -> None:
"""
Replace the image with a new PIL image.
Expand Down Expand Up @@ -713,6 +753,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
Expand Down Expand Up @@ -874,6 +915,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
Expand Down
58 changes: 58 additions & 0 deletions tests/test_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Comment thread
itisar-345 marked this conversation as resolved.
"""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
Comment thread
itisar-345 marked this conversation as resolved.

@pytest.mark.enable_socket
def test_get_image_keyerror_for_non_image_xobject():
Expand Down
Loading