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 ._font import Font
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 deprecate,
58 deprecate_no_replacement,
59 deprecate_with_replacement,
60 logger_warning,
61 matrix_multiply,
62)
63from .actions import Action, PageTrigger
64from .constants import (
65 _INLINE_IMAGE_KEY_MAPPING,
66 _INLINE_IMAGE_VALUE_MAPPING,
67 AnnotationDictionaryAttributes,
68 ImageAttributes,
69)
70from .constants import PageAttributes as PG
71from .constants import Resources as RES
72from .errors import PageSizeNotDefinedError, PdfReadError
73from .generic import (
74 ArrayObject,
75 ContentStream,
76 DictionaryObject,
77 EncodedStreamObject,
78 FloatObject,
79 IndirectObject,
80 NameObject,
81 NullObject,
82 NumberObject,
83 PdfObject,
84 RectangleObject,
85 StreamObject,
86 is_null_or_none,
87)
89try:
90 from PIL.Image import Image
92 pil_not_imported = False
93except ImportError:
94 Image = object # type: ignore[assignment,misc,unused-ignore] # TODO: Remove unused-ignore on Python 3.10
95 pil_not_imported = True # error will be raised only when using images
97MERGE_CROP_BOX = "cropbox" # pypdf <= 3.4.0 used "trimbox"
100def _get_rectangle(self: Any, name: str, defaults: Iterable[str]) -> RectangleObject:
101 retval: Union[None, RectangleObject, ArrayObject, IndirectObject] = self.get(name)
102 if isinstance(retval, RectangleObject):
103 return retval
104 if is_null_or_none(retval):
105 for d in defaults:
106 retval = self.get(d)
107 if retval is not None:
108 break
109 if isinstance(retval, IndirectObject):
110 retval = self.pdf.get_object(retval)
111 if isinstance(retval, ArrayObject) and (length := len(retval)) != 4:
112 if length > 4:
113 # Keep backwards-compatibility with files previously written in a
114 # broken way by pypdf, which carried more than four values.
115 logger_warning(
116 "Expected four values, got %(length)d: %(retval)s",
117 source=__name__,
118 length=length,
119 retval=retval,
120 )
121 retval = RectangleObject(tuple(retval[:4]))
122 else:
123 raise ValueError(
124 f"Expected four values for {name}, got {length}: {retval}"
125 )
126 else:
127 retval = RectangleObject(retval) # type: ignore[arg-type]
128 _set_rectangle(self, name, retval)
129 return retval
132def _set_rectangle(self: Any, name: str, value: Union[RectangleObject, float]) -> None:
133 self[NameObject(name)] = value
136def _delete_rectangle(self: Any, name: str) -> None:
137 del self[name]
140def _create_rectangle_accessor(name: str, fallback: Iterable[str]) -> property:
141 return property(
142 lambda self: _get_rectangle(self, name, fallback),
143 lambda self, value: _set_rectangle(self, name, value),
144 lambda self: _delete_rectangle(self, name),
145 )
148class Transformation:
149 """
150 Represent a 2D transformation.
152 The transformation between two coordinate systems is represented by a 3-by-3
153 transformation matrix with the following form::
155 a b 0
156 c d 0
157 e f 1
159 Because a transformation matrix has only six elements that can be changed,
160 it is usually specified in PDF as the six-element array [ a b c d e f ].
162 Coordinate transformations are expressed as matrix multiplications::
164 a b 0
165 [ x′ y′ 1 ] = [ x y 1 ] × c d 0
166 e f 1
169 Example:
170 >>> from pypdf import PdfWriter, Transformation
171 >>> page = PdfWriter().add_blank_page(800, 600)
172 >>> op = Transformation().scale(sx=2, sy=3).translate(tx=10, ty=20)
173 >>> page.add_transformation(op)
175 """
177 def __init__(self, ctm: CompressedTransformationMatrix = (1, 0, 0, 1, 0, 0)) -> None:
178 self.ctm = ctm
180 @property
181 def matrix(self) -> TransformationMatrixType:
182 """
183 Return the transformation matrix as a tuple of tuples in the form:
185 ((a, b, 0), (c, d, 0), (e, f, 1))
186 """
187 return (
188 (self.ctm[0], self.ctm[1], 0),
189 (self.ctm[2], self.ctm[3], 0),
190 (self.ctm[4], self.ctm[5], 1),
191 )
193 @staticmethod
194 def compress(matrix: TransformationMatrixType) -> CompressedTransformationMatrix:
195 """
196 Compresses the transformation matrix into a tuple of (a, b, c, d, e, f).
198 Args:
199 matrix: The transformation matrix as a tuple of tuples.
201 Returns:
202 A tuple representing the transformation matrix as (a, b, c, d, e, f)
204 """
205 return (
206 matrix[0][0],
207 matrix[0][1],
208 matrix[1][0],
209 matrix[1][1],
210 matrix[2][0],
211 matrix[2][1],
212 )
214 def _to_cm(self) -> str:
215 # Returns the cm operation string for the given transformation matrix
216 return (
217 f"{self.ctm[0]:.4f} {self.ctm[1]:.4f} {self.ctm[2]:.4f} "
218 f"{self.ctm[3]:.4f} {self.ctm[4]:.4f} {self.ctm[5]:.4f} cm"
219 )
221 def transform(self, m: "Transformation") -> "Transformation":
222 """
223 Apply one transformation to another.
225 Args:
226 m: a Transformation to apply.
228 Returns:
229 A new ``Transformation`` instance
231 Example:
232 >>> from pypdf import PdfWriter, Transformation
233 >>> height, width = 40, 50
234 >>> page = PdfWriter().add_blank_page(800, 600)
235 >>> op = Transformation((1, 0, 0, -1, 0, height)) # vertical mirror
236 >>> op = Transformation().transform(Transformation((-1, 0, 0, 1, width, 0))) # horizontal mirror
237 >>> page.add_transformation(op)
239 """
240 ctm = Transformation.compress(matrix_multiply(self.matrix, m.matrix))
241 return Transformation(ctm)
243 def translate(self, tx: float = 0, ty: float = 0) -> "Transformation":
244 """
245 Translate the contents of a page.
247 Args:
248 tx: The translation along the x-axis.
249 ty: The translation along the y-axis.
251 Returns:
252 A new ``Transformation`` instance
254 """
255 m = self.ctm
256 return Transformation(ctm=(m[0], m[1], m[2], m[3], m[4] + tx, m[5] + ty))
258 def scale(
259 self, sx: Optional[float] = None, sy: Optional[float] = None
260 ) -> "Transformation":
261 """
262 Scale the contents of a page towards the origin of the coordinate system.
264 Typically, that is the lower-left corner of the page. That can be
265 changed by translating the contents / the page boxes.
267 Args:
268 sx: The scale factor along the x-axis.
269 sy: The scale factor along the y-axis.
271 Returns:
272 A new Transformation instance with the scaled matrix.
274 """
275 if sx is None and sy is None:
276 raise ValueError("Either sx or sy must be specified")
277 if sx is None:
278 sx = sy
279 if sy is None:
280 sy = sx
281 assert sx is not None
282 assert sy is not None
283 op: TransformationMatrixType = ((sx, 0, 0), (0, sy, 0), (0, 0, 1))
284 ctm = Transformation.compress(matrix_multiply(self.matrix, op))
285 return Transformation(ctm)
287 def rotate(self, rotation: float) -> "Transformation":
288 """
289 Rotate the contents of a page.
291 Args:
292 rotation: The angle of rotation in degrees.
294 Returns:
295 A new ``Transformation`` instance with the rotated matrix.
297 """
298 rotation = math.radians(rotation)
299 op: TransformationMatrixType = (
300 (math.cos(rotation), math.sin(rotation), 0),
301 (-math.sin(rotation), math.cos(rotation), 0),
302 (0, 0, 1),
303 )
304 ctm = Transformation.compress(matrix_multiply(self.matrix, op))
305 return Transformation(ctm)
307 def __repr__(self) -> str:
308 return f"Transformation(ctm={self.ctm})"
310 @overload
311 def apply_on(self, pt: list[float], as_object: bool = False) -> list[float]:
312 ...
314 @overload
315 def apply_on(
316 self, pt: tuple[float, float], as_object: bool = False
317 ) -> tuple[float, float]:
318 ...
320 def apply_on(
321 self,
322 pt: Union[tuple[float, float], list[float]],
323 as_object: bool = False,
324 ) -> Union[tuple[float, float], list[float]]:
325 """
326 Apply the transformation matrix on the given point.
328 Args:
329 pt: A tuple or list representing the point in the form (x, y).
330 as_object: If True, return items as FloatObject, otherwise as plain floats.
332 Returns:
333 A tuple or list representing the transformed point in the form (x', y')
335 """
336 typ = FloatObject if as_object else float
337 pt1 = (
338 typ(float(pt[0]) * self.ctm[0] + float(pt[1]) * self.ctm[2] + self.ctm[4]),
339 typ(float(pt[0]) * self.ctm[1] + float(pt[1]) * self.ctm[3] + self.ctm[5]),
340 )
341 return list(pt1) if isinstance(pt, list) else pt1
344@dataclass
345class ImageFile:
346 """
347 Image within the PDF file. *This object is not designed to be built.*
349 This object should not be modified except using :func:`ImageFile.replace` to replace the image with a new one.
350 """
352 name: str = ""
353 """
354 Filename as identified within the PDF file.
355 """
357 data: bytes = b""
358 """
359 Data as bytes.
360 """
362 image: Optional[Image] = None
363 """
364 Data as PIL image.
365 """
367 indirect_reference: Optional[IndirectObject] = None
368 """
369 Reference to the object storing the stream.
370 """
372 is_inline: bool = False
373 """
374 True if this is an inline image (~0~, ~1~, etc.).
375 """
377 is_displayed: bool = False
378 """
379 True if this image is displayed in the page content stream.
380 """
382 def replace(self, new_image: Image, **kwargs: Any) -> None:
383 """
384 Replace the image with a new PIL image.
386 Args:
387 new_image (PIL.Image.Image): The new PIL image to replace the existing image.
388 **kwargs: Additional keyword arguments to pass to `Image.save()`.
390 Raises:
391 TypeError: If the image is inline or in a PdfReader.
392 TypeError: If the image does not belong to a PdfWriter.
393 TypeError: If `new_image` is not a PIL Image.
395 Note:
396 This method replaces the existing image with a new image.
397 It is not allowed for inline images or images within a PdfReader.
398 The `kwargs` parameter allows passing additional parameters
399 to `Image.save()`, such as quality.
401 """
402 if pil_not_imported:
403 raise ImportError(
404 "pillow is required to do image extraction. "
405 "It can be installed via 'pip install pypdf[image]'"
406 )
408 from ._reader import PdfReader # noqa: PLC0415
409 from .generic import DictionaryObject, PdfObject # noqa: PLC0415
410 from .generic._image_xobject import _xobj_to_image # noqa: PLC0415
412 if self.indirect_reference is None:
413 raise TypeError("Cannot update an inline image.")
414 if not hasattr(self.indirect_reference.pdf, "_id_translated"):
415 raise TypeError("Cannot update an image not belonging to a PdfWriter.")
416 if not isinstance(new_image, Image):
417 raise TypeError("new_image shall be a PIL Image")
418 b = BytesIO()
419 new_image.save(b, "PDF", **kwargs)
420 reader = PdfReader(b)
421 page_image = reader.pages[0].images[0]
422 assert page_image.indirect_reference is not None
423 self.indirect_reference.pdf._objects[self.indirect_reference.idnum - 1] = (
424 page_image.indirect_reference.get_object()
425 )
426 cast(
427 PdfObject, self.indirect_reference.get_object()
428 ).indirect_reference = self.indirect_reference
429 # change the object attributes
430 extension, byte_stream, img = _xobj_to_image(
431 cast(DictionaryObject, self.indirect_reference.get_object()),
432 pillow_parameters=kwargs,
433 )
434 assert extension is not None
435 self.name = self.name[: self.name.rfind(".")] + extension
436 self.data = byte_stream
437 self.image = img
439 def __str__(self) -> str:
440 return f"{self.__class__.__name__}(name={self.name}, data: {_human_readable_bytes(len(self.data))})"
442 def __repr__(self) -> str:
443 return self.__str__()[:-1] + f", hash: {hash(self.data)})"
446class VirtualListImages(Sequence[ImageFile]):
447 """
448 Provides access to images referenced within a page.
449 Only one copy will be returned if the usage is used on the same page multiple times.
450 See :func:`PageObject.images` for more details.
451 """
453 def __init__(
454 self,
455 ids_function: Callable[[], list[Union[str, list[str]]]],
456 get_function: Callable[[Union[str, list[str], tuple[str]]], ImageFile],
457 ) -> None:
458 self.ids_function = ids_function
459 self.get_function = get_function
460 self.current = -1
462 def __len__(self) -> int:
463 return len(self.ids_function())
465 def keys(self) -> list[Union[str, list[str]]]:
466 return self.ids_function()
468 def items(self) -> list[tuple[Union[str, list[str]], ImageFile]]:
469 return [(x, self[x]) for x in self.ids_function()]
471 @overload
472 def __getitem__(self, index: Union[int, str, list[str]]) -> ImageFile:
473 ...
475 @overload
476 def __getitem__(self, index: slice) -> Sequence[ImageFile]:
477 ...
479 def __getitem__(
480 self, index: Union[int, slice, str, list[str], tuple[str]]
481 ) -> Union[ImageFile, Sequence[ImageFile]]:
482 lst = self.ids_function()
483 if isinstance(index, slice):
484 indices = range(*index.indices(len(self)))
485 lst = [lst[x] for x in indices]
486 cls = type(self)
487 return cls((lambda: lst), self.get_function)
488 if isinstance(index, (str, list, tuple)):
489 return self.get_function(index)
490 if not isinstance(index, int):
491 raise TypeError("Invalid sequence indices type")
492 len_self = len(lst)
493 if index < 0:
494 # support negative indexes
495 index += len_self
496 if not (0 <= index < len_self):
497 raise IndexError("Sequence index out of range")
498 return self.get_function(lst[index])
500 def __iter__(self) -> Iterator[ImageFile]:
501 for i in range(len(self)):
502 yield self[i]
504 def __str__(self) -> str:
505 p = [f"Image_{i}={n}" for i, n in enumerate(self.ids_function())]
506 return f"[{', '.join(p)}]"
509class PageObject(DictionaryObject):
510 """
511 PageObject represents a single page within a PDF file.
513 Typically these objects will be created by accessing the
514 :attr:`pages<pypdf.PdfReader.pages>` property of the
515 :class:`PdfReader<pypdf.PdfReader>` class, but it is
516 also possible to create an empty page with the
517 :meth:`create_blank_page()<pypdf._page.PageObject.create_blank_page>` static method.
519 Args:
520 pdf: PDF file the page belongs to.
521 indirect_reference: Stores the original indirect reference to
522 this object in its source PDF
524 """
526 original_page: "PageObject" # very local use in writer when appending
528 def __init__(
529 self,
530 pdf: Optional[PdfCommonDocProtocol] = None,
531 indirect_reference: Optional[IndirectObject] = None,
532 ) -> None:
533 DictionaryObject.__init__(self)
534 self.pdf = pdf
535 self._content_stream_images: Optional[dict[str, Optional[ImageFile]]] = None
536 self.indirect_reference = indirect_reference
537 if not is_null_or_none(indirect_reference):
538 assert indirect_reference is not None, "mypy"
539 self.update(cast(DictionaryObject, indirect_reference.get_object()))
541 def hash_bin(self) -> int:
542 """
543 Used to detect modified object.
545 Note: this function is overloaded to return the same results
546 as a DictionaryObject.
548 Returns:
549 Hash considering type and value.
551 """
552 return hash(
553 (DictionaryObject, tuple(((k, v.hash_bin()) for k, v in self.items())))
554 )
556 def hash_value_data(self) -> bytes:
557 data = super().hash_value_data()
558 data += f"{id(self)}".encode()
559 return data
561 @property
562 def user_unit(self) -> float:
563 """
564 A read-only positive number giving the size of user space units.
566 It is in multiples of 1/72 inch. Hence a value of 1 means a user
567 space unit is 1/72 inch, and a value of 3 means that a user
568 space unit is 3/72 inch.
569 """
570 return cast(float, self.get(PG.USER_UNIT, 1))
572 @staticmethod
573 def create_blank_page(
574 pdf: Optional[PdfCommonDocProtocol] = None,
575 width: Union[float, Decimal, None] = None,
576 height: Union[float, Decimal, None] = None,
577 ) -> "PageObject":
578 """
579 Return a new blank page.
581 If ``width`` or ``height`` is ``None``, try to get the page size
582 from the last page of *pdf*.
584 Args:
585 pdf: PDF file the page is within.
586 width: The width of the new page expressed in default user
587 space units.
588 height: The height of the new page expressed in default user
589 space units.
591 Returns:
592 The new blank page
594 Raises:
595 PageSizeNotDefinedError: if ``pdf`` is ``None`` or contains
596 no page
598 """
599 page = PageObject(pdf)
601 # Creates a new page (cf PDF Reference §7.7.3.3)
602 page.__setitem__(NameObject(PG.TYPE), NameObject("/Page"))
603 page.__setitem__(NameObject(PG.PARENT), NullObject())
604 page.__setitem__(NameObject(PG.RESOURCES), DictionaryObject())
605 if width is None or height is None:
606 if pdf is not None and len(pdf.pages) > 0:
607 lastpage = pdf.pages[len(pdf.pages) - 1]
608 width = lastpage.mediabox.width
609 height = lastpage.mediabox.height
610 else:
611 raise PageSizeNotDefinedError
612 page.__setitem__(
613 NameObject(PG.MEDIABOX), RectangleObject((0, 0, width, height)) # type: ignore[arg-type]
614 )
616 return page
618 def _get_ids_image(
619 self,
620 obj: Optional[DictionaryObject] = None,
621 ancest: Optional[list[str]] = None,
622 call_stack: Optional[list[Any]] = None,
623 ) -> list[Union[str, list[str]]]:
624 if call_stack is None:
625 call_stack = []
626 _i = getattr(obj, "indirect_reference", None)
627 if _i in call_stack:
628 return []
629 call_stack.append(_i)
630 if self._content_stream_images is None:
631 self._content_stream_images = self._parse_images_from_content_stream()
632 if obj is None:
633 obj = self
634 if ancest is None:
635 ancest = []
636 lst: list[Union[str, list[str]]] = []
637 if (
638 PG.RESOURCES not in obj or
639 is_null_or_none(resources := obj[PG.RESOURCES]) or
640 RES.XOBJECT not in cast(DictionaryObject, resources)
641 ):
642 # Forms without XObject resources have no images inside them
643 if len(ancest) > 0:
644 return []
645 # for inline images, cache dict entries are not None
646 return [image_name for image_name, image_value in self._content_stream_images.items() if image_value]
648 x_object = resources[RES.XOBJECT].get_object() # type: ignore
650 # Iterate through all XObject resources
651 for o in x_object:
652 # Skip non-stream objects (only process StreamObject)
653 if not isinstance(x_object[o], StreamObject):
654 continue
655 if x_object[o][ImageAttributes.SUBTYPE] == "/Image":
656 # If it's an image, add it to lst for further processing
657 lst.append(o if len(ancest) == 0 else [*ancest, o])
658 else:
659 # If it's a form, recursively search for images inside it
660 # Forms may contain images that are Do-referenced in their content stream
661 lst.extend(self._get_ids_image(x_object[o], [*ancest, o], call_stack))
663 # Removes duplicates and preserves order
664 deduplicated = lst.copy()
666 # Add inline images from _content_stream_images
667 for object_name, object_value in self._content_stream_images.items():
668 # inline images have cache populated
669 if object_name not in deduplicated and object_value:
670 deduplicated.append(object_name)
672 return deduplicated
674 def _get_image(
675 self,
676 id: Union[str, list[str], tuple[str]],
677 obj: Optional[DictionaryObject] = None,
678 ) -> ImageFile:
679 if obj is None:
680 obj = cast(DictionaryObject, self)
681 if isinstance(id, tuple):
682 id = list(id)
683 if isinstance(id, list) and len(id) == 1:
684 id = id[0]
685 xobjs: Optional[DictionaryObject] = None
686 try:
687 xobjs = cast(
688 DictionaryObject, cast(DictionaryObject, obj[PG.RESOURCES])[RES.XOBJECT]
689 )
690 except KeyError as exc:
691 if not (id[0] == "~" and id[-1] == "~"):
692 raise KeyError(
693 f"Cannot access image object {id} without XObject resources"
694 ) from exc
695 if isinstance(id, str):
696 if id[0] == "~" and id[-1] == "~":
697 if self._content_stream_images is None:
698 self._content_stream_images = self._parse_images_from_content_stream()
699 if id not in self._content_stream_images:
700 raise KeyError(f"Image {id} not found")
701 image_file = self._content_stream_images[id]
702 assert image_file is not None
703 return image_file
705 # Do-referenced image name (non-inline string keys like /Im0)
706 assert xobjs is not None
707 if id not in xobjs:
708 raise KeyError(f"Image {id} not found")
709 xobj = cast(DictionaryObject, xobjs[id])
710 if xobj.get(ImageAttributes.SUBTYPE, "") != "/Image":
711 raise KeyError(f"XObject {id} is not an image")
713 # Check if displayed (in content stream)
714 is_displayed = self._content_stream_images is not None and id in self._content_stream_images
716 from .generic._image_xobject import _xobj_to_image # noqa: PLC0415
717 extension, byte_stream, img = _xobj_to_image(xobj)
718 return ImageFile(
719 name=f"{id[1:]}{extension}",
720 data=byte_stream,
721 image=img,
722 indirect_reference=xobj.indirect_reference,
723 is_inline=False,
724 is_displayed=is_displayed,
725 )
726 # in a subobject
727 assert xobjs is not None
728 ids = id[1:]
729 return self._get_image(ids, cast(DictionaryObject, xobjs[id[0]]))
731 @property
732 def images(self) -> VirtualListImages:
733 """
734 Read-only property emulating a list of images on a page.
736 Get a list of all images on the page. The key can be:
737 - A string (for the top object)
738 - A tuple (for images within XObject forms)
739 - An integer
741 Examples:
742 * `reader.pages[0].images[0]` # return first image
743 * `reader.pages[0].images['/I0']` # return image '/I0'
744 * `reader.pages[0].images['/TP1','/Image1']` # return image '/Image1' within '/TP1' XObject form
745 * `for img in reader.pages[0].images:` # loops through all objects
747 Example usage:
749 >>> from pypdf import PdfWriter
750 >>> writer = PdfWriter()
751 >>> page = writer.add_blank_page(800, 600)
752 >>> images = page.images
754 """
755 return VirtualListImages(self._get_ids_image, self._get_image)
757 @property
758 def inline_images(self) -> Optional[dict[str, ImageFile]]:
759 """
760 Return only inline images from the page.
762 .. deprecated::
763 Use :attr:`images` and filter by :attr:`ImageFile.is_inline` instead.
764 This property will be removed in pypdf 7.0.
766 Examples:
767 >>> from pypdf import PdfWriter
768 >>> writer = PdfWriter()
769 >>> page = writer.add_blank_page(800,600)
770 >>> for image_name, image_file in page.images.items():
771 ... if image_file.is_inline:
772 ... print(f"{image_name} is inline")
773 """
774 deprecate_with_replacement(
775 "PageObject.inline_images",
776 "PageObject.images",
777 "7.0.0",
778 )
779 if self._content_stream_images is None:
780 return None
781 return {
782 image_name: image_file
783 for image_name, image_file in self._content_stream_images.items()
784 if image_file and image_file.is_inline # for inline images, image_file is populated
785 }
787 @inline_images.setter
788 def inline_images(self, value: Optional[dict[str, ImageFile]]) -> None:
789 deprecate_no_replacement(
790 "PageObject.inline_images",
791 "7.0.0",
792 )
793 if value is None:
794 self._content_stream_images = None
795 else:
796 assert self._content_stream_images is not None, "Can't edit inline_images before accessing images"
797 self._content_stream_images.update(value)
799 def _translate_value_inline_image(self, k: str, v: PdfObject) -> PdfObject:
800 """Translate values used in inline image"""
801 try:
802 v = NameObject(_INLINE_IMAGE_VALUE_MAPPING[cast(str, v)])
803 except (TypeError, KeyError):
804 if isinstance(v, NameObject):
805 # It is a custom name, thus we have to look in resources.
806 # The only applicable case is for ColorSpace.
807 try:
808 res = cast(DictionaryObject, self["/Resources"])["/ColorSpace"]
809 v = cast(DictionaryObject, res)[v]
810 except KeyError: # for res and v
811 raise PdfReadError(f"Cannot find resource entry {v} for {k}")
812 return v
814 def _parse_images_from_content_stream(self) -> dict[str, Optional[ImageFile]]:
815 """Load images from content stream. Includes both inline images and Do-referenced images.
817 This method scans the page content stream and extracts:
819 1. **Inline images** (~0~, ~1~...): Embedded directly in content stream via BI/EI operators
820 - is_inline=True, is_displayed=True, indirect_reference=None
822 2. **Do-referenced objects** (/Im0, /Im1..., /Form1...): Referenced via "Do" operator
823 - is_inline=False, is_displayed=True, indirect_reference=<image object>
825 3. **Pure XObject images** (/I0, /Image1...): Defined in Resources only (not in content stream)
826 - is_inline=False, is_displayed=False, indirect_reference=<image object>
828 Returns:
829 Dictionary mapping names to ImageFile instances (inline) or None (Do-referenced).
830 """
831 content = self.get_contents()
832 if is_null_or_none(content):
833 return {}
834 imgs_data = []
835 do_image_names: list[bytes] = []
836 assert content is not None, "mypy"
837 for param, ope in content.operations:
838 if ope == b"INLINE IMAGE":
839 imgs_data.append(
840 {"settings": param["settings"], "__streamdata__": param["data"]}
841 )
842 elif ope == b"Do" and param:
843 do_image_names.append(param[0]) # First operand is the XObject name
844 elif ope in (b"BI", b"EI", b"ID"): # pragma: no cover
845 raise PdfReadError(
846 f"{ope!r} operator met whereas not expected, "
847 "please share use case with pypdf dev team"
848 )
850 files: dict[str, Optional[ImageFile]] = {}
851 # Process Do-referenced objects first (images + forms, no subtype check)
852 files = dict.fromkeys(list(map(str, do_image_names)), None)
854 # Then process inline images
855 for num, ii in enumerate(imgs_data):
856 init = {
857 "__streamdata__": ii["__streamdata__"],
858 "/Length": len(ii["__streamdata__"]),
859 }
860 for k, v in ii["settings"].items():
861 if k in {"/Length", "/L"}: # no length is expected
862 continue
863 if isinstance(v, list):
864 v = ArrayObject(
865 [self._translate_value_inline_image(k, x) for x in v]
866 )
867 else:
868 v = self._translate_value_inline_image(k, v)
869 if k in _INLINE_IMAGE_KEY_MAPPING:
870 k = NameObject(_INLINE_IMAGE_KEY_MAPPING[k])
871 else:
872 logger_warning(
873 "Unknown inline image key %(key)s, keeping it as-is.",
874 source=__name__,
875 key=k,
876 )
877 k = NameObject(k)
878 if k not in init:
879 init[k] = v
880 ii["object"] = EncodedStreamObject.initialize_from_dictionary(init)
881 from .generic._image_xobject import _xobj_to_image # noqa: PLC0415
882 extension, byte_stream, img = _xobj_to_image(ii["object"])
883 files[f"~{num}~"] = ImageFile(
884 name=f"~{num}~{extension}",
885 data=byte_stream,
886 image=img,
887 indirect_reference=None,
888 is_inline=True,
889 is_displayed=True,
890 )
892 return files
894 @property
895 def rotation(self) -> int:
896 """
897 The visual rotation of the page.
899 This number has to be a multiple of 90 degrees: 0, 90, 180, or 270 are
900 valid values. This property does not affect ``/Contents``.
901 """
902 rotate_obj = self.get(PG.ROTATE, 0)
903 return rotate_obj if isinstance(rotate_obj, int) else rotate_obj.get_object()
905 @rotation.setter
906 def rotation(self, r: float) -> None:
907 self[NameObject(PG.ROTATE)] = NumberObject((((int(r) + 45) // 90) * 90) % 360)
909 def transfer_rotation_to_content(self) -> None:
910 """
911 Apply the rotation of the page to the content and the media/crop/...
912 boxes.
914 It is recommended to apply this function before page merging.
915 """
916 r = -self.rotation # rotation to apply is in the otherway
917 self.rotation = 0
918 mb = RectangleObject(self.mediabox)
919 trsf = (
920 Transformation()
921 .translate(
922 -float(mb.left + mb.width / 2), -float(mb.bottom + mb.height / 2)
923 )
924 .rotate(r)
925 )
926 pt1 = trsf.apply_on(mb.lower_left)
927 pt2 = trsf.apply_on(mb.upper_right)
928 trsf = trsf.translate(-min(pt1[0], pt2[0]), -min(pt1[1], pt2[1]))
929 self.add_transformation(trsf, False)
930 for b in ["/MediaBox", "/CropBox", "/BleedBox", "/TrimBox", "/ArtBox"]:
931 if b in self:
932 rr = RectangleObject(self[b]) # type: ignore[arg-type]
933 pt1 = trsf.apply_on(rr.lower_left)
934 pt2 = trsf.apply_on(rr.upper_right)
935 self[NameObject(b)] = RectangleObject(
936 (
937 min(pt1[0], pt2[0]),
938 min(pt1[1], pt2[1]),
939 max(pt1[0], pt2[0]),
940 max(pt1[1], pt2[1]),
941 )
942 )
944 def rotate(self, angle: int) -> "PageObject":
945 """
946 Rotate a page clockwise by increments of 90 degrees.
948 Args:
949 angle: Angle to rotate the page. Must be an increment of 90 deg.
951 Returns:
952 The rotated PageObject
954 """
955 if angle % 90 != 0:
956 raise ValueError("Rotation angle must be a multiple of 90")
957 self[NameObject(PG.ROTATE)] = NumberObject(self.rotation + angle)
958 return self
960 def _merge_resources(
961 self,
962 res1: DictionaryObject,
963 res2: DictionaryObject,
964 resource: Any,
965 new_res1: bool = True,
966 ) -> tuple[dict[str, Any], dict[str, Any]]:
967 try:
968 assert isinstance(self.indirect_reference, IndirectObject)
969 pdf = self.indirect_reference.pdf
970 is_pdf_writer = hasattr(
971 pdf, "_add_object"
972 ) # expect isinstance(pdf, PdfWriter)
973 except (AssertionError, AttributeError):
974 pdf = None
975 is_pdf_writer = False
977 def compute_unique_key(base_key: str) -> tuple[str, bool]:
978 """
979 Find a key that either doesn't already exist or has the same value
980 (indicated by the bool)
982 Args:
983 base_key: An index is added to this to get the computed key
985 Returns:
986 A tuple (computed key, bool) where the boolean indicates
987 if there is a resource of the given computed_key with the same
988 value.
990 """
991 value = page2res.raw_get(base_key)
992 # TODO: a possible improvement for writer, the indirect_reference
993 # cannot be found because translated
995 # try the current key first (e.g. "foo"), but otherwise iterate
996 # through "foo-0", "foo-1", etc. new_res can contain only finitely
997 # many keys, thus this'll eventually end, even if it's been crafted
998 # to be maximally annoying.
999 computed_key = base_key
1000 idx = 0
1001 while computed_key in new_res:
1002 if new_res.raw_get(computed_key) == value:
1003 # there's already a resource of this name, with the exact
1004 # same value
1005 return computed_key, True
1006 computed_key = f"{base_key}-{idx}"
1007 idx += 1
1008 return computed_key, False
1010 if new_res1:
1011 new_res = DictionaryObject()
1012 new_res.update(res1.get(resource, DictionaryObject()).get_object())
1013 else:
1014 new_res = cast(DictionaryObject, res1[resource])
1015 page2res = cast(
1016 DictionaryObject, res2.get(resource, DictionaryObject()).get_object()
1017 )
1018 rename_res = {}
1019 for key in page2res:
1020 unique_key, same_value = compute_unique_key(key)
1021 newname = NameObject(unique_key)
1022 if key != unique_key:
1023 # we have to use a different name for this
1024 rename_res[key] = newname
1026 if not same_value:
1027 if is_pdf_writer:
1028 new_res[newname] = page2res.raw_get(key).clone(pdf)
1029 try:
1030 new_res[newname] = new_res[newname].indirect_reference
1031 except AttributeError:
1032 pass
1033 else:
1034 new_res[newname] = page2res.raw_get(key)
1035 lst = sorted(new_res.items())
1036 new_res.clear()
1037 for el in lst:
1038 new_res[el[0]] = el[1]
1039 return new_res, rename_res
1041 @staticmethod
1042 def _content_stream_rename(
1043 stream: ContentStream,
1044 rename: dict[Any, Any],
1045 pdf: Optional[PdfCommonDocProtocol],
1046 ) -> ContentStream:
1047 if not rename:
1048 return stream
1049 stream = ContentStream(stream, pdf)
1050 for operands, _operator in stream.operations:
1051 if isinstance(operands, list):
1052 for i, op in enumerate(operands):
1053 if isinstance(op, NameObject):
1054 operands[i] = rename.get(op, op)
1055 elif isinstance(operands, dict):
1056 for i, op in operands.items():
1057 if isinstance(op, NameObject):
1058 operands[i] = rename.get(op, op)
1059 else:
1060 raise KeyError(f"Type of operands is {type(operands)}")
1061 return stream
1063 @staticmethod
1064 def _add_transformation_matrix(
1065 contents: Any,
1066 pdf: Optional[PdfCommonDocProtocol],
1067 ctm: CompressedTransformationMatrix,
1068 ) -> ContentStream:
1069 """Add transformation matrix at the beginning of the given contents stream."""
1070 content_stream = ContentStream(contents, pdf)
1071 content_stream.operations.insert(
1072 0,
1073 (
1074 [FloatObject(x) for x in ctm],
1075 b"cm",
1076 ),
1077 )
1078 return content_stream
1080 def _get_contents_as_bytes(self) -> Optional[bytes]:
1081 """
1082 Return the page contents as bytes.
1084 Returns:
1085 The ``/Contents`` object as bytes, or ``None`` if it doesn't exist.
1087 """
1088 if PG.CONTENTS in self:
1089 obj = self[PG.CONTENTS].get_object()
1090 if isinstance(obj, list):
1091 return b"".join(x.get_object().get_data() for x in obj)
1092 return cast(EncodedStreamObject, obj).get_data()
1093 return None
1095 def get_contents(self) -> Optional[ContentStream]:
1096 """
1097 Access the page contents.
1099 Returns:
1100 The ``/Contents`` object, or ``None`` if it does not exist.
1101 ``/Contents`` is optional, as described in §7.7.3.3 of the PDF Reference.
1103 """
1104 if PG.CONTENTS in self:
1105 try:
1106 pdf = cast(IndirectObject, self.indirect_reference).pdf
1107 except AttributeError:
1108 pdf = None
1109 obj = self[PG.CONTENTS]
1110 if is_null_or_none(obj):
1111 return None
1112 resolved_object = obj.get_object()
1113 return ContentStream(resolved_object, pdf)
1114 return None
1116 def replace_contents(
1117 self, content: Union[None, ContentStream, EncodedStreamObject, ArrayObject]
1118 ) -> None:
1119 """
1120 Replace the page contents with the new content and nullify old objects
1121 Args:
1122 content: new content; if None delete the content field.
1123 """
1124 if not hasattr(self, "indirect_reference") or self.indirect_reference is None:
1125 # the page is not attached : the content is directly attached.
1126 self[NameObject(PG.CONTENTS)] = content
1127 return
1129 from pypdf._writer import PdfWriter # noqa: PLC0415
1130 if not isinstance(self.indirect_reference.pdf, PdfWriter):
1131 deprecate(
1132 "Calling `PageObject.replace_contents()` for pages not assigned to a writer is deprecated "
1133 "and will be removed in pypdf 7.0.0. Attach the page to the writer first or use "
1134 "`PdfWriter(clone_from=...)` directly. The existing approach has proved being unreliable."
1135 )
1137 writer = self.indirect_reference.pdf
1138 if isinstance(self.get(PG.CONTENTS, None), ArrayObject):
1139 content_array = cast(ArrayObject, self[PG.CONTENTS])
1140 for reference in content_array:
1141 try:
1142 writer._replace_object(indirect_reference=reference.indirect_reference, obj=NullObject())
1143 except ValueError:
1144 # Occurs when called on PdfReader.
1145 pass
1147 if isinstance(content, ArrayObject):
1148 content = ArrayObject(writer._add_object(obj) for obj in content)
1150 if is_null_or_none(content):
1151 if PG.CONTENTS not in self:
1152 return
1153 assert self[PG.CONTENTS].indirect_reference is not None
1154 writer._replace_object(indirect_reference=self[PG.CONTENTS].indirect_reference, obj=NullObject())
1155 del self[PG.CONTENTS]
1156 elif not hasattr(self.get(PG.CONTENTS, None), "indirect_reference"):
1157 try:
1158 self[NameObject(PG.CONTENTS)] = writer._add_object(content)
1159 except AttributeError:
1160 # applies at least for page not in writer
1161 # as a backup solution, we put content as an object although not in accordance with pdf ref
1162 # this will be fixed with the _add_object
1163 self[NameObject(PG.CONTENTS)] = content
1164 else:
1165 assert content is not None, "mypy"
1166 content.indirect_reference = self[
1167 PG.CONTENTS
1168 ].indirect_reference # TODO: in the future may require generation management
1169 try:
1170 writer._replace_object(indirect_reference=content.indirect_reference, obj=content)
1171 except AttributeError:
1172 # applies at least for page not in writer
1173 # as a backup solution, we put content as an object although not in accordance with pdf ref
1174 # this will be fixed with the _add_object
1175 self[NameObject(PG.CONTENTS)] = content
1176 # forces recalculation of images
1177 self._content_stream_images = None
1179 def merge_page(
1180 self, page2: "PageObject", expand: bool = False, over: bool = True
1181 ) -> None:
1182 """
1183 Merge the content streams of two pages into one.
1185 Resource references (e.g. fonts) are maintained from both pages.
1186 The mediabox, cropbox, etc of this page are not altered.
1187 The parameter page's content stream will
1188 be added to the end of this page's content stream,
1189 meaning that it will be drawn after, or "on top" of this page.
1191 Args:
1192 page2: The page to be merged into this one. Should be
1193 an instance of :class:`PageObject<PageObject>`.
1194 over: set the page2 content over page1 if True (default) else under
1195 expand: If True, the current page dimensions will be
1196 expanded to accommodate the dimensions of the page to be merged.
1198 """
1199 self._merge_page(page2, over=over, expand=expand)
1201 def _merge_page(
1202 self,
1203 page2: "PageObject",
1204 page2_transformation: Optional[Callable[[Any], ContentStream]] = None,
1205 ctm: Optional[CompressedTransformationMatrix] = None,
1206 over: bool = True,
1207 expand: bool = False,
1208 ) -> None:
1209 # First we work on merging the resource dictionaries. This allows us
1210 # to find out what symbols in the content streams we might need to
1211 # rename.
1212 try:
1213 assert isinstance(self.indirect_reference, IndirectObject)
1214 if hasattr(self.indirect_reference.pdf, "_add_object"): # to detect PdfWriter
1215 return self._merge_page_writer(
1216 page2, page2_transformation, ctm, over, expand
1217 )
1218 except (AssertionError, AttributeError):
1219 pass
1221 new_resources = DictionaryObject()
1222 rename: dict[str, Any] = {}
1223 original_resources = cast(DictionaryObject, self.get(PG.RESOURCES, DictionaryObject()).get_object())
1224 page2_resources = cast(DictionaryObject, page2.get(PG.RESOURCES, DictionaryObject()).get_object())
1225 new_annots = ArrayObject()
1227 for page in (self, page2):
1228 if PG.ANNOTS in page:
1229 annots = page[PG.ANNOTS]
1230 if isinstance(annots, ArrayObject):
1231 new_annots.extend(annots)
1232 self[NameObject(PG.ANNOTS)] = new_annots
1234 for res in (
1235 RES.EXT_G_STATE,
1236 RES.COLOR_SPACE,
1237 RES.PATTERN,
1238 RES.SHADING,
1239 RES.XOBJECT,
1240 RES.FONT,
1241 RES.PROPERTIES,
1242 ):
1243 new, new_resource_name = self._merge_resources(
1244 original_resources, page2_resources, res
1245 )
1246 if new:
1247 new_resources[NameObject(res)] = new
1248 rename.update(new_resource_name)
1250 # Combine /ProcSet sets, making sure there is a consistent order
1251 new_resources[NameObject(RES.PROC_SET)] = ArrayObject(
1252 sorted(
1253 set(
1254 original_resources.get(RES.PROC_SET, ArrayObject()).get_object()
1255 ).union(
1256 set(page2_resources.get(RES.PROC_SET, ArrayObject()).get_object())
1257 )
1258 )
1259 )
1261 new_content_array = ArrayObject()
1262 original_content = self.get_contents()
1263 if original_content is not None:
1264 original_content.isolate_graphics_state()
1265 new_content_array.append(original_content)
1267 page2_content = page2.get_contents()
1268 if page2_content is not None:
1269 rect = getattr(page2, MERGE_CROP_BOX)
1270 page2_content.operations.insert(
1271 0,
1272 (
1273 map(
1274 FloatObject,
1275 [
1276 rect.left,
1277 rect.bottom,
1278 rect.width,
1279 rect.height,
1280 ],
1281 ),
1282 b"re",
1283 ),
1284 )
1285 page2_content.operations.insert(1, ([], b"W"))
1286 page2_content.operations.insert(2, ([], b"n"))
1287 if page2_transformation is not None:
1288 page2_content = page2_transformation(page2_content)
1289 page2_content = PageObject._content_stream_rename(
1290 page2_content, rename, self.pdf
1291 )
1292 page2_content.isolate_graphics_state()
1293 if over:
1294 new_content_array.append(page2_content)
1295 else:
1296 new_content_array.insert(0, page2_content)
1298 # if expanding the page to fit a new page, calculate the new media box size
1299 if expand:
1300 self._expand_mediabox(page2, ctm)
1302 self.replace_contents(ContentStream(new_content_array, self.pdf))
1303 self[NameObject(PG.RESOURCES)] = new_resources
1305 return None
1307 def _merge_page_writer(
1308 self,
1309 page2: "PageObject",
1310 page2transformation: Optional[Callable[[Any], ContentStream]] = None,
1311 ctm: Optional[CompressedTransformationMatrix] = None,
1312 over: bool = True,
1313 expand: bool = False,
1314 ) -> None:
1315 # First we work on merging the resource dictionaries. This allows us
1316 # to find which symbols in the content streams we might need to
1317 # rename.
1318 assert isinstance(self.indirect_reference, IndirectObject)
1319 pdf = self.indirect_reference.pdf
1321 if PG.RESOURCES not in self:
1322 self[NameObject(PG.RESOURCES)] = DictionaryObject()
1323 original_resources = cast(DictionaryObject, self[PG.RESOURCES].get_object())
1324 if PG.RESOURCES not in page2:
1325 page2resources = DictionaryObject()
1326 else:
1327 page2resources = cast(DictionaryObject, page2[PG.RESOURCES].get_object())
1329 rename = {}
1330 for res in (
1331 RES.EXT_G_STATE,
1332 RES.COLOR_SPACE,
1333 RES.PATTERN,
1334 RES.SHADING,
1335 RES.XOBJECT,
1336 RES.FONT,
1337 RES.PROPERTIES,
1338 ):
1339 if res in page2resources:
1340 if res not in original_resources:
1341 original_resources[NameObject(res)] = DictionaryObject()
1342 _, newrename = self._merge_resources(
1343 original_resources, page2resources, res, False
1344 )
1345 rename.update(newrename)
1346 # Combine /ProcSet sets
1347 if RES.PROC_SET in page2resources:
1348 if RES.PROC_SET not in original_resources:
1349 original_resources[NameObject(RES.PROC_SET)] = ArrayObject()
1350 arr = cast(ArrayObject, original_resources[RES.PROC_SET])
1351 for x in cast(ArrayObject, page2resources[RES.PROC_SET]):
1352 if x not in arr:
1353 arr.append(x)
1354 arr.sort()
1356 if not is_null_or_none(page2.get(PG.ANNOTS, None)):
1357 if PG.ANNOTS not in self:
1358 self[NameObject(PG.ANNOTS)] = ArrayObject()
1359 annots = cast(ArrayObject, self[PG.ANNOTS].get_object())
1360 if ctm is None:
1361 trsf = Transformation()
1362 else:
1363 trsf = Transformation(ctm)
1364 # Ensure we are working on a copy of the list. Otherwise, if both pages
1365 # are the same object, we might run into an infinite loop.
1366 for a in cast(ArrayObject, deepcopy(page2[PG.ANNOTS])):
1367 a = a.get_object()
1368 aa = a.clone(
1369 pdf,
1370 ignore_fields=("/P", "/StructParent", "/Parent"),
1371 force_duplicate=True,
1372 )
1373 r = cast(ArrayObject, a["/Rect"])
1374 pt1 = trsf.apply_on((r[0], r[1]), True)
1375 pt2 = trsf.apply_on((r[2], r[3]), True)
1376 aa[NameObject("/Rect")] = ArrayObject(
1377 (
1378 min(pt1[0], pt2[0]),
1379 min(pt1[1], pt2[1]),
1380 max(pt1[0], pt2[0]),
1381 max(pt1[1], pt2[1]),
1382 )
1383 )
1384 if "/QuadPoints" in a:
1385 q = cast(ArrayObject, a["/QuadPoints"])
1386 aa[NameObject("/QuadPoints")] = ArrayObject(
1387 trsf.apply_on((q[0], q[1]), True)
1388 + trsf.apply_on((q[2], q[3]), True)
1389 + trsf.apply_on((q[4], q[5]), True)
1390 + trsf.apply_on((q[6], q[7]), True)
1391 )
1392 try:
1393 aa["/Popup"][NameObject("/Parent")] = aa.indirect_reference
1394 except KeyError:
1395 pass
1396 try:
1397 aa[NameObject("/P")] = self.indirect_reference
1398 annots.append(aa.indirect_reference)
1399 except AttributeError:
1400 pass
1402 new_content_array = ArrayObject()
1403 original_content = self.get_contents()
1404 if original_content is not None:
1405 original_content.isolate_graphics_state()
1406 new_content_array.append(original_content)
1408 page2content = page2.get_contents()
1409 if page2content is not None:
1410 rect = getattr(page2, MERGE_CROP_BOX)
1411 page2content.operations.insert(
1412 0,
1413 (
1414 map(
1415 FloatObject,
1416 [
1417 rect.left,
1418 rect.bottom,
1419 rect.width,
1420 rect.height,
1421 ],
1422 ),
1423 b"re",
1424 ),
1425 )
1426 page2content.operations.insert(1, ([], b"W"))
1427 page2content.operations.insert(2, ([], b"n"))
1428 if page2transformation is not None:
1429 page2content = page2transformation(page2content)
1430 page2content = PageObject._content_stream_rename(
1431 page2content, rename, self.pdf
1432 )
1433 page2content.isolate_graphics_state()
1434 if over:
1435 new_content_array.append(page2content)
1436 else:
1437 new_content_array.insert(0, page2content)
1439 # if expanding the page to fit a new page, calculate the new media box size
1440 if expand:
1441 self._expand_mediabox(page2, ctm)
1443 self.replace_contents(new_content_array)
1445 def _expand_mediabox(
1446 self, page2: "PageObject", ctm: Optional[CompressedTransformationMatrix]
1447 ) -> None:
1448 corners1 = (
1449 self.mediabox.left.as_numeric(),
1450 self.mediabox.bottom.as_numeric(),
1451 self.mediabox.right.as_numeric(),
1452 self.mediabox.top.as_numeric(),
1453 )
1454 corners2 = (
1455 page2.mediabox.left.as_numeric(),
1456 page2.mediabox.bottom.as_numeric(),
1457 page2.mediabox.left.as_numeric(),
1458 page2.mediabox.top.as_numeric(),
1459 page2.mediabox.right.as_numeric(),
1460 page2.mediabox.top.as_numeric(),
1461 page2.mediabox.right.as_numeric(),
1462 page2.mediabox.bottom.as_numeric(),
1463 )
1464 if ctm is not None:
1465 ctm = tuple(float(x) for x in ctm) # type: ignore[assignment]
1466 new_x = tuple(
1467 ctm[0] * corners2[i] + ctm[2] * corners2[i + 1] + ctm[4]
1468 for i in range(0, 8, 2)
1469 )
1470 new_y = tuple(
1471 ctm[1] * corners2[i] + ctm[3] * corners2[i + 1] + ctm[5]
1472 for i in range(0, 8, 2)
1473 )
1474 else:
1475 new_x = corners2[0:8:2]
1476 new_y = corners2[1:8:2]
1477 lowerleft = (min(new_x), min(new_y))
1478 upperright = (max(new_x), max(new_y))
1479 lowerleft = (min(corners1[0], lowerleft[0]), min(corners1[1], lowerleft[1]))
1480 upperright = (
1481 max(corners1[2], upperright[0]),
1482 max(corners1[3], upperright[1]),
1483 )
1485 self.mediabox.lower_left = lowerleft
1486 self.mediabox.upper_right = upperright
1488 def merge_transformed_page(
1489 self,
1490 page2: "PageObject",
1491 ctm: Union[CompressedTransformationMatrix, Transformation],
1492 over: bool = True,
1493 expand: bool = False,
1494 ) -> None:
1495 """
1496 Similar to :meth:`~pypdf._page.PageObject.merge_page`, but a transformation
1497 matrix is applied to the merged stream.
1499 Args:
1500 page2: The page to be merged into this one.
1501 ctm: a 6-element tuple containing the operands of the
1502 transformation matrix
1503 over: set the page2 content over page1 if True (default) else under
1504 expand: Whether the page should be expanded to fit the dimensions
1505 of the page to be merged.
1507 """
1508 if isinstance(ctm, Transformation):
1509 ctm = ctm.ctm
1510 self._merge_page(
1511 page2,
1512 lambda page2_content: PageObject._add_transformation_matrix(
1513 page2_content, page2.pdf, ctm
1514 ),
1515 ctm,
1516 over,
1517 expand,
1518 )
1520 def merge_scaled_page(
1521 self, page2: "PageObject", scale: float, over: bool = True, expand: bool = False
1522 ) -> None:
1523 """
1524 Similar to :meth:`~pypdf._page.PageObject.merge_page`, but the stream to be merged
1525 is scaled by applying a transformation matrix.
1527 Args:
1528 page2: The page to be merged into this one.
1529 scale: The scaling factor
1530 over: set the page2 content over page1 if True (default) else under
1531 expand: Whether the page should be expanded to fit the
1532 dimensions of the page to be merged.
1534 """
1535 op = Transformation().scale(scale, scale)
1536 self.merge_transformed_page(page2, op, over, expand)
1538 def merge_rotated_page(
1539 self,
1540 page2: "PageObject",
1541 rotation: float,
1542 over: bool = True,
1543 expand: bool = False,
1544 ) -> None:
1545 """
1546 Similar to :meth:`~pypdf._page.PageObject.merge_page`, but the stream to be merged
1547 is rotated by applying a transformation matrix.
1549 Args:
1550 page2: The page to be merged into this one.
1551 rotation: The angle of the rotation, in degrees
1552 over: set the page2 content over page1 if True (default) else under
1553 expand: Whether the page should be expanded to fit the
1554 dimensions of the page to be merged.
1556 """
1557 op = Transformation().rotate(rotation)
1558 self.merge_transformed_page(page2, op, over, expand)
1560 def merge_translated_page(
1561 self,
1562 page2: "PageObject",
1563 tx: float,
1564 ty: float,
1565 over: bool = True,
1566 expand: bool = False,
1567 ) -> None:
1568 """
1569 Similar to :meth:`~pypdf._page.PageObject.merge_page`, but the stream to be
1570 merged is translated by applying a transformation matrix.
1572 Args:
1573 page2: the page to be merged into this one.
1574 tx: The translation on X axis
1575 ty: The translation on Y axis
1576 over: set the page2 content over page1 if True (default) else under
1577 expand: Whether the page should be expanded to fit the
1578 dimensions of the page to be merged.
1580 """
1581 op = Transformation().translate(tx, ty)
1582 self.merge_transformed_page(page2, op, over, expand)
1584 def add_transformation(
1585 self,
1586 ctm: Union[Transformation, CompressedTransformationMatrix],
1587 expand: bool = False,
1588 ) -> None:
1589 """
1590 Apply a transformation matrix to the page.
1592 Args:
1593 ctm: A 6-element tuple containing the operands of the
1594 transformation matrix. Alternatively, a
1595 :py:class:`Transformation<pypdf.Transformation>`
1596 object can be passed.
1598 See :doc:`/user/cropping-and-transforming`.
1600 """
1601 if isinstance(ctm, Transformation):
1602 ctm = ctm.ctm
1603 content = self.get_contents()
1604 if content is not None:
1605 content = PageObject._add_transformation_matrix(content, self.pdf, ctm)
1606 content.isolate_graphics_state()
1607 self.replace_contents(content)
1608 # if expanding the page to fit a new page, calculate the new media box size
1609 if expand:
1610 corners = [
1611 self.mediabox.left.as_numeric(),
1612 self.mediabox.bottom.as_numeric(),
1613 self.mediabox.left.as_numeric(),
1614 self.mediabox.top.as_numeric(),
1615 self.mediabox.right.as_numeric(),
1616 self.mediabox.top.as_numeric(),
1617 self.mediabox.right.as_numeric(),
1618 self.mediabox.bottom.as_numeric(),
1619 ]
1621 ctm = tuple(float(x) for x in ctm) # type: ignore[assignment]
1622 new_x = [
1623 ctm[0] * corners[i] + ctm[2] * corners[i + 1] + ctm[4]
1624 for i in range(0, 8, 2)
1625 ]
1626 new_y = [
1627 ctm[1] * corners[i] + ctm[3] * corners[i + 1] + ctm[5]
1628 for i in range(0, 8, 2)
1629 ]
1631 self.mediabox.lower_left = (min(new_x), min(new_y))
1632 self.mediabox.upper_right = (max(new_x), max(new_y))
1634 def scale(self, sx: float, sy: float) -> None:
1635 """
1636 Scale a page by the given factors by applying a transformation matrix
1637 to its content and updating the page size.
1639 This updates the various page boundaries (bleedbox, trimbox, etc.)
1640 and the contents of the page.
1642 Args:
1643 sx: The scaling factor on horizontal axis.
1644 sy: The scaling factor on vertical axis.
1646 """
1647 self.add_transformation((sx, 0, 0, sy, 0, 0))
1648 self.bleedbox = self.bleedbox.scale(sx, sy)
1649 self.trimbox = self.trimbox.scale(sx, sy)
1650 self.artbox = self.artbox.scale(sx, sy)
1651 self.cropbox = self.cropbox.scale(sx, sy)
1652 self.mediabox = self.mediabox.scale(sx, sy)
1654 if PG.ANNOTS in self:
1655 annotations = self[PG.ANNOTS]
1656 if isinstance(annotations, ArrayObject):
1657 for annotation in annotations:
1658 annotation_obj = annotation.get_object()
1659 if AnnotationDictionaryAttributes.Rect in annotation_obj:
1660 rectangle = annotation_obj[AnnotationDictionaryAttributes.Rect]
1661 if isinstance(rectangle, ArrayObject):
1662 rectangle[0] = FloatObject(float(rectangle[0]) * sx)
1663 rectangle[1] = FloatObject(float(rectangle[1]) * sy)
1664 rectangle[2] = FloatObject(float(rectangle[2]) * sx)
1665 rectangle[3] = FloatObject(float(rectangle[3]) * sy)
1667 if PG.VP in self:
1668 viewport = self[PG.VP]
1669 if isinstance(viewport, ArrayObject):
1670 bbox = viewport[0]["/BBox"]
1671 else:
1672 bbox = viewport["/BBox"] # type: ignore[index]
1673 scaled_bbox = RectangleObject(
1674 (
1675 float(bbox[0]) * sx,
1676 float(bbox[1]) * sy,
1677 float(bbox[2]) * sx,
1678 float(bbox[3]) * sy,
1679 )
1680 )
1681 if isinstance(viewport, ArrayObject):
1682 self[NameObject(PG.VP)][NumberObject(0)][ # type: ignore[index]
1683 NameObject("/BBox")
1684 ] = scaled_bbox
1685 else:
1686 self[NameObject(PG.VP)][NameObject("/BBox")] = scaled_bbox # type: ignore[index]
1688 def scale_by(self, factor: float) -> None:
1689 """
1690 Scale a page by the given factor by applying a transformation matrix to
1691 its content and updating the page size.
1693 Args:
1694 factor: The scaling factor (for both X and Y axis).
1696 """
1697 self.scale(factor, factor)
1699 def scale_to(self, width: float, height: float) -> None:
1700 """
1701 Scale a page to the specified dimensions by applying a transformation
1702 matrix to its content and updating the page size.
1704 Args:
1705 width: The new width.
1706 height: The new height.
1708 """
1709 sx = width / float(self.mediabox.width)
1710 sy = height / float(self.mediabox.height)
1711 self.scale(sx, sy)
1713 def compress_content_streams(self, level: int = -1) -> None:
1714 """
1715 Compress the size of this page by joining all content streams and
1716 applying a FlateDecode filter.
1718 However, it is possible that this function will perform no action if
1719 content stream compression becomes "automatic".
1720 """
1721 content = self.get_contents()
1722 if content is not None:
1723 content_obj = content.flate_encode(level)
1724 try:
1725 content.indirect_reference.pdf._objects[ # type: ignore[union-attr]
1726 content.indirect_reference.idnum - 1 # type: ignore[union-attr]
1727 ] = content_obj
1728 except AttributeError:
1729 if self.indirect_reference is not None and hasattr(
1730 self.indirect_reference.pdf, "_add_object"
1731 ):
1732 self.replace_contents(content_obj)
1733 else:
1734 raise ValueError("Page must be part of a PdfWriter")
1736 @property
1737 def page_number(self) -> Optional[int]:
1738 """
1739 Read-only property which returns the page number within the PDF file.
1741 Returns:
1742 Page number; None if the page is not attached to a PDF.
1744 """
1745 if self.indirect_reference is None:
1746 return None
1747 try:
1748 lst = self.indirect_reference.pdf.pages
1749 return int(lst.index(self))
1750 except ValueError:
1751 return None
1753 def _debug_for_extract(self) -> str: # pragma: no cover
1754 out = ""
1755 for ope, op in ContentStream(
1756 self["/Contents"].get_object(), self.pdf, "bytes"
1757 ).operations:
1758 if op == b"TJ":
1759 s = [x for x in ope[0] if isinstance(x, str)]
1760 else:
1761 s = []
1762 out += op.decode("utf-8") + " " + "".join(s) + ope.__repr__() + "\n"
1763 out += "\n=============================\n"
1764 try:
1765 for fo in self[PG.RESOURCES]["/Font"]: # type:ignore
1766 out += fo + "\n"
1767 out += self[PG.RESOURCES]["/Font"][fo].__repr__() + "\n" # type:ignore
1768 try:
1769 enc_repr = self[PG.RESOURCES]["/Font"][fo][ # type:ignore
1770 "/Encoding"
1771 ].__repr__()
1772 out += enc_repr + "\n"
1773 except Exception:
1774 pass
1775 try:
1776 out += (
1777 self[PG.RESOURCES]["/Font"][fo][ # type:ignore
1778 "/ToUnicode"
1779 ]
1780 .get_data()
1781 .decode()
1782 + "\n"
1783 )
1784 except Exception:
1785 pass
1787 except KeyError:
1788 out += "No Font\n"
1789 return out
1791 def _extract_text(
1792 self,
1793 obj: DictionaryObject,
1794 pdf: Any,
1795 orientations: tuple[int, ...] = (0, 90, 180, 270),
1796 space_width: float = 200.0,
1797 content_key: Optional[str] = PG.CONTENTS,
1798 visitor_operand_before: Optional[Callable[[Any, Any, Any, Any], None]] = None,
1799 visitor_operand_after: Optional[Callable[[Any, Any, Any, Any], None]] = None,
1800 visitor_text: Optional[Callable[[Any, Any, Any, Any, Any], None]] = None,
1801 *,
1802 known_ids: Optional[set[int]] = None,
1803 ) -> str:
1804 """
1805 See extract_text for most arguments.
1807 Args:
1808 content_key: indicate the default key where to extract data
1809 None = the object; this allows reusing the function on an XObject
1810 default = "/Content"
1812 """
1813 if known_ids is None:
1814 known_ids = set()
1816 extractor = TextExtraction()
1817 font_resources: dict[str, DictionaryObject] = {}
1818 fonts: dict[str, Font] = {}
1820 resources_dict = cast(
1821 Optional[DictionaryObject],
1822 obj.get_inherited(key=PG.RESOURCES, default=DictionaryObject())
1823 )
1824 if is_null_or_none(resources_dict) or not resources_dict:
1825 # No resources means no text is possible (no font); we consider the
1826 # file as not damaged, no need to check for TJ or Tj
1827 return ""
1829 if (
1830 "/Font" in resources_dict
1831 and (font_resources_dict := cast(DictionaryObject, resources_dict["/Font"]))
1832 ):
1833 for font_resource in font_resources_dict:
1834 try:
1835 font_resource_object = cast(DictionaryObject, font_resources_dict[font_resource].get_object())
1836 font_resources[font_resource] = font_resource_object
1837 fonts[font_resource] = Font.from_font_resource(font_resource_object)
1838 # Override space width, if applicable
1839 if fonts[font_resource].character_widths.get(fonts[font_resource].space_char, 0) == 0:
1840 fonts[font_resource].space_width = space_width
1841 except (AttributeError, TypeError):
1842 pass
1844 try:
1845 content = (
1846 obj[content_key].get_object() if isinstance(content_key, str) else obj
1847 )
1848 if not isinstance(content, ContentStream):
1849 content = ContentStream(content, pdf, "bytes")
1850 except (AttributeError, KeyError): # no content can be extracted (certainly empty page)
1851 return ""
1852 # We check all strings are TextStringObjects. ByteStringObjects
1853 # are strings where the byte->string encoding was unknown, so adding
1854 # them to the text here would be gibberish.
1856 # Initialize the extractor with the necessary parameters
1857 extractor.initialize_extraction(orientations, visitor_text, font_resources, fonts)
1859 for operands, operator in content.operations:
1860 if visitor_operand_before is not None:
1861 visitor_operand_before(operator, operands, extractor.cm_matrix, extractor.tm_matrix)
1862 # Multiple operators are handled here
1863 if operator == b"'":
1864 extractor.process_operation(b"T*", [])
1865 extractor.process_operation(b"Tj", operands)
1866 elif operator == b'"' and len(operands) >= 3:
1867 extractor.process_operation(b"Tw", [operands[0]])
1868 extractor.process_operation(b"Tc", [operands[1]])
1869 extractor.process_operation(b"T*", [])
1870 extractor.process_operation(b"Tj", operands[2:])
1871 elif operator == b"TJ":
1872 # The space width may be smaller than the font width, so the width should be 95%.
1873 _confirm_space_width = extractor._space_width * 0.95
1874 if operands:
1875 for op in operands[0]:
1876 if isinstance(op, (str, bytes)):
1877 extractor.process_operation(b"Tj", [op])
1878 if isinstance(op, (int, float, NumberObject, FloatObject)) and (
1879 abs(float(op)) >= _confirm_space_width
1880 and extractor.text
1881 and extractor.text[-1] != " "
1882 ):
1883 extractor.process_operation(b"Tj", [" "])
1884 elif operator == b"TD" and len(operands) >= 2:
1885 extractor.process_operation(b"TL", [-operands[1]])
1886 extractor.process_operation(b"Td", operands)
1887 elif operator == b"Do":
1888 extractor.output += extractor.text
1889 if visitor_text is not None:
1890 visitor_text(
1891 extractor.text,
1892 extractor.memo_cm,
1893 extractor.memo_tm,
1894 extractor.font_resource,
1895 extractor.font_size,
1896 )
1897 try:
1898 if extractor.output[-1] != "\n":
1899 extractor.output += "\n"
1900 if visitor_text is not None:
1901 visitor_text(
1902 "\n",
1903 extractor.memo_cm,
1904 extractor.memo_tm,
1905 extractor.font_resource,
1906 extractor.font_size,
1907 )
1908 except IndexError:
1909 pass
1910 try:
1911 xobj = cast(DictionaryObject, resources_dict["/XObject"])
1912 xform = cast(EncodedStreamObject, xobj[operands[0]])
1913 if xform["/Subtype"] != NameObject("/Image"):
1914 xform_id = id(xform)
1915 if xform_id in known_ids:
1916 logger_warning(
1917 "Detected cyclic form XObject reference, skipping %(operand)s.",
1918 source=__name__,
1919 operand=operands[0]
1920 )
1921 text = ""
1922 else:
1923 known_ids.add(xform_id)
1924 try:
1925 text = self.extract_xform_text(
1926 xform,
1927 orientations,
1928 space_width,
1929 visitor_operand_before,
1930 visitor_operand_after,
1931 visitor_text,
1932 known_ids=known_ids,
1933 )
1934 finally:
1935 known_ids.discard(xform_id)
1936 extractor.output += text
1937 if visitor_text is not None:
1938 visitor_text(
1939 text,
1940 extractor.memo_cm,
1941 extractor.memo_tm,
1942 extractor.font_resource,
1943 extractor.font_size,
1944 )
1945 except Exception as exception:
1946 logger_warning(
1947 "Impossible to decode XFormObject %(operand)s: %(exception)s",
1948 source=__name__,
1949 operand=operands[0],
1950 exception=exception,
1951 )
1952 finally:
1953 extractor.text = ""
1954 extractor.memo_cm = extractor.cm_matrix.copy()
1955 extractor.memo_tm = extractor.tm_matrix.copy()
1956 else:
1957 extractor.process_operation(operator, operands)
1958 if visitor_operand_after is not None:
1959 visitor_operand_after(operator, operands, extractor.cm_matrix, extractor.tm_matrix)
1960 extractor.output += extractor.text # just in case
1961 if extractor.text != "" and visitor_text is not None:
1962 visitor_text(
1963 extractor.text,
1964 extractor.memo_cm,
1965 extractor.memo_tm,
1966 extractor.font_resource,
1967 extractor.font_size,
1968 )
1969 return extractor.output
1971 def _layout_mode_fonts(self) -> dict[str, Font]:
1972 """
1973 Get fonts formatted for "layout" mode text extraction.
1975 Returns:
1976 Dict[str, Font]: dictionary of Font instances keyed by font name
1978 """
1979 # Font retrieval logic adapted from pypdf.PageObject._extract_text()
1980 obj: Any = self
1981 fonts: dict[str, Font] = {}
1982 visited: set[int] = set()
1983 while True:
1984 obj_id = id(obj)
1985 if obj_id in visited:
1986 logger_warning("Detected cycle in /Parent hierarchy when retrieving fonts.", source=__name__)
1987 break
1988 visited.add(obj_id)
1990 resources_dict: Any = obj.get(PG.RESOURCES, {})
1991 if "/Font" in resources_dict and self.pdf is not None:
1992 for font_name in resources_dict["/Font"]:
1993 fonts[font_name] = Font.from_font_resource(resources_dict["/Font"][font_name])
1995 if "/Parent" not in obj:
1996 break
1997 obj = obj["/Parent"].get_object()
1999 return fonts
2001 def _layout_mode_text(
2002 self,
2003 space_vertically: bool = True,
2004 scale_weight: float = 1.25,
2005 strip_rotated: bool = True,
2006 debug_path: Optional[Path] = None,
2007 font_height_weight: float = 1,
2008 ) -> str:
2009 """
2010 Get text preserving fidelity to source PDF text layout.
2012 Args:
2013 space_vertically: include blank lines inferred from y distance + font
2014 height. Defaults to True.
2015 scale_weight: multiplier for string length when calculating weighted
2016 average character width. Defaults to 1.25.
2017 strip_rotated: Removes text that is rotated w.r.t. to the page from
2018 layout mode output. Defaults to True.
2019 debug_path (Path | None): if supplied, must target a directory.
2020 creates the following files with debug information for layout mode
2021 functions if supplied:
2022 - fonts.json: output of self._layout_mode_fonts
2023 - tjs.json: individual text render ops with corresponding transform matrices
2024 - bts.json: text render ops left justified and grouped by BT/ET operators
2025 - bt_groups.json: BT/ET operations grouped by rendered y-coord (aka lines)
2026 Defaults to None.
2027 font_height_weight: multiplier for font height when calculating
2028 blank lines. Defaults to 1.
2030 Returns:
2031 str: multiline string containing page text in a fixed width format that
2032 closely adheres to the rendered layout in the source pdf.
2034 """
2035 fonts = self._layout_mode_fonts()
2036 if debug_path: # pragma: no cover
2037 import json # noqa: PLC0415
2039 debug_path.joinpath("fonts.json").write_text(
2040 json.dumps(fonts, indent=2, default=asdict),
2041 "utf-8"
2042 )
2044 ops = iter(
2045 ContentStream(self["/Contents"].get_object(), self.pdf, "bytes").operations
2046 )
2047 bt_groups = _layout_mode.text_show_operations(
2048 ops, fonts, strip_rotated, debug_path
2049 )
2051 if not bt_groups:
2052 return ""
2054 ty_groups = _layout_mode.y_coordinate_groups(bt_groups, debug_path)
2056 char_width = _layout_mode.fixed_char_width(bt_groups, scale_weight)
2058 return _layout_mode.fixed_width_page(ty_groups, char_width, space_vertically, font_height_weight)
2060 def extract_text(
2061 self,
2062 *args: Any,
2063 orientations: Union[int, tuple[int, ...]] = (0, 90, 180, 270),
2064 space_width: float = 200.0,
2065 visitor_operand_before: Optional[Callable[[Any, Any, Any, Any], None]] = None,
2066 visitor_operand_after: Optional[Callable[[Any, Any, Any, Any], None]] = None,
2067 visitor_text: Optional[Callable[[Any, Any, Any, Any, Any], None]] = None,
2068 extraction_mode: Literal["plain", "layout"] = "plain",
2069 **kwargs: Any,
2070 ) -> str:
2071 """
2072 Locate all text drawing commands, in the order they are provided in the
2073 content stream, and extract the text.
2075 This works well for some PDF files, but poorly for others, depending on
2076 the generator used. This will be refined in the future.
2078 Do not rely on the order of text coming out of this function, as it
2079 will change if this function is made more sophisticated.
2081 Arabic and Hebrew are extracted in the correct order.
2082 If required a custom RTL range of characters can be defined;
2083 see function set_custom_rtl.
2085 Additionally you can provide visitor methods to get informed on all
2086 operations and all text objects.
2087 For example in some PDF files this can be useful to parse tables.
2089 Args:
2090 orientations: list of orientations extract_text will look for
2091 default = (0, 90, 180, 270)
2092 note: currently only 0 (up),90 (turned left), 180 (upside down),
2093 270 (turned right)
2094 Silently ignored in "layout" mode.
2095 space_width: force default space width
2096 if not extracted from font (default: 200)
2097 Silently ignored in "layout" mode.
2098 visitor_operand_before: function to be called before processing an operation.
2099 It has four arguments: operator, operand-arguments,
2100 current transformation matrix and text matrix.
2101 Ignored with a warning in "layout" mode.
2102 visitor_operand_after: function to be called after processing an operation.
2103 It has four arguments: operator, operand-arguments,
2104 current transformation matrix and text matrix.
2105 Ignored with a warning in "layout" mode.
2106 visitor_text: function to be called when extracting some text at some position.
2107 It has five arguments: text, current transformation matrix,
2108 text matrix, font-dictionary and font-size.
2109 The font-dictionary may be None in case of unknown fonts.
2110 If not None it may e.g. contain key "/BaseFont" with value "/Arial,Bold".
2111 Ignored with a warning in "layout" mode.
2112 extraction_mode (Literal["plain", "layout"]): "plain" for legacy functionality,
2113 "layout" for experimental layout mode functionality.
2114 NOTE: orientations, space_width, and visitor_* parameters are NOT respected
2115 in "layout" mode.
2117 kwargs:
2118 layout_mode_space_vertically (bool): include blank lines inferred from
2119 y distance + font height. Defaults to True.
2120 layout_mode_scale_weight (float): multiplier for string length when calculating
2121 weighted average character width. Defaults to 1.25.
2122 layout_mode_strip_rotated (bool): layout mode does not support rotated text.
2123 Set to False to include rotated text anyway. If rotated text is discovered,
2124 layout will be degraded and a warning will result. Defaults to True.
2125 layout_mode_debug_path (Path | None): if supplied, must target a directory.
2126 creates the following files with debug information for layout mode
2127 functions if supplied:
2129 - fonts.json: output of self._layout_mode_fonts
2130 - tjs.json: individual text render ops with corresponding transform matrices
2131 - bts.json: text render ops left justified and grouped by BT/ET operators
2132 - bt_groups.json: BT/ET operations grouped by rendered y-coord (aka lines)
2133 layout_mode_font_height_weight (float): multiplier for font height when calculating
2134 blank lines. Defaults to 1.
2136 Returns:
2137 The extracted text
2139 """
2140 if extraction_mode not in ["plain", "layout"]:
2141 raise ValueError(f"Invalid text extraction mode '{extraction_mode}'")
2142 if extraction_mode == "layout":
2143 for visitor in (
2144 "visitor_operand_before",
2145 "visitor_operand_after",
2146 "visitor_text",
2147 ):
2148 if locals()[visitor]:
2149 logger_warning(
2150 "Argument %(visitor)s is ignored in layout mode",
2151 source=__name__,
2152 visitor=visitor,
2153 )
2154 return self._layout_mode_text(
2155 space_vertically=kwargs.get("layout_mode_space_vertically", True),
2156 scale_weight=kwargs.get("layout_mode_scale_weight", 1.25),
2157 strip_rotated=kwargs.get("layout_mode_strip_rotated", True),
2158 debug_path=kwargs.get("layout_mode_debug_path"),
2159 font_height_weight=kwargs.get("layout_mode_font_height_weight", 1)
2160 )
2161 if len(args) >= 1:
2162 if isinstance(args[0], str):
2163 if len(args) >= 3:
2164 if isinstance(args[2], (tuple, int)):
2165 orientations = args[2]
2166 else:
2167 raise TypeError(f"Invalid positional parameter {args[2]}")
2168 if len(args) >= 4:
2169 if isinstance(args[3], (float, int)):
2170 space_width = args[3]
2171 else:
2172 raise TypeError(f"Invalid positional parameter {args[3]}")
2173 elif isinstance(args[0], (tuple, int)):
2174 orientations = args[0]
2175 if len(args) >= 2:
2176 if isinstance(args[1], (float, int)):
2177 space_width = args[1]
2178 else:
2179 raise TypeError(f"Invalid positional parameter {args[1]}")
2180 else:
2181 raise TypeError(f"Invalid positional parameter {args[0]}")
2183 if isinstance(orientations, int):
2184 orientations = (orientations,)
2186 return self._extract_text(
2187 self,
2188 self.pdf,
2189 orientations,
2190 space_width,
2191 PG.CONTENTS,
2192 visitor_operand_before,
2193 visitor_operand_after,
2194 visitor_text,
2195 )
2197 def extract_xform_text(
2198 self,
2199 xform: EncodedStreamObject,
2200 orientations: tuple[int, ...] = (0, 90, 270, 360),
2201 space_width: float = 200.0,
2202 visitor_operand_before: Optional[Callable[[Any, Any, Any, Any], None]] = None,
2203 visitor_operand_after: Optional[Callable[[Any, Any, Any, Any], None]] = None,
2204 visitor_text: Optional[Callable[[Any, Any, Any, Any, Any], None]] = None,
2205 *,
2206 known_ids: Optional[set[int]] = None,
2207 ) -> str:
2208 """
2209 Extract text from an XObject.
2211 Args:
2212 xform:
2213 orientations:
2214 space_width: force default space width (if not extracted from font (default 200)
2215 visitor_operand_before:
2216 visitor_operand_after:
2217 visitor_text:
2219 Returns:
2220 The extracted text
2222 """
2223 return self._extract_text(
2224 xform,
2225 self.pdf,
2226 orientations,
2227 space_width,
2228 None,
2229 visitor_operand_before,
2230 visitor_operand_after,
2231 visitor_text,
2232 known_ids=known_ids,
2233 )
2235 def _get_fonts(self) -> tuple[set[str], set[str]]:
2236 """
2237 Get the names of embedded fonts and unembedded fonts.
2239 Returns:
2240 A tuple (set of embedded fonts, set of unembedded fonts)
2242 """
2243 obj = self.get_object()
2244 assert isinstance(obj, DictionaryObject)
2245 fonts: set[str] = set()
2246 embedded: set[str] = set()
2247 fonts, embedded = _get_fonts_walk(obj, fonts, embedded)
2248 unembedded = fonts - embedded
2249 return embedded, unembedded
2251 mediabox = _create_rectangle_accessor(PG.MEDIABOX, ())
2252 """A :class:`RectangleObject<pypdf.generic.RectangleObject>`, expressed in
2253 default user space units, defining the boundaries of the physical medium on
2254 which the page is intended to be displayed or printed."""
2256 cropbox = _create_rectangle_accessor("/CropBox", (PG.MEDIABOX,))
2257 """
2258 A :class:`RectangleObject<pypdf.generic.RectangleObject>`, expressed in
2259 default user space units, defining the visible region of default user
2260 space.
2262 When the page is displayed or printed, its contents are to be clipped
2263 (cropped) to this rectangle and then imposed on the output medium in some
2264 implementation-defined manner. Default value: same as
2265 :attr:`mediabox<mediabox>`.
2266 """
2268 bleedbox = _create_rectangle_accessor("/BleedBox", ("/CropBox", PG.MEDIABOX))
2269 """A :class:`RectangleObject<pypdf.generic.RectangleObject>`, expressed in
2270 default user space units, defining the region to which the contents of the
2271 page should be clipped when output in a production environment."""
2273 trimbox = _create_rectangle_accessor("/TrimBox", ("/CropBox", PG.MEDIABOX))
2274 """A :class:`RectangleObject<pypdf.generic.RectangleObject>`, expressed in
2275 default user space units, defining the intended dimensions of the finished
2276 page after trimming."""
2278 artbox = _create_rectangle_accessor("/ArtBox", ("/CropBox", PG.MEDIABOX))
2279 """A :class:`RectangleObject<pypdf.generic.RectangleObject>`, expressed in
2280 default user space units, defining the extent of the page's meaningful
2281 content as intended by the page's creator."""
2283 @property
2284 def annotations(self) -> Optional[ArrayObject]:
2285 if "/Annots" not in self:
2286 return None
2287 return cast(ArrayObject, self["/Annots"])
2289 @annotations.setter
2290 def annotations(self, value: Optional[ArrayObject]) -> None:
2291 """
2292 Set the annotations array of the page.
2294 Typically you do not want to set this value, but append to it.
2295 If you append to it, remember to add the object first to the writer
2296 and only add the indirect object.
2297 """
2298 if value is None:
2299 if "/Annots" not in self:
2300 return
2301 del self[NameObject("/Annots")]
2302 else:
2303 self[NameObject("/Annots")] = value
2305 def add_action(self, trigger: PageTrigger, action: Action) -> None:
2306 """
2307 Add an action which will launch on the given trigger event of this page.
2309 Args:
2310 trigger: The action trigger to use.
2311 action: The action to be done.
2313 Example:
2314 >>> from pypdf import PdfWriter
2315 >>> from pypdf.actions import JavaScript, PageTrigger
2316 >>> writer = PdfWriter()
2317 >>> page = writer.add_blank_page(595, 842)
2318 >>> # Display the page number when the page is opened
2319 >>> page.add_action(PageTrigger("open"), JavaScript("app.alert('This is page ' + this.pageNum);"))
2320 >>> # Display the page number when the page is closed
2321 >>> page.add_action(PageTrigger("close"), JavaScript("app.alert('This is page ' + this.pageNum);"))
2322 """
2323 return Action._create_new(self, trigger, action)
2325 def delete_action(self, trigger: PageTrigger) -> None:
2326 """
2327 Delete all actions associated with an open or close trigger event of this page.
2329 Args:
2330 trigger: An open or close trigger.
2332 Example:
2333 >>> from pypdf import PdfWriter
2334 >>> from pypdf.actions import JavaScript, PageTrigger
2335 >>> writer = PdfWriter()
2336 >>> page = writer.add_blank_page(595, 842)
2337 >>> page.add_action(PageTrigger("open"), JavaScript("app.alert('This is page ' + this.pageNum);"))
2338 >>> page.add_action(PageTrigger("close"), JavaScript("app.alert('This is page ' + this.pageNum);"))
2339 >>> # Delete all actions triggered by a page open
2340 >>> page.delete_action(PageTrigger("open"))
2341 >>> # Delete all actions triggered by a page close
2342 >>> page.delete_action(PageTrigger("close"))
2343 """
2344 return Action._delete(self, trigger)
2347class _VirtualList(Sequence[PageObject]):
2348 def __init__(
2349 self,
2350 length_function: Callable[[], int],
2351 get_function: Callable[[int], PageObject],
2352 ) -> None:
2353 self.length_function = length_function
2354 self.get_function = get_function
2355 self.current = -1
2357 def __len__(self) -> int:
2358 return self.length_function()
2360 @overload
2361 def __getitem__(self, index: int) -> PageObject:
2362 ...
2364 @overload
2365 def __getitem__(self, index: slice) -> Sequence[PageObject]:
2366 ...
2368 def __getitem__(
2369 self, index: Union[int, slice]
2370 ) -> Union[PageObject, Sequence[PageObject]]:
2371 if isinstance(index, slice):
2372 indices = range(*index.indices(len(self)))
2373 cls = type(self)
2374 return cls(indices.__len__, lambda idx: self[indices[idx]])
2375 if not isinstance(index, int):
2376 raise TypeError("Sequence indices must be integers")
2377 len_self = len(self)
2378 if index < 0:
2379 # support negative indexes
2380 index += len_self
2381 if not (0 <= index < len_self):
2382 raise IndexError("Sequence index out of range")
2383 return self.get_function(index)
2385 def __delitem__(self, index: Union[int, slice]) -> None:
2386 if isinstance(index, slice):
2387 r = list(range(*index.indices(len(self))))
2388 # pages have to be deleted from last to first
2389 r.sort()
2390 r.reverse()
2391 for p in r:
2392 del self[p] # recursive call
2393 return
2394 if not isinstance(index, int):
2395 raise TypeError("Index must be integers")
2396 len_self = len(self)
2397 if index < 0:
2398 # support negative indexes
2399 index += len_self
2400 if not (0 <= index < len_self):
2401 raise IndexError("Index out of range")
2402 ind = self[index].indirect_reference
2403 assert ind is not None
2404 parent: Optional[PdfObject] = cast(DictionaryObject, ind.get_object()).get(
2405 "/Parent", None
2406 )
2407 first = True
2408 while parent is not None:
2409 parent = cast(DictionaryObject, parent.get_object())
2410 try:
2411 i = cast(ArrayObject, parent["/Kids"]).index(ind)
2412 del cast(ArrayObject, parent["/Kids"])[i]
2413 first = False
2414 try:
2415 assert ind is not None
2416 del ind.pdf.flattened_pages[index] # case of page in a Reader
2417 except Exception: # pragma: no cover
2418 pass
2419 if "/Count" in parent:
2420 parent[NameObject("/Count")] = NumberObject(
2421 cast(int, parent["/Count"]) - 1
2422 )
2423 if len(cast(ArrayObject, parent["/Kids"])) == 0:
2424 # No more objects in this part of this subtree
2425 ind = parent.indirect_reference
2426 parent = parent.get("/Parent", None)
2427 except ValueError: # from index
2428 if first:
2429 raise PdfReadError(f"Page not found in page tree: {ind}")
2430 break
2432 def __iter__(self) -> Iterator[PageObject]:
2433 for i in range(len(self)):
2434 yield self[i]
2436 def __str__(self) -> str:
2437 p = [f"PageObject({i})" for i in range(self.length_function())]
2438 return f"[{', '.join(p)}]"
2441def _get_fonts_walk(
2442 obj: DictionaryObject,
2443 fnt: set[str],
2444 emb: set[str],
2445) -> tuple[set[str], set[str]]:
2446 """
2447 Get the set of all fonts and all embedded fonts.
2449 Args:
2450 obj: Page resources dictionary
2451 fnt: font
2452 emb: embedded fonts
2454 Returns:
2455 A tuple (fnt, emb)
2457 If there is a key called 'BaseFont', that is a font that is used in the document.
2458 If there is a key called 'FontName' and another key in the same dictionary object
2459 that is called 'FontFilex' (where x is null, 2, or 3), then that fontname is
2460 embedded.
2462 We create and add to two sets, fnt = fonts used and emb = fonts embedded.
2464 """
2465 fontkeys = ("/FontFile", "/FontFile2", "/FontFile3")
2467 def process_font(f: DictionaryObject) -> None:
2468 nonlocal fnt, emb
2469 f = cast(DictionaryObject, f.get_object()) # to be sure
2470 if "/BaseFont" in f:
2471 fnt.add(cast(str, f["/BaseFont"]))
2473 if (
2474 ("/CharProcs" in f)
2475 or (
2476 "/FontDescriptor" in f
2477 and any(
2478 x in cast(DictionaryObject, f["/FontDescriptor"]) for x in fontkeys
2479 )
2480 )
2481 or (
2482 "/DescendantFonts" in f
2483 and "/FontDescriptor"
2484 in cast(
2485 DictionaryObject,
2486 cast(ArrayObject, f["/DescendantFonts"])[0].get_object(),
2487 )
2488 and any(
2489 x
2490 in cast(
2491 DictionaryObject,
2492 cast(
2493 DictionaryObject,
2494 cast(ArrayObject, f["/DescendantFonts"])[0].get_object(),
2495 )["/FontDescriptor"],
2496 )
2497 for x in fontkeys
2498 )
2499 )
2500 ):
2501 # the list comprehension ensures there is FontFile
2502 try:
2503 emb.add(cast(str, f["/BaseFont"]))
2504 except KeyError:
2505 emb.add("(" + cast(str, f["/Subtype"]) + ")")
2507 if "/DR" in obj and "/Font" in cast(DictionaryObject, obj["/DR"]):
2508 for f in cast(DictionaryObject, cast(DictionaryObject, obj["/DR"])["/Font"]):
2509 process_font(f)
2510 if "/Resources" in obj:
2511 if "/Font" in cast(DictionaryObject, obj["/Resources"]):
2512 for f in cast(
2513 DictionaryObject, cast(DictionaryObject, obj["/Resources"])["/Font"]
2514 ).values():
2515 process_font(f)
2516 if "/XObject" in cast(DictionaryObject, obj["/Resources"]):
2517 for x in cast(
2518 DictionaryObject, cast(DictionaryObject, obj["/Resources"])["/XObject"]
2519 ).values():
2520 _get_fonts_walk(cast(DictionaryObject, x.get_object()), fnt, emb)
2521 if "/Annots" in obj:
2522 for a in cast(ArrayObject, obj["/Annots"]):
2523 _get_fonts_walk(cast(DictionaryObject, a.get_object()), fnt, emb)
2524 if "/AP" in obj:
2525 if (
2526 cast(DictionaryObject, cast(DictionaryObject, obj["/AP"])["/N"]).get(
2527 "/Type"
2528 )
2529 == "/XObject"
2530 ):
2531 _get_fonts_walk(
2532 cast(DictionaryObject, cast(DictionaryObject, obj["/AP"])["/N"]),
2533 fnt,
2534 emb,
2535 )
2536 else:
2537 for a in cast(DictionaryObject, cast(DictionaryObject, obj["/AP"])["/N"]):
2538 _get_fonts_walk(cast(DictionaryObject, a), fnt, emb)
2539 return fnt, emb # return the sets for each page