Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/pypdf/_page.py: 16%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# Copyright (c) 2006, Mathieu Fenniak
2# Copyright (c) 2007, Ashish Kulkarni <kulkarni.ashish@gmail.com>
3#
4# All rights reserved.
5#
6# Redistribution and use in source and binary forms, with or without
7# modification, are permitted provided that the following conditions are
8# met:
9#
10# * Redistributions of source code must retain the above copyright notice,
11# this list of conditions and the following disclaimer.
12# * Redistributions in binary form must reproduce the above copyright notice,
13# this list of conditions and the following disclaimer in the documentation
14# and/or other materials provided with the distribution.
15# * The name of the author may not be used to endorse or promote products
16# derived from this software without specific prior written permission.
17#
18# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
21# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
22# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
23# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
24# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
25# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
26# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
27# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
28# POSSIBILITY OF SUCH DAMAGE.
30import math
31from collections.abc import Iterable, Iterator, Sequence
32from copy import deepcopy
33from dataclasses import asdict, dataclass
34from decimal import Decimal
35from io import BytesIO
36from pathlib import Path
37from typing import (
38 Any,
39 Callable,
40 Literal,
41 Optional,
42 Union,
43 cast,
44 overload,
45)
47from ._configuration import get_configuration
48from ._protocols import PdfCommonDocProtocol
49from ._text_extraction import (
50 _layout_mode,
51)
52from ._text_extraction._text_extractor import TextExtraction
53from ._utils import (
54 CompressedTransformationMatrix,
55 TransformationMatrixType,
56 _human_readable_bytes,
57 _TraversalState,
58 deprecate,
59 deprecate_no_replacement,
60 deprecate_with_replacement,
61 logger_warning,
62 matrix_multiply,
63)
64from .actions import Action, PageTrigger
65from .constants import (
66 _INLINE_IMAGE_KEY_MAPPING,
67 _INLINE_IMAGE_VALUE_MAPPING,
68 AnnotationDictionaryAttributes,
69 ImageAttributes,
70)
71from .constants import PageAttributes as PG
72from .constants import Resources as RES
73from .errors import PageSizeNotDefinedError, PdfReadError
74from .generic import (
75 ArrayObject,
76 ContentStream,
77 DictionaryObject,
78 EncodedStreamObject,
79 FloatObject,
80 IndirectObject,
81 NameObject,
82 NullObject,
83 NumberObject,
84 PdfObject,
85 RectangleObject,
86 StreamObject,
87 is_null_or_none,
88)
89from .generic._font import Font
91try:
92 from PIL.Image import Image
94 pil_not_imported = False
95except ImportError:
96 Image = object # type: ignore[assignment,misc,unused-ignore] # TODO: Remove unused-ignore on Python 3.10
97 pil_not_imported = True # error will be raised only when using images
99MERGE_CROP_BOX = "cropbox" # DEPRECATED: Use pypdf.Configuration.
102def _get_page_resources(obj: Any) -> DictionaryObject:
103 """Return the inherited /Resources, or an empty dictionary if malformed."""
104 resources = obj.get_inherited(key=PG.RESOURCES, default=DictionaryObject())
105 if is_null_or_none(resources):
106 return DictionaryObject()
107 if not isinstance(resources, DictionaryObject):
108 logger_warning(
109 "Page resources are not a dictionary: %(resources)s",
110 source=__name__,
111 resources=resources,
112 )
113 return DictionaryObject()
114 return resources
117def _get_font_resources(resources: Any) -> DictionaryObject:
118 """Return the /Font resources, or an empty dictionary if missing or malformed."""
119 fonts = resources.get(RES.FONT)
120 if is_null_or_none(fonts):
121 return DictionaryObject()
122 fonts = fonts.get_object()
123 if not isinstance(fonts, DictionaryObject):
124 logger_warning(
125 "Font resources are not a dictionary: %(fonts)s",
126 source=__name__,
127 fonts=fonts,
128 )
129 return DictionaryObject()
130 return fonts
133def _get_rectangle(self: Any, name: str, defaults: Iterable[str]) -> RectangleObject:
134 retval: Optional[PdfObject] = self.get(name)
135 if isinstance(retval, RectangleObject):
136 return retval
137 if is_null_or_none(retval):
138 for d in defaults:
139 retval = self.get(d)
140 if retval is not None:
141 break
142 if isinstance(retval, IndirectObject):
143 # Pages of a writer have no `pdf`, the reference knows its own document.
144 retval = retval.get_object()
145 if not isinstance(retval, ArrayObject):
146 raise ValueError(f"Expected an array of four values for {name}, got {retval}")
147 if (length := len(retval)) != 4:
148 if length > 4:
149 # Keep backwards-compatibility with files previously written in a
150 # broken way by pypdf, which carried more than four values.
151 logger_warning(
152 "Expected four values, got %(length)d: %(retval)s",
153 source=__name__,
154 length=length,
155 retval=retval,
156 )
157 retval = RectangleObject(tuple(retval[:4]))
158 else:
159 raise ValueError(
160 f"Expected four values for {name}, got {length}: {retval}"
161 )
162 else:
163 retval = RectangleObject(retval)
164 _set_rectangle(self, name, retval)
165 return retval
168def _set_rectangle(self: Any, name: str, value: Union[RectangleObject, float]) -> None:
169 if isinstance(value, (list, tuple)) and len(value) < 4:
170 # The getter tolerates more than four values for backwards compatibility
171 # but cannot do anything with fewer, so writing them would produce a page
172 # whose box cannot be read back.
173 raise ValueError(
174 f"Expected four values for {name}, got {len(value)}: {value}"
175 )
176 self[NameObject(name)] = value
179def _delete_rectangle(self: Any, name: str) -> None:
180 del self[name]
183def _create_rectangle_accessor(name: str, fallback: Iterable[str]) -> property:
184 return property(
185 lambda self: _get_rectangle(self, name, fallback),
186 lambda self, value: _set_rectangle(self, name, value),
187 lambda self: _delete_rectangle(self, name),
188 )
191class Transformation:
192 """
193 Represent a 2D transformation.
195 The transformation between two coordinate systems is represented by a 3-by-3
196 transformation matrix with the following form::
198 a b 0
199 c d 0
200 e f 1
202 Because a transformation matrix has only six elements that can be changed,
203 it is usually specified in PDF as the six-element array [ a b c d e f ].
205 Coordinate transformations are expressed as matrix multiplications::
207 a b 0
208 [ x′ y′ 1 ] = [ x y 1 ] × c d 0
209 e f 1
212 Example:
213 >>> from pypdf import PdfWriter, Transformation
214 >>> page = PdfWriter().add_blank_page(800, 600)
215 >>> op = Transformation().scale(sx=2, sy=3).translate(tx=10, ty=20)
216 >>> page.add_transformation(op)
218 """
220 def __init__(self, ctm: CompressedTransformationMatrix = (1, 0, 0, 1, 0, 0)) -> None:
221 self.ctm = ctm
223 @property
224 def matrix(self) -> TransformationMatrixType:
225 """
226 Return the transformation matrix as a tuple of tuples in the form:
228 ((a, b, 0), (c, d, 0), (e, f, 1))
229 """
230 return (
231 (self.ctm[0], self.ctm[1], 0),
232 (self.ctm[2], self.ctm[3], 0),
233 (self.ctm[4], self.ctm[5], 1),
234 )
236 @staticmethod
237 def compress(matrix: TransformationMatrixType) -> CompressedTransformationMatrix:
238 """
239 Compresses the transformation matrix into a tuple of (a, b, c, d, e, f).
241 Args:
242 matrix: The transformation matrix as a tuple of tuples.
244 Returns:
245 A tuple representing the transformation matrix as (a, b, c, d, e, f)
247 """
248 return (
249 matrix[0][0],
250 matrix[0][1],
251 matrix[1][0],
252 matrix[1][1],
253 matrix[2][0],
254 matrix[2][1],
255 )
257 def _to_cm(self) -> str:
258 # Returns the cm operation string for the given transformation matrix
259 return (
260 f"{self.ctm[0]:.4f} {self.ctm[1]:.4f} {self.ctm[2]:.4f} "
261 f"{self.ctm[3]:.4f} {self.ctm[4]:.4f} {self.ctm[5]:.4f} cm"
262 )
264 def transform(self, m: "Transformation") -> "Transformation":
265 """
266 Apply one transformation to another.
268 Args:
269 m: a Transformation to apply.
271 Returns:
272 A new ``Transformation`` instance
274 Example:
275 >>> from pypdf import PdfWriter, Transformation
276 >>> height, width = 40, 50
277 >>> page = PdfWriter().add_blank_page(800, 600)
278 >>> op = Transformation((1, 0, 0, -1, 0, height)) # vertical mirror
279 >>> op = Transformation().transform(Transformation((-1, 0, 0, 1, width, 0))) # horizontal mirror
280 >>> page.add_transformation(op)
282 """
283 ctm = Transformation.compress(matrix_multiply(self.matrix, m.matrix))
284 return Transformation(ctm)
286 def translate(self, tx: float = 0, ty: float = 0) -> "Transformation":
287 """
288 Translate the contents of a page.
290 Args:
291 tx: The translation along the x-axis.
292 ty: The translation along the y-axis.
294 Returns:
295 A new ``Transformation`` instance
297 """
298 m = self.ctm
299 return Transformation(ctm=(m[0], m[1], m[2], m[3], m[4] + tx, m[5] + ty))
301 def scale(
302 self, sx: Optional[float] = None, sy: Optional[float] = None
303 ) -> "Transformation":
304 """
305 Scale the contents of a page towards the origin of the coordinate system.
307 Typically, that is the lower-left corner of the page. That can be
308 changed by translating the contents / the page boxes.
310 Args:
311 sx: The scale factor along the x-axis.
312 sy: The scale factor along the y-axis.
314 Returns:
315 A new Transformation instance with the scaled matrix.
317 """
318 if sx is None and sy is None:
319 raise ValueError("Either sx or sy must be specified")
320 if sx is None:
321 sx = sy
322 if sy is None:
323 sy = sx
324 assert sx is not None
325 assert sy is not None
326 op: TransformationMatrixType = ((sx, 0, 0), (0, sy, 0), (0, 0, 1))
327 ctm = Transformation.compress(matrix_multiply(self.matrix, op))
328 return Transformation(ctm)
330 def rotate(self, rotation: float) -> "Transformation":
331 """
332 Rotate the contents of a page.
334 Args:
335 rotation: The angle of rotation in degrees.
337 Returns:
338 A new ``Transformation`` instance with the rotated matrix.
340 """
341 rotation = math.radians(rotation)
342 op: TransformationMatrixType = (
343 (math.cos(rotation), math.sin(rotation), 0),
344 (-math.sin(rotation), math.cos(rotation), 0),
345 (0, 0, 1),
346 )
347 ctm = Transformation.compress(matrix_multiply(self.matrix, op))
348 return Transformation(ctm)
350 def __repr__(self) -> str:
351 return f"Transformation(ctm={self.ctm})"
353 @overload
354 def apply_on(self, pt: list[float], as_object: bool = False) -> list[float]:
355 ...
357 @overload
358 def apply_on(
359 self, pt: tuple[float, float], as_object: bool = False
360 ) -> tuple[float, float]:
361 ...
363 def apply_on(
364 self,
365 pt: Union[tuple[float, float], list[float]],
366 as_object: bool = False,
367 ) -> Union[tuple[float, float], list[float]]:
368 """
369 Apply the transformation matrix on the given point.
371 Args:
372 pt: A tuple or list representing the point in the form (x, y).
373 as_object: If True, return items as FloatObject, otherwise as plain floats.
375 Returns:
376 A tuple or list representing the transformed point in the form (x', y')
378 """
379 typ = FloatObject if as_object else float
380 pt1 = (
381 typ(float(pt[0]) * self.ctm[0] + float(pt[1]) * self.ctm[2] + self.ctm[4]),
382 typ(float(pt[0]) * self.ctm[1] + float(pt[1]) * self.ctm[3] + self.ctm[5]),
383 )
384 return list(pt1) if isinstance(pt, list) else pt1
387@dataclass
388class ImageFile:
389 """
390 Image within the PDF file. *This object is not designed to be built.*
392 This object should not be modified except using :func:`ImageFile.replace` to replace the image with a new one.
393 """
395 name: str = ""
396 """
397 Filename as identified within the PDF file.
399 .. warning::
401 This value can contain arbitrary characters. Please make sure to sanitize it before
402 using it to write the file content to the disk for example.
403 """
405 data: bytes = b""
406 """
407 Data as bytes.
408 """
410 image: Optional[Image] = None
411 """
412 Data as PIL image.
413 """
415 indirect_reference: Optional[IndirectObject] = None
416 """
417 Reference to the object storing the stream.
418 """
420 is_inline: bool = False
421 """
422 True if this is an inline image (~0~, ~1~, etc.).
423 """
425 is_displayed: bool = False
426 """
427 True if this image is displayed in the page content stream.
428 """
430 def replace(self, new_image: Image, **kwargs: Any) -> None:
431 """
432 Replace the image with a new PIL image.
434 Args:
435 new_image (PIL.Image.Image): The new PIL image to replace the existing image.
436 **kwargs: Additional keyword arguments to pass to `Image.save()`.
438 Raises:
439 TypeError: If the image is inline or in a PdfReader.
440 TypeError: If the image does not belong to a PdfWriter.
441 TypeError: If `new_image` is not a PIL Image.
443 Note:
444 This method replaces the existing image with a new image.
445 It is not allowed for inline images or images within a PdfReader.
446 The `kwargs` parameter allows passing additional parameters
447 to `Image.save()`, such as quality.
449 """
450 if pil_not_imported:
451 raise ImportError(
452 "pillow is required to do image extraction. "
453 "It can be installed via 'pip install pypdf[image]'"
454 )
456 from ._reader import PdfReader # noqa: PLC0415
457 from .generic import DictionaryObject, PdfObject # noqa: PLC0415
458 from .generic._image_xobject import _xobj_to_image # noqa: PLC0415
460 if self.indirect_reference is None:
461 raise TypeError("Cannot update an inline image.")
462 if not hasattr(self.indirect_reference.pdf, "_id_translated"):
463 raise TypeError("Cannot update an image not belonging to a PdfWriter.")
464 if not isinstance(new_image, Image):
465 raise TypeError("new_image shall be a PIL Image")
466 b = BytesIO()
467 new_image.save(b, "PDF", **kwargs)
468 reader = PdfReader(b)
469 page_image = reader.pages[0].images[0]
470 assert page_image.indirect_reference is not None
471 self.indirect_reference.pdf._objects[self.indirect_reference.idnum - 1] = (
472 page_image.indirect_reference.get_object()
473 )
474 cast(
475 PdfObject, self.indirect_reference.get_object()
476 ).indirect_reference = self.indirect_reference
477 # change the object attributes
478 extension, byte_stream, img = _xobj_to_image(
479 cast(DictionaryObject, self.indirect_reference.get_object()),
480 pillow_parameters=kwargs,
481 )
482 assert extension is not None
483 self.name = self.name[: self.name.rfind(".")] + extension
484 self.data = byte_stream
485 self.image = img
487 def __str__(self) -> str:
488 return f"{self.__class__.__name__}(name={self.name}, data: {_human_readable_bytes(len(self.data))})"
490 def __repr__(self) -> str:
491 return self.__str__()[:-1] + f", hash: {hash(self.data)})"
494class VirtualListImages(Sequence[ImageFile]):
495 """
496 Provides access to images referenced within a page.
497 Only one copy will be returned if the usage is used on the same page multiple times.
498 See :func:`PageObject.images` for more details.
499 """
501 def __init__(
502 self,
503 ids_function: Callable[[], list[Union[str, list[str]]]],
504 get_function: Callable[[Union[str, list[str], tuple[str]]], ImageFile],
505 ) -> None:
506 self.ids_function = ids_function
507 self.get_function = get_function
508 self.current = -1
510 def __len__(self) -> int:
511 return len(self.ids_function())
513 def keys(self) -> list[Union[str, list[str]]]:
514 return self.ids_function()
516 def items(self) -> list[tuple[Union[str, list[str]], ImageFile]]:
517 return [(x, self[x]) for x in self.ids_function()]
519 @overload
520 def __getitem__(self, index: Union[int, str, list[str]]) -> ImageFile:
521 ...
523 @overload
524 def __getitem__(self, index: slice) -> Sequence[ImageFile]:
525 ...
527 def __getitem__(
528 self, index: Union[int, slice, str, list[str], tuple[str]]
529 ) -> Union[ImageFile, Sequence[ImageFile]]:
530 lst = self.ids_function()
531 if isinstance(index, slice):
532 indices = range(*index.indices(len(self)))
533 lst = [lst[x] for x in indices]
534 cls = type(self)
535 return cls((lambda: lst), self.get_function)
536 if isinstance(index, (str, list, tuple)):
537 return self.get_function(index)
538 if not isinstance(index, int):
539 raise TypeError("Invalid sequence indices type")
540 len_self = len(lst)
541 if index < 0:
542 # support negative indexes
543 index += len_self
544 if not (0 <= index < len_self):
545 raise IndexError("Sequence index out of range")
546 return self.get_function(lst[index])
548 def __iter__(self) -> Iterator[ImageFile]:
549 for i in range(len(self)):
550 yield self[i]
552 def __str__(self) -> str:
553 p = [f"Image_{i}={n}" for i, n in enumerate(self.ids_function())]
554 return f"[{', '.join(p)}]"
557class PageObject(DictionaryObject):
558 """
559 PageObject represents a single page within a PDF file.
561 Typically these objects will be created by accessing the
562 :attr:`pages<pypdf.PdfReader.pages>` property of the
563 :class:`PdfReader<pypdf.PdfReader>` class, but it is
564 also possible to create an empty page with the
565 :meth:`create_blank_page()<pypdf._page.PageObject.create_blank_page>` static method.
567 Args:
568 pdf: PDF file the page belongs to.
569 indirect_reference: Stores the original indirect reference to
570 this object in its source PDF
572 """
574 original_page: "PageObject" # very local use in writer when appending
576 def __init__(
577 self,
578 pdf: Optional[PdfCommonDocProtocol] = None,
579 indirect_reference: Optional[IndirectObject] = None,
580 ) -> None:
581 DictionaryObject.__init__(self)
582 self.pdf = pdf
583 self._content_stream_images: Optional[dict[str, Optional[ImageFile]]] = None
584 self.indirect_reference = indirect_reference
585 if not is_null_or_none(indirect_reference):
586 assert indirect_reference is not None, "mypy"
587 self.update(cast(DictionaryObject, indirect_reference.get_object()))
589 def hash_bin(self) -> int:
590 """
591 Used to detect modified object.
593 Note: this function is overloaded to return the same results
594 as a DictionaryObject.
596 Returns:
597 Hash considering type and value.
599 """
600 return hash(
601 (DictionaryObject, tuple(((k, v.hash_bin()) for k, v in self.items())))
602 )
604 def hash_value_data(self) -> bytes:
605 data = super().hash_value_data()
606 data += f"{id(self)}".encode()
607 return data
609 @property
610 def user_unit(self) -> float:
611 """
612 A read-only positive number giving the size of user space units.
614 It is in multiples of 1/72 inch. Hence a value of 1 means a user
615 space unit is 1/72 inch, and a value of 3 means that a user
616 space unit is 3/72 inch.
617 """
618 return cast(float, self.get(PG.USER_UNIT, 1))
620 @staticmethod
621 def create_blank_page(
622 pdf: Optional[PdfCommonDocProtocol] = None,
623 width: Union[float, Decimal, None] = None,
624 height: Union[float, Decimal, None] = None,
625 ) -> "PageObject":
626 """
627 Return a new blank page.
629 If ``width`` or ``height`` is ``None``, try to get the page size
630 from the last page of *pdf*.
632 Args:
633 pdf: PDF file the page is within.
634 width: The width of the new page expressed in default user
635 space units.
636 height: The height of the new page expressed in default user
637 space units.
639 Returns:
640 The new blank page
642 Raises:
643 PageSizeNotDefinedError: if ``pdf`` is ``None`` or contains
644 no page
646 """
647 page = PageObject(pdf)
649 # Creates a new page (cf PDF Reference §7.7.3.3)
650 page.__setitem__(NameObject(PG.TYPE), NameObject("/Page"))
651 page.__setitem__(NameObject(PG.PARENT), NullObject())
652 page.__setitem__(NameObject(PG.RESOURCES), DictionaryObject())
653 if width is None or height is None:
654 if pdf is not None and len(pdf.pages) > 0:
655 lastpage = pdf.pages[len(pdf.pages) - 1]
656 width = lastpage.mediabox.width
657 height = lastpage.mediabox.height
658 else:
659 raise PageSizeNotDefinedError
660 page.__setitem__(
661 NameObject(PG.MEDIABOX), RectangleObject((0, 0, width, height))
662 )
664 return page
666 def _get_ids_image(
667 self,
668 obj: Optional[DictionaryObject] = None,
669 ancest: Optional[list[str]] = None,
670 call_stack: Optional[list[Any]] = None,
671 ) -> list[Union[str, list[str]]]:
672 if call_stack is None:
673 call_stack = []
674 _i = getattr(obj, "indirect_reference", None)
675 if _i in call_stack:
676 return []
677 call_stack.append(_i)
678 if self._content_stream_images is None:
679 self._content_stream_images = self._parse_images_from_content_stream()
680 if obj is None:
681 obj = self
682 if ancest is None:
683 ancest = []
684 lst: list[Union[str, list[str]]] = []
685 if (
686 PG.RESOURCES not in obj or
687 is_null_or_none(resources := cast(DictionaryObject, obj[PG.RESOURCES])) or
688 RES.XOBJECT not in resources
689 ):
690 # Forms without XObject resources have no images inside them
691 if len(ancest) > 0:
692 return []
693 # for inline images, cache dict entries are not None
694 return [image_name for image_name, image_value in self._content_stream_images.items() if image_value]
696 x_object = resources[RES.XOBJECT].get_object()
697 if not isinstance(x_object, DictionaryObject):
698 logger_warning(
699 "XObject resources are not a dictionary: %(x_object)s",
700 source=__name__,
701 x_object=x_object,
702 )
703 return []
705 # Iterate through all XObject resources
706 for o in x_object:
707 entry = x_object[o]
708 # Skip non-stream objects (only process StreamObject)
709 if not isinstance(entry, StreamObject):
710 continue
711 if entry.get(ImageAttributes.SUBTYPE, "") == "/Image":
712 # If it's an image, add it to lst for further processing
713 lst.append(o if len(ancest) == 0 else [*ancest, o])
714 else:
715 # If it's a form, recursively search for images inside it
716 # Forms may contain images that are Do-referenced in their content stream
717 lst.extend(self._get_ids_image(entry, [*ancest, o], call_stack))
719 # Removes duplicates and preserves order
720 deduplicated = lst.copy()
722 # Add inline images from _content_stream_images
723 for object_name, object_value in self._content_stream_images.items():
724 # inline images have cache populated
725 if object_name not in deduplicated and object_value:
726 deduplicated.append(object_name)
728 return deduplicated
730 def _get_image(
731 self,
732 image_id: Union[str, list[str], tuple[str]],
733 obj: Optional[DictionaryObject] = None,
734 ) -> ImageFile:
735 if obj is None:
736 obj = cast(DictionaryObject, self)
737 if isinstance(image_id, tuple):
738 image_id = list(image_id)
739 if isinstance(image_id, list) and len(image_id) == 1:
740 image_id = image_id[0]
741 xobjs: Optional[DictionaryObject] = None
742 try:
743 xobjs = cast(
744 DictionaryObject, cast(DictionaryObject, obj[PG.RESOURCES])[RES.XOBJECT]
745 )
746 except KeyError as exc:
747 if not (image_id[0] == "~" and image_id[-1] == "~"):
748 raise KeyError(
749 f"Cannot access image object {image_id} without XObject resources"
750 ) from exc
751 if isinstance(image_id, str):
752 if image_id[0] == "~" and image_id[-1] == "~":
753 if self._content_stream_images is None:
754 self._content_stream_images = self._parse_images_from_content_stream()
755 if image_id not in self._content_stream_images:
756 raise KeyError(f"Image {image_id} not found")
757 image_file = self._content_stream_images[image_id]
758 assert image_file is not None
759 return image_file
761 # Do-referenced image name (non-inline string keys like /Im0)
762 assert xobjs is not None
763 if image_id not in xobjs:
764 raise KeyError(f"Image {image_id} not found")
765 xobj = cast(DictionaryObject, xobjs[image_id])
766 if xobj.get(ImageAttributes.SUBTYPE, "") != "/Image":
767 raise KeyError(f"XObject {image_id} is not an image")
769 # Check if displayed (in content stream)
770 is_displayed = self._content_stream_images is not None and image_id in self._content_stream_images
772 from .generic._image_xobject import _xobj_to_image # noqa: PLC0415
773 extension, byte_stream, img = _xobj_to_image(xobj)
774 return ImageFile(
775 name=f"{image_id[1:]}{extension}",
776 data=byte_stream,
777 image=img,
778 indirect_reference=xobj.indirect_reference,
779 is_inline=False,
780 is_displayed=is_displayed,
781 )
782 # in a subobject
783 assert xobjs is not None
784 ids = image_id[1:]
785 return self._get_image(ids, cast(DictionaryObject, xobjs[image_id[0]]))
787 @property
788 def images(self) -> VirtualListImages:
789 """
790 Read-only property emulating a list of images on a page.
792 Get a list of all images on the page. The key can be:
793 - A string (for the top object)
794 - A tuple (for images within XObject forms)
795 - An integer
797 Examples:
798 * `reader.pages[0].images[0]` # return first image
799 * `reader.pages[0].images['/I0']` # return image '/I0'
800 * `reader.pages[0].images['/TP1','/Image1']` # return image '/Image1' within '/TP1' XObject form
801 * `for img in reader.pages[0].images:` # loops through all objects
803 Example usage:
805 >>> from pypdf import PdfWriter
806 >>> writer = PdfWriter()
807 >>> page = writer.add_blank_page(800, 600)
808 >>> images = page.images
810 """
811 return VirtualListImages(self._get_ids_image, self._get_image)
813 @property
814 def inline_images(self) -> Optional[dict[str, ImageFile]]:
815 """
816 Return only inline images from the page.
818 .. deprecated::
819 Use :attr:`images` and filter by :attr:`ImageFile.is_inline` instead.
820 This property will be removed in pypdf 7.0.
822 Examples:
823 >>> from pypdf import PdfWriter
824 >>> writer = PdfWriter()
825 >>> page = writer.add_blank_page(800,600)
826 >>> for image_name, image_file in page.images.items():
827 ... if image_file.is_inline:
828 ... print(f"{image_name} is inline")
829 """
830 deprecate_with_replacement(
831 "PageObject.inline_images",
832 "PageObject.images",
833 "7.0.0",
834 )
835 if self._content_stream_images is None:
836 return None
837 return {
838 image_name: image_file
839 for image_name, image_file in self._content_stream_images.items()
840 if image_file and image_file.is_inline # for inline images, image_file is populated
841 }
843 @inline_images.setter
844 def inline_images(self, value: Optional[dict[str, ImageFile]]) -> None:
845 deprecate_no_replacement(
846 "PageObject.inline_images",
847 "7.0.0",
848 )
849 if value is None:
850 self._content_stream_images = None
851 else:
852 assert self._content_stream_images is not None, "Can't edit inline_images before accessing images"
853 self._content_stream_images.update(value)
855 def _translate_value_inline_image(self, k: str, v: PdfObject) -> PdfObject:
856 """Translate values used in inline image"""
857 try:
858 v = NameObject(_INLINE_IMAGE_VALUE_MAPPING[cast(str, v)])
859 except (TypeError, KeyError) as exception:
860 if isinstance(v, NameObject):
861 # It is a custom name, thus we have to look in resources.
862 # The only applicable case is for ColorSpace.
863 if "/Resources" in self:
864 resources = cast(DictionaryObject, self["/Resources"])
865 if "/ColorSpace" in resources and v in cast(DictionaryObject, resources["/ColorSpace"]):
866 color_space = cast(DictionaryObject, resources["/ColorSpace"])
867 return color_space[v]
868 raise PdfReadError(f"Cannot find resource entry {v} for {k}") from exception
869 return v
871 def _parse_images_from_content_stream(self) -> dict[str, Optional[ImageFile]]:
872 """Load images from content stream. Includes both inline images and Do-referenced images.
874 This method scans the page content stream and extracts:
876 1. **Inline images** (~0~, ~1~...): Embedded directly in content stream via BI/EI operators
877 - is_inline=True, is_displayed=True, indirect_reference=None
879 2. **Do-referenced objects** (/Im0, /Im1..., /Form1...): Referenced via "Do" operator
880 - is_inline=False, is_displayed=True, indirect_reference=<image object>
882 3. **Pure XObject images** (/I0, /Image1...): Defined in Resources only (not in content stream)
883 - is_inline=False, is_displayed=False, indirect_reference=<image object>
885 Returns:
886 Dictionary mapping names to ImageFile instances (inline) or None (Do-referenced).
887 """
888 content = self.get_contents()
889 if is_null_or_none(content):
890 return {}
891 imgs_data = []
892 do_image_names: list[bytes] = []
893 assert content is not None, "mypy"
894 for param, ope in content.operations:
895 if ope == b"INLINE IMAGE":
896 imgs_data.append(
897 {"settings": param["settings"], "__streamdata__": param["data"]}
898 )
899 elif ope == b"Do" and param:
900 do_image_names.append(param[0]) # First operand is the XObject name
901 elif ope in (b"BI", b"EI", b"ID"): # pragma: no cover
902 raise PdfReadError(
903 f"{ope!r} operator met whereas not expected, "
904 "please share use case with pypdf dev team"
905 )
907 files: dict[str, Optional[ImageFile]] = {}
908 # Process Do-referenced objects first (images + forms, no subtype check)
909 files = dict.fromkeys(list(map(str, do_image_names)), None)
911 # Then process inline images
912 for num, ii in enumerate(imgs_data):
913 init = {
914 "__streamdata__": ii["__streamdata__"],
915 "/Length": len(ii["__streamdata__"]),
916 }
917 for k, v in ii["settings"].items():
918 if k in {"/Length", "/L"}: # no length is expected
919 continue
920 if isinstance(v, list):
921 v = ArrayObject(
922 [self._translate_value_inline_image(k, x) for x in v]
923 )
924 else:
925 v = self._translate_value_inline_image(k, v)
926 if k in _INLINE_IMAGE_KEY_MAPPING:
927 k = NameObject(_INLINE_IMAGE_KEY_MAPPING[k])
928 else:
929 logger_warning(
930 "Unknown inline image key %(key)s, keeping it as-is.",
931 source=__name__,
932 key=k,
933 )
934 k = NameObject(k)
935 if k not in init:
936 init[k] = v
937 ii["object"] = EncodedStreamObject.initialize_from_dictionary(init)
938 from .generic._image_xobject import _xobj_to_image # noqa: PLC0415
939 extension, byte_stream, img = _xobj_to_image(ii["object"])
940 files[f"~{num}~"] = ImageFile(
941 name=f"~{num}~{extension}",
942 data=byte_stream,
943 image=img,
944 indirect_reference=None,
945 is_inline=True,
946 is_displayed=True,
947 )
949 return files
951 @property
952 def rotation(self) -> int:
953 """
954 The visual rotation of the page.
956 This number has to be a multiple of 90 degrees: 0, 90, 180, or 270 are
957 valid values. This property does not affect ``/Contents``.
958 """
959 rotate_obj = self.get(PG.ROTATE, 0)
960 return rotate_obj if isinstance(rotate_obj, int) else rotate_obj.get_object()
962 @rotation.setter
963 def rotation(self, r: float) -> None:
964 self[NameObject(PG.ROTATE)] = NumberObject((((int(r) + 45) // 90) * 90) % 360)
966 def transfer_rotation_to_content(self) -> None:
967 """
968 Apply the rotation of the page to the content and the media/crop/...
969 boxes.
971 It is recommended to apply this function before page merging.
972 """
973 r = -self.rotation # rotation to apply is in the otherway
974 self.rotation = 0
975 mb = RectangleObject(self.mediabox)
976 trsf = (
977 Transformation()
978 .translate(
979 -float(mb.left + mb.width / 2), -float(mb.bottom + mb.height / 2)
980 )
981 .rotate(r)
982 )
983 pt1 = trsf.apply_on(mb.lower_left)
984 pt2 = trsf.apply_on(mb.upper_right)
985 trsf = trsf.translate(-min(pt1[0], pt2[0]), -min(pt1[1], pt2[1]))
986 self.add_transformation(trsf, False)
987 for b in ["/MediaBox", "/CropBox", "/BleedBox", "/TrimBox", "/ArtBox"]:
988 if b in self:
989 rr = RectangleObject(self[b]) # type: ignore[arg-type]
990 pt1 = trsf.apply_on(rr.lower_left)
991 pt2 = trsf.apply_on(rr.upper_right)
992 self[NameObject(b)] = RectangleObject(
993 (
994 min(pt1[0], pt2[0]),
995 min(pt1[1], pt2[1]),
996 max(pt1[0], pt2[0]),
997 max(pt1[1], pt2[1]),
998 )
999 )
1001 def rotate(self, angle: int) -> "PageObject":
1002 """
1003 Rotate a page clockwise by increments of 90 degrees.
1005 Args:
1006 angle: Angle to rotate the page. Must be an increment of 90 deg.
1008 Returns:
1009 The rotated PageObject
1011 """
1012 if angle % 90 != 0:
1013 raise ValueError("Rotation angle must be a multiple of 90")
1014 self[NameObject(PG.ROTATE)] = NumberObject(self.rotation + angle)
1015 return self
1017 def _merge_resources(
1018 self,
1019 res1: DictionaryObject,
1020 res2: DictionaryObject,
1021 resource: Any,
1022 new_res1: bool = True,
1023 ) -> tuple[dict[str, Any], dict[str, Any]]:
1024 try:
1025 assert isinstance(self.indirect_reference, IndirectObject)
1026 pdf = self.indirect_reference.pdf
1027 is_pdf_writer = hasattr(
1028 pdf, "_add_object"
1029 ) # expect isinstance(pdf, PdfWriter)
1030 except (AssertionError, AttributeError):
1031 pdf = None
1032 is_pdf_writer = False
1034 def compute_unique_key(base_key: str) -> tuple[str, bool]:
1035 """
1036 Find a key that either doesn't already exist or has the same value
1037 (indicated by the bool)
1039 Args:
1040 base_key: An index is added to this to get the computed key
1042 Returns:
1043 A tuple (computed key, bool) where the boolean indicates
1044 if there is a resource of the given computed_key with the same
1045 value.
1047 """
1048 value = page2res.raw_get(base_key)
1049 # TODO: a possible improvement for writer, the indirect_reference
1050 # cannot be found because translated
1052 # try the current key first (e.g. "foo"), but otherwise iterate
1053 # through "foo-0", "foo-1", etc. new_res can contain only finitely
1054 # many keys, thus this'll eventually end, even if it's been crafted
1055 # to be maximally annoying.
1056 computed_key = base_key
1057 idx = 0
1058 while computed_key in new_res:
1059 if new_res.raw_get(computed_key) == value:
1060 # there's already a resource of this name, with the exact
1061 # same value
1062 return computed_key, True
1063 computed_key = f"{base_key}-{idx}"
1064 idx += 1
1065 return computed_key, False
1067 if new_res1:
1068 new_res = DictionaryObject()
1069 new_res.update(res1.get(resource, DictionaryObject()).get_object())
1070 else:
1071 new_res = cast(DictionaryObject, res1[resource])
1072 page2res = cast(
1073 DictionaryObject, res2.get(resource, DictionaryObject()).get_object()
1074 )
1075 rename_res = {}
1076 for key in page2res:
1077 unique_key, same_value = compute_unique_key(key)
1078 newname = NameObject(unique_key)
1079 if key != unique_key:
1080 # we have to use a different name for this
1081 rename_res[key] = newname
1083 if not same_value:
1084 if is_pdf_writer:
1085 new_res[newname] = page2res.raw_get(key).clone(pdf)
1086 try:
1087 new_res[newname] = new_res[newname].indirect_reference
1088 except AttributeError:
1089 pass
1090 else:
1091 new_res[newname] = page2res.raw_get(key)
1092 lst = sorted(new_res.items())
1093 new_res.clear()
1094 for el in lst:
1095 new_res[el[0]] = el[1]
1096 return new_res, rename_res
1098 @staticmethod
1099 def _content_stream_rename(
1100 stream: ContentStream,
1101 rename: dict[Any, Any],
1102 pdf: Optional[PdfCommonDocProtocol],
1103 ) -> ContentStream:
1104 if not rename:
1105 return stream
1106 stream = ContentStream(stream, pdf)
1107 for operands, _operator in stream.operations:
1108 if isinstance(operands, list):
1109 for i, op in enumerate(operands):
1110 if isinstance(op, NameObject):
1111 operands[i] = rename.get(op, op)
1112 elif isinstance(operands, dict):
1113 for i, op in operands.items():
1114 if isinstance(op, NameObject):
1115 operands[i] = rename.get(op, op)
1116 else:
1117 raise KeyError(f"Type of operands is {type(operands)}")
1118 return stream
1120 @staticmethod
1121 def _add_transformation_matrix(
1122 contents: Any,
1123 pdf: Optional[PdfCommonDocProtocol],
1124 ctm: CompressedTransformationMatrix,
1125 ) -> ContentStream:
1126 """Add transformation matrix at the beginning of the given contents stream."""
1127 content_stream = ContentStream(contents, pdf)
1128 content_stream.operations.insert(
1129 0,
1130 (
1131 [FloatObject(x) for x in ctm],
1132 b"cm",
1133 ),
1134 )
1135 return content_stream
1137 def _get_contents_as_bytes(self) -> Optional[bytes]:
1138 """
1139 Return the page contents as bytes.
1141 Returns:
1142 The ``/Contents`` object as bytes, or ``None`` if it doesn't exist.
1144 """
1145 if PG.CONTENTS in self:
1146 obj = self[PG.CONTENTS].get_object()
1147 if isinstance(obj, list):
1148 return b"".join(x.get_object().get_data() for x in obj)
1149 return cast(EncodedStreamObject, obj).get_data()
1150 return None
1152 def get_contents(self) -> Optional[ContentStream]:
1153 """
1154 Access the page contents.
1156 Returns:
1157 The ``/Contents`` object, or ``None`` if it does not exist.
1158 ``/Contents`` is optional, as described in §7.7.3.3 of the PDF Reference.
1160 """
1161 if PG.CONTENTS in self:
1162 try:
1163 pdf = cast(IndirectObject, self.indirect_reference).pdf
1164 except AttributeError:
1165 pdf = None
1166 obj = self[PG.CONTENTS]
1167 if is_null_or_none(obj):
1168 return None
1169 resolved_object = obj.get_object()
1170 return ContentStream(resolved_object, pdf)
1171 return None
1173 def replace_contents(
1174 self, content: Union[ContentStream, EncodedStreamObject, ArrayObject, None]
1175 ) -> None:
1176 """
1177 Replace the page contents with the new content and nullify old objects
1178 Args:
1179 content: new content; if None delete the content field.
1180 """
1181 if not hasattr(self, "indirect_reference") or self.indirect_reference is None:
1182 # the page is not attached : the content is directly attached.
1183 self[NameObject(PG.CONTENTS)] = content
1184 return
1186 from pypdf._writer import PdfWriter # noqa: PLC0415
1187 if not isinstance(self.indirect_reference.pdf, PdfWriter):
1188 deprecate(
1189 "Calling `PageObject.replace_contents()` for pages not assigned to a writer is deprecated "
1190 "and will be removed in pypdf 7.0.0. Attach the page to the writer first or use "
1191 "`PdfWriter(clone_from=...)` directly. The existing approach has proved being unreliable."
1192 )
1194 writer = self.indirect_reference.pdf
1195 # Resolve /Contents because it may be an indirect reference to an
1196 # ArrayObject. Without resolving it, an indirect contents array is not
1197 # recognized and its stream objects are left in the writer's object list.
1198 old_contents = self.get(PG.CONTENTS, None)
1199 if old_contents is not None:
1200 old_contents = old_contents.get_object()
1201 if isinstance(old_contents, ArrayObject):
1202 for reference in old_contents:
1203 if not isinstance(reference, IndirectObject):
1204 # Direct objects are not part of the writer's object list.
1205 continue
1206 try:
1207 writer._replace_object(indirect_reference=reference, obj=NullObject())
1208 except ValueError:
1209 # Occurs when called on PdfReader.
1210 pass
1212 if isinstance(content, ArrayObject):
1213 content = ArrayObject(writer._add_object(obj) for obj in content)
1215 if is_null_or_none(content):
1216 if PG.CONTENTS not in self:
1217 return
1218 assert self[PG.CONTENTS].indirect_reference is not None
1219 writer._replace_object(indirect_reference=self[PG.CONTENTS].indirect_reference, obj=NullObject())
1220 del self[PG.CONTENTS]
1221 elif not hasattr(self.get(PG.CONTENTS, None), "indirect_reference"):
1222 try:
1223 self[NameObject(PG.CONTENTS)] = writer._add_object(content)
1224 except AttributeError:
1225 # applies at least for page not in writer
1226 # as a backup solution, we put content as an object although not in accordance with pdf ref
1227 # this will be fixed with the _add_object
1228 self[NameObject(PG.CONTENTS)] = content
1229 else:
1230 assert content is not None, "mypy"
1231 content.indirect_reference = self[
1232 PG.CONTENTS
1233 ].indirect_reference # TODO: in the future may require generation management
1234 try:
1235 writer._replace_object(indirect_reference=content.indirect_reference, obj=content)
1236 except AttributeError:
1237 # applies at least for page not in writer
1238 # as a backup solution, we put content as an object although not in accordance with pdf ref
1239 # this will be fixed with the _add_object
1240 self[NameObject(PG.CONTENTS)] = content
1241 # forces recalculation of images
1242 self._content_stream_images = None
1244 def merge_page(
1245 self, page2: "PageObject", expand: bool = False, over: bool = True
1246 ) -> None:
1247 """
1248 Merge the content streams of two pages into one.
1250 Resource references (e.g. fonts) are maintained from both pages.
1251 The mediabox, cropbox, etc of this page are not altered.
1252 The parameter page's content stream will
1253 be added to the end of this page's content stream,
1254 meaning that it will be drawn after, or "on top" of this page.
1256 Args:
1257 page2: The page to be merged into this one. Should be
1258 an instance of :class:`PageObject<PageObject>`.
1259 over: set the page2 content over page1 if True (default) else under
1260 expand: If True, the current page dimensions will be
1261 expanded to accommodate the dimensions of the page to be merged.
1263 """
1264 self._merge_page(page2, over=over, expand=expand)
1266 def _merge_page(
1267 self,
1268 page2: "PageObject",
1269 page2_transformation: Optional[Callable[[Any], ContentStream]] = None,
1270 ctm: Optional[CompressedTransformationMatrix] = None,
1271 over: bool = True,
1272 expand: bool = False,
1273 ) -> None:
1274 # First we work on merging the resource dictionaries. This allows us
1275 # to find out what symbols in the content streams we might need to
1276 # rename.
1277 try:
1278 assert isinstance(self.indirect_reference, IndirectObject)
1279 if hasattr(self.indirect_reference.pdf, "_add_object"): # to detect PdfWriter
1280 return self._merge_page_writer(
1281 page2, page2_transformation, ctm, over, expand
1282 )
1283 except (AssertionError, AttributeError):
1284 pass
1286 new_resources = DictionaryObject()
1287 rename: dict[str, Any] = {}
1288 original_resources = cast(DictionaryObject, self.get(PG.RESOURCES, DictionaryObject()).get_object())
1289 page2_resources = cast(DictionaryObject, page2.get(PG.RESOURCES, DictionaryObject()).get_object())
1290 new_annots = ArrayObject()
1292 for page in (self, page2):
1293 if PG.ANNOTS in page:
1294 annots = page[PG.ANNOTS]
1295 if isinstance(annots, ArrayObject):
1296 new_annots.extend(annots)
1297 self[NameObject(PG.ANNOTS)] = new_annots
1299 for res in (
1300 RES.EXT_G_STATE,
1301 RES.COLOR_SPACE,
1302 RES.PATTERN,
1303 RES.SHADING,
1304 RES.XOBJECT,
1305 RES.FONT,
1306 RES.PROPERTIES,
1307 ):
1308 new, new_resource_name = self._merge_resources(
1309 original_resources, page2_resources, res
1310 )
1311 if new:
1312 new_resources[NameObject(res)] = new
1313 rename.update(new_resource_name)
1315 # Combine /ProcSet sets, making sure there is a consistent order
1316 new_resources[NameObject(RES.PROC_SET)] = ArrayObject(
1317 sorted(
1318 set(
1319 original_resources.get(RES.PROC_SET, ArrayObject()).get_object()
1320 ).union(
1321 set(page2_resources.get(RES.PROC_SET, ArrayObject()).get_object())
1322 )
1323 )
1324 )
1326 new_content_array = ArrayObject()
1327 original_content = self.get_contents()
1328 if original_content is not None:
1329 original_content.isolate_graphics_state()
1330 new_content_array.append(original_content)
1332 page2_content = page2.get_contents()
1333 if page2_content is not None:
1334 configuration = get_configuration()
1335 rect = getattr(page2, configuration.page_merge_box)
1336 page2_content.operations.insert(
1337 0,
1338 (
1339 map(
1340 FloatObject,
1341 [
1342 rect.left,
1343 rect.bottom,
1344 rect.width,
1345 rect.height,
1346 ],
1347 ),
1348 b"re",
1349 ),
1350 )
1351 page2_content.operations.insert(1, ([], b"W"))
1352 page2_content.operations.insert(2, ([], b"n"))
1353 if page2_transformation is not None:
1354 page2_content = page2_transformation(page2_content)
1355 page2_content = PageObject._content_stream_rename(
1356 page2_content, rename, self.pdf
1357 )
1358 page2_content.isolate_graphics_state()
1359 if over:
1360 new_content_array.append(page2_content)
1361 else:
1362 new_content_array.insert(0, page2_content)
1364 # if expanding the page to fit a new page, calculate the new media box size
1365 if expand:
1366 self._expand_mediabox(page2, ctm)
1368 self.replace_contents(ContentStream(new_content_array, self.pdf))
1369 self[NameObject(PG.RESOURCES)] = new_resources
1371 return None
1373 def _merge_page_writer(
1374 self,
1375 page2: "PageObject",
1376 page2transformation: Optional[Callable[[Any], ContentStream]] = None,
1377 ctm: Optional[CompressedTransformationMatrix] = None,
1378 over: bool = True,
1379 expand: bool = False,
1380 ) -> None:
1381 # First we work on merging the resource dictionaries. This allows us
1382 # to find which symbols in the content streams we might need to
1383 # rename.
1384 assert isinstance(self.indirect_reference, IndirectObject)
1385 pdf = self.indirect_reference.pdf
1387 if PG.RESOURCES not in self:
1388 self[NameObject(PG.RESOURCES)] = DictionaryObject()
1389 original_resources = cast(DictionaryObject, self[PG.RESOURCES].get_object())
1390 if PG.RESOURCES not in page2:
1391 page2resources = DictionaryObject()
1392 else:
1393 page2resources = cast(DictionaryObject, page2[PG.RESOURCES].get_object())
1395 rename = {}
1396 for res in (
1397 RES.EXT_G_STATE,
1398 RES.COLOR_SPACE,
1399 RES.PATTERN,
1400 RES.SHADING,
1401 RES.XOBJECT,
1402 RES.FONT,
1403 RES.PROPERTIES,
1404 ):
1405 if res in page2resources:
1406 if res not in original_resources:
1407 original_resources[NameObject(res)] = DictionaryObject()
1408 _, newrename = self._merge_resources(
1409 original_resources, page2resources, res, False
1410 )
1411 rename.update(newrename)
1412 # Combine /ProcSet sets
1413 if RES.PROC_SET in page2resources:
1414 if RES.PROC_SET not in original_resources:
1415 original_resources[NameObject(RES.PROC_SET)] = ArrayObject()
1416 arr = cast(ArrayObject, original_resources[RES.PROC_SET])
1417 for x in cast(ArrayObject, page2resources[RES.PROC_SET]):
1418 if x not in arr:
1419 arr.append(x)
1420 arr.sort()
1422 if not is_null_or_none(page2.get(PG.ANNOTS, None)):
1423 if PG.ANNOTS not in self:
1424 self[NameObject(PG.ANNOTS)] = ArrayObject()
1425 annots = cast(ArrayObject, self[PG.ANNOTS].get_object())
1426 if ctm is None:
1427 trsf = Transformation()
1428 else:
1429 trsf = Transformation(ctm)
1430 # Ensure we are working on a copy of the list. Otherwise, if both pages
1431 # are the same object, we might run into an infinite loop.
1432 for a in cast(ArrayObject, deepcopy(page2[PG.ANNOTS])):
1433 a = a.get_object()
1434 aa = a.clone(
1435 pdf,
1436 ignore_fields=("/P", "/StructParent", "/Parent"),
1437 force_duplicate=True,
1438 )
1439 r = cast(ArrayObject, a["/Rect"])
1440 pt1 = trsf.apply_on((r[0], r[1]), True)
1441 pt2 = trsf.apply_on((r[2], r[3]), True)
1442 aa[NameObject("/Rect")] = ArrayObject(
1443 (
1444 min(pt1[0], pt2[0]),
1445 min(pt1[1], pt2[1]),
1446 max(pt1[0], pt2[0]),
1447 max(pt1[1], pt2[1]),
1448 )
1449 )
1450 if "/QuadPoints" in a:
1451 q = cast(ArrayObject, a["/QuadPoints"])
1452 aa[NameObject("/QuadPoints")] = ArrayObject(
1453 trsf.apply_on((q[0], q[1]), True)
1454 + trsf.apply_on((q[2], q[3]), True)
1455 + trsf.apply_on((q[4], q[5]), True)
1456 + trsf.apply_on((q[6], q[7]), True)
1457 )
1458 # The /Rect update above only repositions and resizes the
1459 # annotation's bounding box; it does not touch the
1460 # appearance stream's own coordinate system. See
1461 # transform_annotation_appearance for why that matters.
1462 from pypdf.generic._appearance_stream import ( # noqa: PLC0415
1463 transform_annotation_appearance,
1464 )
1465 transform_annotation_appearance(aa, trsf)
1466 try:
1467 aa["/Popup"][NameObject("/Parent")] = aa.indirect_reference
1468 except KeyError:
1469 pass
1470 try:
1471 aa[NameObject("/P")] = self.indirect_reference
1472 annots.append(aa.indirect_reference)
1473 except AttributeError:
1474 pass
1476 new_content_array = ArrayObject()
1477 original_content = self.get_contents()
1478 if original_content is not None:
1479 original_content.isolate_graphics_state()
1480 new_content_array.append(original_content)
1482 page2content = page2.get_contents()
1483 if page2content is not None:
1484 configuration = get_configuration()
1485 rect = getattr(page2, configuration.page_merge_box)
1486 page2content.operations.insert(
1487 0,
1488 (
1489 map(
1490 FloatObject,
1491 [
1492 rect.left,
1493 rect.bottom,
1494 rect.width,
1495 rect.height,
1496 ],
1497 ),
1498 b"re",
1499 ),
1500 )
1501 page2content.operations.insert(1, ([], b"W"))
1502 page2content.operations.insert(2, ([], b"n"))
1503 if page2transformation is not None:
1504 page2content = page2transformation(page2content)
1505 page2content = PageObject._content_stream_rename(
1506 page2content, rename, self.pdf
1507 )
1508 page2content.isolate_graphics_state()
1509 if over:
1510 new_content_array.append(page2content)
1511 else:
1512 new_content_array.insert(0, page2content)
1514 # if expanding the page to fit a new page, calculate the new media box size
1515 if expand:
1516 self._expand_mediabox(page2, ctm)
1518 self.replace_contents(new_content_array)
1520 def _expand_mediabox(
1521 self, page2: "PageObject", ctm: Optional[CompressedTransformationMatrix]
1522 ) -> None:
1523 corners1 = (
1524 self.mediabox.left.as_numeric(),
1525 self.mediabox.bottom.as_numeric(),
1526 self.mediabox.right.as_numeric(),
1527 self.mediabox.top.as_numeric(),
1528 )
1529 corners2 = (
1530 page2.mediabox.left.as_numeric(),
1531 page2.mediabox.bottom.as_numeric(),
1532 page2.mediabox.left.as_numeric(),
1533 page2.mediabox.top.as_numeric(),
1534 page2.mediabox.right.as_numeric(),
1535 page2.mediabox.top.as_numeric(),
1536 page2.mediabox.right.as_numeric(),
1537 page2.mediabox.bottom.as_numeric(),
1538 )
1539 if ctm is not None:
1540 ctm = tuple(float(x) for x in ctm) # type: ignore[assignment]
1541 new_x = tuple(
1542 ctm[0] * corners2[i] + ctm[2] * corners2[i + 1] + ctm[4]
1543 for i in range(0, 8, 2)
1544 )
1545 new_y = tuple(
1546 ctm[1] * corners2[i] + ctm[3] * corners2[i + 1] + ctm[5]
1547 for i in range(0, 8, 2)
1548 )
1549 else:
1550 new_x = corners2[0:8:2]
1551 new_y = corners2[1:8:2]
1552 lowerleft = (min(new_x), min(new_y))
1553 upperright = (max(new_x), max(new_y))
1554 lowerleft = (min(corners1[0], lowerleft[0]), min(corners1[1], lowerleft[1]))
1555 upperright = (
1556 max(corners1[2], upperright[0]),
1557 max(corners1[3], upperright[1]),
1558 )
1560 self.mediabox.lower_left = lowerleft
1561 self.mediabox.upper_right = upperright
1563 def merge_transformed_page(
1564 self,
1565 page2: "PageObject",
1566 ctm: Union[CompressedTransformationMatrix, Transformation],
1567 over: bool = True,
1568 expand: bool = False,
1569 ) -> None:
1570 """
1571 Similar to :meth:`~pypdf._page.PageObject.merge_page`, but a transformation
1572 matrix is applied to the merged stream.
1574 Args:
1575 page2: The page to be merged into this one.
1576 ctm: a 6-element tuple containing the operands of the
1577 transformation matrix
1578 over: set the page2 content over page1 if True (default) else under
1579 expand: Whether the page should be expanded to fit the dimensions
1580 of the page to be merged.
1582 """
1583 if isinstance(ctm, Transformation):
1584 ctm = ctm.ctm
1585 self._merge_page(
1586 page2,
1587 lambda page2_content: PageObject._add_transformation_matrix(
1588 page2_content, page2.pdf, ctm
1589 ),
1590 ctm,
1591 over,
1592 expand,
1593 )
1595 def merge_scaled_page(
1596 self, page2: "PageObject", scale: float, over: bool = True, expand: bool = False
1597 ) -> None:
1598 """
1599 Similar to :meth:`~pypdf._page.PageObject.merge_page`, but the stream to be merged
1600 is scaled by applying a transformation matrix.
1602 Args:
1603 page2: The page to be merged into this one.
1604 scale: The scaling factor
1605 over: set the page2 content over page1 if True (default) else under
1606 expand: Whether the page should be expanded to fit the
1607 dimensions of the page to be merged.
1609 """
1610 op = Transformation().scale(scale, scale)
1611 self.merge_transformed_page(page2, op, over, expand)
1613 def merge_rotated_page(
1614 self,
1615 page2: "PageObject",
1616 rotation: float,
1617 over: bool = True,
1618 expand: bool = False,
1619 ) -> None:
1620 """
1621 Similar to :meth:`~pypdf._page.PageObject.merge_page`, but the stream to be merged
1622 is rotated by applying a transformation matrix.
1624 Args:
1625 page2: The page to be merged into this one.
1626 rotation: The angle of the rotation, in degrees
1627 over: set the page2 content over page1 if True (default) else under
1628 expand: Whether the page should be expanded to fit the
1629 dimensions of the page to be merged.
1631 """
1632 op = Transformation().rotate(rotation)
1633 self.merge_transformed_page(page2, op, over, expand)
1635 def merge_translated_page(
1636 self,
1637 page2: "PageObject",
1638 tx: float,
1639 ty: float,
1640 over: bool = True,
1641 expand: bool = False,
1642 ) -> None:
1643 """
1644 Similar to :meth:`~pypdf._page.PageObject.merge_page`, but the stream to be
1645 merged is translated by applying a transformation matrix.
1647 Args:
1648 page2: the page to be merged into this one.
1649 tx: The translation on X axis
1650 ty: The translation on Y axis
1651 over: set the page2 content over page1 if True (default) else under
1652 expand: Whether the page should be expanded to fit the
1653 dimensions of the page to be merged.
1655 """
1656 op = Transformation().translate(tx, ty)
1657 self.merge_transformed_page(page2, op, over, expand)
1659 def add_transformation(
1660 self,
1661 ctm: Union[Transformation, CompressedTransformationMatrix],
1662 expand: bool = False,
1663 ) -> None:
1664 """
1665 Apply a transformation matrix to the page.
1667 Args:
1668 ctm: A 6-element tuple containing the operands of the
1669 transformation matrix. Alternatively, a
1670 :py:class:`Transformation<pypdf.Transformation>`
1671 object can be passed.
1673 See :doc:`/user/cropping-and-transforming`.
1675 """
1676 if isinstance(ctm, Transformation):
1677 ctm = ctm.ctm
1678 content = self.get_contents()
1679 if content is not None:
1680 content = PageObject._add_transformation_matrix(content, self.pdf, ctm)
1681 content.isolate_graphics_state()
1682 self.replace_contents(content)
1683 # if expanding the page to fit a new page, calculate the new media box size
1684 if expand:
1685 corners = [
1686 self.mediabox.left.as_numeric(),
1687 self.mediabox.bottom.as_numeric(),
1688 self.mediabox.left.as_numeric(),
1689 self.mediabox.top.as_numeric(),
1690 self.mediabox.right.as_numeric(),
1691 self.mediabox.top.as_numeric(),
1692 self.mediabox.right.as_numeric(),
1693 self.mediabox.bottom.as_numeric(),
1694 ]
1696 ctm = tuple(float(x) for x in ctm) # type: ignore[assignment]
1697 new_x = [
1698 ctm[0] * corners[i] + ctm[2] * corners[i + 1] + ctm[4]
1699 for i in range(0, 8, 2)
1700 ]
1701 new_y = [
1702 ctm[1] * corners[i] + ctm[3] * corners[i + 1] + ctm[5]
1703 for i in range(0, 8, 2)
1704 ]
1706 self.mediabox.lower_left = (min(new_x), min(new_y))
1707 self.mediabox.upper_right = (max(new_x), max(new_y))
1709 def scale(self, sx: float, sy: float) -> None:
1710 """
1711 Scale a page by the given factors by applying a transformation matrix
1712 to its content and updating the page size.
1714 This updates the various page boundaries (bleedbox, trimbox, etc.)
1715 and the contents of the page.
1717 Args:
1718 sx: The scaling factor on horizontal axis.
1719 sy: The scaling factor on vertical axis.
1721 """
1722 self.add_transformation((sx, 0, 0, sy, 0, 0))
1723 self.bleedbox = self.bleedbox.scale(sx, sy)
1724 self.trimbox = self.trimbox.scale(sx, sy)
1725 self.artbox = self.artbox.scale(sx, sy)
1726 self.cropbox = self.cropbox.scale(sx, sy)
1727 self.mediabox = self.mediabox.scale(sx, sy)
1729 if PG.ANNOTS in self:
1730 annotations = self[PG.ANNOTS]
1731 if isinstance(annotations, ArrayObject):
1732 for annotation in annotations:
1733 annotation_obj = annotation.get_object()
1734 if AnnotationDictionaryAttributes.Rect in annotation_obj:
1735 rectangle = annotation_obj[AnnotationDictionaryAttributes.Rect]
1736 if isinstance(rectangle, ArrayObject):
1737 rectangle[0] = FloatObject(float(rectangle[0]) * sx)
1738 rectangle[1] = FloatObject(float(rectangle[1]) * sy)
1739 rectangle[2] = FloatObject(float(rectangle[2]) * sx)
1740 rectangle[3] = FloatObject(float(rectangle[3]) * sy)
1742 if PG.VP in self:
1743 viewport = self[PG.VP]
1744 if isinstance(viewport, ArrayObject):
1745 bbox = viewport[0]["/BBox"]
1746 else:
1747 bbox = viewport["/BBox"] # type: ignore[index]
1748 scaled_bbox = RectangleObject(
1749 (
1750 float(bbox[0]) * sx,
1751 float(bbox[1]) * sy,
1752 float(bbox[2]) * sx,
1753 float(bbox[3]) * sy,
1754 )
1755 )
1756 if isinstance(viewport, ArrayObject):
1757 self[NameObject(PG.VP)][NumberObject(0)][ # type: ignore[index]
1758 NameObject("/BBox")
1759 ] = scaled_bbox
1760 else:
1761 self[NameObject(PG.VP)][NameObject("/BBox")] = scaled_bbox # type: ignore[index]
1763 def scale_by(self, factor: float) -> None:
1764 """
1765 Scale a page by the given factor by applying a transformation matrix to
1766 its content and updating the page size.
1768 Args:
1769 factor: The scaling factor (for both X and Y axis).
1771 """
1772 self.scale(factor, factor)
1774 def scale_to(self, width: float, height: float) -> None:
1775 """
1776 Scale a page to the specified dimensions by applying a transformation
1777 matrix to its content and updating the page size.
1779 Args:
1780 width: The new width.
1781 height: The new height.
1783 """
1784 sx = width / float(self.mediabox.width)
1785 sy = height / float(self.mediabox.height)
1786 self.scale(sx, sy)
1788 def compress_content_streams(self, level: int = -1) -> None:
1789 """
1790 Compress the size of this page by joining all content streams and
1791 applying a FlateDecode filter.
1793 However, it is possible that this function will perform no action if
1794 content stream compression becomes "automatic".
1795 """
1796 content = self.get_contents()
1797 if content is not None:
1798 content_obj = content.flate_encode(level)
1799 try:
1800 content.indirect_reference.pdf._objects[ # type: ignore[union-attr]
1801 content.indirect_reference.idnum - 1 # type: ignore[union-attr]
1802 ] = content_obj
1803 except AttributeError as exception:
1804 if self.indirect_reference is not None and hasattr(
1805 self.indirect_reference.pdf, "_add_object"
1806 ):
1807 self.replace_contents(content_obj)
1808 else:
1809 raise ValueError("Page must be part of a PdfWriter") from exception
1811 @property
1812 def page_number(self) -> Optional[int]:
1813 """
1814 Read-only property which returns the page number within the PDF file.
1816 Returns:
1817 Page number; None if the page is not attached to a PDF.
1819 """
1820 if self.indirect_reference is None:
1821 return None
1822 # Compare the indirect references, not the pages themselves: two pages
1823 # with identical contents compare equal, so `list.index` would return
1824 # the position of the first match for all of them.
1825 for number, page in enumerate(self.indirect_reference.pdf.pages):
1826 if page.indirect_reference == self.indirect_reference:
1827 return number
1828 return None
1830 def _debug_for_extract(self) -> str: # pragma: no cover
1831 out = ""
1832 for ope, op in ContentStream(
1833 self["/Contents"].get_object(), self.pdf, "bytes"
1834 ).operations:
1835 if op == b"TJ":
1836 s = [x for x in ope[0] if isinstance(x, str)]
1837 else:
1838 s = []
1839 out += op.decode("utf-8") + " " + "".join(s) + ope.__repr__() + "\n"
1840 out += "\n=============================\n"
1841 try:
1842 for fo in self[PG.RESOURCES]["/Font"]: # type:ignore
1843 out += fo + "\n"
1844 out += self[PG.RESOURCES]["/Font"][fo].__repr__() + "\n" # type:ignore
1845 try:
1846 enc_repr = self[PG.RESOURCES]["/Font"][fo][ # type:ignore
1847 "/Encoding"
1848 ].__repr__()
1849 out += enc_repr + "\n"
1850 except Exception:
1851 pass
1852 try:
1853 out += (
1854 self[PG.RESOURCES]["/Font"][fo][ # type:ignore
1855 "/ToUnicode"
1856 ]
1857 .get_data()
1858 .decode()
1859 + "\n"
1860 )
1861 except Exception:
1862 pass
1864 except KeyError:
1865 out += "No Font\n"
1866 return out
1868 def _extract_text(
1869 self,
1870 obj: DictionaryObject,
1871 pdf: Any,
1872 orientations: tuple[int, ...] = (0, 90, 180, 270),
1873 space_width: float = 200.0,
1874 content_key: Optional[str] = PG.CONTENTS,
1875 visitor_operand_before: Optional[Callable[[Any, Any, Any, Any], None]] = None,
1876 visitor_operand_after: Optional[Callable[[Any, Any, Any, Any], None]] = None,
1877 visitor_text: Optional[Callable[[Any, Any, Any, Any, Any], None]] = None,
1878 *,
1879 known_ids: Optional[set[int]] = None,
1880 traversal_state: Optional[_TraversalState] = None
1881 ) -> str:
1882 """
1883 See extract_text for most arguments.
1885 Args:
1886 content_key: indicate the default key where to extract data
1887 None = the object; this allows reusing the function on an XObject
1888 default = "/Content"
1890 """
1891 if known_ids is None:
1892 known_ids = set()
1893 if traversal_state is None:
1894 traversal_state = _TraversalState()
1896 extractor = TextExtraction()
1897 font_resources: dict[str, DictionaryObject] = {}
1898 fonts: dict[str, Font] = {}
1900 resources_dict = _get_page_resources(obj)
1901 if not resources_dict:
1902 # No resources means no text is possible (no font); we consider the
1903 # file as not damaged, no need to check for TJ or Tj
1904 return ""
1906 if font_resources_dict := _get_font_resources(resources_dict):
1907 for font_resource in font_resources_dict:
1908 try:
1909 font_resource_object = cast(DictionaryObject, font_resources_dict[font_resource].get_object())
1910 font_resources[font_resource] = font_resource_object
1911 fonts[font_resource] = Font.from_font_resource(font_resource_object)
1912 # Override space width, if applicable
1913 if fonts[font_resource].character_widths.get(fonts[font_resource].space_char, 0) == 0:
1914 fonts[font_resource].space_width = space_width
1915 except (AttributeError, TypeError):
1916 pass
1918 try:
1919 content = (
1920 obj[content_key].get_object() if isinstance(content_key, str) else obj
1921 )
1922 if not isinstance(content, ContentStream):
1923 content = ContentStream(content, pdf, "bytes")
1924 except (AttributeError, KeyError): # no content can be extracted (certainly empty page)
1925 return ""
1926 # We check all strings are TextStringObjects. ByteStringObjects
1927 # are strings where the byte->string encoding was unknown, so adding
1928 # them to the text here would be gibberish.
1930 # Initialize the extractor with the necessary parameters
1931 extractor.initialize_extraction(orientations, visitor_text, font_resources, fonts)
1933 for operands, operator in content.operations:
1934 if visitor_operand_before is not None:
1935 visitor_operand_before(operator, operands, extractor.cm_matrix, extractor.tm_matrix)
1936 # Multiple operators are handled here
1937 if operator == b"'":
1938 extractor.process_operation(b"T*", [])
1939 extractor.process_operation(b"Tj", operands)
1940 elif operator == b'"' and len(operands) >= 3:
1941 extractor.process_operation(b"Tw", [operands[0]])
1942 extractor.process_operation(b"Tc", [operands[1]])
1943 extractor.process_operation(b"T*", [])
1944 extractor.process_operation(b"Tj", operands[2:])
1945 elif operator == b"TJ":
1946 # The space width may be smaller than the font width, so the width should be 95%.
1947 _confirm_space_width = extractor._space_width * 0.95
1948 if operands and isinstance(operands[0], ArrayObject):
1949 for op in operands[0]:
1950 # A name is a str too, but not text to show
1951 if isinstance(op, (str, bytes)) and not isinstance(op, NameObject):
1952 extractor.process_operation(b"Tj", [op])
1953 if isinstance(op, (int, float, NumberObject, FloatObject)) and (
1954 abs(float(op)) >= _confirm_space_width
1955 and extractor.text
1956 and extractor.text[-1] != " "
1957 ):
1958 extractor.process_operation(b"Tj", [" "])
1959 elif operator == b"TD" and len(operands) >= 2:
1960 extractor.process_operation(b"TL", [-operands[1]])
1961 extractor.process_operation(b"Td", operands)
1962 elif operator == b"Do" and operands:
1963 extractor.output += extractor.text
1964 if visitor_text is not None:
1965 visitor_text(
1966 extractor.text,
1967 extractor.memo_cm,
1968 extractor.memo_tm,
1969 extractor.font_resource,
1970 extractor.font_size,
1971 )
1972 try:
1973 if extractor.output[-1] != "\n":
1974 extractor.output += "\n"
1975 if visitor_text is not None:
1976 visitor_text(
1977 "\n",
1978 extractor.memo_cm,
1979 extractor.memo_tm,
1980 extractor.font_resource,
1981 extractor.font_size,
1982 )
1983 except IndexError:
1984 pass
1985 try:
1986 xform_text = self._extract_text__xform(
1987 resources_dict=resources_dict,
1988 operands=operands,
1989 orientations=orientations,
1990 space_width=space_width,
1991 visitor_operand_before=visitor_operand_before,
1992 visitor_operand_after=visitor_operand_after,
1993 visitor_text=visitor_text,
1994 known_ids=known_ids,
1995 traversal_state=traversal_state
1996 )
1997 if xform_text is not None:
1998 text = xform_text
1999 extractor.output += text
2000 # Nothing else to do: each text piece inside the form has
2001 # already been reported via visitor_text while the form was
2002 # being extracted recursively (issue #4079).
2003 except Exception as exception:
2004 logger_warning(
2005 "Impossible to decode XFormObject %(operand)s: %(exception)s",
2006 source=__name__,
2007 operand=operands[0],
2008 exception=exception,
2009 )
2010 finally:
2011 extractor.text = ""
2012 extractor.memo_cm = extractor.cm_matrix.copy()
2013 extractor.memo_tm = extractor.tm_matrix.copy()
2014 else:
2015 extractor.process_operation(operator, operands)
2016 if visitor_operand_after is not None:
2017 visitor_operand_after(operator, operands, extractor.cm_matrix, extractor.tm_matrix)
2018 extractor.output += extractor.text # just in case
2019 if extractor.text != "" and visitor_text is not None:
2020 visitor_text(
2021 extractor.text,
2022 extractor.memo_cm,
2023 extractor.memo_tm,
2024 extractor.font_resource,
2025 extractor.font_size,
2026 )
2027 return extractor.output
2029 def _extract_text__xform(
2030 self,
2031 *,
2032 resources_dict: DictionaryObject,
2033 operands: Any,
2034 orientations: tuple[int, ...] = (0, 90, 180, 270),
2035 space_width: float = 200.0,
2036 visitor_operand_before: Optional[Callable[[Any, Any, Any, Any], None]] = None,
2037 visitor_operand_after: Optional[Callable[[Any, Any, Any, Any], None]] = None,
2038 visitor_text: Optional[Callable[[Any, Any, Any, Any, Any], None]] = None,
2039 known_ids: set[int],
2040 traversal_state: _TraversalState
2041 ) -> Optional[str]:
2042 xobj = cast(DictionaryObject, resources_dict["/XObject"])
2043 xform = cast(EncodedStreamObject, xobj[operands[0]])
2044 if xform["/Subtype"] == NameObject("/Image"):
2045 return None
2047 xform_id = id(xform)
2048 if xform_id in known_ids:
2049 logger_warning(
2050 "Detected cyclic form XObject reference, skipping %(operand)s.",
2051 source=__name__,
2052 operand=operands[0]
2053 )
2054 return ""
2056 configuration = get_configuration()
2057 if traversal_state.entry_count >= configuration.xform_maximum_invocations_per_extraction:
2058 if not traversal_state.has_logged:
2059 traversal_state.has_logged = True
2060 logger_warning(
2061 (
2062 "Exceeded %(limit)d form XObject invocations while extracting text; "
2063 "further form content is skipped."
2064 ),
2065 source=__name__,
2066 limit=configuration.xform_maximum_invocations_per_extraction
2067 )
2068 return ""
2070 traversal_state.entry_count += 1
2071 known_ids.add(xform_id)
2072 try:
2073 text = self.extract_xform_text(
2074 xform,
2075 orientations,
2076 space_width,
2077 visitor_operand_before,
2078 visitor_operand_after,
2079 visitor_text,
2080 known_ids=known_ids,
2081 traversal_state=traversal_state,
2082 )
2083 finally:
2084 known_ids.discard(xform_id)
2085 return text
2087 def _layout_mode_fonts(self) -> dict[str, Font]:
2088 """
2089 Get fonts formatted for "layout" mode text extraction.
2091 Returns:
2092 Dict[str, Font]: dictionary of Font instances keyed by font name
2094 """
2095 # Font retrieval logic adapted from pypdf.PageObject._extract_text()
2096 obj: Any = self
2097 fonts: dict[str, Font] = {}
2098 visited: set[int] = set()
2099 while True:
2100 obj_id = id(obj)
2101 if obj_id in visited:
2102 logger_warning("Detected cycle in /Parent hierarchy when retrieving fonts.", source=__name__)
2103 break
2104 visited.add(obj_id)
2106 resources_dict: Any = obj.get(PG.RESOURCES, DictionaryObject()).get_object()
2107 if not isinstance(resources_dict, DictionaryObject):
2108 logger_warning(
2109 "Page resources are not a dictionary: %(resources)s",
2110 source=__name__,
2111 resources=resources_dict,
2112 )
2113 resources_dict = DictionaryObject()
2114 if self.pdf is not None and (font_resources := _get_font_resources(resources_dict)):
2115 for font_name in font_resources:
2116 font_object = font_resources[font_name].get_object()
2117 if isinstance(font_object, DictionaryObject):
2118 fonts[font_name] = Font.from_font_resource(font_object)
2120 parent = obj.get("/Parent", NullObject()).get_object()
2121 if not isinstance(parent, DictionaryObject):
2122 break
2123 obj = parent
2125 return fonts
2127 def _layout_mode_text(
2128 self,
2129 space_vertically: bool = True,
2130 scale_weight: float = 1.25,
2131 strip_rotated: bool = True,
2132 debug_path: Optional[Path] = None,
2133 font_height_weight: float = 1,
2134 ) -> str:
2135 """
2136 Get text preserving fidelity to source PDF text layout.
2138 Args:
2139 space_vertically: include blank lines inferred from y distance + font
2140 height. Defaults to True.
2141 scale_weight: multiplier for string length when calculating weighted
2142 average character width. Defaults to 1.25.
2143 strip_rotated: Removes text that is rotated w.r.t. to the page from
2144 layout mode output. Defaults to True.
2145 debug_path (Path | None): if supplied, must target a directory.
2146 creates the following files with debug information for layout mode
2147 functions if supplied:
2148 - fonts.json: output of self._layout_mode_fonts
2149 - tjs.json: individual text render ops with corresponding transform matrices
2150 - bts.json: text render ops left justified and grouped by BT/ET operators
2151 - bt_groups.json: BT/ET operations grouped by rendered y-coord (aka lines)
2152 Defaults to None.
2153 font_height_weight: multiplier for font height when calculating
2154 blank lines. Defaults to 1.
2156 Returns:
2157 str: multiline string containing page text in a fixed width format that
2158 closely adheres to the rendered layout in the source pdf.
2160 """
2161 fonts = self._layout_mode_fonts()
2162 if debug_path: # pragma: no cover
2163 import json # noqa: PLC0415
2165 debug_path.joinpath("fonts.json").write_text(
2166 json.dumps(fonts, indent=2, default=asdict),
2167 "utf-8"
2168 )
2170 contents = self.get("/Contents")
2171 if contents is not None:
2172 contents = contents.get_object()
2173 if not isinstance(contents, (ArrayObject, StreamObject)):
2174 # No content can be extracted (certainly an empty page).
2175 return ""
2177 ops = iter(ContentStream(contents, self.pdf, "bytes").operations)
2178 bt_groups = _layout_mode.text_show_operations(
2179 ops, fonts, strip_rotated, debug_path
2180 )
2182 if not bt_groups:
2183 return ""
2185 ty_groups = _layout_mode.y_coordinate_groups(bt_groups, debug_path)
2187 char_width = _layout_mode.fixed_char_width(bt_groups, scale_weight)
2189 return _layout_mode.fixed_width_page(ty_groups, char_width, space_vertically, font_height_weight)
2191 def extract_text(
2192 self,
2193 *args: Any,
2194 orientations: Union[int, tuple[int, ...]] = (0, 90, 180, 270),
2195 space_width: float = 200.0,
2196 visitor_operand_before: Optional[Callable[[Any, Any, Any, Any], None]] = None,
2197 visitor_operand_after: Optional[Callable[[Any, Any, Any, Any], None]] = None,
2198 visitor_text: Optional[Callable[[Any, Any, Any, Any, Any], None]] = None,
2199 extraction_mode: Literal["plain", "layout"] = "plain",
2200 **kwargs: Any,
2201 ) -> str:
2202 """
2203 Locate all text drawing commands, in the order they are provided in the
2204 content stream, and extract the text.
2206 This works well for some PDF files, but poorly for others, depending on
2207 the generator used. This will be refined in the future.
2209 Do not rely on the order of text coming out of this function, as it
2210 will change if this function is made more sophisticated.
2212 Arabic and Hebrew are extracted in the correct order.
2213 If required a custom RTL range of characters can be defined;
2214 see function set_custom_rtl.
2216 Additionally you can provide visitor methods to get informed on all
2217 operations and all text objects.
2218 For example in some PDF files this can be useful to parse tables.
2220 Args:
2221 orientations: list of orientations extract_text will look for
2222 default = (0, 90, 180, 270)
2223 note: currently only 0 (up),90 (turned left), 180 (upside down),
2224 270 (turned right)
2225 Silently ignored in "layout" mode.
2226 space_width: force default space width
2227 if not extracted from font (default: 200)
2228 Silently ignored in "layout" mode.
2229 visitor_operand_before: function to be called before processing an operation.
2230 It has four arguments: operator, operand-arguments,
2231 current transformation matrix and text matrix.
2232 Ignored with a warning in "layout" mode.
2233 visitor_operand_after: function to be called after processing an operation.
2234 It has four arguments: operator, operand-arguments,
2235 current transformation matrix and text matrix.
2236 Ignored with a warning in "layout" mode.
2237 visitor_text: function to be called when extracting some text at some position.
2238 It has five arguments: text, current transformation matrix,
2239 text matrix, font-dictionary and font-size.
2240 The font-dictionary may be None in case of unknown fonts.
2241 If not None it may e.g. contain key "/BaseFont" with value "/Arial,Bold".
2242 Ignored with a warning in "layout" mode.
2243 extraction_mode (Literal["plain", "layout"]): "plain" for legacy functionality,
2244 "layout" for experimental layout mode functionality.
2245 NOTE: orientations, space_width, and visitor_* parameters are NOT respected
2246 in "layout" mode.
2248 kwargs:
2249 layout_mode_space_vertically (bool): include blank lines inferred from
2250 y distance + font height. Defaults to True.
2251 layout_mode_scale_weight (float): multiplier for string length when calculating
2252 weighted average character width. Defaults to 1.25.
2253 layout_mode_strip_rotated (bool): layout mode does not support rotated text.
2254 Set to False to include rotated text anyway. If rotated text is discovered,
2255 layout will be degraded and a warning will result. Defaults to True.
2256 layout_mode_debug_path (Path | None): if supplied, must target a directory.
2257 creates the following files with debug information for layout mode
2258 functions if supplied:
2260 - fonts.json: output of self._layout_mode_fonts
2261 - tjs.json: individual text render ops with corresponding transform matrices
2262 - bts.json: text render ops left justified and grouped by BT/ET operators
2263 - bt_groups.json: BT/ET operations grouped by rendered y-coord (aka lines)
2264 layout_mode_font_height_weight (float): multiplier for font height when calculating
2265 blank lines. Defaults to 1.
2267 Returns:
2268 The extracted text
2270 """
2271 if extraction_mode not in ["plain", "layout"]:
2272 raise ValueError(f"Invalid text extraction mode '{extraction_mode}'")
2273 if extraction_mode == "layout":
2274 for visitor in (
2275 "visitor_operand_before",
2276 "visitor_operand_after",
2277 "visitor_text",
2278 ):
2279 if locals()[visitor]:
2280 logger_warning(
2281 "Argument %(visitor)s is ignored in layout mode",
2282 source=__name__,
2283 visitor=visitor,
2284 )
2285 return self._layout_mode_text(
2286 space_vertically=kwargs.get("layout_mode_space_vertically", True),
2287 scale_weight=kwargs.get("layout_mode_scale_weight", 1.25),
2288 strip_rotated=kwargs.get("layout_mode_strip_rotated", True),
2289 debug_path=kwargs.get("layout_mode_debug_path"),
2290 font_height_weight=kwargs.get("layout_mode_font_height_weight", 1)
2291 )
2292 if len(args) >= 1:
2293 if isinstance(args[0], str):
2294 if len(args) >= 3:
2295 if isinstance(args[2], (tuple, int)):
2296 orientations = args[2]
2297 else:
2298 raise TypeError(f"Invalid positional parameter {args[2]}")
2299 if len(args) >= 4:
2300 if isinstance(args[3], (float, int)):
2301 space_width = args[3]
2302 else:
2303 raise TypeError(f"Invalid positional parameter {args[3]}")
2304 elif isinstance(args[0], (tuple, int)):
2305 orientations = args[0]
2306 if len(args) >= 2:
2307 if isinstance(args[1], (float, int)):
2308 space_width = args[1]
2309 else:
2310 raise TypeError(f"Invalid positional parameter {args[1]}")
2311 else:
2312 raise TypeError(f"Invalid positional parameter {args[0]}")
2314 if isinstance(orientations, int):
2315 orientations = (orientations,)
2317 return self._extract_text(
2318 self,
2319 self.pdf,
2320 orientations,
2321 space_width,
2322 PG.CONTENTS,
2323 visitor_operand_before,
2324 visitor_operand_after,
2325 visitor_text,
2326 )
2328 def extract_xform_text(
2329 self,
2330 xform: EncodedStreamObject,
2331 orientations: tuple[int, ...] = (0, 90, 270, 360),
2332 space_width: float = 200.0,
2333 visitor_operand_before: Optional[Callable[[Any, Any, Any, Any], None]] = None,
2334 visitor_operand_after: Optional[Callable[[Any, Any, Any, Any], None]] = None,
2335 visitor_text: Optional[Callable[[Any, Any, Any, Any, Any], None]] = None,
2336 *,
2337 known_ids: Optional[set[int]] = None,
2338 traversal_state: Optional[Any] = None
2339 ) -> str:
2340 """
2341 Extract text from an XObject.
2343 Args:
2344 xform:
2345 orientations:
2346 space_width: force default space width (if not extracted from font (default 200)
2347 visitor_operand_before:
2348 visitor_operand_after:
2349 visitor_text:
2350 known_ids:
2351 traversal_state:
2353 Returns:
2354 The extracted text
2356 """
2357 # The type hint would have to use an internal type otherwise, which is not desired.
2358 assert traversal_state is None or isinstance(traversal_state, _TraversalState)
2359 return self._extract_text(
2360 xform,
2361 self.pdf,
2362 orientations,
2363 space_width,
2364 None,
2365 visitor_operand_before,
2366 visitor_operand_after,
2367 visitor_text,
2368 known_ids=known_ids,
2369 traversal_state=traversal_state,
2370 )
2372 def _get_fonts(self) -> tuple[set[str], set[str]]:
2373 """
2374 Get the names of embedded fonts and unembedded fonts.
2376 Returns:
2377 A tuple (set of embedded fonts, set of unembedded fonts)
2379 """
2380 obj = self.get_object()
2381 assert isinstance(obj, DictionaryObject)
2382 fonts: set[str] = set()
2383 embedded: set[str] = set()
2384 fonts, embedded = _get_fonts_walk(obj, fonts, embedded)
2385 unembedded = fonts - embedded
2386 return embedded, unembedded
2388 mediabox = _create_rectangle_accessor(PG.MEDIABOX, ())
2389 """A :class:`RectangleObject<pypdf.generic.RectangleObject>`, expressed in
2390 default user space units, defining the boundaries of the physical medium on
2391 which the page is intended to be displayed or printed."""
2393 cropbox = _create_rectangle_accessor("/CropBox", (PG.MEDIABOX,))
2394 """
2395 A :class:`RectangleObject<pypdf.generic.RectangleObject>`, expressed in
2396 default user space units, defining the visible region of default user
2397 space.
2399 When the page is displayed or printed, its contents are to be clipped
2400 (cropped) to this rectangle and then imposed on the output medium in some
2401 implementation-defined manner. Default value: same as
2402 :attr:`mediabox<mediabox>`.
2403 """
2405 bleedbox = _create_rectangle_accessor("/BleedBox", ("/CropBox", PG.MEDIABOX))
2406 """A :class:`RectangleObject<pypdf.generic.RectangleObject>`, expressed in
2407 default user space units, defining the region to which the contents of the
2408 page should be clipped when output in a production environment."""
2410 trimbox = _create_rectangle_accessor("/TrimBox", ("/CropBox", PG.MEDIABOX))
2411 """A :class:`RectangleObject<pypdf.generic.RectangleObject>`, expressed in
2412 default user space units, defining the intended dimensions of the finished
2413 page after trimming."""
2415 artbox = _create_rectangle_accessor("/ArtBox", ("/CropBox", PG.MEDIABOX))
2416 """A :class:`RectangleObject<pypdf.generic.RectangleObject>`, expressed in
2417 default user space units, defining the extent of the page's meaningful
2418 content as intended by the page's creator."""
2420 @property
2421 def annotations(self) -> Optional[ArrayObject]:
2422 if "/Annots" not in self:
2423 return None
2424 annotations = self["/Annots"].get_object()
2425 if not isinstance(annotations, ArrayObject):
2426 logger_warning(
2427 "Annotations are not an array: %(annotations)s",
2428 source=__name__,
2429 annotations=annotations,
2430 )
2431 return None
2432 return annotations
2434 @annotations.setter
2435 def annotations(self, value: Optional[ArrayObject]) -> None:
2436 """
2437 Set the annotations array of the page.
2439 Typically you do not want to set this value, but append to it.
2440 If you append to it, remember to add the object first to the writer
2441 and only add the indirect object.
2442 """
2443 if value is None:
2444 if "/Annots" not in self:
2445 return
2446 del self[NameObject("/Annots")]
2447 else:
2448 self[NameObject("/Annots")] = value
2450 def add_action(self, trigger: PageTrigger, action: Action) -> None:
2451 """
2452 Add an action which will launch on the given trigger event of this page.
2454 Args:
2455 trigger: The action trigger to use.
2456 action: The action to be done.
2458 Example:
2459 >>> from pypdf import PdfWriter
2460 >>> from pypdf.actions import JavaScript, PageTrigger
2461 >>> writer = PdfWriter()
2462 >>> page = writer.add_blank_page(595, 842)
2463 >>> # Display the page number when the page is opened
2464 >>> page.add_action(PageTrigger("open"), JavaScript("app.alert('This is page ' + this.pageNum);"))
2465 >>> # Display the page number when the page is closed
2466 >>> page.add_action(PageTrigger("close"), JavaScript("app.alert('This is page ' + this.pageNum);"))
2467 """
2468 return Action._create_new(self, trigger, action)
2470 def delete_action(self, trigger: PageTrigger) -> None:
2471 """
2472 Delete all actions associated with an open or close trigger event of this page.
2474 Args:
2475 trigger: An open or close trigger.
2477 Example:
2478 >>> from pypdf import PdfWriter
2479 >>> from pypdf.actions import JavaScript, PageTrigger
2480 >>> writer = PdfWriter()
2481 >>> page = writer.add_blank_page(595, 842)
2482 >>> page.add_action(PageTrigger("open"), JavaScript("app.alert('This is page ' + this.pageNum);"))
2483 >>> page.add_action(PageTrigger("close"), JavaScript("app.alert('This is page ' + this.pageNum);"))
2484 >>> # Delete all actions triggered by a page open
2485 >>> page.delete_action(PageTrigger("open"))
2486 >>> # Delete all actions triggered by a page close
2487 >>> page.delete_action(PageTrigger("close"))
2488 """
2489 return Action._delete(self, trigger)
2492class _VirtualList(Sequence[PageObject]):
2493 def __init__(
2494 self,
2495 length_function: Callable[[], int],
2496 get_function: Callable[[int], PageObject],
2497 ) -> None:
2498 self.length_function = length_function
2499 self.get_function = get_function
2500 self.current = -1
2502 def __len__(self) -> int:
2503 return self.length_function()
2505 @overload
2506 def __getitem__(self, index: int) -> PageObject:
2507 ...
2509 @overload
2510 def __getitem__(self, index: slice) -> Sequence[PageObject]:
2511 ...
2513 def __getitem__(
2514 self, index: Union[int, slice]
2515 ) -> Union[PageObject, Sequence[PageObject]]:
2516 if isinstance(index, slice):
2517 indices = range(*index.indices(len(self)))
2518 cls = type(self)
2519 return cls(indices.__len__, lambda idx: self[indices[idx]])
2520 if not isinstance(index, int):
2521 raise TypeError("Sequence indices must be integers")
2522 len_self = len(self)
2523 if index < 0:
2524 # support negative indexes
2525 index += len_self
2526 if not (0 <= index < len_self):
2527 raise IndexError("Sequence index out of range")
2528 return self.get_function(index)
2530 def __delitem__(self, index: Union[int, slice]) -> None:
2531 if isinstance(index, slice):
2532 r = list(range(*index.indices(len(self))))
2533 # pages have to be deleted from last to first
2534 r.sort()
2535 r.reverse()
2536 for p in r:
2537 del self[p] # recursive call
2538 return
2539 if not isinstance(index, int):
2540 raise TypeError("Index must be integers")
2541 len_self = len(self)
2542 if index < 0:
2543 # support negative indexes
2544 index += len_self
2545 if not (0 <= index < len_self):
2546 raise IndexError("Index out of range")
2547 ind = self[index].indirect_reference
2548 assert ind is not None
2549 parent: Optional[PdfObject] = cast(DictionaryObject, ind.get_object()).get(
2550 "/Parent", None
2551 )
2552 first = True
2553 while parent is not None:
2554 parent = cast(DictionaryObject, parent.get_object())
2555 try:
2556 i = cast(ArrayObject, parent["/Kids"]).index(ind)
2557 del cast(ArrayObject, parent["/Kids"])[i]
2558 first = False
2559 try:
2560 assert ind is not None
2561 del ind.pdf.flattened_pages[index] # case of page in a Reader
2562 except Exception: # pragma: no cover
2563 pass
2564 if "/Count" in parent:
2565 parent[NameObject("/Count")] = NumberObject(
2566 cast(int, parent["/Count"]) - 1
2567 )
2568 if len(cast(ArrayObject, parent["/Kids"])) == 0:
2569 # No more objects in this part of this subtree
2570 ind = parent.indirect_reference
2571 parent = parent.get("/Parent", None)
2572 except ValueError as exception: # from index
2573 if first:
2574 raise PdfReadError(f"Page not found in page tree: {ind}") from exception
2575 break
2577 def __iter__(self) -> Iterator[PageObject]:
2578 for i in range(len(self)):
2579 yield self[i]
2581 def __str__(self) -> str:
2582 p = [f"PageObject({i})" for i in range(self.length_function())]
2583 return f"[{', '.join(p)}]"
2586def _get_fonts_walk(
2587 obj: DictionaryObject,
2588 fnt: set[str],
2589 emb: set[str],
2590) -> tuple[set[str], set[str]]:
2591 """
2592 Get the set of all fonts and all embedded fonts.
2594 Args:
2595 obj: Page resources dictionary
2596 fnt: font
2597 emb: embedded fonts
2599 Returns:
2600 A tuple (fnt, emb)
2602 If there is a key called 'BaseFont', that is a font that is used in the document.
2603 If there is a key called 'FontName' and another key in the same dictionary object
2604 that is called 'FontFilex' (where x is null, 2, or 3), then that fontname is
2605 embedded.
2607 We create and add to two sets, fnt = fonts used and emb = fonts embedded.
2609 """
2610 fontkeys = ("/FontFile", "/FontFile2", "/FontFile3")
2612 def process_font(f: PdfObject) -> None:
2613 nonlocal fnt, emb
2614 f = cast(DictionaryObject, f.get_object()) # to be sure
2615 if "/BaseFont" in f:
2616 fnt.add(cast(str, f["/BaseFont"]))
2618 if (
2619 ("/CharProcs" in f)
2620 or (
2621 "/FontDescriptor" in f
2622 and any(
2623 x in cast(DictionaryObject, f["/FontDescriptor"]) for x in fontkeys
2624 )
2625 )
2626 or (
2627 "/DescendantFonts" in f
2628 and "/FontDescriptor"
2629 in cast(
2630 DictionaryObject,
2631 cast(ArrayObject, f["/DescendantFonts"])[0].get_object(),
2632 )
2633 and any(
2634 x
2635 in cast(
2636 DictionaryObject,
2637 cast(
2638 DictionaryObject,
2639 cast(ArrayObject, f["/DescendantFonts"])[0].get_object(),
2640 )["/FontDescriptor"],
2641 )
2642 for x in fontkeys
2643 )
2644 )
2645 ):
2646 # the list comprehension ensures there is FontFile
2647 try:
2648 emb.add(cast(str, f["/BaseFont"]))
2649 except KeyError:
2650 emb.add("(" + cast(str, f["/Subtype"]) + ")")
2652 if "/DR" in obj and "/Font" in cast(DictionaryObject, obj["/DR"]):
2653 for f in cast(
2654 DictionaryObject, cast(DictionaryObject, obj["/DR"])["/Font"]
2655 ).values():
2656 process_font(f)
2657 if "/Resources" in obj:
2658 if "/Font" in cast(DictionaryObject, obj["/Resources"]):
2659 for f in cast(
2660 DictionaryObject, cast(DictionaryObject, obj["/Resources"])["/Font"]
2661 ).values():
2662 process_font(f)
2663 if "/XObject" in cast(DictionaryObject, obj["/Resources"]):
2664 for x in cast(
2665 DictionaryObject, cast(DictionaryObject, obj["/Resources"])["/XObject"]
2666 ).values():
2667 _get_fonts_walk(cast(DictionaryObject, x.get_object()), fnt, emb)
2668 if "/Annots" in obj:
2669 for a in cast(ArrayObject, obj["/Annots"]):
2670 _get_fonts_walk(cast(DictionaryObject, a.get_object()), fnt, emb)
2671 if "/AP" in obj:
2672 if (
2673 cast(DictionaryObject, cast(DictionaryObject, obj["/AP"])["/N"]).get(
2674 "/Type"
2675 )
2676 == "/XObject"
2677 ):
2678 _get_fonts_walk(
2679 cast(DictionaryObject, cast(DictionaryObject, obj["/AP"])["/N"]),
2680 fnt,
2681 emb,
2682 )
2683 else:
2684 for a in cast(DictionaryObject, cast(DictionaryObject, obj["/AP"])["/N"]):
2685 _get_fonts_walk(cast(DictionaryObject, a), fnt, emb)
2686 return fnt, emb # return the sets for each page