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
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
33 changes: 32 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 @@ -379,6 +379,35 @@ 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 stream, used to derive width/height/data_size."""

@property
Comment thread
itisar-345 marked this conversation as resolved.
def width(self) -> int:
"""Image width in pixels, read from the stream's /Width."""
if self._stream_obj is None:
raise ValueError("No stream attached to this ImageFile; width is unavailable.")
return self._stream_obj.get(ImageAttributes.WIDTH)

@property
def height(self) -> int:
"""Image height in pixels, read from the stream's /Height."""
if self._stream_obj is None:
raise ValueError("No stream attached to this ImageFile; height is unavailable.")
return self._stream_obj.get(ImageAttributes.HEIGHT)

@property
def data_size(self) -> int:
"""Compressed size in bytes of the raw stream data, without decoding."""
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", NullObject()).get_object()
return int(length) if not is_null_or_none(length) else 0

def replace(self, new_image: Image, **kwargs: Any) -> None:
"""
Replace the image with a new PIL image.
Expand Down Expand Up @@ -722,6 +751,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 @@ -887,6 +917,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
54 changes: 52 additions & 2 deletions tests/test_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from io import BytesIO
from pathlib import Path
from typing import Union
from typing import Any, Union
from unittest import mock
from zipfile import ZipFile

Expand All @@ -18,7 +18,7 @@
from pypdf._page import ImageFile
from pypdf.errors import LimitReachedError
from pypdf.filters import JBIG2Decode
from pypdf.generic import ContentStream, NameObject, NullObject
from pypdf.generic import ContentStream, DictionaryObject, NameObject, NullObject, NumberObject

from . import RESOURCE_ROOT, SAMPLE_ROOT, get_data_from_url
from .utils import get_image_data
Expand Down Expand Up @@ -236,6 +236,56 @@ def test_image_extraction(src, page_index, image_key, expected):
assert image_similarity(BytesIO(actual_image.data), expected) >= 0.99


class _StreamWithoutPrivateData:
"""
Minimal stream-like stand-in with no private `_data` cache, used to
exercise the `/Length`-based fallback branch of `ImageFile.data_size`.
"""

def __init__(self, mapping: dict) -> None:
self._mapping = mapping

def get(self, key: str, default: Any = None) -> Any:
return self._mapping.get(key, default)


def test_image_file_width_height_data_size():
stream = DictionaryObject()
stream[NameObject("/Width")] = NumberObject(100)
stream[NameObject("/Height")] = NumberObject(50)
stream._data = b"0123456789"

image = ImageFile(_stream_obj=stream)

assert image.width == 100
assert image.height == 50
assert image.data_size == 10


def test_image_file_width_height_data_size_without_stream():
image = ImageFile()

with pytest.raises(ValueError, match="width is unavailable"):
_ = image.width
with pytest.raises(ValueError, match="height is unavailable"):
_ = image.height
with pytest.raises(ValueError, match="data_size is unavailable"):
_ = image.data_size


def test_image_file_data_size_falls_back_to_length():
"""When there is no private `_data` cache, data_size falls back to /Length."""
stream = _StreamWithoutPrivateData({"/Length": NumberObject(42)})
image = ImageFile(_stream_obj=stream)
assert image.data_size == 42


def test_image_file_data_size_falls_back_to_zero_when_length_missing():
stream_empty = _StreamWithoutPrivateData({})
image = ImageFile(_stream_obj=stream_empty)
assert image.data_size == 0


def test_get_inline_image_without_xobject_resources():
page = PageObject(None, None)
inline_image = ImageFile(is_inline=True, is_displayed=True)
Expand Down