Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/pypdf/_writer.py: 21%
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 decimal
31import enum
32import hashlib
33import re
34import struct
35import sys
36from collections.abc import Iterable, Mapping, Sequence
37from io import BytesIO, FileIO, IOBase
38from itertools import compress
39from pathlib import Path
40from re import Pattern
41from types import TracebackType
42from typing import (
43 IO,
44 Any,
45 Callable,
46 Literal,
47 Optional,
48 Union,
49 cast,
50)
52if sys.version_info >= (3, 11):
53 from typing import Self
54else:
55 from typing_extensions import Self
57from ._doc_common import DocumentInformation, PdfDocCommon
58from ._encryption import EncryptAlgorithm, Encryption
59from ._page import PageObject, Transformation
60from ._page_labels import nums_clear_range, nums_insert, nums_next
61from ._reader import PdfReader
62from ._utils import (
63 StrByteType,
64 StreamType,
65 _get_max_pdf_version_header,
66 deprecate_with_replacement,
67 deprecation_no_replacement,
68 logger_warning,
69)
70from .actions import Action, JavaScript
71from .constants import AnnotationDictionaryAttributes as AA
72from .constants import (
73 CatalogAttributes,
74 Core,
75 GoToActionArguments,
76 ImageType,
77 InteractiveFormDictEntries,
78 OutlineFontFlag,
79 PageLabelStyle,
80 PagesAttributes,
81 UserAccessPermissions,
82)
83from .constants import FieldDictionaryAttributes as FA
84from .constants import PageAttributes as PG
85from .constants import TrailerKeys as TK
86from .errors import LimitReachedError, PdfReadError, PyPdfError
87from .generic import (
88 PAGE_FIT,
89 ArrayObject,
90 BooleanObject,
91 ByteStringObject,
92 ContentStream,
93 Destination,
94 DictionaryObject,
95 EmbeddedFile,
96 Fit,
97 FloatObject,
98 IndirectObject,
99 NameObject,
100 NullObject,
101 NumberObject,
102 PdfObject,
103 RectangleObject,
104 ReferenceLink,
105 StreamObject,
106 TextStringObject,
107 TreeObject,
108 ViewerPreferences,
109 create_string_object,
110 extract_links,
111 hex_to_rgb,
112 is_null_or_none,
113)
114from .generic._appearance_stream import TextStreamAppearance
115from .pagerange import PageRange, PageRangeSpec
116from .types import (
117 AnnotationSubtype,
118 BorderArrayType,
119 LayoutType,
120 OutlineItemType,
121 OutlineType,
122 PagemodeType,
123)
124from .xmp import XmpInformation
126ALL_DOCUMENT_PERMISSIONS = UserAccessPermissions.all()
129class ObjectDeletionFlag(enum.IntFlag):
130 NONE = 0
131 TEXT = enum.auto()
132 LINKS = enum.auto()
133 ATTACHMENTS = enum.auto()
134 OBJECTS_3D = enum.auto()
135 ALL_ANNOTATIONS = enum.auto()
136 XOBJECT_IMAGES = enum.auto()
137 INLINE_IMAGES = enum.auto()
138 DRAWING_IMAGES = enum.auto()
139 IMAGES = XOBJECT_IMAGES | INLINE_IMAGES | DRAWING_IMAGES
142def _rolling_checksum(stream: BytesIO, blocksize: int = 65536) -> str:
143 hash_object = hashlib.md5(usedforsecurity=False)
144 for block in iter(lambda: stream.read(blocksize), b""):
145 hash_object.update(block)
146 return hash_object.hexdigest()
149class PdfWriter(PdfDocCommon):
150 """
151 Write a PDF file out, given pages produced by another class or through
152 cloning a PDF file during initialization.
154 Typically data is added from a :class:`PdfReader<pypdf.PdfReader>`.
156 Args:
157 clone_from: identical to fileobj (for compatibility)
159 incremental: If true, loads the document and set the PdfWriter in incremental mode.
161 When writing incrementally, the original document is written first and new/modified
162 content is appended. To be used for signed document/forms to keep signature valid.
164 full: If true, loads all the objects (always full if incremental = True).
165 This parameter may allow loading large PDFs.
167 strict: If true, pypdf will raise an exception if a PDF does not follow the specification.
168 If false, pypdf will try to be forgiving and do something reasonable, but it will log
169 a warning message. It is a best-effort approach.
171 keep_initial_header: If true, the PDF header of the cloned document is kept
172 when using ``clone_from`` in non-incremental mode.
174 """
176 incremental: bool
177 """
178 Returns if the PdfWriter object has been started in incremental mode.
179 """
181 _objects: list[Optional[PdfObject]]
182 """
183 The indirect objects in the PDF.
184 For the incremental case, it will be filled with None
185 in clone_reader_document_root.
186 """
188 _id_translated: dict[int, dict[Union[int, Literal["PreventGC"]], Any]]
189 """
190 List of already translated IDs.
191 dict[id(pdf)][(idnum, generation)]
192 """
194 _reader: Optional[PdfReader]
195 """
196 The document being appended to, in incremental mode only.
197 """
199 def __init__(
200 self,
201 fileobj: Union[PdfReader, StrByteType, Path, None] = "",
202 clone_from: Union[PdfReader, StrByteType, Path, None] = None,
203 incremental: bool = False,
204 full: bool = False,
205 strict: bool = False,
206 *,
207 keep_initial_header: bool = False,
208 incremental_clone_object_count_limit: Optional[int] = 500_000,
209 incremental_clone_object_id_limit: Optional[int] = 1_000_000,
210 ) -> None:
211 self.strict = strict
212 """
213 If true, pypdf will raise an exception if a PDF does not follow the specification.
214 If false, pypdf will try to be forgiving and do something reasonable, but it will log
215 a warning message. It is a best-effort approach.
216 """
218 self.incremental = incremental or full
220 self._objects = []
222 self._original_hash: list[int] = []
223 """
224 List of hashes after import; used to identify changes.
225 """
227 self._idnum_hash: dict[bytes, tuple[IndirectObject, list[IndirectObject]]] = {}
228 """
229 Maps hash values of indirect objects to the list of IndirectObjects.
230 This is used for compression.
231 """
233 self._id_translated = {}
235 self._info_obj: Optional[PdfObject]
236 """The PDF files's document information dictionary,
237 defined by Info in the PDF file's trailer dictionary."""
239 self._reader = None
241 self._ID: Union[ArrayObject, None] = None
242 """The PDF file identifier,
243 defined by the ID in the PDF file's trailer dictionary."""
245 self._unresolved_links: list[tuple[ReferenceLink, ReferenceLink]] = []
246 "Tracks links in pages added to the writer for resolving later."
247 self._merged_in_pages: dict[Optional[IndirectObject], Optional[IndirectObject]] = {}
248 "Tracks pages added to the writer and what page they turned into."
250 # Security parameters.
251 self._incremental_clone_object_count_limit = (
252 incremental_clone_object_count_limit
253 if isinstance(incremental_clone_object_count_limit, int)
254 else sys.maxsize
255 )
256 self._incremental_clone_object_id_limit = (
257 incremental_clone_object_id_limit if isinstance(incremental_clone_object_id_limit, int) else sys.maxsize
258 )
260 if self.incremental:
261 if isinstance(fileobj, (str, Path)):
262 with open(fileobj, "rb") as f:
263 fileobj = BytesIO(f.read(-1))
264 if isinstance(fileobj, BytesIO):
265 fileobj = PdfReader(fileobj)
266 if not isinstance(fileobj, PdfReader):
267 raise PyPdfError("Invalid type for incremental mode")
268 self._reader = fileobj # prev content is in _reader.stream
269 self._header = fileobj.pdf_header.encode()
270 self._readonly = True # TODO: to be analysed
271 else:
272 self._header = b"%PDF-1.3"
273 self._info_obj = self._add_object(
274 DictionaryObject(
275 {NameObject("/Producer"): create_string_object("pypdf")}
276 )
277 )
279 def _get_clone_from(
280 fileobj: Union[PdfReader, str, Path, IO[Any], BytesIO, None],
281 clone_from: Union[PdfReader, str, Path, IO[Any], BytesIO, None],
282 ) -> Union[PdfReader, str, Path, IO[Any], BytesIO, None]:
283 if isinstance(fileobj, (str, Path, IO, BytesIO)) and (
284 fileobj == "" or clone_from is not None
285 ):
286 return clone_from
287 cloning = True
288 if isinstance(fileobj, (str, Path)):
289 fileobj_path = Path(fileobj)
290 if not fileobj_path.exists() or fileobj_path.stat().st_size == 0:
291 cloning = False
292 elif isinstance(fileobj, (IOBase, BytesIO)):
293 t = fileobj.tell()
294 if fileobj.seek(0, 2) == 0:
295 cloning = False
296 fileobj.seek(t, 0)
297 if cloning:
298 clone_from = fileobj
299 return clone_from
301 clone_from = _get_clone_from(fileobj, clone_from)
302 # To prevent overwriting
303 self.temp_fileobj = fileobj
304 self.fileobj = ""
305 self._with_as_usage = False
306 self._cloned = False
307 # The root of our page tree node
308 pages = DictionaryObject(
309 {
310 NameObject(PagesAttributes.TYPE): NameObject("/Pages"),
311 NameObject(PagesAttributes.COUNT): NumberObject(0),
312 NameObject(PagesAttributes.KIDS): ArrayObject(),
313 }
314 )
315 self.flattened_pages = []
316 self._encryption: Optional[Encryption] = None
317 self._encrypt_entry: Optional[DictionaryObject] = None
319 if clone_from is not None:
320 if not isinstance(clone_from, PdfReader):
321 clone_from = PdfReader(clone_from)
322 self.clone_document_from_reader(clone_from)
323 self._cloned = True
324 if keep_initial_header and not self.incremental:
325 self._header = clone_from.pdf_header.encode()
326 else:
327 self._pages = self._add_object(pages)
328 self._root_object = DictionaryObject(
329 {
330 NameObject(PagesAttributes.TYPE): NameObject(Core.CATALOG),
331 NameObject(Core.PAGES): self._pages,
332 }
333 )
334 self._add_object(self._root_object)
335 if full and not incremental:
336 self.incremental = False
337 if isinstance(self._ID, list):
338 if isinstance(self._ID[0], TextStringObject):
339 self._ID[0] = ByteStringObject(self._ID[0].get_original_bytes())
340 if isinstance(self._ID[1], TextStringObject):
341 self._ID[1] = ByteStringObject(self._ID[1].get_original_bytes())
343 # for commonality
344 @property
345 def is_encrypted(self) -> bool:
346 """
347 Read-only boolean property showing whether this PDF file is encrypted.
349 Note that this property, if true, will remain true even after the
350 :meth:`decrypt()<pypdf.PdfReader.decrypt>` method is called.
351 """
352 return False
354 @property
355 def root_object(self) -> DictionaryObject:
356 """
357 Provide direct access to PDF Structure.
359 Note:
360 Recommended only for read access.
362 """
363 return self._root_object
365 @property
366 def _info(self) -> Optional[DictionaryObject]:
367 """
368 Provide access to "/Info". Standardized with PdfReader.
370 Returns:
371 /Info Dictionary; None if the entry does not exist
373 """
374 return (
375 None
376 if self._info_obj is None
377 else cast(DictionaryObject, self._info_obj.get_object())
378 )
380 @_info.setter
381 def _info(self, value: Optional[Union[IndirectObject, DictionaryObject]]) -> None:
382 if value is None:
383 try:
384 self._objects[self._info_obj.indirect_reference.idnum - 1] = None # type: ignore[union-attr]
385 except (KeyError, AttributeError):
386 pass
387 self._info_obj = None
388 else:
389 if self._info_obj is None:
390 self._info_obj = self._add_object(DictionaryObject())
391 obj = cast(DictionaryObject, self._info_obj.get_object())
392 obj.clear()
393 obj.update(cast(DictionaryObject, value.get_object()))
395 @property
396 def xmp_metadata(self) -> Optional[XmpInformation]:
397 """XMP (Extensible Metadata Platform) data."""
398 return cast(XmpInformation, self.root_object.xmp_metadata)
400 @xmp_metadata.setter
401 def xmp_metadata(self, value: Union[XmpInformation, bytes, None]) -> None:
402 """XMP (Extensible Metadata Platform) data."""
403 if value is None:
404 if "/Metadata" in self.root_object:
405 del self.root_object["/Metadata"]
406 return
408 metadata = self.root_object.get("/Metadata", None)
409 if not isinstance(metadata, IndirectObject):
410 if metadata is not None:
411 del self.root_object["/Metadata"]
412 metadata_stream = StreamObject()
413 stream_reference = self._add_object(metadata_stream)
414 self.root_object[NameObject("/Metadata")] = stream_reference
415 else:
416 metadata_stream = cast(StreamObject, metadata.get_object())
418 if isinstance(value, XmpInformation):
419 bytes_data = value.stream.get_data()
420 else:
421 bytes_data = value
422 metadata_stream.set_data(bytes_data)
424 @property
425 def with_as_usage(self) -> bool:
426 deprecation_no_replacement("with_as_usage", "5.0")
428 @with_as_usage.setter
429 def with_as_usage(self, value: bool) -> None:
430 deprecation_no_replacement("with_as_usage", "5.0")
432 def __enter__(self) -> Self:
433 """Store how writer is initialized by 'with'."""
434 c: bool = self._cloned
435 t = self.temp_fileobj
436 self.__init__() # type: ignore[misc]
437 self._cloned = c
438 self._with_as_usage = True
439 self.fileobj = t # type: ignore[assignment]
440 return self
442 def __exit__(
443 self,
444 exc_type: Optional[type[BaseException]],
445 exc: Optional[BaseException],
446 traceback: Optional[TracebackType],
447 ) -> None:
448 """Write data to the fileobj."""
449 if self.fileobj and not self._cloned:
450 self.write(self.fileobj)
452 @property
453 def pdf_header(self) -> str:
454 """
455 Read/Write property of the PDF header that is written.
457 This should be something like ``'%PDF-1.5'``. It is recommended to set
458 the lowest version that supports all features which are used within the
459 PDF file.
461 Note: `pdf_header` returns a string but accepts bytes or str for writing
462 """
463 return self._header.decode()
465 @pdf_header.setter
466 def pdf_header(self, new_header: Union[str, bytes]) -> None:
467 if isinstance(new_header, str):
468 new_header = new_header.encode()
469 self._header = new_header
471 def _add_object(self, obj: PdfObject) -> IndirectObject:
472 if (
473 getattr(obj, "indirect_reference", None) is not None
474 and obj.indirect_reference.pdf == self # type: ignore[union-attr]
475 ):
476 return obj.indirect_reference # type: ignore[return-value]
477 # check for /Contents in Pages (/Contents in annotations are strings)
478 if isinstance(obj, DictionaryObject) and isinstance(
479 obj.get(PG.CONTENTS, None), (ArrayObject, DictionaryObject)
480 ):
481 obj[NameObject(PG.CONTENTS)] = self._add_object(obj[PG.CONTENTS])
482 self._objects.append(obj)
483 obj.indirect_reference = IndirectObject(len(self._objects), 0, self)
484 return obj.indirect_reference
486 def get_object(
487 self,
488 indirect_reference: Union[int, IndirectObject],
489 ) -> PdfObject:
490 if isinstance(indirect_reference, int):
491 obj = self._objects[indirect_reference - 1]
492 elif indirect_reference.pdf != self:
493 raise ValueError("PDF must be self")
494 else:
495 obj = self._objects[indirect_reference.idnum - 1]
496 if obj is None:
497 raise PdfReadError(f"Object {indirect_reference!r} not found!")
498 return obj
500 def _replace_object(
501 self,
502 indirect_reference: Union[int, IndirectObject],
503 obj: PdfObject,
504 ) -> PdfObject:
505 if isinstance(indirect_reference, IndirectObject):
506 if indirect_reference.pdf != self:
507 raise ValueError("PDF must be self")
508 indirect_reference = indirect_reference.idnum
509 gen = self._objects[indirect_reference - 1].indirect_reference.generation # type: ignore[union-attr]
510 if (
511 getattr(obj, "indirect_reference", None) is not None
512 and obj.indirect_reference.pdf != self # type: ignore[union-attr]
513 ):
514 obj = obj.clone(self)
515 self._objects[indirect_reference - 1] = obj
516 obj.indirect_reference = IndirectObject(indirect_reference, gen, self)
518 assert isinstance(obj, PdfObject), "mypy"
519 return obj
521 def _add_page(
522 self,
523 page: PageObject,
524 index: int,
525 excluded_keys: Iterable[str] = (),
526 ) -> PageObject:
527 if not isinstance(page, PageObject) or page.get(PagesAttributes.TYPE, None) != Core.PAGE:
528 raise ValueError("Invalid page object")
529 assert self.flattened_pages is not None, "mypy"
530 page_org = page
531 excluded_keys = list(excluded_keys)
532 excluded_keys += [PagesAttributes.PARENT, "/StructParents"]
533 # Acrobat does not accept two indirect references pointing on the same
534 # page; therefore in order to add multiple copies of the same
535 # page, we need to create a new dictionary for the page, however the
536 # objects below (including content) are not duplicated:
537 try: # delete an already existing page
538 del self._id_translated[id(page_org.indirect_reference.pdf)][ # type: ignore[union-attr]
539 page_org.indirect_reference.idnum # type: ignore[union-attr]
540 ]
541 except Exception:
542 pass
544 page = cast(
545 "PageObject", page_org.clone(self, False, excluded_keys).get_object()
546 )
547 if page_org.pdf is not None:
548 other = page_org.pdf.pdf_header
549 self.pdf_header = _get_max_pdf_version_header(self.pdf_header, other)
551 node, idx = self._get_page_in_node(index)
552 page[NameObject(PagesAttributes.PARENT)] = node.indirect_reference
554 if idx >= 0:
555 cast(ArrayObject, node[PagesAttributes.KIDS]).insert(idx, page.indirect_reference)
556 self.flattened_pages.insert(index, page)
557 else:
558 cast(ArrayObject, node[PagesAttributes.KIDS]).append(page.indirect_reference)
559 self.flattened_pages.append(page)
560 current: Optional[PdfObject] = node
561 recurse = 0
562 while not is_null_or_none(current):
563 assert current is not None, "mypy" # guarded by is_null_or_none
564 node_dict = cast(DictionaryObject, current.get_object())
565 node_dict[NameObject(PagesAttributes.COUNT)] = NumberObject(cast(int, node_dict[PagesAttributes.COUNT]) + 1)
566 current = node_dict.get(PagesAttributes.PARENT, None)
567 recurse += 1
568 if recurse > 1000:
569 raise PyPdfError("Too many recursive calls!")
571 if page_org.pdf is not None:
572 # the page may contain links to other pages, and those other
573 # pages may or may not already be added. we store the
574 # information we need, so that we can resolve the references
575 # later.
576 if "/Annots" not in excluded_keys:
577 self._unresolved_links.extend(extract_links(page, page_org))
578 self._merged_in_pages[page_org.indirect_reference] = page.indirect_reference
580 return page
582 def set_need_appearances_writer(self, state: bool = True) -> None:
583 """
584 Sets the "NeedAppearances" flag in the PDF writer.
586 The "NeedAppearances" flag indicates whether the appearance dictionary
587 for form fields should be automatically generated by the PDF viewer or
588 if the embedded appearance should be used.
590 Args:
591 state: The actual value of the NeedAppearances flag.
593 Returns:
594 None
596 """
597 # See §12.7.2 and §7.7.2 for more information:
598 # https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf
599 try:
600 # get the AcroForm tree
601 if CatalogAttributes.ACRO_FORM not in self._root_object:
602 self._root_object[
603 NameObject(CatalogAttributes.ACRO_FORM)
604 ] = self._add_object(DictionaryObject())
606 need_appearances = NameObject(InteractiveFormDictEntries.NeedAppearances)
607 cast(DictionaryObject, self._root_object[CatalogAttributes.ACRO_FORM])[
608 need_appearances
609 ] = BooleanObject(state)
610 except Exception as exc: # pragma: no cover
611 logger_warning(
612 "set_need_appearances_writer(%(state)s) catch : %(exc)s",
613 source=__name__,
614 state=state,
615 exc=exc,
616 )
618 def create_viewer_preferences(self) -> ViewerPreferences:
619 o = ViewerPreferences()
620 self._root_object[
621 NameObject(CatalogAttributes.VIEWER_PREFERENCES)
622 ] = self._add_object(o)
623 return o
625 def add_page(
626 self,
627 page: PageObject,
628 excluded_keys: Iterable[str] = (),
629 ) -> PageObject:
630 """
631 Add a page to this PDF file.
633 Recommended for advanced usage including the adequate excluded_keys.
635 The page is usually acquired from a :class:`PdfReader<pypdf.PdfReader>`
636 instance.
638 Args:
639 page: The page to add to the document. Should be
640 an instance of :class:`PageObject<pypdf._page.PageObject>`
641 excluded_keys:
643 Returns:
644 The added PageObject.
646 """
647 assert self.flattened_pages is not None, "mypy"
648 return self._add_page(page, len(self.flattened_pages), excluded_keys)
650 def insert_page(
651 self,
652 page: PageObject,
653 index: int = 0,
654 excluded_keys: Iterable[str] = (),
655 ) -> PageObject:
656 """
657 Insert a page in this PDF file. The page is usually acquired from a
658 :class:`PdfReader<pypdf.PdfReader>` instance.
660 Args:
661 page: The page to add to the document.
662 index: Position at which the page will be inserted.
663 excluded_keys:
665 Returns:
666 The added PageObject.
668 """
669 assert self.flattened_pages is not None, "mypy"
670 if index < 0:
671 index += len(self.flattened_pages)
672 if index < 0:
673 raise ValueError("Invalid index value")
674 if index >= len(self.flattened_pages):
675 return self.add_page(page, excluded_keys)
676 return self._add_page(page, index, excluded_keys)
678 def _get_page_number_by_indirect(
679 self, indirect_reference: Union[int, NullObject, IndirectObject, None]
680 ) -> Optional[int]:
681 """
682 Generate _page_id2num.
684 Args:
685 indirect_reference:
687 Returns:
688 The page number or None
690 """
691 # To provide same function as in PdfReader
692 if is_null_or_none(indirect_reference):
693 return None
694 assert indirect_reference is not None, "mypy"
695 if isinstance(indirect_reference, int):
696 indirect_reference = IndirectObject(indirect_reference, 0, self)
697 obj = indirect_reference.get_object()
698 if isinstance(obj, PageObject):
699 return obj.page_number
700 return None
702 def add_blank_page(
703 self, width: Optional[float] = None, height: Optional[float] = None
704 ) -> PageObject:
705 """
706 Append a blank page to this PDF file and return it.
708 If no page size is specified, use the size of the last page.
710 Args:
711 width: The width of the new page expressed in default user
712 space units.
713 height: The height of the new page expressed in default
714 user space units.
716 Returns:
717 The newly appended page.
719 Raises:
720 PageSizeNotDefinedError: if width and height are not defined
721 and previous page does not exist.
723 """
724 page = PageObject.create_blank_page(self, width, height)
725 return self.add_page(page)
727 def insert_blank_page(
728 self,
729 width: Optional[Union[float, decimal.Decimal]] = None,
730 height: Optional[Union[float, decimal.Decimal]] = None,
731 index: int = 0,
732 ) -> PageObject:
733 """
734 Insert a blank page to this PDF file and return it.
736 If no page size is specified for a dimension, use the size of the last page.
738 Args:
739 width: The width of the new page in default user space units.
740 height: The height of the new page in default user space units.
741 index: Position to add the page.
743 Returns:
744 The newly inserted page.
746 Raises:
747 PageSizeNotDefinedError: if width and height are not defined
748 and previous page does not exist.
749 IndexError: Index is outside of [-self.get_num_pages(), self.get_num_pages()]
750 """
751 num_pages = self.get_num_pages()
752 if abs(index) > num_pages:
753 raise IndexError(f"Index should be in range [-{num_pages}, {num_pages}]")
755 if num_pages:
756 # Use the chosen index, but do not exceed the available pages
757 fixed_index = min(index, num_pages - 1)
758 mediabox = self.pages[fixed_index].mediabox
759 if width is None or width <= 0:
760 width = mediabox.width
761 if height is None or height <= 0:
762 height = mediabox.height
764 page = PageObject.create_blank_page(self, width, height)
765 self.insert_page(page, index)
766 return page
768 @property
769 def open_destination(
770 self,
771 ) -> Union[Destination, TextStringObject, ByteStringObject, None]:
772 return super().open_destination
774 @open_destination.setter
775 def open_destination(self, dest: Union[str, Destination, PageObject, None]) -> None:
776 if dest is None:
777 try:
778 del self._root_object["/OpenAction"]
779 except KeyError:
780 pass
781 elif isinstance(dest, str):
782 self._root_object[NameObject("/OpenAction")] = TextStringObject(dest)
783 elif isinstance(dest, Destination):
784 self._root_object[NameObject("/OpenAction")] = dest.dest_array
785 elif isinstance(dest, PageObject):
786 self._root_object[NameObject("/OpenAction")] = Destination(
787 "Opening",
788 dest.indirect_reference
789 if dest.indirect_reference is not None
790 else NullObject(),
791 PAGE_FIT,
792 ).dest_array
794 def add_js(self, javascript: str) -> None:
795 """
796 Add JavaScript which will launch upon opening this PDF.
798 Args:
799 javascript: Your JavaScript.
801 Example:
802 This will launch the print window when the PDF is opened.
804 >>> from pypdf import PdfWriter
805 >>> output = PdfWriter()
806 >>> output.add_js("this.print({bUI:true,bSilent:false,bShrinkToFit:true});")
808 """
809 deprecate_with_replacement("add_js", "add_open_action", "7.0.0")
810 self.add_open_action(JavaScript(javascript))
812 def add_open_action(self, action: Action) -> None:
813 """
814 Add an action to the document-level JavaScript name tree.
816 Args:
817 action: The action to add.
819 Example:
820 This will launch the print window when the PDF is opened.
822 >>> from pypdf import PdfWriter
823 >>> from pypdf.actions import JavaScript
824 >>> output = PdfWriter()
825 >>> output.add_open_action(JavaScript("this.print({bUI:true,bSilent:false,bShrinkToFit:true});"))
826 """
827 return Action._create_open_action(self, action)
829 def add_attachment(self, filename: str, data: Union[str, bytes]) -> "EmbeddedFile":
830 """
831 Embed a file inside the PDF.
833 Reference:
834 https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf
835 Section 7.11.3
837 Args:
838 filename: The filename to display.
839 data: The data in the file.
841 Returns:
842 EmbeddedFile instance for the newly created embedded file.
844 """
845 return EmbeddedFile._create_new(self, filename, data)
847 def append_pages_from_reader(
848 self,
849 reader: PdfReader,
850 after_page_append: Optional[Callable[[PageObject], None]] = None,
851 ) -> None:
852 """
853 Copy pages from reader to writer. Includes an optional callback
854 parameter which is invoked after pages are appended to the writer.
856 ``append`` should be preferred.
858 Args:
859 reader: a PdfReader object from which to copy page
860 annotations to this writer object. The writer's annots
861 will then be updated.
862 after_page_append:
863 Callback function that is invoked after each page is appended to
864 the writer. Signature includes a reference to the appended page
865 (delegates to append_pages_from_reader). The single parameter of
866 the callback is a reference to the page just appended to the
867 document.
869 """
870 reader_num_pages = len(reader.pages)
871 # Copy pages from reader to writer
872 for reader_page_number in range(reader_num_pages):
873 reader_page = reader.pages[reader_page_number]
874 writer_page = self.add_page(reader_page)
875 # Trigger callback, pass writer page as parameter
876 if callable(after_page_append):
877 after_page_append(writer_page)
879 def _merge_content_stream_to_page(
880 self,
881 page: PageObject,
882 new_content_data: bytes,
883 ) -> None:
884 """
885 Combines existing content stream(s) with new content (as bytes).
887 Args:
888 page: The page to which the new content data will be added.
889 new_content_data: A binary-encoded new content stream, for
890 instance the commands to draw an XObject.
891 """
892 # First resolve the existing page content. This always is an IndirectObject:
893 # PDF Explained by John Whitington
894 # https://www.oreilly.com/library/view/pdf-explained/9781449321581/ch04.html
895 if NameObject("/Contents") in page:
896 existing_content_ref = page[NameObject("/Contents")]
897 existing_content = existing_content_ref.get_object()
899 if isinstance(existing_content, ArrayObject):
900 # Create a new StreamObject for the new_content_data
901 new_stream_obj = StreamObject()
902 new_stream_obj.set_data(new_content_data)
903 existing_content.append(self._add_object(new_stream_obj))
904 page[NameObject("/Contents")] = self._add_object(existing_content)
905 if isinstance(existing_content, StreamObject):
906 # Merge new content to existing StreamObject
907 merged_data = existing_content.get_data() + b"\n" + new_content_data
908 new_stream = StreamObject()
909 new_stream.set_data(merged_data)
910 page[NameObject("/Contents")] = self._add_object(new_stream)
911 else:
912 # If no existing content, then we have an empty page.
913 # Create a new StreamObject in a new /Contents entry.
914 new_stream = StreamObject()
915 new_stream.set_data(new_content_data)
916 page[NameObject("/Contents")] = self._add_object(new_stream)
918 def _add_apstream_object(
919 self,
920 page: PageObject,
921 appearance_stream_obj: StreamObject,
922 object_name: str,
923 x_offset: float,
924 y_offset: float,
925 ) -> None:
926 """
927 Adds an appearance stream to the page content in the form of
928 an XObject.
930 Args:
931 page: The page to which to add the appearance stream.
932 appearance_stream_obj: The appearance stream.
933 object_name: The name of the appearance stream.
934 x_offset: The horizontal offset for the appearance stream.
935 y_offset: The vertical offset for the appearance stream.
936 """
937 pg_res = cast(DictionaryObject, page[PG.RESOURCES])
938 # Always add the resolved stream object to the writer to get a new IndirectObject.
939 # This ensures we have a valid IndirectObject managed by *this* writer.
940 xobject_ref = self._add_object(appearance_stream_obj)
941 xobject_name = NameObject(f"/Fm_{object_name}")._sanitize()
942 if "/XObject" not in pg_res:
943 pg_res[NameObject("/XObject")] = DictionaryObject()
944 pg_xo_res = cast(DictionaryObject, pg_res["/XObject"])
945 if xobject_name not in pg_xo_res:
946 pg_xo_res[xobject_name] = xobject_ref
947 else:
948 logger_warning(
949 "XObject %(xobject_name)r already added to page resources. This might be an issue.",
950 source=__name__,
951 xobject_name=xobject_name,
952 )
953 xobject_cm = Transformation().translate(x_offset, y_offset)
954 xobject_drawing_commands = f"q\n{xobject_cm._to_cm()}\n{xobject_name} Do\nQ".encode()
955 self._merge_content_stream_to_page(page, xobject_drawing_commands)
957 FFBITS_NUL = FA.FfBits(0)
959 def update_page_form_field_values(
960 self,
961 page: Union[PageObject, list[PageObject], None],
962 fields: Mapping[str, Union[str, list[str], tuple[str, str, float]]],
963 flags: FA.FfBits = FFBITS_NUL,
964 auto_regenerate: Optional[bool] = True,
965 flatten: bool = False,
966 ) -> None:
967 """
968 Update the form field values for a given page from a fields dictionary.
970 Copy field texts and values from fields to page.
971 If the field links to a parent object, add the information to the parent.
973 Args:
974 page: `PageObject` - references **PDF writer's page** where the
975 annotations and field data will be updated.
976 `List[Pageobject]` - provides list of pages to be processed.
977 `None` - all pages.
978 fields: a Python dictionary of:
980 * field names (/T) as keys and text values (/V) as value
981 * field names (/T) as keys and list of text values (/V) for multiple choice list
982 * field names (/T) as keys and tuple of:
983 * text values (/V)
984 * font id (e.g. /F1, the font id must exist)
985 * font size (0 for autosize)
987 flags: A set of flags from :class:`~pypdf.constants.FieldDictionaryAttributes.FfBits`.
989 auto_regenerate: Set/unset the need_appearances flag;
990 the flag is unchanged if auto_regenerate is None.
992 flatten: Whether or not to flatten the annotation. If True, this adds the annotation's
993 appearance stream to the page contents. Note that this option does not remove the
994 annotation itself.
996 """
997 if CatalogAttributes.ACRO_FORM not in self._root_object:
998 raise PyPdfError("No /AcroForm dictionary in PDF of PdfWriter Object")
999 acro_form = cast(DictionaryObject, self._root_object[CatalogAttributes.ACRO_FORM])
1000 if InteractiveFormDictEntries.Fields not in acro_form:
1001 raise PyPdfError("No /Fields dictionary in PDF of PdfWriter Object")
1002 if isinstance(auto_regenerate, bool):
1003 self.set_need_appearances_writer(auto_regenerate)
1004 # Iterate through pages, update field values
1005 if page is None:
1006 page = list(self.pages)
1007 if isinstance(page, list):
1008 for p in page:
1009 if PG.ANNOTS in p: # just to prevent warnings
1010 self.update_page_form_field_values(p, fields, flags, None, flatten=flatten)
1011 return
1012 if PG.ANNOTS not in page:
1013 logger_warning("No fields to update on this page", source=__name__)
1014 return
1015 appearance_stream_obj: Optional[StreamObject] = None
1017 for annotation in page[PG.ANNOTS]: # type: ignore[attr-defined]
1018 annotation = cast(DictionaryObject, annotation.get_object())
1019 if annotation.get("/Subtype", "") != "/Widget":
1020 continue
1021 if "/FT" in annotation and "/T" in annotation:
1022 parent_annotation = annotation
1023 else:
1024 parent_annotation = annotation.get(
1025 PG.PARENT, DictionaryObject()
1026 ).get_object()
1028 for field, value in fields.items():
1029 rectangle = cast(RectangleObject, annotation[AA.Rect])
1030 if not (
1031 self._get_qualified_field_name(parent=parent_annotation) == field
1032 or parent_annotation.get("/T", None) == field
1033 ):
1034 continue
1035 if (
1036 parent_annotation.get("/FT", None) == "/Ch"
1037 and "/I" in parent_annotation
1038 ):
1039 del parent_annotation["/I"]
1040 if flags:
1041 annotation[NameObject(FA.Ff)] = NumberObject(flags)
1042 # Set the field value
1043 if not (value is None and flatten): # Only change values if given by user and not flattening.
1044 if isinstance(value, list):
1045 lst = ArrayObject(TextStringObject(v) for v in value)
1046 parent_annotation[NameObject(FA.V)] = lst
1047 elif isinstance(value, tuple):
1048 annotation[NameObject(FA.V)] = TextStringObject(
1049 value[0],
1050 )
1051 else:
1052 parent_annotation[NameObject(FA.V)] = TextStringObject(value)
1053 # Get or create the field's appearance stream object
1054 if parent_annotation.get(FA.FT) == "/Btn":
1055 # Checkbox button (no /FT found in Radio widgets);
1056 # We can find the associated appearance stream object
1057 # within the annotation.
1058 v = NameObject(value)
1059 ap = cast(DictionaryObject, annotation[NameObject(AA.AP)])
1060 normal_ap = cast(DictionaryObject, ap["/N"])
1061 if v not in normal_ap:
1062 v = NameObject("/Off")
1063 appearance_stream_obj = normal_ap.get(v)
1064 # Other cases will be updated through the for loop
1065 annotation[NameObject(AA.AS)] = v
1066 annotation[NameObject(FA.V)] = v
1067 elif (
1068 parent_annotation.get(FA.FT) == "/Tx"
1069 or parent_annotation.get(FA.FT) == "/Ch"
1070 ):
1071 # Textbox; we need to generate the appearance stream object
1072 if isinstance(value, tuple):
1073 appearance_stream_obj = TextStreamAppearance.from_text_annotation(
1074 self, page, flatten, acro_form, parent_annotation, annotation, value[1], value[2]
1075 )
1076 else:
1077 appearance_stream_obj = TextStreamAppearance.from_text_annotation(
1078 self, page, flatten, acro_form, parent_annotation, annotation
1079 )
1080 # Add the appearance stream object
1081 if AA.AP not in annotation:
1082 annotation[NameObject(AA.AP)] = DictionaryObject(
1083 {NameObject("/N"): self._add_object(appearance_stream_obj)}
1084 )
1085 elif "/N" not in (ap:= cast(DictionaryObject, annotation[AA.AP])):
1086 cast(DictionaryObject, annotation[NameObject(AA.AP)])[
1087 NameObject("/N")
1088 ] = self._add_object(appearance_stream_obj)
1089 else: # [/AP][/N] exists
1090 n = annotation[AA.AP]["/N"].indirect_reference.idnum # type: ignore[index]
1091 self._objects[n - 1] = appearance_stream_obj
1092 appearance_stream_obj.indirect_reference = IndirectObject(n, 0, self)
1093 elif (
1094 annotation.get(FA.FT) == "/Sig"
1095 ): # deprecated # not implemented yet
1096 logger_warning("Signature forms not implemented yet", source=__name__)
1098 if appearance_stream_obj and flatten:
1099 self._add_apstream_object(page, appearance_stream_obj, field, rectangle[0], rectangle[1])
1101 def reattach_fields(
1102 self, page: Optional[PageObject] = None
1103 ) -> list[DictionaryObject]:
1104 """
1105 Parse annotations within the page looking for orphan fields and
1106 reattach then into the Fields Structure.
1108 Args:
1109 page: page to analyze.
1110 If none is provided, all pages will be analyzed.
1112 Returns:
1113 list of reattached fields.
1115 """
1116 lst = []
1117 if page is None:
1118 for p in self.pages:
1119 lst += self.reattach_fields(p)
1120 return lst
1122 try:
1123 af = cast(DictionaryObject, self._root_object[CatalogAttributes.ACRO_FORM])
1124 except KeyError:
1125 af = DictionaryObject()
1126 self._root_object[NameObject(CatalogAttributes.ACRO_FORM)] = af
1127 try:
1128 fields = cast(ArrayObject, af[InteractiveFormDictEntries.Fields])
1129 except KeyError:
1130 fields = ArrayObject()
1131 af[NameObject(InteractiveFormDictEntries.Fields)] = fields
1133 if "/Annots" not in page:
1134 return lst
1135 annotations = cast(ArrayObject, page["/Annots"])
1136 for idx, annotation in enumerate(annotations):
1137 is_indirect = isinstance(annotation, IndirectObject)
1138 annotation = cast(DictionaryObject, annotation.get_object())
1139 if annotation.get("/Subtype", "") == "/Widget" and "/FT" in annotation:
1140 if (
1141 "indirect_reference" in annotation.__dict__
1142 and annotation.indirect_reference in fields
1143 ):
1144 continue
1145 if not is_indirect:
1146 annotations[idx] = self._add_object(annotation)
1147 fields.append(annotation.indirect_reference)
1148 lst.append(annotation)
1149 return lst
1151 def _collect_incremental_clone_object_ids(self, reader: PdfReader) -> list[int]:
1152 object_ids: set[int] = set()
1153 for xref_entry in reader.xref.values():
1154 object_ids.update(filter(None, xref_entry))
1155 object_ids.update(filter(None, reader.xref_objStm))
1157 object_count = len(object_ids)
1158 if object_count > self._incremental_clone_object_count_limit:
1159 raise LimitReachedError(
1160 f"Incremental clone object count {object_count} exceeds "
1161 f"maximum allowed count {self._incremental_clone_object_count_limit}."
1162 )
1164 max_object_id = max(object_ids, default=0)
1165 if max_object_id > self._incremental_clone_object_id_limit:
1166 raise LimitReachedError(
1167 f"Incremental clone object ID {max_object_id} exceeds "
1168 f"maximum allowed ID {self._incremental_clone_object_id_limit}."
1169 )
1171 return sorted(object_ids)
1173 def clone_reader_document_root(self, reader: PdfReader) -> None:
1174 """
1175 Copy the reader document root to the writer and all sub-elements,
1176 including pages, threads, outlines,... For partial insertion, ``append``
1177 should be considered.
1179 Args:
1180 reader: PdfReader from which the document root should be copied.
1182 """
1183 self._info_obj = None
1184 if self.incremental:
1185 object_ids = self._collect_incremental_clone_object_ids(reader)
1186 self._objects = [None] * (object_ids[-1] if object_ids else 0)
1187 for object_id in object_ids:
1188 reader_object = reader.get_object(object_id)
1189 if reader_object is not None:
1190 self._objects[object_id - 1] = reader_object.replicate(self)
1191 else:
1192 self._objects.clear()
1193 self._root_object = reader.root_object.clone(self)
1194 self._pages = self._root_object.raw_get("/Pages")
1196 trailer_size = cast(int, reader.trailer["/Size"])
1197 if len(self._objects) > trailer_size:
1198 if self.strict:
1199 raise PdfReadError(
1200 f"Object count {len(self._objects)} exceeds defined trailer size {trailer_size}"
1201 )
1202 logger_warning(
1203 "Object count %(object_count)d exceeds defined trailer size %(trailer_size)d",
1204 source=__name__,
1205 object_count=len(self._objects),
1206 trailer_size=trailer_size,
1207 )
1209 # must be done here before rewriting
1210 if self.incremental:
1211 self._original_hash = [
1212 (obj.hash_bin() if obj is not None else 0) for obj in self._objects
1213 ]
1215 try:
1216 self._flatten()
1217 except IndexError as exception: # pragma: no cover
1218 raise PdfReadError("Got index error while flattening.") from exception # pragma: no cover
1220 assert self.flattened_pages is not None
1221 for p in self.flattened_pages:
1222 self._replace_object(cast(IndirectObject, p.indirect_reference).idnum, p)
1223 if not self.incremental:
1224 p[NameObject("/Parent")] = self._pages
1225 if not self.incremental:
1226 cast(DictionaryObject, self._pages.get_object())[
1227 NameObject("/Kids")
1228 ] = ArrayObject([p.indirect_reference for p in self.flattened_pages])
1230 def clone_document_from_reader(
1231 self,
1232 reader: PdfReader,
1233 after_page_append: Optional[Callable[[PageObject], None]] = None,
1234 ) -> None:
1235 """
1236 Create a copy (clone) of a document from a PDF file reader cloning
1237 section '/Root' and '/Info' and '/ID' of the pdf.
1239 Args:
1240 reader: PDF file reader instance from which the clone
1241 should be created.
1242 after_page_append:
1243 Callback function that is invoked after each page is appended to
1244 the writer. Signature includes a reference to the appended page
1245 (delegates to append_pages_from_reader). The single parameter of
1246 the callback is a reference to the page just appended to the
1247 document.
1249 """
1250 self.clone_reader_document_root(reader)
1251 inf = reader._info
1252 if self.incremental:
1253 if inf is not None:
1254 self._info_obj = cast(
1255 IndirectObject, inf.clone(self).indirect_reference
1256 )
1257 assert isinstance(self._info, DictionaryObject), "mypy"
1258 self._original_hash[
1259 self._info_obj.indirect_reference.idnum - 1
1260 ] = self._info.hash_bin()
1261 elif inf is not None:
1262 self._info_obj = self._add_object(
1263 DictionaryObject(cast(DictionaryObject, inf.get_object()))
1264 )
1265 # else: _info_obj = None done in clone_reader_document_root()
1267 try:
1268 self._ID = cast(ArrayObject, reader._ID).clone(self)
1269 except AttributeError:
1270 pass
1272 if callable(after_page_append):
1273 for page in cast(
1274 ArrayObject, cast(DictionaryObject, self._pages.get_object())["/Kids"]
1275 ):
1276 after_page_append(page.get_object())
1278 def _compute_document_identifier(self) -> ByteStringObject:
1279 stream = BytesIO()
1280 self._write_pdf_structure(stream)
1281 stream.seek(0)
1282 return ByteStringObject(_rolling_checksum(stream).encode("utf8"))
1284 def generate_file_identifiers(self) -> None:
1285 """
1286 Generate an identifier for the PDF that will be written.
1288 The only point of this is ensuring uniqueness. Reproducibility is not
1289 required.
1290 When a file is first written, both identifiers shall be set to the same value.
1291 If both identifiers match when a file reference is resolved, it is very
1292 likely that the correct and unchanged file has been found. If only the first
1293 identifier matches, a different version of the correct file has been found.
1294 see §14.4 "File Identifiers".
1295 """
1296 if self._ID:
1297 id1 = self._ID[0]
1298 id2 = self._compute_document_identifier()
1299 else:
1300 id1 = self._compute_document_identifier()
1301 id2 = id1
1302 self._ID = ArrayObject((id1, id2))
1304 def encrypt(
1305 self,
1306 user_password: str,
1307 owner_password: Optional[str] = None,
1308 use_128bit: bool = True,
1309 permissions_flag: UserAccessPermissions = ALL_DOCUMENT_PERMISSIONS,
1310 *,
1311 algorithm: Optional[str] = None,
1312 ) -> None:
1313 """
1314 Encrypt this PDF file with the PDF Standard encryption handler.
1316 Args:
1317 user_password: The password which allows for opening
1318 and reading the PDF file with the restrictions provided.
1319 owner_password: The password which allows for
1320 opening the PDF files without any restrictions. By default,
1321 the owner password is the same as the user password.
1322 use_128bit: flag as to whether to use 128bit
1323 encryption. When false, 40bit encryption will be used.
1324 By default, this flag is on.
1325 permissions_flag: permissions as described in
1326 Table 3.20 of the PDF 1.7 specification. A bit value of 1 means
1327 the permission is granted.
1328 Hence an integer value of -1 will set all flags.
1329 Bit position 3 is for printing, 4 is for modifying content,
1330 5 and 6 control annotations, 9 for form fields,
1331 10 for extraction of text and graphics.
1332 algorithm: encrypt algorithm. Values may be one of "RC4-40", "RC4-128",
1333 "AES-128", "AES-256-R5", "AES-256". If it is valid,
1334 `use_128bit` will be ignored.
1336 """
1337 if self.incremental:
1338 raise NotImplementedError("Encrypting incremental PDF files is currently not supported.")
1340 if owner_password is None:
1341 owner_password = user_password
1343 if algorithm is not None:
1344 try:
1345 alg = getattr(EncryptAlgorithm, algorithm.replace("-", "_"))
1346 except AttributeError as exception: # pragma: no cover
1347 raise ValueError(f"Algorithm '{algorithm}' NOT supported") from exception # pragma: no cover
1348 else:
1349 alg = EncryptAlgorithm.RC4_128
1350 if not use_128bit:
1351 alg = EncryptAlgorithm.RC4_40
1352 self.generate_file_identifiers()
1353 assert self._ID
1354 self._encryption = Encryption.make(alg, permissions_flag, self._ID[0])
1355 # in case call `encrypt` again
1356 entry = self._encryption.write_entry(user_password, owner_password, strict=self.strict)
1357 if self._encrypt_entry:
1358 # replace old encrypt_entry
1359 assert self._encrypt_entry.indirect_reference is not None
1360 entry.indirect_reference = self._encrypt_entry.indirect_reference
1361 self._objects[entry.indirect_reference.idnum - 1] = entry
1362 else:
1363 self._add_object(entry)
1364 self._encrypt_entry = entry
1366 def _resolve_links(self) -> None:
1367 """Patch up links that were added to the document earlier, to
1368 make sure they still point to the same pages.
1369 """
1370 for (new_link, old_link) in self._unresolved_links:
1371 old_page = old_link.find_referenced_page()
1372 if not old_page:
1373 continue
1374 new_page = self._merged_in_pages.get(old_page)
1375 if new_page is None:
1376 continue
1377 new_link.patch_reference(self, new_page)
1379 def write_stream(self, stream: StreamType) -> None:
1380 if hasattr(stream, "mode") and "b" not in stream.mode:
1381 logger_warning(
1382 "File <%(stream_name)s> to write to is not in binary mode. "
1383 "It may not be written to correctly.",
1384 source=__name__,
1385 stream_name=stream.name,
1386 )
1387 self._resolve_links()
1389 if self.incremental:
1390 assert self._reader is not None, "mypy"
1391 self._reader.stream.seek(0)
1392 stream.write(self._reader.stream.read(-1))
1393 if len(self.list_objects_in_increment()) > 0:
1394 self._write_increment(stream) # writes objs, xref stream and startxref
1395 else:
1396 object_positions, free_objects = self._write_pdf_structure(stream)
1397 xref_location = self._write_xref_table(
1398 stream, object_positions, free_objects
1399 )
1400 self._write_trailer(stream, xref_location)
1402 def write(self, stream: Union[Path, StrByteType]) -> tuple[bool, IO[Any]]:
1403 """
1404 Write the collection of pages added to this object out as a PDF file.
1406 Args:
1407 stream: An object to write the file to. The object can support
1408 the write method and the tell method, similar to a file object, or
1409 be a file path, just like the fileobj, just named it stream to keep
1410 existing workflow.
1412 Returns:
1413 A tuple (bool, IO).
1415 """
1416 my_file = False
1418 if stream == "":
1419 raise ValueError(f"Output({stream=}) is empty.")
1421 if isinstance(stream, (str, Path)):
1422 stream = FileIO(stream, "wb")
1423 my_file = True
1425 self.write_stream(stream)
1427 if my_file:
1428 stream.close()
1429 else:
1430 stream.flush()
1432 return my_file, stream
1434 def list_objects_in_increment(self) -> list[IndirectObject]:
1435 """
1436 For analysis or debugging.
1437 Provides the list of new or modified objects that will be written
1438 in the increment.
1439 Deleted objects will not be freed but will become orphans.
1441 Returns:
1442 List of new or modified IndirectObjects
1444 """
1445 original_hash_count = len(self._original_hash)
1446 return [
1447 cast(IndirectObject, obj).indirect_reference
1448 for i, obj in enumerate(self._objects)
1449 if (
1450 obj is not None
1451 and (
1452 i >= original_hash_count
1453 or obj.hash_bin() != self._original_hash[i]
1454 )
1455 )
1456 ]
1458 def _write_increment(self, stream: StreamType) -> None:
1459 # Only reached from `write()` inside `if self.incremental`.
1460 assert self._reader is not None, "mypy"
1461 object_positions = {}
1462 object_blocks = []
1463 current_start = -1
1464 current_stop = -2
1465 original_hash_count = len(self._original_hash)
1466 for i, obj in enumerate(self._objects):
1467 if obj is not None and (
1468 i >= original_hash_count
1469 or obj.hash_bin() != self._original_hash[i]
1470 ):
1471 idnum = i + 1
1472 assert isinstance(obj, PdfObject), "mypy"
1473 # first write new/modified object
1474 object_positions[idnum] = stream.tell()
1475 stream.write(f"{idnum} 0 obj\n".encode())
1476 """ encryption is not operational
1477 if self._encryption and obj != self._encrypt_entry:
1478 obj = self._encryption.encrypt_object(obj, idnum, 0)
1479 """
1480 obj.write_to_stream(stream)
1481 stream.write(b"\nendobj\n")
1483 # prepare xref
1484 if idnum != current_stop:
1485 if current_start > 0:
1486 object_blocks.append(
1487 [current_start, current_stop - current_start]
1488 )
1489 current_start = idnum
1490 current_stop = idnum + 1
1491 assert current_start > 0, "for pytest only"
1492 object_blocks.append([current_start, current_stop - current_start])
1493 # write incremented xref
1494 xref_location = stream.tell()
1495 xr_id = len(self._objects) + 1
1496 stream.write(f"{xr_id} 0 obj".encode())
1497 init_data = {
1498 NameObject("/Type"): NameObject("/XRef"),
1499 NameObject("/Size"): NumberObject(xr_id + 1),
1500 NameObject("/Root"): self.root_object.indirect_reference,
1501 NameObject("/Filter"): NameObject("/FlateDecode"),
1502 NameObject("/Index"): ArrayObject(
1503 [NumberObject(_it) for _su in object_blocks for _it in _su]
1504 ),
1505 NameObject("/W"): ArrayObject(
1506 [NumberObject(1), NumberObject(4), NumberObject(1)]
1507 ),
1508 "__streamdata__": b"",
1509 }
1510 if self._info is not None and (
1511 self._info.indirect_reference.idnum - 1 # type: ignore[union-attr]
1512 >= len(self._original_hash)
1513 or cast(IndirectObject, self._info).hash_bin() # kept for future
1514 != self._original_hash[
1515 self._info.indirect_reference.idnum - 1 # type: ignore[union-attr]
1516 ]
1517 ):
1518 init_data[NameObject(TK.INFO)] = self._info.indirect_reference
1519 init_data[NameObject(TK.PREV)] = NumberObject(self._reader._startxref)
1520 if self._ID:
1521 init_data[NameObject(TK.ID)] = self._ID
1522 xr = StreamObject.initialize_from_dictionary(init_data)
1523 xr.set_data(
1524 b"".join(
1525 [struct.pack(b">BIB", 1, _pos, 0) for _pos in object_positions.values()]
1526 )
1527 )
1528 xr.write_to_stream(stream)
1529 stream.write(f"\nendobj\nstartxref\n{xref_location}\n%%EOF\n".encode()) # eof
1531 def _write_pdf_structure(self, stream: StreamType) -> tuple[list[int], list[int]]:
1532 object_positions = []
1533 free_objects = []
1534 stream.write(self.pdf_header.encode() + b"\n")
1535 stream.write(b"%\xE2\xE3\xCF\xD3\n")
1537 for idnum, obj in enumerate(self._objects, start=1):
1538 if obj is not None:
1539 object_positions.append(stream.tell())
1540 stream.write(f"{idnum} 0 obj\n".encode())
1541 if self._encryption and obj != self._encrypt_entry:
1542 obj = self._encryption.encrypt_object(obj, idnum, 0)
1543 obj.write_to_stream(stream)
1544 stream.write(b"\nendobj\n")
1545 else:
1546 object_positions.append(-1)
1547 free_objects.append(idnum)
1548 free_objects.append(0) # add 0 to loop in accordance with specification
1549 return object_positions, free_objects
1551 def _write_xref_table(
1552 self, stream: StreamType, object_positions: list[int], free_objects: list[int]
1553 ) -> int:
1554 xref_location = stream.tell()
1555 stream.write(b"xref\n")
1556 stream.write(f"0 {len(self._objects) + 1}\n".encode())
1557 stream.write(f"{free_objects[0]:0>10} {65535:0>5} f \n".encode())
1558 free_idx = 1
1559 for offset in object_positions:
1560 if offset > 0:
1561 stream.write(f"{offset:0>10} {0:0>5} n \n".encode())
1562 else:
1563 stream.write(f"{free_objects[free_idx]:0>10} {1:0>5} f \n".encode())
1564 free_idx += 1
1565 return xref_location
1567 def _write_trailer(self, stream: StreamType, xref_location: int) -> None:
1568 """
1569 Write the PDF trailer to the stream.
1571 To quote the PDF specification:
1572 [The] trailer [gives] the location of the cross-reference table and
1573 of certain special objects within the body of the file.
1574 """
1575 stream.write(b"trailer\n")
1576 trailer = DictionaryObject(
1577 {
1578 NameObject(TK.SIZE): NumberObject(len(self._objects) + 1),
1579 NameObject(TK.ROOT): self.root_object.indirect_reference,
1580 }
1581 )
1582 if self._info is not None:
1583 trailer[NameObject(TK.INFO)] = self._info.indirect_reference
1584 if self._ID is not None:
1585 trailer[NameObject(TK.ID)] = self._ID
1586 if self._encrypt_entry:
1587 trailer[NameObject(TK.ENCRYPT)] = self._encrypt_entry.indirect_reference
1588 trailer.write_to_stream(stream)
1589 stream.write(f"\nstartxref\n{xref_location}\n%%EOF\n".encode()) # eof
1591 @property
1592 def metadata(self) -> Optional[DocumentInformation]:
1593 """
1594 Retrieve/set the PDF file's document information dictionary, if it exists.
1596 Args:
1597 value: dict with the entries to be set. if None : remove the /Info entry from the pdf.
1599 Note that some PDF files use (XMP) metadata streams instead of document
1600 information dictionaries, and these metadata streams will not be
1601 accessed by this function, but by :meth:`~xmp_metadata`.
1603 """
1604 return super().metadata
1606 @metadata.setter
1607 def metadata(
1608 self,
1609 value: Optional[Union[DocumentInformation, DictionaryObject, dict[Any, Any]]],
1610 ) -> None:
1611 if value is None:
1612 self._info = None
1613 else:
1614 if self._info is not None:
1615 self._info.clear()
1617 self.add_metadata(value)
1619 def add_metadata(self, infos: dict[str, Any]) -> None:
1620 """
1621 Add custom metadata to the output.
1623 Args:
1624 infos: a Python dictionary where each key is a field
1625 and each value is your new metadata.
1627 """
1628 args = {}
1629 if isinstance(infos, PdfObject):
1630 infos = cast(DictionaryObject, infos.get_object())
1631 for key, value in list(infos.items()):
1632 if isinstance(value, PdfObject):
1633 value = value.get_object()
1634 args[NameObject(key)] = create_string_object(str(value))
1635 if self._info is None:
1636 self._info = DictionaryObject()
1637 self._info.update(args)
1639 _UNSET = object()
1641 def compress_identical_objects(
1642 self,
1643 remove_identicals: Any = _UNSET,
1644 remove_orphans: Any = _UNSET,
1645 *,
1646 remove_duplicates: bool = True,
1647 remove_unreferenced: bool = True,
1648 ) -> None:
1649 """
1650 Parse the PDF file and merge objects that have the same hash.
1651 This will make objects common to multiple pages.
1652 Recommended to be used just before writing output.
1654 Args:
1655 remove_identicals: Deprecated.
1656 remove_orphans: Deprecated.
1657 remove_duplicates: Remove duplicate objects.
1658 remove_unreferenced: Remove unreferenced objects.
1660 """
1661 if remove_identicals != self._UNSET:
1662 deprecate_with_replacement("remove_identicals", "remove_duplicates", "7.0.0")
1663 assert isinstance(remove_identicals, bool)
1664 remove_duplicates = remove_identicals
1665 if remove_orphans != self._UNSET:
1666 deprecate_with_replacement("remove_orphans", "remove_unreferenced", "7.0.0")
1667 assert isinstance(remove_orphans, bool)
1668 remove_unreferenced = remove_orphans
1670 def replace_in_obj(
1671 obj: PdfObject, crossref: dict[IndirectObject, IndirectObject]
1672 ) -> None:
1673 if isinstance(obj, DictionaryObject):
1674 key_val = obj.items()
1675 elif isinstance(obj, ArrayObject):
1676 key_val = enumerate(obj) # type: ignore[assignment]
1677 else:
1678 return
1679 assert isinstance(obj, (DictionaryObject, ArrayObject))
1680 for k, v in key_val:
1681 if isinstance(v, IndirectObject):
1682 unreferenced[v.idnum - 1] = False
1683 if v in crossref:
1684 obj[k] = crossref[v]
1685 else:
1686 """The filtering on DictionaryObject and ArrayObject only
1687 will be performed within replace_in_obj"""
1688 replace_in_obj(v, crossref)
1690 # _idnum_hash: dict[hash] = (1st_ind_obj, [2nd_ind_obj,...])
1691 self._idnum_hash = {}
1692 unreferenced = [True] * len(self._objects)
1693 # look for similar objects
1694 for idx, obj in enumerate(self._objects):
1695 if is_null_or_none(obj):
1696 continue
1697 assert obj is not None, "mypy" # mypy: TypeGuard of `is_null_or_none` does not help here.
1698 assert isinstance(obj.indirect_reference, IndirectObject)
1699 h = obj.hash_value()
1700 if remove_duplicates and h in self._idnum_hash:
1701 self._idnum_hash[h][1].append(obj.indirect_reference)
1702 self._objects[idx] = None
1703 else:
1704 self._idnum_hash[h] = (obj.indirect_reference, [])
1706 # generate the dict converting others to 1st
1707 cnv = {v[0]: v[1] for v in self._idnum_hash.values() if len(v[1]) > 0}
1708 cnv_rev: dict[IndirectObject, IndirectObject] = {}
1709 for k, v in cnv.items():
1710 cnv_rev.update(zip(v, (k,) * len(v)))
1712 # replace reference to merged objects
1713 for obj in self._objects:
1714 if isinstance(obj, (DictionaryObject, ArrayObject)):
1715 replace_in_obj(obj, cnv_rev)
1717 if remove_unreferenced:
1718 unreferenced[self.root_object.indirect_reference.idnum - 1] = False # type: ignore[union-attr]
1720 if not is_null_or_none(self._info):
1721 unreferenced[self._info.indirect_reference.idnum - 1] = False # type: ignore[union-attr]
1723 try:
1724 unreferenced[self._ID.indirect_reference.idnum - 1] = False # type: ignore[union-attr]
1725 except AttributeError:
1726 pass
1728 for i in compress(range(len(self._objects)), unreferenced):
1729 self._objects[i] = None
1731 def get_reference(self, obj: PdfObject) -> IndirectObject:
1732 idnum = self._objects.index(obj) + 1
1733 ref = IndirectObject(idnum, 0, self)
1734 assert ref.get_object() == obj
1735 return ref
1737 def get_outline_root(self) -> TreeObject:
1738 if Core.OUTLINES in self._root_object:
1739 # Entries in the catalog dictionary
1740 outline = cast(TreeObject, self._root_object[Core.OUTLINES])
1741 if not isinstance(outline, TreeObject):
1742 t = TreeObject(outline)
1743 self._replace_object(outline.indirect_reference.idnum, t)
1744 outline = t
1745 idnum = self._objects.index(outline) + 1
1746 outline_ref = IndirectObject(idnum, 0, self)
1747 assert outline_ref.get_object() == outline
1748 else:
1749 outline = TreeObject()
1750 outline.update({})
1751 outline_ref = self._add_object(outline)
1752 self._root_object[NameObject(Core.OUTLINES)] = outline_ref
1754 return outline
1756 def get_threads_root(self) -> ArrayObject:
1757 """
1758 The list of threads.
1760 See §12.4.3 of the PDF 1.7 or PDF 2.0 specification.
1762 Returns:
1763 An array (possibly empty) of Dictionaries with an ``/F`` key,
1764 and optionally information about the thread in ``/I`` or ``/Metadata`` keys.
1766 """
1767 if Core.THREADS in self._root_object:
1768 # Entries in the catalog dictionary
1769 threads = cast(ArrayObject, self._root_object[Core.THREADS])
1770 else:
1771 threads = ArrayObject()
1772 self._root_object[NameObject(Core.THREADS)] = threads
1773 return threads
1775 @property
1776 def threads(self) -> ArrayObject:
1777 """
1778 Read-only property for the list of threads.
1780 See §12.4.3 of the PDF 1.7 or PDF 2.0 specification.
1782 Each element is a dictionary with an ``/F`` key, and optionally
1783 information about the thread in ``/I`` or ``/Metadata`` keys.
1784 """
1785 return self.get_threads_root()
1787 def add_outline_item_destination(
1788 self,
1789 page_destination: Union[IndirectObject, PageObject, TreeObject],
1790 parent: Union[TreeObject, IndirectObject, None] = None,
1791 before: Union[TreeObject, IndirectObject, None] = None,
1792 is_open: bool = True,
1793 ) -> IndirectObject:
1794 page_destination = cast(PageObject, page_destination.get_object())
1795 if isinstance(page_destination, PageObject):
1796 return self.add_outline_item_destination(
1797 Destination(
1798 f"page #{page_destination.page_number}",
1799 cast(IndirectObject, page_destination.indirect_reference),
1800 Fit.fit(),
1801 )
1802 )
1804 if parent is None:
1805 parent = self.get_outline_root()
1807 page_destination[NameObject("/%is_open%")] = BooleanObject(is_open)
1808 parent = cast(TreeObject, parent.get_object())
1809 page_destination_ref = self._add_object(page_destination)
1810 if before is not None:
1811 before = before.indirect_reference
1812 parent.insert_child(
1813 page_destination_ref,
1814 before,
1815 self,
1816 page_destination.inc_parent_counter_outline,
1817 )
1818 if "/Count" not in page_destination:
1819 page_destination[NameObject("/Count")] = NumberObject(0)
1821 return page_destination_ref
1823 def add_outline_item_dict(
1824 self,
1825 outline_item: OutlineItemType,
1826 parent: Union[TreeObject, IndirectObject, None] = None,
1827 before: Union[TreeObject, IndirectObject, None] = None,
1828 is_open: bool = True,
1829 ) -> IndirectObject:
1830 outline_item_object = TreeObject()
1831 outline_item_object.update(outline_item)
1833 """code currently unreachable
1834 if "/A" in outline_item:
1835 action = DictionaryObject()
1836 a_dict = cast(DictionaryObject, outline_item["/A"])
1837 for k, v in list(a_dict.items()):
1838 action[NameObject(str(k))] = v
1839 action_ref = self._add_object(action)
1840 outline_item_object[NameObject("/A")] = action_ref
1841 """
1842 return self.add_outline_item_destination(
1843 outline_item_object, parent, before, is_open
1844 )
1846 def add_outline_item(
1847 self,
1848 title: str,
1849 page_number: Union[PageObject, IndirectObject, int, None],
1850 parent: Union[TreeObject, IndirectObject, None] = None,
1851 before: Union[TreeObject, IndirectObject, None] = None,
1852 color: Optional[Union[tuple[float, float, float], str]] = None,
1853 bold: bool = False,
1854 italic: bool = False,
1855 fit: Fit = PAGE_FIT,
1856 is_open: bool = True,
1857 ) -> IndirectObject:
1858 """
1859 Add an outline item (commonly referred to as a "Bookmark") to the PDF file.
1861 Args:
1862 title: Title to use for this outline item.
1863 page_number: Page number this outline item will point to.
1864 parent: A reference to a parent outline item to create nested
1865 outline items.
1866 before:
1867 color: Color of the outline item's font as a red, green, blue tuple
1868 from 0.0 to 1.0 or as a Hex String (#RRGGBB)
1869 bold: Outline item font is bold
1870 italic: Outline item font is italic
1871 fit: The fit of the destination page.
1873 Returns:
1874 The added outline item as an indirect object.
1876 """
1877 page_ref: Union[NullObject, IndirectObject, NumberObject, None]
1878 if isinstance(italic, Fit): # it means that we are on the old params
1879 if fit is not None and page_number is None:
1880 page_number = fit
1881 return self.add_outline_item(
1882 title, page_number, parent, None, before, color, bold, italic, is_open=is_open
1883 )
1884 if page_number is None:
1885 action_ref = None
1886 else:
1887 if isinstance(page_number, IndirectObject):
1888 page_ref = page_number
1889 elif isinstance(page_number, PageObject):
1890 page_ref = page_number.indirect_reference
1891 elif isinstance(page_number, int):
1892 try:
1893 page_ref = self.pages[page_number].indirect_reference
1894 except IndexError:
1895 page_ref = NumberObject(page_number)
1896 else:
1897 raise TypeError(f"page_number: invalid type {type(page_number)}")
1898 if page_ref is None:
1899 logger_warning(
1900 "can not find reference of page %(page_number)s",
1901 source=__name__,
1902 page_number=page_number,
1903 )
1904 page_ref = NullObject()
1905 dest = Destination(
1906 NameObject("/" + title + " outline item"),
1907 page_ref,
1908 fit,
1909 )
1911 action_ref = self._add_object(
1912 DictionaryObject(
1913 {
1914 NameObject(GoToActionArguments.D): dest.dest_array,
1915 NameObject(GoToActionArguments.S): NameObject("/GoTo"),
1916 }
1917 )
1918 )
1919 outline_item = self._add_object(
1920 _create_outline_item(action_ref, title, color, italic, bold)
1921 )
1923 if parent is None:
1924 parent = self.get_outline_root()
1925 return self.add_outline_item_destination(outline_item, parent, before, is_open)
1927 def add_outline(self) -> None:
1928 raise NotImplementedError(
1929 "This method is not yet implemented. Use :meth:`add_outline_item` instead."
1930 )
1932 def add_named_destination_array(
1933 self, title: TextStringObject, destination: Union[IndirectObject, ArrayObject]
1934 ) -> None:
1935 named_dest = self.get_named_dest_root()
1936 i = 0
1937 while i < len(named_dest):
1938 if title < named_dest[i]:
1939 named_dest.insert(i, destination)
1940 named_dest.insert(i, TextStringObject(title))
1941 return
1942 i += 2
1943 named_dest.extend([TextStringObject(title), destination])
1944 return
1946 def add_named_destination_object(
1947 self,
1948 page_destination: PdfObject,
1949 ) -> IndirectObject:
1950 page_destination_ref = self._add_object(page_destination.dest_array) # type: ignore[attr-defined]
1951 self.add_named_destination_array(
1952 cast("TextStringObject", page_destination["/Title"]), page_destination_ref # type: ignore[index]
1953 )
1955 return page_destination_ref
1957 def _get_page_reference(self, page_number: int) -> Any:
1958 """Look up a page by number, reporting a bad index rather than an IndexError from the kids array."""
1959 pages = cast(DictionaryObject, self.get_object(self._pages))
1960 kids = cast(ArrayObject, pages[PagesAttributes.KIDS])
1961 count = len(kids)
1962 if not (-count <= page_number < count):
1963 raise IndexError(f"Page number {page_number} is out of range")
1964 return kids[page_number]
1966 def add_named_destination(
1967 self,
1968 title: str,
1969 page_number: int,
1970 fit: Optional[Fit] = None,
1971 ) -> IndirectObject:
1972 """
1973 Add a named destination to a page in this PDF.
1975 Args:
1976 title: Name of the destination.
1977 page_number: Index of the destination page, starting at zero.
1978 fit: How the destination is displayed.
1979 Defaults to fitting the page horizontally with its
1980 top edge at the top of the window.
1982 Returns:
1983 A reference to the destination's GoTo action.
1985 """
1986 page_ref = self._get_page_reference(page_number)
1987 if fit is None:
1988 top = cast(DictionaryObject, page_ref.get_object()).get(PG.MEDIABOX)
1989 top = RectangleObject(top).top if top is not None else 0
1990 fit = Fit.fit_horizontally(top=top)
1991 if not isinstance(title, TextStringObject):
1992 title = TextStringObject(str(title))
1993 dest = DictionaryObject()
1994 dest.update(
1995 {
1996 NameObject(GoToActionArguments.D): Destination(title, page_ref, fit).dest_array,
1997 NameObject(GoToActionArguments.S): NameObject("/GoTo"),
1998 }
1999 )
2001 dest_ref = self._add_object(dest)
2002 self.add_named_destination_array(title, dest_ref)
2003 return dest_ref
2005 def remove_links(self) -> None:
2006 """Remove links and annotations from this output."""
2007 for page in self.pages:
2008 self.remove_objects_from_page(page, ObjectDeletionFlag.ALL_ANNOTATIONS)
2010 def remove_annotations(
2011 self, subtypes: Optional[Union[AnnotationSubtype, Iterable[AnnotationSubtype]]]
2012 ) -> None:
2013 """
2014 Remove annotations by annotation subtype.
2016 Args:
2017 subtypes: subtype or list of subtypes to be removed.
2018 Examples are: "/Link", "/FileAttachment", "/Sound",
2019 "/Movie", "/Screen", ...
2020 If you want to remove all annotations, use subtypes=None.
2022 """
2023 for page in self.pages:
2024 self._remove_annots_from_page(page, subtypes)
2026 def _remove_annots_from_page(
2027 self,
2028 page: Union[IndirectObject, PageObject, DictionaryObject],
2029 subtypes: Optional[Iterable[str]],
2030 ) -> None:
2031 page = cast(DictionaryObject, page.get_object())
2032 if PG.ANNOTS in page:
2033 i = 0
2034 while i < len(cast(ArrayObject, page[PG.ANNOTS])):
2035 an = cast(ArrayObject, page[PG.ANNOTS])[i]
2036 obj = cast(DictionaryObject, an.get_object())
2037 if subtypes is None or cast(str, obj["/Subtype"]) in subtypes:
2038 if isinstance(an, IndirectObject):
2039 self._objects[an.idnum - 1] = NullObject() # to reduce PDF size
2040 del page[PG.ANNOTS][i] # type:ignore
2041 else:
2042 i += 1
2044 def remove_objects_from_page(
2045 self,
2046 page: Union[PageObject, DictionaryObject],
2047 to_delete: Union[ObjectDeletionFlag, Iterable[ObjectDeletionFlag]],
2048 text_filters: Optional[dict[str, Any]] = None
2049 ) -> None:
2050 """
2051 Remove objects specified by ``to_delete`` from the given page.
2053 Args:
2054 page: Page object to clean up.
2055 to_delete: Objects to be deleted; can be a ``ObjectDeletionFlag``
2056 or a list of ObjectDeletionFlag
2057 text_filters: Properties of text to be deleted, if applicable. Optional.
2058 This is a Python dictionary with the following properties:
2060 * font_ids: List of font resource IDs (such as /F1 or /T1_0) to be deleted.
2062 """
2063 if isinstance(to_delete, (list, tuple)):
2064 for to_d in to_delete:
2065 self.remove_objects_from_page(page, to_d)
2066 return None
2067 assert isinstance(to_delete, ObjectDeletionFlag)
2069 if to_delete & ObjectDeletionFlag.LINKS:
2070 return self._remove_annots_from_page(page, ("/Link",))
2071 if to_delete & ObjectDeletionFlag.ATTACHMENTS:
2072 return self._remove_annots_from_page(
2073 page, ("/FileAttachment", "/Sound", "/Movie", "/Screen")
2074 )
2075 if to_delete & ObjectDeletionFlag.OBJECTS_3D:
2076 return self._remove_annots_from_page(page, ("/3D",))
2077 if to_delete & ObjectDeletionFlag.ALL_ANNOTATIONS:
2078 return self._remove_annots_from_page(page, None)
2080 jump_operators = []
2081 if to_delete & ObjectDeletionFlag.DRAWING_IMAGES:
2082 jump_operators = [
2083 b"w", b"J", b"j", b"M", b"d", b"i",
2084 b"W", b"W*",
2085 b"b", b"b*", b"B", b"B*", b"S", b"s", b"f", b"f*", b"F", b"n",
2086 b"m", b"l", b"c", b"v", b"y", b"h", b"re",
2087 b"sh"
2088 ]
2089 if to_delete & ObjectDeletionFlag.TEXT:
2090 jump_operators = [b"Tj", b"TJ", b"'", b'"']
2092 if not isinstance(page, PageObject):
2093 page = PageObject(self, page.indirect_reference) # pragma: no cover
2094 if "/Contents" in page:
2095 content = cast(ContentStream, page.get_contents())
2097 images, forms = self._remove_objects_from_page__clean_forms(
2098 elt=page, stack=[], jump_operators=jump_operators, to_delete=to_delete, text_filters=text_filters,
2099 )
2101 self._remove_objects_from_page__clean(
2102 content=content, images=images, forms=forms,
2103 jump_operators=jump_operators, to_delete=to_delete,
2104 text_filters=text_filters
2105 )
2106 page.replace_contents(content)
2107 return None
2109 def _remove_objects_from_page__clean(
2110 self,
2111 content: ContentStream,
2112 images: list[str],
2113 forms: list[str],
2114 jump_operators: list[bytes],
2115 to_delete: ObjectDeletionFlag,
2116 text_filters: Optional[dict[str, Any]] = None,
2117 ) -> None:
2118 font_id = None
2119 font_ids_to_delete = []
2120 if text_filters and to_delete & ObjectDeletionFlag.TEXT:
2121 font_ids_to_delete = text_filters.get("font_ids", [])
2123 i = 0
2124 while i < len(content.operations):
2125 operands, operator = content.operations[i]
2126 if operator == b"Tf":
2127 font_id = operands[0]
2128 if (
2129 (
2130 operator == b"INLINE IMAGE"
2131 and (to_delete & ObjectDeletionFlag.INLINE_IMAGES)
2132 )
2133 or (operator in jump_operators)
2134 or (
2135 operator == b"Do"
2136 and (to_delete & ObjectDeletionFlag.XOBJECT_IMAGES)
2137 and (operands[0] in images)
2138 )
2139 ):
2140 if (
2141 not to_delete & ObjectDeletionFlag.TEXT
2142 or (to_delete & ObjectDeletionFlag.TEXT and not text_filters)
2143 or (to_delete & ObjectDeletionFlag.TEXT and font_id in font_ids_to_delete)
2144 ):
2145 del content.operations[i]
2146 else:
2147 i += 1
2148 else:
2149 i += 1
2150 content.get_data() # this ensures ._data is rebuilt from the .operations
2152 def _remove_objects_from_page__clean_forms(
2153 self,
2154 elt: DictionaryObject,
2155 stack: list[DictionaryObject],
2156 jump_operators: list[bytes],
2157 to_delete: ObjectDeletionFlag,
2158 text_filters: Optional[dict[str, Any]] = None,
2159 ) -> tuple[list[str], list[str]]:
2160 # elt in recursive call is a new ContentStream object, so we have to check the indirect_reference
2161 if (elt in stack) or (
2162 hasattr(elt, "indirect_reference") and any(
2163 elt.indirect_reference == getattr(x, "indirect_reference", -1)
2164 for x in stack
2165 )
2166 ):
2167 # to prevent infinite looping
2168 return [], [] # pragma: no cover
2169 try:
2170 d = cast(
2171 dict[Any, Any],
2172 cast(DictionaryObject, elt["/Resources"])["/XObject"],
2173 )
2174 except KeyError:
2175 d = {}
2176 images = []
2177 forms = []
2178 for k, v in d.items():
2179 o = v.get_object()
2180 try:
2181 content: Any = None
2182 if (
2183 to_delete & ObjectDeletionFlag.XOBJECT_IMAGES
2184 and o["/Subtype"] == "/Image"
2185 ):
2186 content = NullObject() # to delete the image keeping the entry
2187 images.append(k)
2188 if o["/Subtype"] == "/Form":
2189 forms.append(k)
2190 if isinstance(o, ContentStream):
2191 content = o
2192 else:
2193 content = ContentStream(o, self)
2194 content.update(
2195 {
2196 k1: v1
2197 for k1, v1 in o.items()
2198 if k1 not in ["/Length", "/Filter", "/DecodeParms"]
2199 }
2200 )
2201 try:
2202 content.indirect_reference = o.indirect_reference
2203 except AttributeError: # pragma: no cover
2204 pass
2205 stack.append(elt)
2207 # clean subforms
2208 self._remove_objects_from_page__clean_forms(
2209 elt=content, stack=stack, jump_operators=jump_operators, to_delete=to_delete,
2210 text_filters=text_filters,
2211 )
2212 if content is not None:
2213 if isinstance(v, IndirectObject):
2214 self._objects[v.idnum - 1] = content
2215 else:
2216 # should only occur in a PDF not respecting PDF spec
2217 # where streams must be indirected.
2218 d[k] = self._add_object(content) # pragma: no cover
2219 except (TypeError, KeyError):
2220 pass
2221 for im in images:
2222 del d[im] # for clean-up
2223 if isinstance(elt, StreamObject): # for /Form
2224 if not isinstance(elt, ContentStream): # pragma: no cover
2225 e = ContentStream(elt, self)
2226 e.update(elt.items())
2227 elt = e
2228 # clean the content
2229 self._remove_objects_from_page__clean(
2230 content=elt, images=images, forms=forms, jump_operators=jump_operators,
2231 to_delete=to_delete, text_filters=text_filters
2232 )
2233 return images, forms
2235 def remove_images(
2236 self,
2237 to_delete: ImageType = ImageType.ALL,
2238 ) -> None:
2239 """
2240 Remove images from this output.
2242 Args:
2243 to_delete: The type of images to be deleted
2244 (default = all images types)
2246 """
2247 if isinstance(to_delete, bool):
2248 to_delete = ImageType.ALL
2250 i = ObjectDeletionFlag.NONE
2252 for image in ("XOBJECT_IMAGES", "INLINE_IMAGES", "DRAWING_IMAGES"):
2253 if to_delete & ImageType[image]:
2254 i |= ObjectDeletionFlag[image]
2256 for page in self.pages:
2257 self.remove_objects_from_page(page, i)
2259 def remove_text(self, font_names: Optional[list[str]] = None) -> None:
2260 """
2261 Remove text from the PDF.
2263 Args:
2264 font_names: List of font names to remove, such as "Helvetica-Bold".
2265 Optional. If not specified, all text will be removed.
2266 """
2267 if not font_names:
2268 font_names = []
2270 for page in self.pages:
2271 resource_ids_to_remove = []
2273 # Content streams reference fonts and other resources with names like "/F1" or "/T1_0"
2274 # Font names need to be converted to resource names/IDs for easier removal
2275 if font_names:
2276 # Recursively loop through page objects to gather font info
2277 def get_font_info(
2278 obj: Any,
2279 font_info: Optional[dict[str, Any]] = None,
2280 key: Optional[str] = None
2281 ) -> dict[str, Any]:
2282 if font_info is None:
2283 font_info = {}
2284 if isinstance(obj, IndirectObject):
2285 obj = obj.get_object()
2286 if isinstance(obj, dict):
2287 if obj.get("/Type") == "/Font":
2288 font_name = obj.get("/BaseFont", "")
2289 # Normalize font names like "/RRXFFV+Palatino-Bold" to "Palatino-Bold"
2290 normalized_font_name = font_name.lstrip("/").split("+")[-1]
2291 if normalized_font_name not in font_info:
2292 font_info[normalized_font_name] = {
2293 "normalized_font_name": normalized_font_name,
2294 "resource_ids": [],
2295 }
2296 if key not in font_info[normalized_font_name]["resource_ids"]:
2297 font_info[normalized_font_name]["resource_ids"].append(key)
2298 for k in obj:
2299 font_info = get_font_info(obj[k], font_info, k)
2300 elif isinstance(obj, (list, ArrayObject)):
2301 for child_obj in obj:
2302 font_info = get_font_info(child_obj, font_info)
2303 return font_info
2305 # Add relevant resource names for removal
2306 font_info = get_font_info(page.get("/Resources"))
2307 for font_name in font_names:
2308 if font_name in font_info:
2309 resource_ids_to_remove.extend(font_info[font_name]["resource_ids"])
2311 text_filters = {}
2312 if font_names:
2313 text_filters["font_ids"] = resource_ids_to_remove
2314 self.remove_objects_from_page(page, ObjectDeletionFlag.TEXT, text_filters=text_filters)
2316 def add_uri(
2317 self,
2318 page_number: int,
2319 uri: str,
2320 rect: RectangleObject,
2321 border: Optional[Sequence[Any]] = None,
2322 ) -> None:
2323 """
2324 Add an URI from a rectangular area to the specified page.
2326 Args:
2327 page_number: index of the page on which to place the URI action.
2328 uri: URI of resource to link to.
2329 rect: :class:`RectangleObject<pypdf.generic.RectangleObject>` or
2330 array of four integers specifying the clickable rectangular area
2331 ``[xLL, yLL, xUR, yUR]``, or string in the form
2332 ``"[ xLL yLL xUR yUR ]"``.
2333 border: if provided, an array describing border-drawing
2334 properties. See the PDF spec for details. No border will be
2335 drawn if this argument is omitted.
2337 """
2338 page_link = self._get_page_reference(page_number)
2339 page_ref = cast(dict[str, Any], self.get_object(page_link))
2341 border_arr: BorderArrayType
2342 if border is not None:
2343 border_arr = [NumberObject(n) for n in border[:3]]
2344 if len(border) == 4:
2345 dash_pattern = ArrayObject([NumberObject(n) for n in border[3]])
2346 border_arr.append(dash_pattern)
2347 else:
2348 border_arr = [NumberObject(2), NumberObject(2), NumberObject(2)]
2350 if isinstance(rect, str):
2351 coords = [float(n) for n in rect.strip().strip("[]").split()]
2352 rect = RectangleObject(coords)
2353 elif isinstance(rect, RectangleObject):
2354 pass
2355 else:
2356 rect = RectangleObject(rect)
2358 lnk2 = DictionaryObject()
2359 lnk2.update(
2360 {
2361 NameObject("/S"): NameObject("/URI"),
2362 NameObject("/URI"): TextStringObject(uri),
2363 }
2364 )
2365 lnk = DictionaryObject()
2366 lnk.update(
2367 {
2368 NameObject(AA.Type): NameObject("/Annot"),
2369 NameObject(AA.Subtype): NameObject("/Link"),
2370 NameObject(AA.P): page_link,
2371 NameObject(AA.Rect): rect,
2372 NameObject("/H"): NameObject("/I"),
2373 NameObject(AA.Border): ArrayObject(border_arr),
2374 NameObject("/A"): lnk2,
2375 }
2376 )
2377 lnk_ref = self._add_object(lnk)
2379 if PG.ANNOTS in page_ref:
2380 page_ref[PG.ANNOTS].append(lnk_ref)
2381 else:
2382 page_ref[NameObject(PG.ANNOTS)] = ArrayObject([lnk_ref])
2384 _valid_layouts = (
2385 "/NoLayout",
2386 "/SinglePage",
2387 "/OneColumn",
2388 "/TwoColumnLeft",
2389 "/TwoColumnRight",
2390 "/TwoPageLeft",
2391 "/TwoPageRight",
2392 )
2394 def _get_page_layout(self) -> Optional[LayoutType]:
2395 try:
2396 return cast(LayoutType, self._root_object["/PageLayout"])
2397 except KeyError:
2398 return None
2400 def _set_page_layout(self, layout: Union[NameObject, LayoutType]) -> None:
2401 """
2402 Set the page layout.
2404 Args:
2405 layout: The page layout to be used.
2407 .. list-table:: Valid ``layout`` arguments
2408 :widths: 50 200
2410 * - /NoLayout
2411 - Layout explicitly not specified
2412 * - /SinglePage
2413 - Show one page at a time
2414 * - /OneColumn
2415 - Show one column at a time
2416 * - /TwoColumnLeft
2417 - Show pages in two columns, odd-numbered pages on the left
2418 * - /TwoColumnRight
2419 - Show pages in two columns, odd-numbered pages on the right
2420 * - /TwoPageLeft
2421 - Show two pages at a time, odd-numbered pages on the left
2422 * - /TwoPageRight
2423 - Show two pages at a time, odd-numbered pages on the right
2425 """
2426 if not isinstance(layout, NameObject):
2427 if layout not in self._valid_layouts:
2428 logger_warning(
2429 "Layout should be one of: %(layouts)s",
2430 source=__name__,
2431 layouts=", ".join(self._valid_layouts),
2432 )
2433 layout = NameObject(layout)
2434 self._root_object.update({NameObject("/PageLayout"): layout})
2436 def set_page_layout(self, layout: LayoutType) -> None:
2437 """
2438 Set the page layout.
2440 Args:
2441 layout: The page layout to be used
2443 .. list-table:: Valid ``layout`` arguments
2444 :widths: 50 200
2446 * - /NoLayout
2447 - Layout explicitly not specified
2448 * - /SinglePage
2449 - Show one page at a time
2450 * - /OneColumn
2451 - Show one column at a time
2452 * - /TwoColumnLeft
2453 - Show pages in two columns, odd-numbered pages on the left
2454 * - /TwoColumnRight
2455 - Show pages in two columns, odd-numbered pages on the right
2456 * - /TwoPageLeft
2457 - Show two pages at a time, odd-numbered pages on the left
2458 * - /TwoPageRight
2459 - Show two pages at a time, odd-numbered pages on the right
2461 """
2462 self._set_page_layout(layout)
2464 @property
2465 def page_layout(self) -> Optional[LayoutType]:
2466 """
2467 Page layout property.
2469 .. list-table:: Valid ``layout`` values
2470 :widths: 50 200
2472 * - /NoLayout
2473 - Layout explicitly not specified
2474 * - /SinglePage
2475 - Show one page at a time
2476 * - /OneColumn
2477 - Show one column at a time
2478 * - /TwoColumnLeft
2479 - Show pages in two columns, odd-numbered pages on the left
2480 * - /TwoColumnRight
2481 - Show pages in two columns, odd-numbered pages on the right
2482 * - /TwoPageLeft
2483 - Show two pages at a time, odd-numbered pages on the left
2484 * - /TwoPageRight
2485 - Show two pages at a time, odd-numbered pages on the right
2486 """
2487 return self._get_page_layout()
2489 @page_layout.setter
2490 def page_layout(self, layout: LayoutType) -> None:
2491 self._set_page_layout(layout)
2493 _valid_modes = (
2494 "/UseNone",
2495 "/UseOutlines",
2496 "/UseThumbs",
2497 "/FullScreen",
2498 "/UseOC",
2499 "/UseAttachments",
2500 )
2502 def _get_page_mode(self) -> Optional[PagemodeType]:
2503 try:
2504 return cast(PagemodeType, self._root_object["/PageMode"])
2505 except KeyError:
2506 return None
2508 @property
2509 def page_mode(self) -> Optional[PagemodeType]:
2510 """
2511 Page mode property.
2513 .. list-table:: Valid ``mode`` values
2514 :widths: 50 200
2516 * - /UseNone
2517 - Do not show outline or thumbnails panels
2518 * - /UseOutlines
2519 - Show outline (aka bookmarks) panel
2520 * - /UseThumbs
2521 - Show page thumbnails panel
2522 * - /FullScreen
2523 - Fullscreen view
2524 * - /UseOC
2525 - Show Optional Content Group (OCG) panel
2526 * - /UseAttachments
2527 - Show attachments panel
2528 """
2529 return self._get_page_mode()
2531 @page_mode.setter
2532 def page_mode(self, mode: PagemodeType) -> None:
2533 if isinstance(mode, NameObject):
2534 mode_name: NameObject = mode
2535 else:
2536 if mode not in self._valid_modes:
2537 logger_warning(
2538 "Mode should be one of: %(modes)s",
2539 source=__name__,
2540 modes=", ".join(self._valid_modes),
2541 )
2542 mode_name = NameObject(mode)
2543 self._root_object.update({NameObject("/PageMode"): mode_name})
2545 def add_annotation(
2546 self,
2547 page_number: Union[int, PageObject],
2548 annotation: dict[str, Any],
2549 ) -> DictionaryObject:
2550 """
2551 Add a single annotation to the page.
2552 The added annotation must be a new annotation.
2553 It cannot be recycled.
2555 Args:
2556 page_number: PageObject or page index.
2557 annotation: Annotation to be added (created with annotation).
2559 Returns:
2560 The inserted object.
2561 This can be used for popup creation, for example.
2563 """
2564 page = page_number
2565 if isinstance(page, int):
2566 page = self.pages[page]
2567 elif not isinstance(page, PageObject):
2568 raise TypeError("page: invalid type")
2570 to_add = cast(DictionaryObject, _pdf_objectify(annotation))
2571 to_add[NameObject("/P")] = page.indirect_reference
2573 if page.annotations is None:
2574 page[NameObject("/Annots")] = ArrayObject()
2575 assert page.annotations is not None
2577 # Resolve pypdf's intermediate internal-link destination.
2578 destination = to_add.get("/Dest")
2579 if (
2580 to_add.get("/Subtype") == "/Link"
2581 and isinstance(destination, DictionaryObject)
2582 and "target_page_index" in destination
2583 ):
2584 target_page_reference: Union[IndirectObject, NumberObject]
2585 target_page_index = cast(int, destination["target_page_index"])
2586 fit_type = cast(str, destination["fit"])
2587 # Work around the intermediate destination containing native Python
2588 # objects instead of PdfObject instances.
2589 fit_args = cast(Sequence[Any], dict(destination)["fit_args"])
2591 if 0 <= target_page_index < len(self.pages):
2592 target_page_reference = cast(
2593 IndirectObject,
2594 self.pages[target_page_index].indirect_reference,
2595 )
2596 else:
2597 # Preserve support for referencing a page that may be added
2598 # later. See #2450.
2599 target_page_reference = NumberObject(target_page_index)
2601 to_add[NameObject("/Dest")] = Destination(
2602 NameObject("/LinkName"),
2603 target_page_reference,
2604 Fit(fit_type=fit_type, fit_args=fit_args),
2605 ).dest_array
2607 page.annotations.append(self._add_object(to_add))
2609 if to_add.get("/Subtype") == "/Popup" and NameObject("/Parent") in to_add:
2610 cast(DictionaryObject, to_add["/Parent"].get_object())[
2611 NameObject("/Popup")
2612 ] = to_add.indirect_reference
2614 return to_add
2616 def clean_page(self, page: Union[PageObject, IndirectObject]) -> PageObject:
2617 """
2618 Perform some clean up in the page.
2619 Currently: convert NameObject named destination to TextStringObject
2620 (required for names/dests list)
2622 Args:
2623 page:
2625 Returns:
2626 The cleaned PageObject
2628 """
2629 page = cast("PageObject", page.get_object())
2630 for a in page.get("/Annots", []):
2631 a_obj = a.get_object()
2632 d = a_obj.get("/Dest", None)
2633 act = a_obj.get("/A", None)
2634 if isinstance(d, NameObject):
2635 a_obj[NameObject("/Dest")] = TextStringObject(d)
2636 elif act is not None:
2637 act = act.get_object()
2638 d = act.get("/D", None)
2639 if isinstance(d, NameObject):
2640 act[NameObject("/D")] = TextStringObject(d)
2641 return page
2643 def _create_stream(
2644 self, fileobj: Union[Path, StrByteType, PdfReader]
2645 ) -> tuple[IOBase, Optional[Encryption]]:
2646 # If the fileobj parameter is a string, assume it is a path
2647 # and create a file object at that location. If it is a file,
2648 # copy the file's contents into a BytesIO stream object; if
2649 # it is a PdfReader, copy that reader's stream into a
2650 # BytesIO stream.
2651 # If fileobj is none of the above types, it is not modified
2652 encryption_obj = None
2653 stream: IOBase
2654 if isinstance(fileobj, (str, Path)):
2655 with FileIO(fileobj, "rb") as f:
2656 stream = BytesIO(f.read())
2657 elif isinstance(fileobj, PdfReader):
2658 if fileobj._encryption:
2659 encryption_obj = fileobj._encryption
2660 orig_tell = fileobj.stream.tell()
2661 fileobj.stream.seek(0)
2662 stream = BytesIO(fileobj.stream.read())
2664 # reset the stream to its original location
2665 fileobj.stream.seek(orig_tell)
2666 elif hasattr(fileobj, "seek") and hasattr(fileobj, "read"):
2667 fileobj.seek(0)
2668 filecontent = fileobj.read()
2669 stream = BytesIO(filecontent)
2670 else:
2671 raise NotImplementedError(
2672 "Merging requires an object that PdfReader can parse. "
2673 "Typically, that is a Path or a string representing a Path, "
2674 "a file object, or an object implementing .seek and .read. "
2675 "Passing a PdfReader directly works as well."
2676 )
2677 return stream, encryption_obj
2679 def append(
2680 self,
2681 fileobj: Union[StrByteType, PdfReader, Path],
2682 outline_item: Union[str, PageRange, tuple[int, int], tuple[int, int, int], list[int], None] = None,
2683 pages: Union[PageRange, tuple[int, int], tuple[int, int, int], list[int], list[PageObject], None] = None,
2684 import_outline: bool = True,
2685 excluded_fields: Optional[Union[list[str], tuple[str, ...]]] = None,
2686 ) -> None:
2687 """
2688 Identical to the :meth:`merge()<merge>` method, but assumes you want to
2689 concatenate all pages onto the end of the file instead of specifying a
2690 position.
2692 Args:
2693 fileobj: A File Object or an object that supports the standard
2694 read and seek methods similar to a File Object. Could also be a
2695 string representing a path to a PDF file.
2696 outline_item: Optionally, you may specify a string to build an
2697 outline (aka 'bookmark') to identify the beginning of the
2698 included file.
2699 pages: Can be a :class:`PageRange<pypdf.pagerange.PageRange>`
2700 or a ``(start, stop[, step])`` tuple
2701 or a list of pages to be processed
2702 to merge only the specified range of pages from the source
2703 document into the output document.
2704 import_outline: You may prevent the source document's
2705 outline (collection of outline items, previously referred to as
2706 'bookmarks') from being imported by specifying this as ``False``.
2707 excluded_fields: Provide the list of fields/keys to be ignored
2708 if ``/Annots`` is part of the list, the annotation will be ignored
2709 if ``/B`` is part of the list, the articles will be ignored
2711 """
2712 if excluded_fields is None:
2713 excluded_fields = ()
2714 if isinstance(outline_item, (tuple, list, PageRange)):
2715 if isinstance(pages, bool):
2716 if not isinstance(import_outline, bool):
2717 excluded_fields = import_outline
2718 import_outline = pages
2719 pages = outline_item
2720 self.merge(
2721 None,
2722 fileobj,
2723 None,
2724 pages,
2725 import_outline,
2726 excluded_fields,
2727 )
2728 else: # if isinstance(outline_item, str):
2729 self.merge(
2730 None,
2731 fileobj,
2732 outline_item,
2733 pages,
2734 import_outline,
2735 excluded_fields,
2736 )
2738 def merge(
2739 self,
2740 position: Optional[int],
2741 fileobj: Union[Path, StrByteType, PdfReader],
2742 outline_item: Optional[str] = None,
2743 pages: Optional[Union[PageRangeSpec, list[PageObject]]] = None,
2744 import_outline: bool = True,
2745 excluded_fields: Optional[Union[list[str], tuple[str, ...]]] = (),
2746 ) -> None:
2747 """
2748 Merge the pages from the given file into the output file at the
2749 specified page number.
2751 Args:
2752 position: The *page number* to insert this file. File will
2753 be inserted after the given number.
2754 fileobj: A File Object or an object that supports the standard
2755 read and seek methods similar to a File Object. Could also be a
2756 string representing a path to a PDF file.
2757 outline_item: Optionally, you may specify a string to build an outline
2758 (aka 'bookmark') to identify the
2759 beginning of the included file.
2760 pages: can be a :class:`PageRange<pypdf.pagerange.PageRange>`
2761 or a ``(start, stop[, step])`` tuple
2762 or a list of pages to be processed
2763 to merge only the specified range of pages from the source
2764 document into the output document.
2765 import_outline: You may prevent the source document's
2766 outline (collection of outline items, previously referred to as
2767 'bookmarks') from being imported by specifying this as ``False``.
2768 excluded_fields: provide the list of fields/keys to be ignored
2769 if ``/Annots`` is part of the list, the annotation will be ignored
2770 if ``/B`` is part of the list, the articles will be ignored
2772 Raises:
2773 TypeError: The pages attribute is not configured properly
2775 """
2776 if isinstance(fileobj, PdfDocCommon):
2777 reader = fileobj
2778 else:
2779 stream, _encryption_obj = self._create_stream(fileobj)
2780 # Create a new PdfReader instance using the stream
2781 # (either file or BytesIO or StringIO) created above
2782 reader = PdfReader(stream, strict=False) # type: ignore[arg-type]
2784 if excluded_fields is None:
2785 excluded_fields = ()
2786 # Find the range of pages to merge.
2787 if pages is None:
2788 pages = list(range(len(reader.pages)))
2789 elif isinstance(pages, PageRange):
2790 pages = list(range(*pages.indices(len(reader.pages))))
2791 elif isinstance(pages, list):
2792 pass # keep unchanged
2793 elif isinstance(pages, tuple) and len(pages) <= 3:
2794 pages = list(range(*pages))
2795 elif not isinstance(pages, tuple):
2796 raise TypeError(
2797 '"pages" must be a tuple of (start, stop[, step]) or a list'
2798 )
2800 srcpages = {}
2801 for page in pages:
2802 if isinstance(page, PageObject):
2803 pg = page
2804 else:
2805 pg = reader.pages[page]
2806 assert pg.indirect_reference is not None
2807 if position is None:
2808 # numbers in the exclude list identifies that the exclusion is
2809 # only applicable to 1st level of cloning
2810 srcpages[pg.indirect_reference.idnum] = self.add_page(
2811 pg, [*list(excluded_fields), 1, "/B", 1, "/Annots"] # type: ignore[list-item]
2812 )
2813 else:
2814 srcpages[pg.indirect_reference.idnum] = self.insert_page(
2815 pg, position, [*list(excluded_fields), 1, "/B", 1, "/Annots"] # type: ignore[list-item]
2816 )
2817 position += 1
2818 srcpages[pg.indirect_reference.idnum].original_page = pg
2820 reader._named_destinations = (
2821 reader.named_destinations
2822 ) # need for the outline processing below
2824 arr: Any
2826 for dest in reader._named_destinations.values():
2827 self._merge__process_named_dests(dest=dest, reader=reader, srcpages=srcpages)
2829 outline_item_typ: TreeObject
2830 if outline_item is not None:
2831 outline_item_typ = cast(
2832 "TreeObject",
2833 self.add_outline_item(
2834 TextStringObject(outline_item),
2835 next(iter(srcpages.values())).indirect_reference,
2836 fit=PAGE_FIT,
2837 ).get_object(),
2838 )
2839 else:
2840 outline_item_typ = self.get_outline_root()
2842 _ro = reader.root_object
2843 if import_outline and Core.OUTLINES in _ro:
2844 outline = self._get_filtered_outline(
2845 node=_ro.get(Core.OUTLINES, None), pages=srcpages, reader=reader
2846 )
2847 self._insert_filtered_outline(
2848 outline, outline_item_typ, None
2849 ) # TODO: use before parameter
2851 if "/Annots" not in excluded_fields:
2852 parent_fields: dict[int, DictionaryObject] = {}
2853 for pag in srcpages.values():
2854 lst = self._insert_filtered_annotations(
2855 annots=pag.original_page.get("/Annots", []),
2856 page=pag,
2857 pages=srcpages,
2858 reader=reader,
2859 parent_fields=parent_fields,
2860 )
2861 if len(lst) > 0:
2862 pag[NameObject("/Annots")] = lst
2863 self.clean_page(pag)
2864 self._set_cloned_kids(parent_fields, reader)
2866 if "/AcroForm" in _ro and not is_null_or_none(_ro["/AcroForm"]):
2867 if "/AcroForm" not in self._root_object:
2868 self._root_object[NameObject("/AcroForm")] = self._add_object(
2869 cast(
2870 DictionaryObject,
2871 reader.root_object["/AcroForm"],
2872 ).clone(self, False, ("/Fields",))
2873 )
2874 arr = ArrayObject()
2875 else:
2876 arr = cast(
2877 ArrayObject,
2878 cast(DictionaryObject, self._root_object["/AcroForm"])["/Fields"],
2879 )
2880 trslat = self._id_translated[id(reader)]
2881 try:
2882 for f in reader.root_object["/AcroForm"]["/Fields"]: # type: ignore[index]
2883 try:
2884 ind = IndirectObject(trslat[f.idnum], 0, self)
2885 if ind not in arr:
2886 arr.append(ind)
2887 except KeyError:
2888 # for trslat[] which mean the field has not be copied
2889 # through the page
2890 pass
2891 except KeyError: # for /Acroform or /Fields are not existing
2892 arr = self._add_object(ArrayObject())
2893 cast(DictionaryObject, self._root_object["/AcroForm"])[
2894 NameObject("/Fields")
2895 ] = arr
2897 if "/B" not in excluded_fields:
2898 self.add_filtered_articles("", srcpages, reader)
2900 def _merge__process_named_dests(self, dest: Any, reader: PdfDocCommon, srcpages: dict[int, PageObject]) -> None:
2901 arr: Any = dest.dest_array
2902 if "/Names" in self._root_object and dest["/Title"] in cast(
2903 list[Any],
2904 cast(
2905 DictionaryObject,
2906 cast(DictionaryObject, self._root_object["/Names"]).get("/Dests", DictionaryObject()),
2907 ).get("/Names", DictionaryObject()),
2908 ):
2909 # already exists: should not duplicate it
2910 pass
2911 elif dest["/Page"] is None or isinstance(dest["/Page"], NullObject):
2912 pass
2913 elif isinstance(dest["/Page"], int):
2914 # the page reference is a page number normally not a PDF Reference
2915 # page numbers as int are normally accepted only in external goto
2916 try:
2917 p = reader.pages[dest["/Page"]]
2918 except IndexError:
2919 return
2920 assert p.indirect_reference is not None
2921 try:
2922 arr[NumberObject(0)] = NumberObject(
2923 srcpages[p.indirect_reference.idnum].page_number
2924 )
2925 self.add_named_destination_array(dest["/Title"], arr)
2926 except KeyError:
2927 pass
2928 elif dest["/Page"].indirect_reference.idnum in srcpages:
2929 arr[NumberObject(0)] = srcpages[
2930 dest["/Page"].indirect_reference.idnum
2931 ].indirect_reference
2932 self.add_named_destination_array(dest["/Title"], arr)
2934 def _add_articles_thread(
2935 self,
2936 thread: DictionaryObject,
2937 pages: dict[int, PageObject],
2938 reader: PdfReader,
2939 ) -> IndirectObject:
2940 """
2941 Clone the thread with only the applicable articles.
2943 Args:
2944 thread: Thread entry from the reader's array of threads
2945 pages: Mapping of object numbers to page objects.
2946 reader: The corresponding reader.
2948 Returns:
2949 The added thread as an indirect reference
2951 """
2952 new_thread = thread.clone(
2953 self, force_duplicate=True, ignore_fields=("/F",)
2954 ) # use of clone to keep link between reader and writer
2955 self.threads.append(new_thread.indirect_reference)
2956 first_article = cast("DictionaryObject", thread["/F"])
2957 current_article: Optional[DictionaryObject] = first_article
2958 new_article: Optional[DictionaryObject] = None
2960 visited: set[int] = set()
2961 while current_article is not None:
2962 article_id = id(current_article)
2963 if article_id in visited:
2964 raise LimitReachedError("Detected cyclic article structure.")
2965 visited.add(article_id)
2967 page = self._get_cloned_page(
2968 cast("PageObject", current_article["/P"]), pages, reader
2969 )
2970 if page is not None:
2971 if new_article is None:
2972 new_article = cast(
2973 "DictionaryObject",
2974 self._add_object(DictionaryObject()).get_object(),
2975 )
2976 new_first = new_article
2977 new_thread[NameObject("/F")] = new_article.indirect_reference
2978 else:
2979 new_article2 = cast(
2980 "DictionaryObject",
2981 self._add_object(
2982 DictionaryObject(
2983 {NameObject("/V"): new_article.indirect_reference}
2984 )
2985 ).get_object(),
2986 )
2987 new_article[NameObject("/N")] = new_article2.indirect_reference
2988 new_article = new_article2
2989 new_article[NameObject("/P")] = page
2990 new_article[NameObject("/T")] = new_thread.indirect_reference
2991 new_article[NameObject("/R")] = current_article["/R"]
2992 page_object = cast("PageObject", page.get_object())
2993 if "/B" not in page_object:
2994 page_object[NameObject("/B")] = ArrayObject()
2995 cast("ArrayObject", page_object["/B"]).append(
2996 new_article.indirect_reference
2997 )
2999 current_article = cast("DictionaryObject", current_article["/N"])
3000 if current_article == first_article:
3001 new_article[NameObject("/N")] = new_first.indirect_reference # type: ignore[index]
3002 new_first[NameObject("/V")] = new_article.indirect_reference # type: ignore[union-attr]
3003 current_article = None
3005 assert new_thread.indirect_reference is not None
3006 return new_thread.indirect_reference
3008 def add_filtered_articles(
3009 self,
3010 fltr: Union[
3011 Pattern[Any], str
3012 ], # thread entry from the reader's array of threads
3013 pages: dict[int, PageObject],
3014 reader: PdfReader,
3015 ) -> None:
3016 """
3017 Add articles matching the defined criteria.
3019 Args:
3020 fltr:
3021 pages:
3022 reader:
3024 """
3025 if isinstance(fltr, str):
3026 fltr = re.compile(fltr)
3027 elif not isinstance(fltr, Pattern):
3028 fltr = re.compile("")
3029 for p in pages.values():
3030 pp = p.original_page
3031 for a in pp.get("/B", ()):
3032 a_obj = a.get_object()
3033 if is_null_or_none(a_obj):
3034 continue
3035 thr = a_obj.get("/T")
3036 if thr is None:
3037 continue
3038 thr = thr.get_object()
3039 if thr.indirect_reference.idnum not in self._id_translated[
3040 id(reader)
3041 ] and fltr.search((thr.get("/I", {})).get("/Title", "")):
3042 self._add_articles_thread(thr, pages, reader)
3044 def _get_cloned_page(
3045 self,
3046 page: Union[IndirectObject, PageObject, NullObject, int, None],
3047 pages: dict[int, PageObject],
3048 reader: PdfReader,
3049 ) -> Optional[IndirectObject]:
3050 if isinstance(page, NullObject):
3051 return None
3052 if isinstance(page, int):
3053 # An explicit destination may reference the target page by its
3054 # (zero-based) index in the source document rather than by an
3055 # indirect reference; this is what `Link(target_page_index=...)`
3056 # produces. Resolve the index through the reader's page list so it
3057 # can be remapped like a regular page reference. A destination that
3058 # points past the end of the source document is dropped.
3059 try:
3060 page = reader.pages[page].indirect_reference
3061 except IndexError:
3062 return None
3063 if isinstance(page, DictionaryObject) and page.get("/Type", "") == "/Page":
3064 _i = page.indirect_reference
3065 elif isinstance(page, IndirectObject):
3066 _i = page
3067 try:
3068 return pages[_i.idnum].indirect_reference # type: ignore[union-attr]
3069 except Exception:
3070 return None
3072 def _insert_filtered_annotations(
3073 self,
3074 *,
3075 annots: Union[IndirectObject, list[PdfObject], None],
3076 page: PageObject,
3077 pages: dict[int, PageObject],
3078 reader: PdfReader,
3079 parent_fields: Optional[dict[int, DictionaryObject]] = None,
3080 ) -> list[Destination]:
3081 outlist = ArrayObject()
3082 if isinstance(annots, IndirectObject):
3083 annots = cast("list[Any]", annots.get_object())
3084 if annots is None:
3085 return outlist
3086 if not isinstance(annots, list):
3087 logger_warning(
3088 "Expected list of annotations, got %(annots)s of type %(annots_type)s.",
3089 source=__name__,
3090 annots=annots,
3091 annots_type=annots.__class__.__name__,
3092 )
3093 return outlist
3094 for an in annots:
3095 ano = cast("DictionaryObject", an.get_object())
3096 if (
3097 ano.get("/Subtype") != "/Link"
3098 or "/A" not in ano
3099 or cast("DictionaryObject", ano["/A"])["/S"] != "/GoTo" # type: ignore[comparison-overlap]
3100 or "/Dest" in ano
3101 ):
3102 if (
3103 parent_fields is not None
3104 and ano.get("/Subtype") == "/Widget"
3105 and isinstance(ano.get("/Parent"), IndirectObject)
3106 ):
3107 outlist.append(self._clone_widget_annotation(ano, parent_fields))
3108 elif "/Dest" not in ano:
3109 outlist.append(self._add_object(ano.clone(self)))
3110 else:
3111 d = ano["/Dest"]
3112 if isinstance(d, str):
3113 # it is a named dest
3114 if str(d) in self.get_named_dest_root():
3115 outlist.append(ano.clone(self).indirect_reference)
3116 else:
3117 if not isinstance(d, ArrayObject) or not d:
3118 continue
3119 p = self._get_cloned_page(d[0], pages, reader)
3120 if p is not None:
3121 anc = ano.clone(self, ignore_fields=("/Dest",))
3122 anc[NameObject("/Dest")] = ArrayObject([p, *d[1:]])
3123 outlist.append(self._add_object(anc))
3124 else:
3125 d = cast("DictionaryObject", ano["/A"]).get("/D", NullObject())
3126 if is_null_or_none(d):
3127 continue
3128 if isinstance(d, str):
3129 # it is a named dest
3130 if str(d) in self.get_named_dest_root():
3131 outlist.append(ano.clone(self).indirect_reference)
3132 else:
3133 if not isinstance(d, ArrayObject) or not d:
3134 continue
3135 p = self._get_cloned_page(d[0], pages, reader)
3136 if p is not None:
3137 anc = ano.clone(self, ignore_fields=("/D",))
3138 cast("DictionaryObject", anc["/A"])[
3139 NameObject("/D")
3140 ] = ArrayObject([p, *d[1:]])
3141 outlist.append(self._add_object(anc))
3142 return outlist
3144 def _clone_widget_annotation(
3145 self, annotation: DictionaryObject, parent_fields: dict[int, DictionaryObject]
3146 ) -> IndirectObject:
3147 """
3148 Clone a widget annotation and link it to its parent fields.
3150 Cloning ``/Parent`` directly would also clone the ``/Kids`` of the
3151 parent fields, and with them the widgets and pages of sibling fields
3152 which are not part of the merge. Instead, the parent fields are cloned
3153 without ``/Kids`` and collected in ``parent_fields``, so that their kids
3154 can be set by :meth:`_set_cloned_kids` once all annotations are cloned.
3155 """
3156 clone = annotation.clone(self, ignore_fields=(1, "/Parent"))
3157 source, target = annotation, clone
3158 while isinstance(source.get("/Parent"), IndirectObject):
3159 parent_reference = cast(IndirectObject, source.get("/Parent"))
3160 source_parent = parent_reference.get_object()
3161 if not isinstance(source_parent, DictionaryObject):
3162 break
3163 parent = source_parent.clone(self, ignore_fields=(1, "/Kids", 1, "/Parent"))
3164 target[NameObject("/Parent")] = parent.indirect_reference
3165 if parent_reference.idnum in parent_fields:
3166 # The ancestors have already been linked.
3167 break
3168 parent_fields[parent_reference.idnum] = source_parent
3169 source, target = source_parent, parent
3170 assert clone.indirect_reference is not None
3171 return clone.indirect_reference
3173 def _set_cloned_kids(self, parent_fields: dict[int, DictionaryObject], reader: PdfReader) -> None:
3174 """Set the ``/Kids`` of the cloned parent fields to the cloned kids, in their original order."""
3175 translated = self._id_translated.get(id(reader), {})
3176 for idnum, source_parent in parent_fields.items():
3177 kids = source_parent.get("/Kids", ArrayObject())
3178 parent = cast(DictionaryObject, self.get_object(translated[idnum]))
3179 parent[NameObject("/Kids")] = ArrayObject(
3180 IndirectObject(translated[kid.idnum], 0, self)
3181 for kid in cast(ArrayObject, kids)
3182 if isinstance(kid, IndirectObject) and kid.idnum in translated
3183 )
3185 def _get_filtered_outline(
3186 self,
3187 *,
3188 node: Any,
3189 pages: dict[int, PageObject],
3190 reader: PdfReader,
3191 visited: Optional[set[int]] = None,
3192 ) -> list[Destination]:
3193 """
3194 Extract outline item entries that are part of the specified page set.
3196 Returns:
3197 A list of destination objects.
3199 """
3200 if visited is None:
3201 visited = set()
3202 new_outline: list[Destination] = []
3203 if node is None:
3204 return new_outline
3205 node = node.get_object()
3206 if is_null_or_none(node):
3207 node = DictionaryObject()
3209 if node.get("/Type", "") == "/Outlines" or "/Title" not in node:
3210 node_id = id(node)
3211 if node_id in visited:
3212 logger_warning("Detected cycle in outlines.", source=__name__)
3213 return []
3214 visited.add(node_id)
3216 node = node.get("/First", None)
3217 if node is not None:
3218 node = node.get_object()
3219 new_outline += self._get_filtered_outline(node=node, pages=pages, reader=reader, visited=visited)
3220 else:
3221 cloned_page: Union[IndirectObject, NullObject, None]
3222 while True:
3223 node = node.get_object()
3224 node_id = id(node)
3225 if node_id in visited:
3226 logger_warning("Detected cycle in outlines.", source=__name__)
3227 break
3228 visited.add(node_id)
3230 destination = cast("Destination", reader._build_outline_item(node))
3231 cloned_page = self._get_cloned_page(cast("PageObject", destination["/Page"]), pages, reader)
3232 if cloned_page is None:
3233 cloned_page = NullObject()
3234 destination[NameObject("/Page")] = cloned_page
3235 if "/First" in node:
3236 destination._filtered_children = self._get_filtered_outline(
3237 node=node["/First"], pages=pages, reader=reader, visited=visited
3238 )
3239 else:
3240 destination._filtered_children = []
3241 if (
3242 not isinstance(cloned_page, NullObject)
3243 or len(destination._filtered_children) > 0
3244 ):
3245 new_outline.append(destination)
3247 if "/Next" not in node:
3248 break
3249 node = node["/Next"]
3250 return new_outline
3252 def _clone_outline(self, dest: Destination) -> TreeObject:
3253 n_ol = TreeObject()
3254 self._add_object(n_ol)
3255 n_ol[NameObject("/Title")] = TextStringObject(dest["/Title"])
3256 if not isinstance(dest["/Page"], NullObject):
3257 if dest.node is not None and "/A" in dest.node:
3258 n_ol[NameObject("/A")] = dest.node["/A"].clone(self)
3259 else:
3260 n_ol[NameObject("/Dest")] = dest.dest_array
3261 # TODO: /SE
3262 if dest.node is not None:
3263 n_ol[NameObject("/F")] = NumberObject(dest.node.get("/F", 0))
3264 color = dest.node.get("/C", NullObject()).get_object()
3265 if not isinstance(color, list):
3266 color = [FloatObject(0.0), FloatObject(0.0), FloatObject(0.0)]
3267 n_ol[NameObject("/C")] = ArrayObject(color)
3268 return n_ol
3270 def _insert_filtered_outline(
3271 self,
3272 outlines: list[Destination],
3273 parent: Union[TreeObject, IndirectObject],
3274 before: Union[TreeObject, IndirectObject, None] = None,
3275 ) -> None:
3276 for dest in outlines:
3277 # TODO: can be improved to keep A and SE entries (ignored for the moment)
3278 # with np=self.add_outline_item_destination(dest,parent,before)
3279 if dest.get("/Type", "") == "/Outlines" or "/Title" not in dest:
3280 np = parent
3281 else:
3282 np = self._clone_outline(dest)
3283 cast(TreeObject, parent.get_object()).insert_child(np, before, self)
3284 self._insert_filtered_outline(dest._filtered_children, np, None)
3286 def close(self) -> None:
3287 """Implemented for API harmonization."""
3288 return
3290 def find_outline_item(
3291 self,
3292 outline_item: Union[IndirectObject, str],
3293 root: Optional[OutlineType] = None,
3294 *,
3295 visited: Optional[set[int]] = None,
3296 ) -> Optional[list[int]]:
3297 if root is None:
3298 outline = self.get_outline_root()
3299 else:
3300 outline = cast("TreeObject", root)
3301 if visited is None:
3302 visited = set()
3304 index = 0
3305 while outline is not None:
3306 if (
3307 outline.indirect_reference == outline_item
3308 or outline.get("/Title", None) == outline_item
3309 ):
3310 return [index]
3312 outline_id = id(outline)
3313 if outline_id in visited:
3314 raise LimitReachedError("Detected cyclic outline hierarchy.")
3315 visited.add(outline_id)
3317 if "/First" in outline:
3318 result = self.find_outline_item(
3319 outline_item=outline_item,
3320 root=cast(OutlineType, outline["/First"]),
3321 visited=visited,
3322 )
3323 if result:
3324 return ([index] if "/Title" in outline else []) + result
3325 if "/Next" in outline:
3326 index += 1
3327 outline = cast(TreeObject, outline["/Next"])
3328 else:
3329 return None
3330 raise PyPdfError("This line is theoretically unreachable.") # pragma: no cover
3332 def reset_translation(
3333 self, reader: Union[PdfReader, IndirectObject, None] = None
3334 ) -> None:
3335 """
3336 Reset the translation table between reader and the writer object.
3338 Late cloning will create new independent objects.
3340 Args:
3341 reader: PdfReader or IndirectObject referencing a PdfReader object.
3342 if set to None or omitted, all tables will be reset.
3344 """
3345 if reader is None:
3346 self._id_translated = {}
3347 elif isinstance(reader, PdfReader):
3348 try:
3349 del self._id_translated[id(reader)]
3350 except Exception:
3351 pass
3352 elif isinstance(reader, IndirectObject):
3353 try:
3354 del self._id_translated[id(reader.pdf)]
3355 except Exception:
3356 pass
3357 else:
3358 raise TypeError(f"Invalid parameter {reader}")
3360 def set_page_label(
3361 self,
3362 page_index_from: int,
3363 page_index_to: int,
3364 style: Optional[PageLabelStyle] = None,
3365 prefix: Optional[str] = None,
3366 start: Optional[int] = 0,
3367 ) -> None:
3368 """
3369 Set a page label to a range of pages.
3371 Page indexes must be given starting from 0.
3372 Labels must have a style, a prefix or both.
3373 If a range is not assigned any page label, a decimal label starting from 1 is applied.
3375 Args:
3376 page_index_from: page index of the beginning of the range starting from 0
3377 page_index_to: page index of the beginning of the range starting from 0
3378 style: The numbering style to be used for the numeric portion of each page label:
3380 * ``/D`` Decimal Arabic numerals
3381 * ``/R`` Uppercase Roman numerals
3382 * ``/r`` Lowercase Roman numerals
3383 * ``/A`` Uppercase letters (A to Z for the first 26 pages,
3384 AA to ZZ for the next 26, and so on)
3385 * ``/a`` Lowercase letters (a to z for the first 26 pages,
3386 aa to zz for the next 26, and so on)
3388 prefix: The label prefix for page labels in this range.
3389 start: The value of the numeric portion for the first page label
3390 in the range.
3391 Subsequent pages are numbered sequentially from this value,
3392 which must be greater than or equal to 1.
3393 Default value: 1.
3395 """
3396 if style is None and prefix is None:
3397 raise ValueError("At least one of style and prefix must be given")
3398 if style is not None and style not in tuple(PageLabelStyle):
3399 raise ValueError(
3400 f"style must be one of: {', '.join(PageLabelStyle)}, got {style!r}"
3401 )
3402 if page_index_from < 0:
3403 raise ValueError("page_index_from must be greater or equal than 0")
3404 if page_index_to < page_index_from:
3405 raise ValueError(
3406 "page_index_to must be greater or equal than page_index_from"
3407 )
3408 if page_index_to >= len(self.pages):
3409 raise ValueError("page_index_to exceeds number of pages")
3410 if start is not None and start != 0 and start < 1:
3411 raise ValueError("If given, start must be greater or equal than one")
3413 self._set_page_label(page_index_from, page_index_to, style, prefix, start)
3415 def _set_page_label(
3416 self,
3417 page_index_from: int,
3418 page_index_to: int,
3419 style: Optional[PageLabelStyle] = None,
3420 prefix: Optional[str] = None,
3421 start: Optional[int] = 0,
3422 ) -> None:
3423 """
3424 Set a page label to a range of pages.
3426 Page indexes must be given starting from 0.
3427 Labels must have a style, a prefix or both.
3428 If a range is not assigned any page label a decimal label starting from 1 is applied.
3430 Args:
3431 page_index_from: page index of the beginning of the range starting from 0
3432 page_index_to: page index of the beginning of the range starting from 0
3433 style: The numbering style to be used for the numeric portion of each page label:
3434 /D Decimal Arabic numerals
3435 /R Uppercase Roman numerals
3436 /r Lowercase Roman numerals
3437 /A Uppercase letters (A to Z for the first 26 pages,
3438 AA to ZZ for the next 26, and so on)
3439 /a Lowercase letters (a to z for the first 26 pages,
3440 aa to zz for the next 26, and so on)
3441 prefix: The label prefix for page labels in this range.
3442 start: The value of the numeric portion for the first page label
3443 in the range.
3444 Subsequent pages are numbered sequentially from this value,
3445 which must be greater than or equal to 1. Default value: 1.
3447 """
3448 default_page_label = DictionaryObject()
3449 default_page_label[NameObject("/S")] = NameObject("/D")
3451 new_page_label = DictionaryObject()
3452 if style is not None:
3453 new_page_label[NameObject("/S")] = NameObject(style)
3454 if prefix is not None:
3455 new_page_label[NameObject("/P")] = TextStringObject(prefix)
3456 if start != 0:
3457 new_page_label[NameObject("/St")] = NumberObject(start)
3459 if NameObject(CatalogAttributes.PAGE_LABELS) not in self._root_object:
3460 nums = ArrayObject()
3461 nums_insert(NumberObject(0), default_page_label, nums)
3462 page_labels = TreeObject()
3463 page_labels[NameObject("/Nums")] = nums
3464 self._root_object[NameObject(CatalogAttributes.PAGE_LABELS)] = page_labels
3466 page_labels = cast(
3467 TreeObject, self._root_object[NameObject(CatalogAttributes.PAGE_LABELS)]
3468 )
3469 nums = cast(ArrayObject, page_labels[NameObject("/Nums")])
3471 nums_insert(NumberObject(page_index_from), new_page_label, nums)
3472 nums_clear_range(NumberObject(page_index_from), page_index_to, nums)
3473 next_label_pos, *_ = nums_next(NumberObject(page_index_from), nums)
3474 if next_label_pos != page_index_to + 1 and page_index_to + 1 < len(self.pages):
3475 nums_insert(NumberObject(page_index_to + 1), default_page_label, nums)
3477 page_labels[NameObject("/Nums")] = nums
3478 self._root_object[NameObject(CatalogAttributes.PAGE_LABELS)] = page_labels
3480 def _repr_mimebundle_(
3481 self,
3482 include: Union[Iterable[str], None] = None,
3483 exclude: Union[Iterable[str], None] = None,
3484 ) -> dict[str, Any]:
3485 """
3486 Integration into Jupyter Notebooks.
3488 This method returns a dictionary that maps a mime-type to its
3489 representation.
3491 .. seealso::
3493 https://ipython.readthedocs.io/en/stable/config/integrating.html
3494 """
3495 pdf_data = BytesIO()
3496 self.write(pdf_data)
3497 data = {
3498 "application/pdf": pdf_data,
3499 }
3501 if include is not None:
3502 # Filter representations based on include list
3503 data = {k: v for k, v in data.items() if k in include}
3505 if exclude is not None:
3506 # Remove representations based on exclude list
3507 data = {k: v for k, v in data.items() if k not in exclude}
3509 return data
3512def _pdf_objectify(obj: Union[dict[str, Any], str, float, list[Any]]) -> PdfObject:
3513 if isinstance(obj, PdfObject):
3514 return obj
3515 if isinstance(obj, dict):
3516 to_add = DictionaryObject()
3517 for key, value in obj.items():
3518 to_add[NameObject(key)] = _pdf_objectify(value)
3519 return to_add
3520 if isinstance(obj, str):
3521 if obj.startswith("/"):
3522 return NameObject(obj)
3523 return TextStringObject(obj)
3524 if isinstance(obj, (float, int)):
3525 return FloatObject(obj)
3526 if isinstance(obj, list):
3527 return ArrayObject(_pdf_objectify(i) for i in obj)
3528 raise NotImplementedError(
3529 f"{type(obj)=} could not be cast to a PdfObject"
3530 )
3533def _create_outline_item(
3534 action_ref: Union[IndirectObject, None],
3535 title: str,
3536 color: Union[tuple[float, float, float], str, None],
3537 italic: bool,
3538 bold: bool,
3539) -> TreeObject:
3540 outline_item = TreeObject()
3541 if action_ref is not None:
3542 outline_item[NameObject("/A")] = action_ref
3543 outline_item.update(
3544 {
3545 NameObject("/Title"): create_string_object(title),
3546 }
3547 )
3548 if color:
3549 if isinstance(color, str):
3550 color = hex_to_rgb(color)
3551 outline_item.update(
3552 {NameObject("/C"): ArrayObject([FloatObject(c) for c in color])}
3553 )
3554 if italic or bold:
3555 format_flag = 0
3556 if italic:
3557 format_flag += OutlineFontFlag.italic
3558 if bold:
3559 format_flag += OutlineFontFlag.bold
3560 outline_item.update({NameObject("/F"): NumberObject(format_flag)})
3561 return outline_item