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