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

1526 statements  

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. 

29 

30import decimal 

31import enum 

32import hashlib 

33import re 

34import struct 

35import sys 

36from collections.abc import Iterable, Mapping, Sequence 

37from io import BytesIO, FileIO, IOBase 

38from itertools import compress 

39from pathlib import Path 

40from re import Pattern 

41from types import TracebackType 

42from typing import ( 

43 IO, 

44 Any, 

45 Callable, 

46 Literal, 

47 Optional, 

48 Union, 

49 cast, 

50) 

51 

52if sys.version_info >= (3, 11): 

53 from typing import Self 

54else: 

55 from typing_extensions import Self 

56 

57from ._doc_common import DocumentInformation, PdfDocCommon 

58from ._encryption import EncryptAlgorithm, Encryption 

59from ._page import PageObject, Transformation 

60from ._page_labels import nums_clear_range, nums_insert, nums_next 

61from ._reader import PdfReader 

62from ._utils import ( 

63 StrByteType, 

64 StreamType, 

65 _get_max_pdf_version_header, 

66 deprecate_with_replacement, 

67 deprecation_no_replacement, 

68 logger_warning, 

69) 

70from .actions import Action, JavaScript 

71from .constants import AnnotationDictionaryAttributes as AA 

72from .constants import ( 

73 CatalogAttributes, 

74 Core, 

75 GoToActionArguments, 

76 ImageType, 

77 InteractiveFormDictEntries, 

78 OutlineFontFlag, 

79 PageLabelStyle, 

80 PagesAttributes, 

81 UserAccessPermissions, 

82) 

83from .constants import FieldDictionaryAttributes as FA 

84from .constants import PageAttributes as PG 

85from .constants import TrailerKeys as TK 

86from .errors import LimitReachedError, PdfReadError, PyPdfError 

87from .generic import ( 

88 PAGE_FIT, 

89 ArrayObject, 

90 BooleanObject, 

91 ByteStringObject, 

92 ContentStream, 

93 Destination, 

94 DictionaryObject, 

95 EmbeddedFile, 

96 Fit, 

97 FloatObject, 

98 IndirectObject, 

99 NameObject, 

100 NullObject, 

101 NumberObject, 

102 PdfObject, 

103 RectangleObject, 

104 ReferenceLink, 

105 StreamObject, 

106 TextStringObject, 

107 TreeObject, 

108 ViewerPreferences, 

109 create_string_object, 

110 extract_links, 

111 hex_to_rgb, 

112 is_null_or_none, 

113) 

114from .generic._appearance_stream import TextStreamAppearance 

115from .pagerange import PageRange, PageRangeSpec 

116from .types import ( 

117 AnnotationSubtype, 

118 BorderArrayType, 

119 LayoutType, 

120 OutlineItemType, 

121 OutlineType, 

122 PagemodeType, 

123) 

124from .xmp import XmpInformation 

125 

126ALL_DOCUMENT_PERMISSIONS = UserAccessPermissions.all() 

127 

128 

129class ObjectDeletionFlag(enum.IntFlag): 

130 NONE = 0 

131 TEXT = enum.auto() 

132 LINKS = enum.auto() 

133 ATTACHMENTS = enum.auto() 

134 OBJECTS_3D = enum.auto() 

135 ALL_ANNOTATIONS = enum.auto() 

136 XOBJECT_IMAGES = enum.auto() 

137 INLINE_IMAGES = enum.auto() 

138 DRAWING_IMAGES = enum.auto() 

139 IMAGES = XOBJECT_IMAGES | INLINE_IMAGES | DRAWING_IMAGES 

140 

141 

142def _rolling_checksum(stream: BytesIO, blocksize: int = 65536) -> str: 

143 hash_object = hashlib.md5(usedforsecurity=False) 

144 for block in iter(lambda: stream.read(blocksize), b""): 

145 hash_object.update(block) 

146 return hash_object.hexdigest() 

147 

148 

149class PdfWriter(PdfDocCommon): 

150 """ 

151 Write a PDF file out, given pages produced by another class or through 

152 cloning a PDF file during initialization. 

153 

154 Typically data is added from a :class:`PdfReader<pypdf.PdfReader>`. 

155 

156 Args: 

157 clone_from: identical to fileobj (for compatibility) 

158 

159 incremental: If true, loads the document and set the PdfWriter in incremental mode. 

160 

161 When writing incrementally, the original document is written first and new/modified 

162 content is appended. To be used for signed document/forms to keep signature valid. 

163 

164 full: If true, loads all the objects (always full if incremental = True). 

165 This parameter may allow loading large PDFs. 

166 

167 strict: If true, pypdf will raise an exception if a PDF does not follow the specification. 

168 If false, pypdf will try to be forgiving and do something reasonable, but it will log 

169 a warning message. It is a best-effort approach. 

170 

171 keep_initial_header: If true, the PDF header of the cloned document is kept 

172 when using ``clone_from`` in non-incremental mode. 

173 

174 """ 

175 

176 incremental: bool 

177 """ 

178 Returns if the PdfWriter object has been started in incremental mode. 

179 """ 

180 

181 _objects: list[Optional[PdfObject]] 

182 """ 

183 The indirect objects in the PDF. 

184 For the incremental case, it will be filled with None 

185 in clone_reader_document_root. 

186 """ 

187 

188 _id_translated: dict[int, dict[Union[int, Literal["PreventGC"]], Any]] 

189 """ 

190 List of already translated IDs. 

191 dict[id(pdf)][(idnum, generation)] 

192 """ 

193 

194 _reader: Optional[PdfReader] 

195 """ 

196 The document being appended to, in incremental mode only. 

197 """ 

198 

199 def __init__( 

200 self, 

201 fileobj: Union[PdfReader, StrByteType, Path, None] = "", 

202 clone_from: Union[PdfReader, StrByteType, Path, None] = None, 

203 incremental: bool = False, 

204 full: bool = False, 

205 strict: bool = False, 

206 *, 

207 keep_initial_header: bool = False, 

208 incremental_clone_object_count_limit: Optional[int] = 500_000, 

209 incremental_clone_object_id_limit: Optional[int] = 1_000_000, 

210 ) -> None: 

211 self.strict = strict 

212 """ 

213 If true, pypdf will raise an exception if a PDF does not follow the specification. 

214 If false, pypdf will try to be forgiving and do something reasonable, but it will log 

215 a warning message. It is a best-effort approach. 

216 """ 

217 

218 self.incremental = incremental or full 

219 

220 self._objects = [] 

221 

222 self._original_hash: list[int] = [] 

223 """ 

224 List of hashes after import; used to identify changes. 

225 """ 

226 

227 self._idnum_hash: dict[bytes, tuple[IndirectObject, list[IndirectObject]]] = {} 

228 """ 

229 Maps hash values of indirect objects to the list of IndirectObjects. 

230 This is used for compression. 

231 """ 

232 

233 self._id_translated = {} 

234 

235 self._info_obj: Optional[PdfObject] 

236 """The PDF files's document information dictionary, 

237 defined by Info in the PDF file's trailer dictionary.""" 

238 

239 self._reader = None 

240 

241 self._ID: Union[ArrayObject, None] = None 

242 """The PDF file identifier, 

243 defined by the ID in the PDF file's trailer dictionary.""" 

244 

245 self._unresolved_links: list[tuple[ReferenceLink, ReferenceLink]] = [] 

246 "Tracks links in pages added to the writer for resolving later." 

247 self._merged_in_pages: dict[Optional[IndirectObject], Optional[IndirectObject]] = {} 

248 "Tracks pages added to the writer and what page they turned into." 

249 

250 # Security parameters. 

251 self._incremental_clone_object_count_limit = ( 

252 incremental_clone_object_count_limit 

253 if isinstance(incremental_clone_object_count_limit, int) 

254 else sys.maxsize 

255 ) 

256 self._incremental_clone_object_id_limit = ( 

257 incremental_clone_object_id_limit if isinstance(incremental_clone_object_id_limit, int) else sys.maxsize 

258 ) 

259 

260 if self.incremental: 

261 if isinstance(fileobj, (str, Path)): 

262 with open(fileobj, "rb") as f: 

263 fileobj = BytesIO(f.read(-1)) 

264 if isinstance(fileobj, BytesIO): 

265 fileobj = PdfReader(fileobj) 

266 if not isinstance(fileobj, PdfReader): 

267 raise PyPdfError("Invalid type for incremental mode") 

268 self._reader = fileobj # prev content is in _reader.stream 

269 self._header = fileobj.pdf_header.encode() 

270 self._readonly = True # TODO: to be analysed 

271 else: 

272 self._header = b"%PDF-1.3" 

273 self._info_obj = self._add_object( 

274 DictionaryObject( 

275 {NameObject("/Producer"): create_string_object("pypdf")} 

276 ) 

277 ) 

278 

279 def _get_clone_from( 

280 fileobj: Union[PdfReader, str, Path, IO[Any], BytesIO, None], 

281 clone_from: Union[PdfReader, str, Path, IO[Any], BytesIO, None], 

282 ) -> Union[PdfReader, str, Path, IO[Any], BytesIO, None]: 

283 if isinstance(fileobj, (str, Path, IO, BytesIO)) and ( 

284 fileobj == "" or clone_from is not None 

285 ): 

286 return clone_from 

287 cloning = True 

288 if isinstance(fileobj, (str, Path)): 

289 fileobj_path = Path(fileobj) 

290 if not fileobj_path.exists() or fileobj_path.stat().st_size == 0: 

291 cloning = False 

292 elif isinstance(fileobj, (IOBase, BytesIO)): 

293 t = fileobj.tell() 

294 if fileobj.seek(0, 2) == 0: 

295 cloning = False 

296 fileobj.seek(t, 0) 

297 if cloning: 

298 clone_from = fileobj 

299 return clone_from 

300 

301 clone_from = _get_clone_from(fileobj, clone_from) 

302 # To prevent overwriting 

303 self.temp_fileobj = fileobj 

304 self.fileobj = "" 

305 self._with_as_usage = False 

306 self._cloned = False 

307 # The root of our page tree node 

308 pages = DictionaryObject( 

309 { 

310 NameObject(PagesAttributes.TYPE): NameObject("/Pages"), 

311 NameObject(PagesAttributes.COUNT): NumberObject(0), 

312 NameObject(PagesAttributes.KIDS): ArrayObject(), 

313 } 

314 ) 

315 self.flattened_pages = [] 

316 self._encryption: Optional[Encryption] = None 

317 self._encrypt_entry: Optional[DictionaryObject] = None 

318 

319 if clone_from is not None: 

320 if not isinstance(clone_from, PdfReader): 

321 clone_from = PdfReader(clone_from) 

322 self.clone_document_from_reader(clone_from) 

323 self._cloned = True 

324 if keep_initial_header and not self.incremental: 

325 self._header = clone_from.pdf_header.encode() 

326 else: 

327 self._pages = self._add_object(pages) 

328 self._root_object = DictionaryObject( 

329 { 

330 NameObject(PagesAttributes.TYPE): NameObject(Core.CATALOG), 

331 NameObject(Core.PAGES): self._pages, 

332 } 

333 ) 

334 self._add_object(self._root_object) 

335 if full and not incremental: 

336 self.incremental = False 

337 if isinstance(self._ID, list): 

338 if isinstance(self._ID[0], TextStringObject): 

339 self._ID[0] = ByteStringObject(self._ID[0].get_original_bytes()) 

340 if isinstance(self._ID[1], TextStringObject): 

341 self._ID[1] = ByteStringObject(self._ID[1].get_original_bytes()) 

342 

343 # for commonality 

344 @property 

345 def is_encrypted(self) -> bool: 

346 """ 

347 Read-only boolean property showing whether this PDF file is encrypted. 

348 

349 Note that this property, if true, will remain true even after the 

350 :meth:`decrypt()<pypdf.PdfReader.decrypt>` method is called. 

351 """ 

352 return False 

353 

354 @property 

355 def root_object(self) -> DictionaryObject: 

356 """ 

357 Provide direct access to PDF Structure. 

358 

359 Note: 

360 Recommended only for read access. 

361 

362 """ 

363 return self._root_object 

364 

365 @property 

366 def _info(self) -> Optional[DictionaryObject]: 

367 """ 

368 Provide access to "/Info". Standardized with PdfReader. 

369 

370 Returns: 

371 /Info Dictionary; None if the entry does not exist 

372 

373 """ 

374 return ( 

375 None 

376 if self._info_obj is None 

377 else cast(DictionaryObject, self._info_obj.get_object()) 

378 ) 

379 

380 @_info.setter 

381 def _info(self, value: Optional[Union[IndirectObject, DictionaryObject]]) -> None: 

382 if value is None: 

383 try: 

384 self._objects[self._info_obj.indirect_reference.idnum - 1] = None # type: ignore[union-attr] 

385 except (KeyError, AttributeError): 

386 pass 

387 self._info_obj = None 

388 else: 

389 if self._info_obj is None: 

390 self._info_obj = self._add_object(DictionaryObject()) 

391 obj = cast(DictionaryObject, self._info_obj.get_object()) 

392 obj.clear() 

393 obj.update(cast(DictionaryObject, value.get_object())) 

394 

395 @property 

396 def xmp_metadata(self) -> Optional[XmpInformation]: 

397 """XMP (Extensible Metadata Platform) data.""" 

398 return cast(XmpInformation, self.root_object.xmp_metadata) 

399 

400 @xmp_metadata.setter 

401 def xmp_metadata(self, value: Union[XmpInformation, bytes, None]) -> None: 

402 """XMP (Extensible Metadata Platform) data.""" 

403 if value is None: 

404 if "/Metadata" in self.root_object: 

405 del self.root_object["/Metadata"] 

406 return 

407 

408 metadata = self.root_object.get("/Metadata", None) 

409 if not isinstance(metadata, IndirectObject): 

410 if metadata is not None: 

411 del self.root_object["/Metadata"] 

412 metadata_stream = StreamObject() 

413 stream_reference = self._add_object(metadata_stream) 

414 self.root_object[NameObject("/Metadata")] = stream_reference 

415 else: 

416 metadata_stream = cast(StreamObject, metadata.get_object()) 

417 

418 if isinstance(value, XmpInformation): 

419 bytes_data = value.stream.get_data() 

420 else: 

421 bytes_data = value 

422 metadata_stream.set_data(bytes_data) 

423 

424 @property 

425 def with_as_usage(self) -> bool: 

426 deprecation_no_replacement("with_as_usage", "5.0") 

427 

428 @with_as_usage.setter 

429 def with_as_usage(self, value: bool) -> None: 

430 deprecation_no_replacement("with_as_usage", "5.0") 

431 

432 def __enter__(self) -> Self: 

433 """Store how writer is initialized by 'with'.""" 

434 c: bool = self._cloned 

435 t = self.temp_fileobj 

436 self.__init__() # type: ignore[misc] 

437 self._cloned = c 

438 self._with_as_usage = True 

439 self.fileobj = t # type: ignore[assignment] 

440 return self 

441 

442 def __exit__( 

443 self, 

444 exc_type: Optional[type[BaseException]], 

445 exc: Optional[BaseException], 

446 traceback: Optional[TracebackType], 

447 ) -> None: 

448 """Write data to the fileobj.""" 

449 if self.fileobj and not self._cloned: 

450 self.write(self.fileobj) 

451 

452 @property 

453 def pdf_header(self) -> str: 

454 """ 

455 Read/Write property of the PDF header that is written. 

456 

457 This should be something like ``'%PDF-1.5'``. It is recommended to set 

458 the lowest version that supports all features which are used within the 

459 PDF file. 

460 

461 Note: `pdf_header` returns a string but accepts bytes or str for writing 

462 """ 

463 return self._header.decode() 

464 

465 @pdf_header.setter 

466 def pdf_header(self, new_header: Union[str, bytes]) -> None: 

467 if isinstance(new_header, str): 

468 new_header = new_header.encode() 

469 self._header = new_header 

470 

471 def _add_object(self, obj: PdfObject) -> IndirectObject: 

472 if ( 

473 getattr(obj, "indirect_reference", None) is not None 

474 and obj.indirect_reference.pdf == self # type: ignore[union-attr] 

475 ): 

476 return obj.indirect_reference # type: ignore[return-value] 

477 # check for /Contents in Pages (/Contents in annotations are strings) 

478 if isinstance(obj, DictionaryObject) and isinstance( 

479 obj.get(PG.CONTENTS, None), (ArrayObject, DictionaryObject) 

480 ): 

481 obj[NameObject(PG.CONTENTS)] = self._add_object(obj[PG.CONTENTS]) 

482 self._objects.append(obj) 

483 obj.indirect_reference = IndirectObject(len(self._objects), 0, self) 

484 return obj.indirect_reference 

485 

486 def get_object( 

487 self, 

488 indirect_reference: Union[int, IndirectObject], 

489 ) -> PdfObject: 

490 if isinstance(indirect_reference, int): 

491 obj = self._objects[indirect_reference - 1] 

492 elif indirect_reference.pdf != self: 

493 raise ValueError("PDF must be self") 

494 else: 

495 obj = self._objects[indirect_reference.idnum - 1] 

496 if obj is None: 

497 raise PdfReadError(f"Object {indirect_reference!r} not found!") 

498 return obj 

499 

500 def _replace_object( 

501 self, 

502 indirect_reference: Union[int, IndirectObject], 

503 obj: PdfObject, 

504 ) -> PdfObject: 

505 if isinstance(indirect_reference, IndirectObject): 

506 if indirect_reference.pdf != self: 

507 raise ValueError("PDF must be self") 

508 indirect_reference = indirect_reference.idnum 

509 gen = self._objects[indirect_reference - 1].indirect_reference.generation # type: ignore[union-attr] 

510 if ( 

511 getattr(obj, "indirect_reference", None) is not None 

512 and obj.indirect_reference.pdf != self # type: ignore[union-attr] 

513 ): 

514 obj = obj.clone(self) 

515 self._objects[indirect_reference - 1] = obj 

516 obj.indirect_reference = IndirectObject(indirect_reference, gen, self) 

517 

518 assert isinstance(obj, PdfObject), "mypy" 

519 return obj 

520 

521 def _add_page( 

522 self, 

523 page: PageObject, 

524 index: int, 

525 excluded_keys: Iterable[str] = (), 

526 ) -> PageObject: 

527 if not isinstance(page, PageObject) or page.get(PagesAttributes.TYPE, None) != Core.PAGE: 

528 raise ValueError("Invalid page object") 

529 assert self.flattened_pages is not None, "mypy" 

530 page_org = page 

531 excluded_keys = list(excluded_keys) 

532 excluded_keys += [PagesAttributes.PARENT, "/StructParents"] 

533 # Acrobat does not accept two indirect references pointing on the same 

534 # page; therefore in order to add multiple copies of the same 

535 # page, we need to create a new dictionary for the page, however the 

536 # objects below (including content) are not duplicated: 

537 try: # delete an already existing page 

538 del self._id_translated[id(page_org.indirect_reference.pdf)][ # type: ignore[union-attr] 

539 page_org.indirect_reference.idnum # type: ignore[union-attr] 

540 ] 

541 except Exception: 

542 pass 

543 

544 page = cast( 

545 "PageObject", page_org.clone(self, False, excluded_keys).get_object() 

546 ) 

547 if page_org.pdf is not None: 

548 other = page_org.pdf.pdf_header 

549 self.pdf_header = _get_max_pdf_version_header(self.pdf_header, other) 

550 

551 node, idx = self._get_page_in_node(index) 

552 page[NameObject(PagesAttributes.PARENT)] = node.indirect_reference 

553 

554 if idx >= 0: 

555 cast(ArrayObject, node[PagesAttributes.KIDS]).insert(idx, page.indirect_reference) 

556 self.flattened_pages.insert(index, page) 

557 else: 

558 cast(ArrayObject, node[PagesAttributes.KIDS]).append(page.indirect_reference) 

559 self.flattened_pages.append(page) 

560 current: Optional[PdfObject] = node 

561 recurse = 0 

562 while not is_null_or_none(current): 

563 assert current is not None, "mypy" # guarded by is_null_or_none 

564 node_dict = cast(DictionaryObject, current.get_object()) 

565 node_dict[NameObject(PagesAttributes.COUNT)] = NumberObject(cast(int, node_dict[PagesAttributes.COUNT]) + 1) 

566 current = node_dict.get(PagesAttributes.PARENT, None) 

567 recurse += 1 

568 if recurse > 1000: 

569 raise PyPdfError("Too many recursive calls!") 

570 

571 if page_org.pdf is not None: 

572 # the page may contain links to other pages, and those other 

573 # pages may or may not already be added. we store the 

574 # information we need, so that we can resolve the references 

575 # later. 

576 if "/Annots" not in excluded_keys: 

577 self._unresolved_links.extend(extract_links(page, page_org)) 

578 self._merged_in_pages[page_org.indirect_reference] = page.indirect_reference 

579 

580 return page 

581 

582 def set_need_appearances_writer(self, state: bool = True) -> None: 

583 """ 

584 Sets the "NeedAppearances" flag in the PDF writer. 

585 

586 The "NeedAppearances" flag indicates whether the appearance dictionary 

587 for form fields should be automatically generated by the PDF viewer or 

588 if the embedded appearance should be used. 

589 

590 Args: 

591 state: The actual value of the NeedAppearances flag. 

592 

593 Returns: 

594 None 

595 

596 """ 

597 # See §12.7.2 and §7.7.2 for more information: 

598 # https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf 

599 try: 

600 # get the AcroForm tree 

601 if CatalogAttributes.ACRO_FORM not in self._root_object: 

602 self._root_object[ 

603 NameObject(CatalogAttributes.ACRO_FORM) 

604 ] = self._add_object(DictionaryObject()) 

605 

606 need_appearances = NameObject(InteractiveFormDictEntries.NeedAppearances) 

607 cast(DictionaryObject, self._root_object[CatalogAttributes.ACRO_FORM])[ 

608 need_appearances 

609 ] = BooleanObject(state) 

610 except Exception as exc: # pragma: no cover 

611 logger_warning( 

612 "set_need_appearances_writer(%(state)s) catch : %(exc)s", 

613 source=__name__, 

614 state=state, 

615 exc=exc, 

616 ) 

617 

618 def create_viewer_preferences(self) -> ViewerPreferences: 

619 o = ViewerPreferences() 

620 self._root_object[ 

621 NameObject(CatalogAttributes.VIEWER_PREFERENCES) 

622 ] = self._add_object(o) 

623 return o 

624 

625 def add_page( 

626 self, 

627 page: PageObject, 

628 excluded_keys: Iterable[str] = (), 

629 ) -> PageObject: 

630 """ 

631 Add a page to this PDF file. 

632 

633 Recommended for advanced usage including the adequate excluded_keys. 

634 

635 The page is usually acquired from a :class:`PdfReader<pypdf.PdfReader>` 

636 instance. 

637 

638 Args: 

639 page: The page to add to the document. Should be 

640 an instance of :class:`PageObject<pypdf._page.PageObject>` 

641 excluded_keys: 

642 

643 Returns: 

644 The added PageObject. 

645 

646 """ 

647 assert self.flattened_pages is not None, "mypy" 

648 return self._add_page(page, len(self.flattened_pages), excluded_keys) 

649 

650 def insert_page( 

651 self, 

652 page: PageObject, 

653 index: int = 0, 

654 excluded_keys: Iterable[str] = (), 

655 ) -> PageObject: 

656 """ 

657 Insert a page in this PDF file. The page is usually acquired from a 

658 :class:`PdfReader<pypdf.PdfReader>` instance. 

659 

660 Args: 

661 page: The page to add to the document. 

662 index: Position at which the page will be inserted. 

663 excluded_keys: 

664 

665 Returns: 

666 The added PageObject. 

667 

668 """ 

669 assert self.flattened_pages is not None, "mypy" 

670 if index < 0: 

671 index += len(self.flattened_pages) 

672 if index < 0: 

673 raise ValueError("Invalid index value") 

674 if index >= len(self.flattened_pages): 

675 return self.add_page(page, excluded_keys) 

676 return self._add_page(page, index, excluded_keys) 

677 

678 def _get_page_number_by_indirect( 

679 self, indirect_reference: Union[int, NullObject, IndirectObject, None] 

680 ) -> Optional[int]: 

681 """ 

682 Generate _page_id2num. 

683 

684 Args: 

685 indirect_reference: 

686 

687 Returns: 

688 The page number or None 

689 

690 """ 

691 # To provide same function as in PdfReader 

692 if is_null_or_none(indirect_reference): 

693 return None 

694 assert indirect_reference is not None, "mypy" 

695 if isinstance(indirect_reference, int): 

696 indirect_reference = IndirectObject(indirect_reference, 0, self) 

697 obj = indirect_reference.get_object() 

698 if isinstance(obj, PageObject): 

699 return obj.page_number 

700 return None 

701 

702 def add_blank_page( 

703 self, width: Optional[float] = None, height: Optional[float] = None 

704 ) -> PageObject: 

705 """ 

706 Append a blank page to this PDF file and return it. 

707 

708 If no page size is specified, use the size of the last page. 

709 

710 Args: 

711 width: The width of the new page expressed in default user 

712 space units. 

713 height: The height of the new page expressed in default 

714 user space units. 

715 

716 Returns: 

717 The newly appended page. 

718 

719 Raises: 

720 PageSizeNotDefinedError: if width and height are not defined 

721 and previous page does not exist. 

722 

723 """ 

724 page = PageObject.create_blank_page(self, width, height) 

725 return self.add_page(page) 

726 

727 def insert_blank_page( 

728 self, 

729 width: Optional[Union[float, decimal.Decimal]] = None, 

730 height: Optional[Union[float, decimal.Decimal]] = None, 

731 index: int = 0, 

732 ) -> PageObject: 

733 """ 

734 Insert a blank page to this PDF file and return it. 

735 

736 If no page size is specified for a dimension, use the size of the last page. 

737 

738 Args: 

739 width: The width of the new page in default user space units. 

740 height: The height of the new page in default user space units. 

741 index: Position to add the page. 

742 

743 Returns: 

744 The newly inserted page. 

745 

746 Raises: 

747 PageSizeNotDefinedError: if width and height are not defined 

748 and previous page does not exist. 

749 IndexError: Index is outside of [-self.get_num_pages(), self.get_num_pages()] 

750 """ 

751 num_pages = self.get_num_pages() 

752 if abs(index) > num_pages: 

753 raise IndexError(f"Index should be in range [-{num_pages}, {num_pages}]") 

754 

755 if num_pages: 

756 # Use the chosen index, but do not exceed the available pages 

757 fixed_index = min(index, num_pages - 1) 

758 mediabox = self.pages[fixed_index].mediabox 

759 if width is None or width <= 0: 

760 width = mediabox.width 

761 if height is None or height <= 0: 

762 height = mediabox.height 

763 

764 page = PageObject.create_blank_page(self, width, height) 

765 self.insert_page(page, index) 

766 return page 

767 

768 @property 

769 def open_destination( 

770 self, 

771 ) -> Union[Destination, TextStringObject, ByteStringObject, None]: 

772 return super().open_destination 

773 

774 @open_destination.setter 

775 def open_destination(self, dest: Union[str, Destination, PageObject, None]) -> None: 

776 if dest is None: 

777 try: 

778 del self._root_object["/OpenAction"] 

779 except KeyError: 

780 pass 

781 elif isinstance(dest, str): 

782 self._root_object[NameObject("/OpenAction")] = TextStringObject(dest) 

783 elif isinstance(dest, Destination): 

784 self._root_object[NameObject("/OpenAction")] = dest.dest_array 

785 elif isinstance(dest, PageObject): 

786 self._root_object[NameObject("/OpenAction")] = Destination( 

787 "Opening", 

788 dest.indirect_reference 

789 if dest.indirect_reference is not None 

790 else NullObject(), 

791 PAGE_FIT, 

792 ).dest_array 

793 

794 def add_js(self, javascript: str) -> None: 

795 """ 

796 Add JavaScript which will launch upon opening this PDF. 

797 

798 Args: 

799 javascript: Your JavaScript. 

800 

801 Example: 

802 This will launch the print window when the PDF is opened. 

803 

804 >>> from pypdf import PdfWriter 

805 >>> output = PdfWriter() 

806 >>> output.add_js("this.print({bUI:true,bSilent:false,bShrinkToFit:true});") 

807 

808 """ 

809 deprecate_with_replacement("add_js", "add_open_action", "7.0.0") 

810 self.add_open_action(JavaScript(javascript)) 

811 

812 def add_open_action(self, action: Action) -> None: 

813 """ 

814 Add an action to the document-level JavaScript name tree. 

815 

816 Args: 

817 action: The action to add. 

818 

819 Example: 

820 This will launch the print window when the PDF is opened. 

821 

822 >>> from pypdf import PdfWriter 

823 >>> from pypdf.actions import JavaScript 

824 >>> output = PdfWriter() 

825 >>> output.add_open_action(JavaScript("this.print({bUI:true,bSilent:false,bShrinkToFit:true});")) 

826 """ 

827 return Action._create_open_action(self, action) 

828 

829 def add_attachment(self, filename: str, data: Union[str, bytes]) -> "EmbeddedFile": 

830 """ 

831 Embed a file inside the PDF. 

832 

833 Reference: 

834 https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf 

835 Section 7.11.3 

836 

837 Args: 

838 filename: The filename to display. 

839 data: The data in the file. 

840 

841 Returns: 

842 EmbeddedFile instance for the newly created embedded file. 

843 

844 """ 

845 return EmbeddedFile._create_new(self, filename, data) 

846 

847 def append_pages_from_reader( 

848 self, 

849 reader: PdfReader, 

850 after_page_append: Optional[Callable[[PageObject], None]] = None, 

851 ) -> None: 

852 """ 

853 Copy pages from reader to writer. Includes an optional callback 

854 parameter which is invoked after pages are appended to the writer. 

855 

856 ``append`` should be preferred. 

857 

858 Args: 

859 reader: a PdfReader object from which to copy page 

860 annotations to this writer object. The writer's annots 

861 will then be updated. 

862 after_page_append: 

863 Callback function that is invoked after each page is appended to 

864 the writer. Signature includes a reference to the appended page 

865 (delegates to append_pages_from_reader). The single parameter of 

866 the callback is a reference to the page just appended to the 

867 document. 

868 

869 """ 

870 reader_num_pages = len(reader.pages) 

871 # Copy pages from reader to writer 

872 for reader_page_number in range(reader_num_pages): 

873 reader_page = reader.pages[reader_page_number] 

874 writer_page = self.add_page(reader_page) 

875 # Trigger callback, pass writer page as parameter 

876 if callable(after_page_append): 

877 after_page_append(writer_page) 

878 

879 def _merge_content_stream_to_page( 

880 self, 

881 page: PageObject, 

882 new_content_data: bytes, 

883 ) -> None: 

884 """ 

885 Combines existing content stream(s) with new content (as bytes). 

886 

887 Args: 

888 page: The page to which the new content data will be added. 

889 new_content_data: A binary-encoded new content stream, for 

890 instance the commands to draw an XObject. 

891 """ 

892 # First resolve the existing page content. This always is an IndirectObject: 

893 # PDF Explained by John Whitington 

894 # https://www.oreilly.com/library/view/pdf-explained/9781449321581/ch04.html 

895 if NameObject("/Contents") in page: 

896 existing_content_ref = page[NameObject("/Contents")] 

897 existing_content = existing_content_ref.get_object() 

898 

899 if isinstance(existing_content, ArrayObject): 

900 # Create a new StreamObject for the new_content_data 

901 new_stream_obj = StreamObject() 

902 new_stream_obj.set_data(new_content_data) 

903 existing_content.append(self._add_object(new_stream_obj)) 

904 page[NameObject("/Contents")] = self._add_object(existing_content) 

905 if isinstance(existing_content, StreamObject): 

906 # Merge new content to existing StreamObject 

907 merged_data = existing_content.get_data() + b"\n" + new_content_data 

908 new_stream = StreamObject() 

909 new_stream.set_data(merged_data) 

910 page[NameObject("/Contents")] = self._add_object(new_stream) 

911 else: 

912 # If no existing content, then we have an empty page. 

913 # Create a new StreamObject in a new /Contents entry. 

914 new_stream = StreamObject() 

915 new_stream.set_data(new_content_data) 

916 page[NameObject("/Contents")] = self._add_object(new_stream) 

917 

918 def _add_apstream_object( 

919 self, 

920 page: PageObject, 

921 appearance_stream_obj: StreamObject, 

922 object_name: str, 

923 x_offset: float, 

924 y_offset: float, 

925 ) -> None: 

926 """ 

927 Adds an appearance stream to the page content in the form of 

928 an XObject. 

929 

930 Args: 

931 page: The page to which to add the appearance stream. 

932 appearance_stream_obj: The appearance stream. 

933 object_name: The name of the appearance stream. 

934 x_offset: The horizontal offset for the appearance stream. 

935 y_offset: The vertical offset for the appearance stream. 

936 """ 

937 pg_res = cast(DictionaryObject, page[PG.RESOURCES]) 

938 # Always add the resolved stream object to the writer to get a new IndirectObject. 

939 # This ensures we have a valid IndirectObject managed by *this* writer. 

940 xobject_ref = self._add_object(appearance_stream_obj) 

941 xobject_name = NameObject(f"/Fm_{object_name}")._sanitize() 

942 if "/XObject" not in pg_res: 

943 pg_res[NameObject("/XObject")] = DictionaryObject() 

944 pg_xo_res = cast(DictionaryObject, pg_res["/XObject"]) 

945 if xobject_name not in pg_xo_res: 

946 pg_xo_res[xobject_name] = xobject_ref 

947 else: 

948 logger_warning( 

949 "XObject %(xobject_name)r already added to page resources. This might be an issue.", 

950 source=__name__, 

951 xobject_name=xobject_name, 

952 ) 

953 xobject_cm = Transformation().translate(x_offset, y_offset) 

954 xobject_drawing_commands = f"q\n{xobject_cm._to_cm()}\n{xobject_name} Do\nQ".encode() 

955 self._merge_content_stream_to_page(page, xobject_drawing_commands) 

956 

957 FFBITS_NUL = FA.FfBits(0) 

958 

959 def update_page_form_field_values( 

960 self, 

961 page: Union[PageObject, list[PageObject], None], 

962 fields: Mapping[str, Union[str, list[str], tuple[str, str, float]]], 

963 flags: FA.FfBits = FFBITS_NUL, 

964 auto_regenerate: Optional[bool] = True, 

965 flatten: bool = False, 

966 ) -> None: 

967 """ 

968 Update the form field values for a given page from a fields dictionary. 

969 

970 Copy field texts and values from fields to page. 

971 If the field links to a parent object, add the information to the parent. 

972 

973 Args: 

974 page: `PageObject` - references **PDF writer's page** where the 

975 annotations and field data will be updated. 

976 `List[Pageobject]` - provides list of pages to be processed. 

977 `None` - all pages. 

978 fields: a Python dictionary of: 

979 

980 * field names (/T) as keys and text values (/V) as value 

981 * field names (/T) as keys and list of text values (/V) for multiple choice list 

982 * field names (/T) as keys and tuple of: 

983 * text values (/V) 

984 * font id (e.g. /F1, the font id must exist) 

985 * font size (0 for autosize) 

986 

987 flags: A set of flags from :class:`~pypdf.constants.FieldDictionaryAttributes.FfBits`. 

988 

989 auto_regenerate: Set/unset the need_appearances flag; 

990 the flag is unchanged if auto_regenerate is None. 

991 

992 flatten: Whether or not to flatten the annotation. If True, this adds the annotation's 

993 appearance stream to the page contents. Note that this option does not remove the 

994 annotation itself. 

995 

996 """ 

997 if CatalogAttributes.ACRO_FORM not in self._root_object: 

998 raise PyPdfError("No /AcroForm dictionary in PDF of PdfWriter Object") 

999 acro_form = cast(DictionaryObject, self._root_object[CatalogAttributes.ACRO_FORM]) 

1000 if InteractiveFormDictEntries.Fields not in acro_form: 

1001 raise PyPdfError("No /Fields dictionary in PDF of PdfWriter Object") 

1002 if isinstance(auto_regenerate, bool): 

1003 self.set_need_appearances_writer(auto_regenerate) 

1004 # Iterate through pages, update field values 

1005 if page is None: 

1006 page = list(self.pages) 

1007 if isinstance(page, list): 

1008 for p in page: 

1009 if PG.ANNOTS in p: # just to prevent warnings 

1010 self.update_page_form_field_values(p, fields, flags, None, flatten=flatten) 

1011 return 

1012 if PG.ANNOTS not in page: 

1013 logger_warning("No fields to update on this page", source=__name__) 

1014 return 

1015 appearance_stream_obj: Optional[StreamObject] = None 

1016 

1017 for annotation in page[PG.ANNOTS]: # type: ignore[attr-defined] 

1018 annotation = cast(DictionaryObject, annotation.get_object()) 

1019 if annotation.get("/Subtype", "") != "/Widget": 

1020 continue 

1021 if "/FT" in annotation and "/T" in annotation: 

1022 parent_annotation = annotation 

1023 else: 

1024 parent_annotation = annotation.get( 

1025 PG.PARENT, DictionaryObject() 

1026 ).get_object() 

1027 

1028 for field, value in fields.items(): 

1029 rectangle = cast(RectangleObject, annotation[AA.Rect]) 

1030 if not ( 

1031 self._get_qualified_field_name(parent=parent_annotation) == field 

1032 or parent_annotation.get("/T", None) == field 

1033 ): 

1034 continue 

1035 if ( 

1036 parent_annotation.get("/FT", None) == "/Ch" 

1037 and "/I" in parent_annotation 

1038 ): 

1039 del parent_annotation["/I"] 

1040 if flags: 

1041 annotation[NameObject(FA.Ff)] = NumberObject(flags) 

1042 # Set the field value 

1043 if not (value is None and flatten): # Only change values if given by user and not flattening. 

1044 if isinstance(value, list): 

1045 lst = ArrayObject(TextStringObject(v) for v in value) 

1046 parent_annotation[NameObject(FA.V)] = lst 

1047 elif isinstance(value, tuple): 

1048 annotation[NameObject(FA.V)] = TextStringObject( 

1049 value[0], 

1050 ) 

1051 else: 

1052 parent_annotation[NameObject(FA.V)] = TextStringObject(value) 

1053 # Get or create the field's appearance stream object 

1054 if parent_annotation.get(FA.FT) == "/Btn": 

1055 # Checkbox button (no /FT found in Radio widgets); 

1056 # We can find the associated appearance stream object 

1057 # within the annotation. 

1058 v = NameObject(value) 

1059 ap = cast(DictionaryObject, annotation[NameObject(AA.AP)]) 

1060 normal_ap = cast(DictionaryObject, ap["/N"]) 

1061 if v not in normal_ap: 

1062 v = NameObject("/Off") 

1063 appearance_stream_obj = normal_ap.get(v) 

1064 # Other cases will be updated through the for loop 

1065 annotation[NameObject(AA.AS)] = v 

1066 annotation[NameObject(FA.V)] = v 

1067 elif ( 

1068 parent_annotation.get(FA.FT) == "/Tx" 

1069 or parent_annotation.get(FA.FT) == "/Ch" 

1070 ): 

1071 # Textbox; we need to generate the appearance stream object 

1072 if isinstance(value, tuple): 

1073 appearance_stream_obj = TextStreamAppearance.from_text_annotation( 

1074 self, page, flatten, acro_form, parent_annotation, annotation, value[1], value[2] 

1075 ) 

1076 else: 

1077 appearance_stream_obj = TextStreamAppearance.from_text_annotation( 

1078 self, page, flatten, acro_form, parent_annotation, annotation 

1079 ) 

1080 # Add the appearance stream object 

1081 if AA.AP not in annotation: 

1082 annotation[NameObject(AA.AP)] = DictionaryObject( 

1083 {NameObject("/N"): self._add_object(appearance_stream_obj)} 

1084 ) 

1085 elif "/N" not in (ap:= cast(DictionaryObject, annotation[AA.AP])): 

1086 cast(DictionaryObject, annotation[NameObject(AA.AP)])[ 

1087 NameObject("/N") 

1088 ] = self._add_object(appearance_stream_obj) 

1089 else: # [/AP][/N] exists 

1090 n = annotation[AA.AP]["/N"].indirect_reference.idnum # type: ignore[index] 

1091 self._objects[n - 1] = appearance_stream_obj 

1092 appearance_stream_obj.indirect_reference = IndirectObject(n, 0, self) 

1093 elif ( 

1094 annotation.get(FA.FT) == "/Sig" 

1095 ): # deprecated # not implemented yet 

1096 logger_warning("Signature forms not implemented yet", source=__name__) 

1097 

1098 if appearance_stream_obj and flatten: 

1099 self._add_apstream_object(page, appearance_stream_obj, field, rectangle[0], rectangle[1]) 

1100 

1101 def reattach_fields( 

1102 self, page: Optional[PageObject] = None 

1103 ) -> list[DictionaryObject]: 

1104 """ 

1105 Parse annotations within the page looking for orphan fields and 

1106 reattach then into the Fields Structure. 

1107 

1108 Args: 

1109 page: page to analyze. 

1110 If none is provided, all pages will be analyzed. 

1111 

1112 Returns: 

1113 list of reattached fields. 

1114 

1115 """ 

1116 lst = [] 

1117 if page is None: 

1118 for p in self.pages: 

1119 lst += self.reattach_fields(p) 

1120 return lst 

1121 

1122 try: 

1123 af = cast(DictionaryObject, self._root_object[CatalogAttributes.ACRO_FORM]) 

1124 except KeyError: 

1125 af = DictionaryObject() 

1126 self._root_object[NameObject(CatalogAttributes.ACRO_FORM)] = af 

1127 try: 

1128 fields = cast(ArrayObject, af[InteractiveFormDictEntries.Fields]) 

1129 except KeyError: 

1130 fields = ArrayObject() 

1131 af[NameObject(InteractiveFormDictEntries.Fields)] = fields 

1132 

1133 if "/Annots" not in page: 

1134 return lst 

1135 annotations = cast(ArrayObject, page["/Annots"]) 

1136 for idx, annotation in enumerate(annotations): 

1137 is_indirect = isinstance(annotation, IndirectObject) 

1138 annotation = cast(DictionaryObject, annotation.get_object()) 

1139 if annotation.get("/Subtype", "") == "/Widget" and "/FT" in annotation: 

1140 if ( 

1141 "indirect_reference" in annotation.__dict__ 

1142 and annotation.indirect_reference in fields 

1143 ): 

1144 continue 

1145 if not is_indirect: 

1146 annotations[idx] = self._add_object(annotation) 

1147 fields.append(annotation.indirect_reference) 

1148 lst.append(annotation) 

1149 return lst 

1150 

1151 def _collect_incremental_clone_object_ids(self, reader: PdfReader) -> list[int]: 

1152 object_ids: set[int] = set() 

1153 for xref_entry in reader.xref.values(): 

1154 object_ids.update(filter(None, xref_entry)) 

1155 object_ids.update(filter(None, reader.xref_objStm)) 

1156 

1157 object_count = len(object_ids) 

1158 if object_count > self._incremental_clone_object_count_limit: 

1159 raise LimitReachedError( 

1160 f"Incremental clone object count {object_count} exceeds " 

1161 f"maximum allowed count {self._incremental_clone_object_count_limit}." 

1162 ) 

1163 

1164 max_object_id = max(object_ids, default=0) 

1165 if max_object_id > self._incremental_clone_object_id_limit: 

1166 raise LimitReachedError( 

1167 f"Incremental clone object ID {max_object_id} exceeds " 

1168 f"maximum allowed ID {self._incremental_clone_object_id_limit}." 

1169 ) 

1170 

1171 return sorted(object_ids) 

1172 

1173 def clone_reader_document_root(self, reader: PdfReader) -> None: 

1174 """ 

1175 Copy the reader document root to the writer and all sub-elements, 

1176 including pages, threads, outlines,... For partial insertion, ``append`` 

1177 should be considered. 

1178 

1179 Args: 

1180 reader: PdfReader from which the document root should be copied. 

1181 

1182 """ 

1183 self._info_obj = None 

1184 if self.incremental: 

1185 object_ids = self._collect_incremental_clone_object_ids(reader) 

1186 self._objects = [None] * (object_ids[-1] if object_ids else 0) 

1187 for object_id in object_ids: 

1188 reader_object = reader.get_object(object_id) 

1189 if reader_object is not None: 

1190 self._objects[object_id - 1] = reader_object.replicate(self) 

1191 else: 

1192 self._objects.clear() 

1193 self._root_object = reader.root_object.clone(self) 

1194 self._pages = self._root_object.raw_get("/Pages") 

1195 

1196 trailer_size = cast(int, reader.trailer["/Size"]) 

1197 if len(self._objects) > trailer_size: 

1198 if self.strict: 

1199 raise PdfReadError( 

1200 f"Object count {len(self._objects)} exceeds defined trailer size {trailer_size}" 

1201 ) 

1202 logger_warning( 

1203 "Object count %(object_count)d exceeds defined trailer size %(trailer_size)d", 

1204 source=__name__, 

1205 object_count=len(self._objects), 

1206 trailer_size=trailer_size, 

1207 ) 

1208 

1209 # must be done here before rewriting 

1210 if self.incremental: 

1211 self._original_hash = [ 

1212 (obj.hash_bin() if obj is not None else 0) for obj in self._objects 

1213 ] 

1214 

1215 try: 

1216 self._flatten() 

1217 except IndexError as exception: # pragma: no cover 

1218 raise PdfReadError("Got index error while flattening.") from exception # pragma: no cover 

1219 

1220 assert self.flattened_pages is not None 

1221 for p in self.flattened_pages: 

1222 self._replace_object(cast(IndirectObject, p.indirect_reference).idnum, p) 

1223 if not self.incremental: 

1224 p[NameObject("/Parent")] = self._pages 

1225 if not self.incremental: 

1226 cast(DictionaryObject, self._pages.get_object())[ 

1227 NameObject("/Kids") 

1228 ] = ArrayObject([p.indirect_reference for p in self.flattened_pages]) 

1229 

1230 def clone_document_from_reader( 

1231 self, 

1232 reader: PdfReader, 

1233 after_page_append: Optional[Callable[[PageObject], None]] = None, 

1234 ) -> None: 

1235 """ 

1236 Create a copy (clone) of a document from a PDF file reader cloning 

1237 section '/Root' and '/Info' and '/ID' of the pdf. 

1238 

1239 Args: 

1240 reader: PDF file reader instance from which the clone 

1241 should be created. 

1242 after_page_append: 

1243 Callback function that is invoked after each page is appended to 

1244 the writer. Signature includes a reference to the appended page 

1245 (delegates to append_pages_from_reader). The single parameter of 

1246 the callback is a reference to the page just appended to the 

1247 document. 

1248 

1249 """ 

1250 self.clone_reader_document_root(reader) 

1251 inf = reader._info 

1252 if self.incremental: 

1253 if inf is not None: 

1254 self._info_obj = cast( 

1255 IndirectObject, inf.clone(self).indirect_reference 

1256 ) 

1257 assert isinstance(self._info, DictionaryObject), "mypy" 

1258 self._original_hash[ 

1259 self._info_obj.indirect_reference.idnum - 1 

1260 ] = self._info.hash_bin() 

1261 elif inf is not None: 

1262 self._info_obj = self._add_object( 

1263 DictionaryObject(cast(DictionaryObject, inf.get_object())) 

1264 ) 

1265 # else: _info_obj = None done in clone_reader_document_root() 

1266 

1267 try: 

1268 self._ID = cast(ArrayObject, reader._ID).clone(self) 

1269 except AttributeError: 

1270 pass 

1271 

1272 if callable(after_page_append): 

1273 for page in cast( 

1274 ArrayObject, cast(DictionaryObject, self._pages.get_object())["/Kids"] 

1275 ): 

1276 after_page_append(page.get_object()) 

1277 

1278 def _compute_document_identifier(self) -> ByteStringObject: 

1279 stream = BytesIO() 

1280 self._write_pdf_structure(stream) 

1281 stream.seek(0) 

1282 return ByteStringObject(_rolling_checksum(stream).encode("utf8")) 

1283 

1284 def generate_file_identifiers(self) -> None: 

1285 """ 

1286 Generate an identifier for the PDF that will be written. 

1287 

1288 The only point of this is ensuring uniqueness. Reproducibility is not 

1289 required. 

1290 When a file is first written, both identifiers shall be set to the same value. 

1291 If both identifiers match when a file reference is resolved, it is very 

1292 likely that the correct and unchanged file has been found. If only the first 

1293 identifier matches, a different version of the correct file has been found. 

1294 see §14.4 "File Identifiers". 

1295 """ 

1296 if self._ID: 

1297 id1 = self._ID[0] 

1298 id2 = self._compute_document_identifier() 

1299 else: 

1300 id1 = self._compute_document_identifier() 

1301 id2 = id1 

1302 self._ID = ArrayObject((id1, id2)) 

1303 

1304 def encrypt( 

1305 self, 

1306 user_password: str, 

1307 owner_password: Optional[str] = None, 

1308 use_128bit: bool = True, 

1309 permissions_flag: UserAccessPermissions = ALL_DOCUMENT_PERMISSIONS, 

1310 *, 

1311 algorithm: Optional[str] = None, 

1312 ) -> None: 

1313 """ 

1314 Encrypt this PDF file with the PDF Standard encryption handler. 

1315 

1316 Args: 

1317 user_password: The password which allows for opening 

1318 and reading the PDF file with the restrictions provided. 

1319 owner_password: The password which allows for 

1320 opening the PDF files without any restrictions. By default, 

1321 the owner password is the same as the user password. 

1322 use_128bit: flag as to whether to use 128bit 

1323 encryption. When false, 40bit encryption will be used. 

1324 By default, this flag is on. 

1325 permissions_flag: permissions as described in 

1326 Table 3.20 of the PDF 1.7 specification. A bit value of 1 means 

1327 the permission is granted. 

1328 Hence an integer value of -1 will set all flags. 

1329 Bit position 3 is for printing, 4 is for modifying content, 

1330 5 and 6 control annotations, 9 for form fields, 

1331 10 for extraction of text and graphics. 

1332 algorithm: encrypt algorithm. Values may be one of "RC4-40", "RC4-128", 

1333 "AES-128", "AES-256-R5", "AES-256". If it is valid, 

1334 `use_128bit` will be ignored. 

1335 

1336 """ 

1337 if self.incremental: 

1338 raise NotImplementedError("Encrypting incremental PDF files is currently not supported.") 

1339 

1340 if owner_password is None: 

1341 owner_password = user_password 

1342 

1343 if algorithm is not None: 

1344 try: 

1345 alg = getattr(EncryptAlgorithm, algorithm.replace("-", "_")) 

1346 except AttributeError as exception: # pragma: no cover 

1347 raise ValueError(f"Algorithm '{algorithm}' NOT supported") from exception # pragma: no cover 

1348 else: 

1349 alg = EncryptAlgorithm.RC4_128 

1350 if not use_128bit: 

1351 alg = EncryptAlgorithm.RC4_40 

1352 self.generate_file_identifiers() 

1353 assert self._ID 

1354 self._encryption = Encryption.make(alg, permissions_flag, self._ID[0]) 

1355 # in case call `encrypt` again 

1356 entry = self._encryption.write_entry(user_password, owner_password, strict=self.strict) 

1357 if self._encrypt_entry: 

1358 # replace old encrypt_entry 

1359 assert self._encrypt_entry.indirect_reference is not None 

1360 entry.indirect_reference = self._encrypt_entry.indirect_reference 

1361 self._objects[entry.indirect_reference.idnum - 1] = entry 

1362 else: 

1363 self._add_object(entry) 

1364 self._encrypt_entry = entry 

1365 

1366 def _resolve_links(self) -> None: 

1367 """Patch up links that were added to the document earlier, to 

1368 make sure they still point to the same pages. 

1369 """ 

1370 for (new_link, old_link) in self._unresolved_links: 

1371 old_page = old_link.find_referenced_page() 

1372 if not old_page: 

1373 continue 

1374 new_page = self._merged_in_pages.get(old_page) 

1375 if new_page is None: 

1376 continue 

1377 new_link.patch_reference(self, new_page) 

1378 

1379 def write_stream(self, stream: StreamType) -> None: 

1380 if hasattr(stream, "mode") and "b" not in stream.mode: 

1381 logger_warning( 

1382 "File <%(stream_name)s> to write to is not in binary mode. " 

1383 "It may not be written to correctly.", 

1384 source=__name__, 

1385 stream_name=stream.name, 

1386 ) 

1387 self._resolve_links() 

1388 

1389 if self.incremental: 

1390 assert self._reader is not None, "mypy" 

1391 self._reader.stream.seek(0) 

1392 stream.write(self._reader.stream.read(-1)) 

1393 if len(self.list_objects_in_increment()) > 0: 

1394 self._write_increment(stream) # writes objs, xref stream and startxref 

1395 else: 

1396 object_positions, free_objects = self._write_pdf_structure(stream) 

1397 xref_location = self._write_xref_table( 

1398 stream, object_positions, free_objects 

1399 ) 

1400 self._write_trailer(stream, xref_location) 

1401 

1402 def write(self, stream: Union[Path, StrByteType]) -> tuple[bool, IO[Any]]: 

1403 """ 

1404 Write the collection of pages added to this object out as a PDF file. 

1405 

1406 Args: 

1407 stream: An object to write the file to. The object can support 

1408 the write method and the tell method, similar to a file object, or 

1409 be a file path, just like the fileobj, just named it stream to keep 

1410 existing workflow. 

1411 

1412 Returns: 

1413 A tuple (bool, IO). 

1414 

1415 """ 

1416 my_file = False 

1417 

1418 if stream == "": 

1419 raise ValueError(f"Output({stream=}) is empty.") 

1420 

1421 if isinstance(stream, (str, Path)): 

1422 stream = FileIO(stream, "wb") 

1423 my_file = True 

1424 

1425 self.write_stream(stream) 

1426 

1427 if my_file: 

1428 stream.close() 

1429 else: 

1430 stream.flush() 

1431 

1432 return my_file, stream 

1433 

1434 def list_objects_in_increment(self) -> list[IndirectObject]: 

1435 """ 

1436 For analysis or debugging. 

1437 Provides the list of new or modified objects that will be written 

1438 in the increment. 

1439 Deleted objects will not be freed but will become orphans. 

1440 

1441 Returns: 

1442 List of new or modified IndirectObjects 

1443 

1444 """ 

1445 original_hash_count = len(self._original_hash) 

1446 return [ 

1447 cast(IndirectObject, obj).indirect_reference 

1448 for i, obj in enumerate(self._objects) 

1449 if ( 

1450 obj is not None 

1451 and ( 

1452 i >= original_hash_count 

1453 or obj.hash_bin() != self._original_hash[i] 

1454 ) 

1455 ) 

1456 ] 

1457 

1458 def _write_increment(self, stream: StreamType) -> None: 

1459 # Only reached from `write()` inside `if self.incremental`. 

1460 assert self._reader is not None, "mypy" 

1461 object_positions = {} 

1462 object_blocks = [] 

1463 current_start = -1 

1464 current_stop = -2 

1465 original_hash_count = len(self._original_hash) 

1466 for i, obj in enumerate(self._objects): 

1467 if obj is not None and ( 

1468 i >= original_hash_count 

1469 or obj.hash_bin() != self._original_hash[i] 

1470 ): 

1471 idnum = i + 1 

1472 assert isinstance(obj, PdfObject), "mypy" 

1473 # first write new/modified object 

1474 object_positions[idnum] = stream.tell() 

1475 stream.write(f"{idnum} 0 obj\n".encode()) 

1476 """ encryption is not operational 

1477 if self._encryption and obj != self._encrypt_entry: 

1478 obj = self._encryption.encrypt_object(obj, idnum, 0) 

1479 """ 

1480 obj.write_to_stream(stream) 

1481 stream.write(b"\nendobj\n") 

1482 

1483 # prepare xref 

1484 if idnum != current_stop: 

1485 if current_start > 0: 

1486 object_blocks.append( 

1487 [current_start, current_stop - current_start] 

1488 ) 

1489 current_start = idnum 

1490 current_stop = idnum + 1 

1491 assert current_start > 0, "for pytest only" 

1492 object_blocks.append([current_start, current_stop - current_start]) 

1493 # write incremented xref 

1494 xref_location = stream.tell() 

1495 xr_id = len(self._objects) + 1 

1496 stream.write(f"{xr_id} 0 obj".encode()) 

1497 init_data = { 

1498 NameObject("/Type"): NameObject("/XRef"), 

1499 NameObject("/Size"): NumberObject(xr_id + 1), 

1500 NameObject("/Root"): self.root_object.indirect_reference, 

1501 NameObject("/Filter"): NameObject("/FlateDecode"), 

1502 NameObject("/Index"): ArrayObject( 

1503 [NumberObject(_it) for _su in object_blocks for _it in _su] 

1504 ), 

1505 NameObject("/W"): ArrayObject( 

1506 [NumberObject(1), NumberObject(4), NumberObject(1)] 

1507 ), 

1508 "__streamdata__": b"", 

1509 } 

1510 if self._info is not None and ( 

1511 self._info.indirect_reference.idnum - 1 # type: ignore[union-attr] 

1512 >= len(self._original_hash) 

1513 or cast(IndirectObject, self._info).hash_bin() # kept for future 

1514 != self._original_hash[ 

1515 self._info.indirect_reference.idnum - 1 # type: ignore[union-attr] 

1516 ] 

1517 ): 

1518 init_data[NameObject(TK.INFO)] = self._info.indirect_reference 

1519 init_data[NameObject(TK.PREV)] = NumberObject(self._reader._startxref) 

1520 if self._ID: 

1521 init_data[NameObject(TK.ID)] = self._ID 

1522 xr = StreamObject.initialize_from_dictionary(init_data) 

1523 xr.set_data( 

1524 b"".join( 

1525 [struct.pack(b">BIB", 1, _pos, 0) for _pos in object_positions.values()] 

1526 ) 

1527 ) 

1528 xr.write_to_stream(stream) 

1529 stream.write(f"\nendobj\nstartxref\n{xref_location}\n%%EOF\n".encode()) # eof 

1530 

1531 def _write_pdf_structure(self, stream: StreamType) -> tuple[list[int], list[int]]: 

1532 object_positions = [] 

1533 free_objects = [] 

1534 stream.write(self.pdf_header.encode() + b"\n") 

1535 stream.write(b"%\xE2\xE3\xCF\xD3\n") 

1536 

1537 for idnum, obj in enumerate(self._objects, start=1): 

1538 if obj is not None: 

1539 object_positions.append(stream.tell()) 

1540 stream.write(f"{idnum} 0 obj\n".encode()) 

1541 if self._encryption and obj != self._encrypt_entry: 

1542 obj = self._encryption.encrypt_object(obj, idnum, 0) 

1543 obj.write_to_stream(stream) 

1544 stream.write(b"\nendobj\n") 

1545 else: 

1546 object_positions.append(-1) 

1547 free_objects.append(idnum) 

1548 free_objects.append(0) # add 0 to loop in accordance with specification 

1549 return object_positions, free_objects 

1550 

1551 def _write_xref_table( 

1552 self, stream: StreamType, object_positions: list[int], free_objects: list[int] 

1553 ) -> int: 

1554 xref_location = stream.tell() 

1555 stream.write(b"xref\n") 

1556 stream.write(f"0 {len(self._objects) + 1}\n".encode()) 

1557 stream.write(f"{free_objects[0]:0>10} {65535:0>5} f \n".encode()) 

1558 free_idx = 1 

1559 for offset in object_positions: 

1560 if offset > 0: 

1561 stream.write(f"{offset:0>10} {0:0>5} n \n".encode()) 

1562 else: 

1563 stream.write(f"{free_objects[free_idx]:0>10} {1:0>5} f \n".encode()) 

1564 free_idx += 1 

1565 return xref_location 

1566 

1567 def _write_trailer(self, stream: StreamType, xref_location: int) -> None: 

1568 """ 

1569 Write the PDF trailer to the stream. 

1570 

1571 To quote the PDF specification: 

1572 [The] trailer [gives] the location of the cross-reference table and 

1573 of certain special objects within the body of the file. 

1574 """ 

1575 stream.write(b"trailer\n") 

1576 trailer = DictionaryObject( 

1577 { 

1578 NameObject(TK.SIZE): NumberObject(len(self._objects) + 1), 

1579 NameObject(TK.ROOT): self.root_object.indirect_reference, 

1580 } 

1581 ) 

1582 if self._info is not None: 

1583 trailer[NameObject(TK.INFO)] = self._info.indirect_reference 

1584 if self._ID is not None: 

1585 trailer[NameObject(TK.ID)] = self._ID 

1586 if self._encrypt_entry: 

1587 trailer[NameObject(TK.ENCRYPT)] = self._encrypt_entry.indirect_reference 

1588 trailer.write_to_stream(stream) 

1589 stream.write(f"\nstartxref\n{xref_location}\n%%EOF\n".encode()) # eof 

1590 

1591 @property 

1592 def metadata(self) -> Optional[DocumentInformation]: 

1593 """ 

1594 Retrieve/set the PDF file's document information dictionary, if it exists. 

1595 

1596 Args: 

1597 value: dict with the entries to be set. if None : remove the /Info entry from the pdf. 

1598 

1599 Note that some PDF files use (XMP) metadata streams instead of document 

1600 information dictionaries, and these metadata streams will not be 

1601 accessed by this function, but by :meth:`~xmp_metadata`. 

1602 

1603 """ 

1604 return super().metadata 

1605 

1606 @metadata.setter 

1607 def metadata( 

1608 self, 

1609 value: Optional[Union[DocumentInformation, DictionaryObject, dict[Any, Any]]], 

1610 ) -> None: 

1611 if value is None: 

1612 self._info = None 

1613 else: 

1614 if self._info is not None: 

1615 self._info.clear() 

1616 

1617 self.add_metadata(value) 

1618 

1619 def add_metadata(self, infos: dict[str, Any]) -> None: 

1620 """ 

1621 Add custom metadata to the output. 

1622 

1623 Args: 

1624 infos: a Python dictionary where each key is a field 

1625 and each value is your new metadata. 

1626 

1627 """ 

1628 args = {} 

1629 if isinstance(infos, PdfObject): 

1630 infos = cast(DictionaryObject, infos.get_object()) 

1631 for key, value in list(infos.items()): 

1632 if isinstance(value, PdfObject): 

1633 value = value.get_object() 

1634 args[NameObject(key)] = create_string_object(str(value)) 

1635 if self._info is None: 

1636 self._info = DictionaryObject() 

1637 self._info.update(args) 

1638 

1639 _UNSET = object() 

1640 

1641 def compress_identical_objects( 

1642 self, 

1643 remove_identicals: Any = _UNSET, 

1644 remove_orphans: Any = _UNSET, 

1645 *, 

1646 remove_duplicates: bool = True, 

1647 remove_unreferenced: bool = True, 

1648 ) -> None: 

1649 """ 

1650 Parse the PDF file and merge objects that have the same hash. 

1651 This will make objects common to multiple pages. 

1652 Recommended to be used just before writing output. 

1653 

1654 Args: 

1655 remove_identicals: Deprecated. 

1656 remove_orphans: Deprecated. 

1657 remove_duplicates: Remove duplicate objects. 

1658 remove_unreferenced: Remove unreferenced objects. 

1659 

1660 """ 

1661 if remove_identicals != self._UNSET: 

1662 deprecate_with_replacement("remove_identicals", "remove_duplicates", "7.0.0") 

1663 assert isinstance(remove_identicals, bool) 

1664 remove_duplicates = remove_identicals 

1665 if remove_orphans != self._UNSET: 

1666 deprecate_with_replacement("remove_orphans", "remove_unreferenced", "7.0.0") 

1667 assert isinstance(remove_orphans, bool) 

1668 remove_unreferenced = remove_orphans 

1669 

1670 def replace_in_obj( 

1671 obj: PdfObject, crossref: dict[IndirectObject, IndirectObject] 

1672 ) -> None: 

1673 if isinstance(obj, DictionaryObject): 

1674 key_val = obj.items() 

1675 elif isinstance(obj, ArrayObject): 

1676 key_val = enumerate(obj) # type: ignore[assignment] 

1677 else: 

1678 return 

1679 assert isinstance(obj, (DictionaryObject, ArrayObject)) 

1680 for k, v in key_val: 

1681 if isinstance(v, IndirectObject): 

1682 unreferenced[v.idnum - 1] = False 

1683 if v in crossref: 

1684 obj[k] = crossref[v] 

1685 else: 

1686 """The filtering on DictionaryObject and ArrayObject only 

1687 will be performed within replace_in_obj""" 

1688 replace_in_obj(v, crossref) 

1689 

1690 # _idnum_hash: dict[hash] = (1st_ind_obj, [2nd_ind_obj,...]) 

1691 self._idnum_hash = {} 

1692 unreferenced = [True] * len(self._objects) 

1693 # look for similar objects 

1694 for idx, obj in enumerate(self._objects): 

1695 if is_null_or_none(obj): 

1696 continue 

1697 assert obj is not None, "mypy" # mypy: TypeGuard of `is_null_or_none` does not help here. 

1698 assert isinstance(obj.indirect_reference, IndirectObject) 

1699 h = obj.hash_value() 

1700 if remove_duplicates and h in self._idnum_hash: 

1701 self._idnum_hash[h][1].append(obj.indirect_reference) 

1702 self._objects[idx] = None 

1703 else: 

1704 self._idnum_hash[h] = (obj.indirect_reference, []) 

1705 

1706 # generate the dict converting others to 1st 

1707 cnv = {v[0]: v[1] for v in self._idnum_hash.values() if len(v[1]) > 0} 

1708 cnv_rev: dict[IndirectObject, IndirectObject] = {} 

1709 for k, v in cnv.items(): 

1710 cnv_rev.update(zip(v, (k,) * len(v))) 

1711 

1712 # replace reference to merged objects 

1713 for obj in self._objects: 

1714 if isinstance(obj, (DictionaryObject, ArrayObject)): 

1715 replace_in_obj(obj, cnv_rev) 

1716 

1717 if remove_unreferenced: 

1718 unreferenced[self.root_object.indirect_reference.idnum - 1] = False # type: ignore[union-attr] 

1719 

1720 if not is_null_or_none(self._info): 

1721 unreferenced[self._info.indirect_reference.idnum - 1] = False # type: ignore[union-attr] 

1722 

1723 try: 

1724 unreferenced[self._ID.indirect_reference.idnum - 1] = False # type: ignore[union-attr] 

1725 except AttributeError: 

1726 pass 

1727 

1728 for i in compress(range(len(self._objects)), unreferenced): 

1729 self._objects[i] = None 

1730 

1731 def get_reference(self, obj: PdfObject) -> IndirectObject: 

1732 idnum = self._objects.index(obj) + 1 

1733 ref = IndirectObject(idnum, 0, self) 

1734 assert ref.get_object() == obj 

1735 return ref 

1736 

1737 def get_outline_root(self) -> TreeObject: 

1738 if Core.OUTLINES in self._root_object: 

1739 # Entries in the catalog dictionary 

1740 outline = cast(TreeObject, self._root_object[Core.OUTLINES]) 

1741 if not isinstance(outline, TreeObject): 

1742 t = TreeObject(outline) 

1743 self._replace_object(outline.indirect_reference.idnum, t) 

1744 outline = t 

1745 idnum = self._objects.index(outline) + 1 

1746 outline_ref = IndirectObject(idnum, 0, self) 

1747 assert outline_ref.get_object() == outline 

1748 else: 

1749 outline = TreeObject() 

1750 outline.update({}) 

1751 outline_ref = self._add_object(outline) 

1752 self._root_object[NameObject(Core.OUTLINES)] = outline_ref 

1753 

1754 return outline 

1755 

1756 def get_threads_root(self) -> ArrayObject: 

1757 """ 

1758 The list of threads. 

1759 

1760 See §12.4.3 of the PDF 1.7 or PDF 2.0 specification. 

1761 

1762 Returns: 

1763 An array (possibly empty) of Dictionaries with an ``/F`` key, 

1764 and optionally information about the thread in ``/I`` or ``/Metadata`` keys. 

1765 

1766 """ 

1767 if Core.THREADS in self._root_object: 

1768 # Entries in the catalog dictionary 

1769 threads = cast(ArrayObject, self._root_object[Core.THREADS]) 

1770 else: 

1771 threads = ArrayObject() 

1772 self._root_object[NameObject(Core.THREADS)] = threads 

1773 return threads 

1774 

1775 @property 

1776 def threads(self) -> ArrayObject: 

1777 """ 

1778 Read-only property for the list of threads. 

1779 

1780 See §12.4.3 of the PDF 1.7 or PDF 2.0 specification. 

1781 

1782 Each element is a dictionary with an ``/F`` key, and optionally 

1783 information about the thread in ``/I`` or ``/Metadata`` keys. 

1784 """ 

1785 return self.get_threads_root() 

1786 

1787 def add_outline_item_destination( 

1788 self, 

1789 page_destination: Union[IndirectObject, PageObject, TreeObject], 

1790 parent: Union[TreeObject, IndirectObject, None] = None, 

1791 before: Union[TreeObject, IndirectObject, None] = None, 

1792 is_open: bool = True, 

1793 ) -> IndirectObject: 

1794 page_destination = cast(PageObject, page_destination.get_object()) 

1795 if isinstance(page_destination, PageObject): 

1796 return self.add_outline_item_destination( 

1797 Destination( 

1798 f"page #{page_destination.page_number}", 

1799 cast(IndirectObject, page_destination.indirect_reference), 

1800 Fit.fit(), 

1801 ) 

1802 ) 

1803 

1804 if parent is None: 

1805 parent = self.get_outline_root() 

1806 

1807 page_destination[NameObject("/%is_open%")] = BooleanObject(is_open) 

1808 parent = cast(TreeObject, parent.get_object()) 

1809 page_destination_ref = self._add_object(page_destination) 

1810 if before is not None: 

1811 before = before.indirect_reference 

1812 parent.insert_child( 

1813 page_destination_ref, 

1814 before, 

1815 self, 

1816 page_destination.inc_parent_counter_outline, 

1817 ) 

1818 if "/Count" not in page_destination: 

1819 page_destination[NameObject("/Count")] = NumberObject(0) 

1820 

1821 return page_destination_ref 

1822 

1823 def add_outline_item_dict( 

1824 self, 

1825 outline_item: OutlineItemType, 

1826 parent: Union[TreeObject, IndirectObject, None] = None, 

1827 before: Union[TreeObject, IndirectObject, None] = None, 

1828 is_open: bool = True, 

1829 ) -> IndirectObject: 

1830 outline_item_object = TreeObject() 

1831 outline_item_object.update(outline_item) 

1832 

1833 """code currently unreachable 

1834 if "/A" in outline_item: 

1835 action = DictionaryObject() 

1836 a_dict = cast(DictionaryObject, outline_item["/A"]) 

1837 for k, v in list(a_dict.items()): 

1838 action[NameObject(str(k))] = v 

1839 action_ref = self._add_object(action) 

1840 outline_item_object[NameObject("/A")] = action_ref 

1841 """ 

1842 return self.add_outline_item_destination( 

1843 outline_item_object, parent, before, is_open 

1844 ) 

1845 

1846 def add_outline_item( 

1847 self, 

1848 title: str, 

1849 page_number: Union[PageObject, IndirectObject, int, None], 

1850 parent: Union[TreeObject, IndirectObject, None] = None, 

1851 before: Union[TreeObject, IndirectObject, None] = None, 

1852 color: Optional[Union[tuple[float, float, float], str]] = None, 

1853 bold: bool = False, 

1854 italic: bool = False, 

1855 fit: Fit = PAGE_FIT, 

1856 is_open: bool = True, 

1857 ) -> IndirectObject: 

1858 """ 

1859 Add an outline item (commonly referred to as a "Bookmark") to the PDF file. 

1860 

1861 Args: 

1862 title: Title to use for this outline item. 

1863 page_number: Page number this outline item will point to. 

1864 parent: A reference to a parent outline item to create nested 

1865 outline items. 

1866 before: 

1867 color: Color of the outline item's font as a red, green, blue tuple 

1868 from 0.0 to 1.0 or as a Hex String (#RRGGBB) 

1869 bold: Outline item font is bold 

1870 italic: Outline item font is italic 

1871 fit: The fit of the destination page. 

1872 

1873 Returns: 

1874 The added outline item as an indirect object. 

1875 

1876 """ 

1877 page_ref: Union[NullObject, IndirectObject, NumberObject, None] 

1878 if isinstance(italic, Fit): # it means that we are on the old params 

1879 if fit is not None and page_number is None: 

1880 page_number = fit 

1881 return self.add_outline_item( 

1882 title, page_number, parent, None, before, color, bold, italic, is_open=is_open 

1883 ) 

1884 if page_number is None: 

1885 action_ref = None 

1886 else: 

1887 if isinstance(page_number, IndirectObject): 

1888 page_ref = page_number 

1889 elif isinstance(page_number, PageObject): 

1890 page_ref = page_number.indirect_reference 

1891 elif isinstance(page_number, int): 

1892 try: 

1893 page_ref = self.pages[page_number].indirect_reference 

1894 except IndexError: 

1895 page_ref = NumberObject(page_number) 

1896 else: 

1897 raise TypeError(f"page_number: invalid type {type(page_number)}") 

1898 if page_ref is None: 

1899 logger_warning( 

1900 "can not find reference of page %(page_number)s", 

1901 source=__name__, 

1902 page_number=page_number, 

1903 ) 

1904 page_ref = NullObject() 

1905 dest = Destination( 

1906 NameObject("/" + title + " outline item"), 

1907 page_ref, 

1908 fit, 

1909 ) 

1910 

1911 action_ref = self._add_object( 

1912 DictionaryObject( 

1913 { 

1914 NameObject(GoToActionArguments.D): dest.dest_array, 

1915 NameObject(GoToActionArguments.S): NameObject("/GoTo"), 

1916 } 

1917 ) 

1918 ) 

1919 outline_item = self._add_object( 

1920 _create_outline_item(action_ref, title, color, italic, bold) 

1921 ) 

1922 

1923 if parent is None: 

1924 parent = self.get_outline_root() 

1925 return self.add_outline_item_destination(outline_item, parent, before, is_open) 

1926 

1927 def add_outline(self) -> None: 

1928 raise NotImplementedError( 

1929 "This method is not yet implemented. Use :meth:`add_outline_item` instead." 

1930 ) 

1931 

1932 def add_named_destination_array( 

1933 self, title: TextStringObject, destination: Union[IndirectObject, ArrayObject] 

1934 ) -> None: 

1935 named_dest = self.get_named_dest_root() 

1936 i = 0 

1937 while i < len(named_dest): 

1938 if title < named_dest[i]: 

1939 named_dest.insert(i, destination) 

1940 named_dest.insert(i, TextStringObject(title)) 

1941 return 

1942 i += 2 

1943 named_dest.extend([TextStringObject(title), destination]) 

1944 return 

1945 

1946 def add_named_destination_object( 

1947 self, 

1948 page_destination: PdfObject, 

1949 ) -> IndirectObject: 

1950 page_destination_ref = self._add_object(page_destination.dest_array) # type: ignore[attr-defined] 

1951 self.add_named_destination_array( 

1952 cast("TextStringObject", page_destination["/Title"]), page_destination_ref # type: ignore[index] 

1953 ) 

1954 

1955 return page_destination_ref 

1956 

1957 def _get_page_reference(self, page_number: int) -> Any: 

1958 """Look up a page by number, reporting a bad index rather than an IndexError from the kids array.""" 

1959 pages = cast(DictionaryObject, self.get_object(self._pages)) 

1960 kids = cast(ArrayObject, pages[PagesAttributes.KIDS]) 

1961 count = len(kids) 

1962 if not (-count <= page_number < count): 

1963 raise IndexError(f"Page number {page_number} is out of range") 

1964 return kids[page_number] 

1965 

1966 def add_named_destination( 

1967 self, 

1968 title: str, 

1969 page_number: int, 

1970 fit: Optional[Fit] = None, 

1971 ) -> IndirectObject: 

1972 """ 

1973 Add a named destination to a page in this PDF. 

1974 

1975 Args: 

1976 title: Name of the destination. 

1977 page_number: Index of the destination page, starting at zero. 

1978 fit: How the destination is displayed. 

1979 Defaults to fitting the page horizontally with its 

1980 top edge at the top of the window. 

1981 

1982 Returns: 

1983 A reference to the destination's GoTo action. 

1984 

1985 """ 

1986 page_ref = self._get_page_reference(page_number) 

1987 if fit is None: 

1988 top = cast(DictionaryObject, page_ref.get_object()).get(PG.MEDIABOX) 

1989 top = RectangleObject(top).top if top is not None else 0 

1990 fit = Fit.fit_horizontally(top=top) 

1991 if not isinstance(title, TextStringObject): 

1992 title = TextStringObject(str(title)) 

1993 dest = DictionaryObject() 

1994 dest.update( 

1995 { 

1996 NameObject(GoToActionArguments.D): Destination(title, page_ref, fit).dest_array, 

1997 NameObject(GoToActionArguments.S): NameObject("/GoTo"), 

1998 } 

1999 ) 

2000 

2001 dest_ref = self._add_object(dest) 

2002 self.add_named_destination_array(title, dest_ref) 

2003 return dest_ref 

2004 

2005 def remove_links(self) -> None: 

2006 """Remove links and annotations from this output.""" 

2007 for page in self.pages: 

2008 self.remove_objects_from_page(page, ObjectDeletionFlag.ALL_ANNOTATIONS) 

2009 

2010 def remove_annotations( 

2011 self, subtypes: Optional[Union[AnnotationSubtype, Iterable[AnnotationSubtype]]] 

2012 ) -> None: 

2013 """ 

2014 Remove annotations by annotation subtype. 

2015 

2016 Args: 

2017 subtypes: subtype or list of subtypes to be removed. 

2018 Examples are: "/Link", "/FileAttachment", "/Sound", 

2019 "/Movie", "/Screen", ... 

2020 If you want to remove all annotations, use subtypes=None. 

2021 

2022 """ 

2023 for page in self.pages: 

2024 self._remove_annots_from_page(page, subtypes) 

2025 

2026 def _remove_annots_from_page( 

2027 self, 

2028 page: Union[IndirectObject, PageObject, DictionaryObject], 

2029 subtypes: Optional[Iterable[str]], 

2030 ) -> None: 

2031 page = cast(DictionaryObject, page.get_object()) 

2032 if PG.ANNOTS in page: 

2033 i = 0 

2034 while i < len(cast(ArrayObject, page[PG.ANNOTS])): 

2035 an = cast(ArrayObject, page[PG.ANNOTS])[i] 

2036 obj = cast(DictionaryObject, an.get_object()) 

2037 if subtypes is None or cast(str, obj["/Subtype"]) in subtypes: 

2038 if isinstance(an, IndirectObject): 

2039 self._objects[an.idnum - 1] = NullObject() # to reduce PDF size 

2040 del page[PG.ANNOTS][i] # type:ignore 

2041 else: 

2042 i += 1 

2043 

2044 def remove_objects_from_page( 

2045 self, 

2046 page: Union[PageObject, DictionaryObject], 

2047 to_delete: Union[ObjectDeletionFlag, Iterable[ObjectDeletionFlag]], 

2048 text_filters: Optional[dict[str, Any]] = None 

2049 ) -> None: 

2050 """ 

2051 Remove objects specified by ``to_delete`` from the given page. 

2052 

2053 Args: 

2054 page: Page object to clean up. 

2055 to_delete: Objects to be deleted; can be a ``ObjectDeletionFlag`` 

2056 or a list of ObjectDeletionFlag 

2057 text_filters: Properties of text to be deleted, if applicable. Optional. 

2058 This is a Python dictionary with the following properties: 

2059 

2060 * font_ids: List of font resource IDs (such as /F1 or /T1_0) to be deleted. 

2061 

2062 """ 

2063 if isinstance(to_delete, (list, tuple)): 

2064 for to_d in to_delete: 

2065 self.remove_objects_from_page(page, to_d) 

2066 return None 

2067 assert isinstance(to_delete, ObjectDeletionFlag) 

2068 

2069 if to_delete & ObjectDeletionFlag.LINKS: 

2070 return self._remove_annots_from_page(page, ("/Link",)) 

2071 if to_delete & ObjectDeletionFlag.ATTACHMENTS: 

2072 return self._remove_annots_from_page( 

2073 page, ("/FileAttachment", "/Sound", "/Movie", "/Screen") 

2074 ) 

2075 if to_delete & ObjectDeletionFlag.OBJECTS_3D: 

2076 return self._remove_annots_from_page(page, ("/3D",)) 

2077 if to_delete & ObjectDeletionFlag.ALL_ANNOTATIONS: 

2078 return self._remove_annots_from_page(page, None) 

2079 

2080 jump_operators = [] 

2081 if to_delete & ObjectDeletionFlag.DRAWING_IMAGES: 

2082 jump_operators = [ 

2083 b"w", b"J", b"j", b"M", b"d", b"i", 

2084 b"W", b"W*", 

2085 b"b", b"b*", b"B", b"B*", b"S", b"s", b"f", b"f*", b"F", b"n", 

2086 b"m", b"l", b"c", b"v", b"y", b"h", b"re", 

2087 b"sh" 

2088 ] 

2089 if to_delete & ObjectDeletionFlag.TEXT: 

2090 jump_operators = [b"Tj", b"TJ", b"'", b'"'] 

2091 

2092 if not isinstance(page, PageObject): 

2093 page = PageObject(self, page.indirect_reference) # pragma: no cover 

2094 if "/Contents" in page: 

2095 content = cast(ContentStream, page.get_contents()) 

2096 

2097 images, forms = self._remove_objects_from_page__clean_forms( 

2098 elt=page, stack=[], jump_operators=jump_operators, to_delete=to_delete, text_filters=text_filters, 

2099 ) 

2100 

2101 self._remove_objects_from_page__clean( 

2102 content=content, images=images, forms=forms, 

2103 jump_operators=jump_operators, to_delete=to_delete, 

2104 text_filters=text_filters 

2105 ) 

2106 page.replace_contents(content) 

2107 return None 

2108 

2109 def _remove_objects_from_page__clean( 

2110 self, 

2111 content: ContentStream, 

2112 images: list[str], 

2113 forms: list[str], 

2114 jump_operators: list[bytes], 

2115 to_delete: ObjectDeletionFlag, 

2116 text_filters: Optional[dict[str, Any]] = None, 

2117 ) -> None: 

2118 font_id = None 

2119 font_ids_to_delete = [] 

2120 if text_filters and to_delete & ObjectDeletionFlag.TEXT: 

2121 font_ids_to_delete = text_filters.get("font_ids", []) 

2122 

2123 i = 0 

2124 while i < len(content.operations): 

2125 operands, operator = content.operations[i] 

2126 if operator == b"Tf": 

2127 font_id = operands[0] 

2128 if ( 

2129 ( 

2130 operator == b"INLINE IMAGE" 

2131 and (to_delete & ObjectDeletionFlag.INLINE_IMAGES) 

2132 ) 

2133 or (operator in jump_operators) 

2134 or ( 

2135 operator == b"Do" 

2136 and (to_delete & ObjectDeletionFlag.XOBJECT_IMAGES) 

2137 and (operands[0] in images) 

2138 ) 

2139 ): 

2140 if ( 

2141 not to_delete & ObjectDeletionFlag.TEXT 

2142 or (to_delete & ObjectDeletionFlag.TEXT and not text_filters) 

2143 or (to_delete & ObjectDeletionFlag.TEXT and font_id in font_ids_to_delete) 

2144 ): 

2145 del content.operations[i] 

2146 else: 

2147 i += 1 

2148 else: 

2149 i += 1 

2150 content.get_data() # this ensures ._data is rebuilt from the .operations 

2151 

2152 def _remove_objects_from_page__clean_forms( 

2153 self, 

2154 elt: DictionaryObject, 

2155 stack: list[DictionaryObject], 

2156 jump_operators: list[bytes], 

2157 to_delete: ObjectDeletionFlag, 

2158 text_filters: Optional[dict[str, Any]] = None, 

2159 ) -> tuple[list[str], list[str]]: 

2160 # elt in recursive call is a new ContentStream object, so we have to check the indirect_reference 

2161 if (elt in stack) or ( 

2162 hasattr(elt, "indirect_reference") and any( 

2163 elt.indirect_reference == getattr(x, "indirect_reference", -1) 

2164 for x in stack 

2165 ) 

2166 ): 

2167 # to prevent infinite looping 

2168 return [], [] # pragma: no cover 

2169 try: 

2170 d = cast( 

2171 dict[Any, Any], 

2172 cast(DictionaryObject, elt["/Resources"])["/XObject"], 

2173 ) 

2174 except KeyError: 

2175 d = {} 

2176 images = [] 

2177 forms = [] 

2178 for k, v in d.items(): 

2179 o = v.get_object() 

2180 try: 

2181 content: Any = None 

2182 if ( 

2183 to_delete & ObjectDeletionFlag.XOBJECT_IMAGES 

2184 and o["/Subtype"] == "/Image" 

2185 ): 

2186 content = NullObject() # to delete the image keeping the entry 

2187 images.append(k) 

2188 if o["/Subtype"] == "/Form": 

2189 forms.append(k) 

2190 if isinstance(o, ContentStream): 

2191 content = o 

2192 else: 

2193 content = ContentStream(o, self) 

2194 content.update( 

2195 { 

2196 k1: v1 

2197 for k1, v1 in o.items() 

2198 if k1 not in ["/Length", "/Filter", "/DecodeParms"] 

2199 } 

2200 ) 

2201 try: 

2202 content.indirect_reference = o.indirect_reference 

2203 except AttributeError: # pragma: no cover 

2204 pass 

2205 stack.append(elt) 

2206 

2207 # clean subforms 

2208 self._remove_objects_from_page__clean_forms( 

2209 elt=content, stack=stack, jump_operators=jump_operators, to_delete=to_delete, 

2210 text_filters=text_filters, 

2211 ) 

2212 if content is not None: 

2213 if isinstance(v, IndirectObject): 

2214 self._objects[v.idnum - 1] = content 

2215 else: 

2216 # should only occur in a PDF not respecting PDF spec 

2217 # where streams must be indirected. 

2218 d[k] = self._add_object(content) # pragma: no cover 

2219 except (TypeError, KeyError): 

2220 pass 

2221 for im in images: 

2222 del d[im] # for clean-up 

2223 if isinstance(elt, StreamObject): # for /Form 

2224 if not isinstance(elt, ContentStream): # pragma: no cover 

2225 e = ContentStream(elt, self) 

2226 e.update(elt.items()) 

2227 elt = e 

2228 # clean the content 

2229 self._remove_objects_from_page__clean( 

2230 content=elt, images=images, forms=forms, jump_operators=jump_operators, 

2231 to_delete=to_delete, text_filters=text_filters 

2232 ) 

2233 return images, forms 

2234 

2235 def remove_images( 

2236 self, 

2237 to_delete: ImageType = ImageType.ALL, 

2238 ) -> None: 

2239 """ 

2240 Remove images from this output. 

2241 

2242 Args: 

2243 to_delete: The type of images to be deleted 

2244 (default = all images types) 

2245 

2246 """ 

2247 if isinstance(to_delete, bool): 

2248 to_delete = ImageType.ALL 

2249 

2250 i = ObjectDeletionFlag.NONE 

2251 

2252 for image in ("XOBJECT_IMAGES", "INLINE_IMAGES", "DRAWING_IMAGES"): 

2253 if to_delete & ImageType[image]: 

2254 i |= ObjectDeletionFlag[image] 

2255 

2256 for page in self.pages: 

2257 self.remove_objects_from_page(page, i) 

2258 

2259 def remove_text(self, font_names: Optional[list[str]] = None) -> None: 

2260 """ 

2261 Remove text from the PDF. 

2262 

2263 Args: 

2264 font_names: List of font names to remove, such as "Helvetica-Bold". 

2265 Optional. If not specified, all text will be removed. 

2266 """ 

2267 if not font_names: 

2268 font_names = [] 

2269 

2270 for page in self.pages: 

2271 resource_ids_to_remove = [] 

2272 

2273 # Content streams reference fonts and other resources with names like "/F1" or "/T1_0" 

2274 # Font names need to be converted to resource names/IDs for easier removal 

2275 if font_names: 

2276 # Recursively loop through page objects to gather font info 

2277 def get_font_info( 

2278 obj: Any, 

2279 font_info: Optional[dict[str, Any]] = None, 

2280 key: Optional[str] = None 

2281 ) -> dict[str, Any]: 

2282 if font_info is None: 

2283 font_info = {} 

2284 if isinstance(obj, IndirectObject): 

2285 obj = obj.get_object() 

2286 if isinstance(obj, dict): 

2287 if obj.get("/Type") == "/Font": 

2288 font_name = obj.get("/BaseFont", "") 

2289 # Normalize font names like "/RRXFFV+Palatino-Bold" to "Palatino-Bold" 

2290 normalized_font_name = font_name.lstrip("/").split("+")[-1] 

2291 if normalized_font_name not in font_info: 

2292 font_info[normalized_font_name] = { 

2293 "normalized_font_name": normalized_font_name, 

2294 "resource_ids": [], 

2295 } 

2296 if key not in font_info[normalized_font_name]["resource_ids"]: 

2297 font_info[normalized_font_name]["resource_ids"].append(key) 

2298 for k in obj: 

2299 font_info = get_font_info(obj[k], font_info, k) 

2300 elif isinstance(obj, (list, ArrayObject)): 

2301 for child_obj in obj: 

2302 font_info = get_font_info(child_obj, font_info) 

2303 return font_info 

2304 

2305 # Add relevant resource names for removal 

2306 font_info = get_font_info(page.get("/Resources")) 

2307 for font_name in font_names: 

2308 if font_name in font_info: 

2309 resource_ids_to_remove.extend(font_info[font_name]["resource_ids"]) 

2310 

2311 text_filters = {} 

2312 if font_names: 

2313 text_filters["font_ids"] = resource_ids_to_remove 

2314 self.remove_objects_from_page(page, ObjectDeletionFlag.TEXT, text_filters=text_filters) 

2315 

2316 def add_uri( 

2317 self, 

2318 page_number: int, 

2319 uri: str, 

2320 rect: RectangleObject, 

2321 border: Optional[Sequence[Any]] = None, 

2322 ) -> None: 

2323 """ 

2324 Add an URI from a rectangular area to the specified page. 

2325 

2326 Args: 

2327 page_number: index of the page on which to place the URI action. 

2328 uri: URI of resource to link to. 

2329 rect: :class:`RectangleObject<pypdf.generic.RectangleObject>` or 

2330 array of four integers specifying the clickable rectangular area 

2331 ``[xLL, yLL, xUR, yUR]``, or string in the form 

2332 ``"[ xLL yLL xUR yUR ]"``. 

2333 border: if provided, an array describing border-drawing 

2334 properties. See the PDF spec for details. No border will be 

2335 drawn if this argument is omitted. 

2336 

2337 """ 

2338 page_link = self._get_page_reference(page_number) 

2339 page_ref = cast(dict[str, Any], self.get_object(page_link)) 

2340 

2341 border_arr: BorderArrayType 

2342 if border is not None: 

2343 border_arr = [NumberObject(n) for n in border[:3]] 

2344 if len(border) == 4: 

2345 dash_pattern = ArrayObject([NumberObject(n) for n in border[3]]) 

2346 border_arr.append(dash_pattern) 

2347 else: 

2348 border_arr = [NumberObject(2), NumberObject(2), NumberObject(2)] 

2349 

2350 if isinstance(rect, str): 

2351 coords = [float(n) for n in rect.strip().strip("[]").split()] 

2352 rect = RectangleObject(coords) 

2353 elif isinstance(rect, RectangleObject): 

2354 pass 

2355 else: 

2356 rect = RectangleObject(rect) 

2357 

2358 lnk2 = DictionaryObject() 

2359 lnk2.update( 

2360 { 

2361 NameObject("/S"): NameObject("/URI"), 

2362 NameObject("/URI"): TextStringObject(uri), 

2363 } 

2364 ) 

2365 lnk = DictionaryObject() 

2366 lnk.update( 

2367 { 

2368 NameObject(AA.Type): NameObject("/Annot"), 

2369 NameObject(AA.Subtype): NameObject("/Link"), 

2370 NameObject(AA.P): page_link, 

2371 NameObject(AA.Rect): rect, 

2372 NameObject("/H"): NameObject("/I"), 

2373 NameObject(AA.Border): ArrayObject(border_arr), 

2374 NameObject("/A"): lnk2, 

2375 } 

2376 ) 

2377 lnk_ref = self._add_object(lnk) 

2378 

2379 if PG.ANNOTS in page_ref: 

2380 page_ref[PG.ANNOTS].append(lnk_ref) 

2381 else: 

2382 page_ref[NameObject(PG.ANNOTS)] = ArrayObject([lnk_ref]) 

2383 

2384 _valid_layouts = ( 

2385 "/NoLayout", 

2386 "/SinglePage", 

2387 "/OneColumn", 

2388 "/TwoColumnLeft", 

2389 "/TwoColumnRight", 

2390 "/TwoPageLeft", 

2391 "/TwoPageRight", 

2392 ) 

2393 

2394 def _get_page_layout(self) -> Optional[LayoutType]: 

2395 try: 

2396 return cast(LayoutType, self._root_object["/PageLayout"]) 

2397 except KeyError: 

2398 return None 

2399 

2400 def _set_page_layout(self, layout: Union[NameObject, LayoutType]) -> None: 

2401 """ 

2402 Set the page layout. 

2403 

2404 Args: 

2405 layout: The page layout to be used. 

2406 

2407 .. list-table:: Valid ``layout`` arguments 

2408 :widths: 50 200 

2409 

2410 * - /NoLayout 

2411 - Layout explicitly not specified 

2412 * - /SinglePage 

2413 - Show one page at a time 

2414 * - /OneColumn 

2415 - Show one column at a time 

2416 * - /TwoColumnLeft 

2417 - Show pages in two columns, odd-numbered pages on the left 

2418 * - /TwoColumnRight 

2419 - Show pages in two columns, odd-numbered pages on the right 

2420 * - /TwoPageLeft 

2421 - Show two pages at a time, odd-numbered pages on the left 

2422 * - /TwoPageRight 

2423 - Show two pages at a time, odd-numbered pages on the right 

2424 

2425 """ 

2426 if not isinstance(layout, NameObject): 

2427 if layout not in self._valid_layouts: 

2428 logger_warning( 

2429 "Layout should be one of: %(layouts)s", 

2430 source=__name__, 

2431 layouts=", ".join(self._valid_layouts), 

2432 ) 

2433 layout = NameObject(layout) 

2434 self._root_object.update({NameObject("/PageLayout"): layout}) 

2435 

2436 def set_page_layout(self, layout: LayoutType) -> None: 

2437 """ 

2438 Set the page layout. 

2439 

2440 Args: 

2441 layout: The page layout to be used 

2442 

2443 .. list-table:: Valid ``layout`` arguments 

2444 :widths: 50 200 

2445 

2446 * - /NoLayout 

2447 - Layout explicitly not specified 

2448 * - /SinglePage 

2449 - Show one page at a time 

2450 * - /OneColumn 

2451 - Show one column at a time 

2452 * - /TwoColumnLeft 

2453 - Show pages in two columns, odd-numbered pages on the left 

2454 * - /TwoColumnRight 

2455 - Show pages in two columns, odd-numbered pages on the right 

2456 * - /TwoPageLeft 

2457 - Show two pages at a time, odd-numbered pages on the left 

2458 * - /TwoPageRight 

2459 - Show two pages at a time, odd-numbered pages on the right 

2460 

2461 """ 

2462 self._set_page_layout(layout) 

2463 

2464 @property 

2465 def page_layout(self) -> Optional[LayoutType]: 

2466 """ 

2467 Page layout property. 

2468 

2469 .. list-table:: Valid ``layout`` values 

2470 :widths: 50 200 

2471 

2472 * - /NoLayout 

2473 - Layout explicitly not specified 

2474 * - /SinglePage 

2475 - Show one page at a time 

2476 * - /OneColumn 

2477 - Show one column at a time 

2478 * - /TwoColumnLeft 

2479 - Show pages in two columns, odd-numbered pages on the left 

2480 * - /TwoColumnRight 

2481 - Show pages in two columns, odd-numbered pages on the right 

2482 * - /TwoPageLeft 

2483 - Show two pages at a time, odd-numbered pages on the left 

2484 * - /TwoPageRight 

2485 - Show two pages at a time, odd-numbered pages on the right 

2486 """ 

2487 return self._get_page_layout() 

2488 

2489 @page_layout.setter 

2490 def page_layout(self, layout: LayoutType) -> None: 

2491 self._set_page_layout(layout) 

2492 

2493 _valid_modes = ( 

2494 "/UseNone", 

2495 "/UseOutlines", 

2496 "/UseThumbs", 

2497 "/FullScreen", 

2498 "/UseOC", 

2499 "/UseAttachments", 

2500 ) 

2501 

2502 def _get_page_mode(self) -> Optional[PagemodeType]: 

2503 try: 

2504 return cast(PagemodeType, self._root_object["/PageMode"]) 

2505 except KeyError: 

2506 return None 

2507 

2508 @property 

2509 def page_mode(self) -> Optional[PagemodeType]: 

2510 """ 

2511 Page mode property. 

2512 

2513 .. list-table:: Valid ``mode`` values 

2514 :widths: 50 200 

2515 

2516 * - /UseNone 

2517 - Do not show outline or thumbnails panels 

2518 * - /UseOutlines 

2519 - Show outline (aka bookmarks) panel 

2520 * - /UseThumbs 

2521 - Show page thumbnails panel 

2522 * - /FullScreen 

2523 - Fullscreen view 

2524 * - /UseOC 

2525 - Show Optional Content Group (OCG) panel 

2526 * - /UseAttachments 

2527 - Show attachments panel 

2528 """ 

2529 return self._get_page_mode() 

2530 

2531 @page_mode.setter 

2532 def page_mode(self, mode: PagemodeType) -> None: 

2533 if isinstance(mode, NameObject): 

2534 mode_name: NameObject = mode 

2535 else: 

2536 if mode not in self._valid_modes: 

2537 logger_warning( 

2538 "Mode should be one of: %(modes)s", 

2539 source=__name__, 

2540 modes=", ".join(self._valid_modes), 

2541 ) 

2542 mode_name = NameObject(mode) 

2543 self._root_object.update({NameObject("/PageMode"): mode_name}) 

2544 

2545 def add_annotation( 

2546 self, 

2547 page_number: Union[int, PageObject], 

2548 annotation: dict[str, Any], 

2549 ) -> DictionaryObject: 

2550 """ 

2551 Add a single annotation to the page. 

2552 The added annotation must be a new annotation. 

2553 It cannot be recycled. 

2554 

2555 Args: 

2556 page_number: PageObject or page index. 

2557 annotation: Annotation to be added (created with annotation). 

2558 

2559 Returns: 

2560 The inserted object. 

2561 This can be used for popup creation, for example. 

2562 

2563 """ 

2564 page = page_number 

2565 if isinstance(page, int): 

2566 page = self.pages[page] 

2567 elif not isinstance(page, PageObject): 

2568 raise TypeError("page: invalid type") 

2569 

2570 to_add = cast(DictionaryObject, _pdf_objectify(annotation)) 

2571 to_add[NameObject("/P")] = page.indirect_reference 

2572 

2573 if page.annotations is None: 

2574 page[NameObject("/Annots")] = ArrayObject() 

2575 assert page.annotations is not None 

2576 

2577 # Resolve pypdf's intermediate internal-link destination. 

2578 destination = to_add.get("/Dest") 

2579 if ( 

2580 to_add.get("/Subtype") == "/Link" 

2581 and isinstance(destination, DictionaryObject) 

2582 and "target_page_index" in destination 

2583 ): 

2584 target_page_reference: Union[IndirectObject, NumberObject] 

2585 target_page_index = cast(int, destination["target_page_index"]) 

2586 fit_type = cast(str, destination["fit"]) 

2587 # Work around the intermediate destination containing native Python 

2588 # objects instead of PdfObject instances. 

2589 fit_args = cast(Sequence[Any], dict(destination)["fit_args"]) 

2590 

2591 if 0 <= target_page_index < len(self.pages): 

2592 target_page_reference = cast( 

2593 IndirectObject, 

2594 self.pages[target_page_index].indirect_reference, 

2595 ) 

2596 else: 

2597 # Preserve support for referencing a page that may be added 

2598 # later. See #2450. 

2599 target_page_reference = NumberObject(target_page_index) 

2600 

2601 to_add[NameObject("/Dest")] = Destination( 

2602 NameObject("/LinkName"), 

2603 target_page_reference, 

2604 Fit(fit_type=fit_type, fit_args=fit_args), 

2605 ).dest_array 

2606 

2607 page.annotations.append(self._add_object(to_add)) 

2608 

2609 if to_add.get("/Subtype") == "/Popup" and NameObject("/Parent") in to_add: 

2610 cast(DictionaryObject, to_add["/Parent"].get_object())[ 

2611 NameObject("/Popup") 

2612 ] = to_add.indirect_reference 

2613 

2614 return to_add 

2615 

2616 def clean_page(self, page: Union[PageObject, IndirectObject]) -> PageObject: 

2617 """ 

2618 Perform some clean up in the page. 

2619 Currently: convert NameObject named destination to TextStringObject 

2620 (required for names/dests list) 

2621 

2622 Args: 

2623 page: 

2624 

2625 Returns: 

2626 The cleaned PageObject 

2627 

2628 """ 

2629 page = cast("PageObject", page.get_object()) 

2630 for a in page.get("/Annots", []): 

2631 a_obj = a.get_object() 

2632 d = a_obj.get("/Dest", None) 

2633 act = a_obj.get("/A", None) 

2634 if isinstance(d, NameObject): 

2635 a_obj[NameObject("/Dest")] = TextStringObject(d) 

2636 elif act is not None: 

2637 act = act.get_object() 

2638 d = act.get("/D", None) 

2639 if isinstance(d, NameObject): 

2640 act[NameObject("/D")] = TextStringObject(d) 

2641 return page 

2642 

2643 def _create_stream( 

2644 self, fileobj: Union[Path, StrByteType, PdfReader] 

2645 ) -> tuple[IOBase, Optional[Encryption]]: 

2646 # If the fileobj parameter is a string, assume it is a path 

2647 # and create a file object at that location. If it is a file, 

2648 # copy the file's contents into a BytesIO stream object; if 

2649 # it is a PdfReader, copy that reader's stream into a 

2650 # BytesIO stream. 

2651 # If fileobj is none of the above types, it is not modified 

2652 encryption_obj = None 

2653 stream: IOBase 

2654 if isinstance(fileobj, (str, Path)): 

2655 with FileIO(fileobj, "rb") as f: 

2656 stream = BytesIO(f.read()) 

2657 elif isinstance(fileobj, PdfReader): 

2658 if fileobj._encryption: 

2659 encryption_obj = fileobj._encryption 

2660 orig_tell = fileobj.stream.tell() 

2661 fileobj.stream.seek(0) 

2662 stream = BytesIO(fileobj.stream.read()) 

2663 

2664 # reset the stream to its original location 

2665 fileobj.stream.seek(orig_tell) 

2666 elif hasattr(fileobj, "seek") and hasattr(fileobj, "read"): 

2667 fileobj.seek(0) 

2668 filecontent = fileobj.read() 

2669 stream = BytesIO(filecontent) 

2670 else: 

2671 raise NotImplementedError( 

2672 "Merging requires an object that PdfReader can parse. " 

2673 "Typically, that is a Path or a string representing a Path, " 

2674 "a file object, or an object implementing .seek and .read. " 

2675 "Passing a PdfReader directly works as well." 

2676 ) 

2677 return stream, encryption_obj 

2678 

2679 def append( 

2680 self, 

2681 fileobj: Union[StrByteType, PdfReader, Path], 

2682 outline_item: Union[str, PageRange, tuple[int, int], tuple[int, int, int], list[int], None] = None, 

2683 pages: Union[PageRange, tuple[int, int], tuple[int, int, int], list[int], list[PageObject], None] = None, 

2684 import_outline: bool = True, 

2685 excluded_fields: Optional[Union[list[str], tuple[str, ...]]] = None, 

2686 ) -> None: 

2687 """ 

2688 Identical to the :meth:`merge()<merge>` method, but assumes you want to 

2689 concatenate all pages onto the end of the file instead of specifying a 

2690 position. 

2691 

2692 Args: 

2693 fileobj: A File Object or an object that supports the standard 

2694 read and seek methods similar to a File Object. Could also be a 

2695 string representing a path to a PDF file. 

2696 outline_item: Optionally, you may specify a string to build an 

2697 outline (aka 'bookmark') to identify the beginning of the 

2698 included file. 

2699 pages: Can be a :class:`PageRange<pypdf.pagerange.PageRange>` 

2700 or a ``(start, stop[, step])`` tuple 

2701 or a list of pages to be processed 

2702 to merge only the specified range of pages from the source 

2703 document into the output document. 

2704 import_outline: You may prevent the source document's 

2705 outline (collection of outline items, previously referred to as 

2706 'bookmarks') from being imported by specifying this as ``False``. 

2707 excluded_fields: Provide the list of fields/keys to be ignored 

2708 if ``/Annots`` is part of the list, the annotation will be ignored 

2709 if ``/B`` is part of the list, the articles will be ignored 

2710 

2711 """ 

2712 if excluded_fields is None: 

2713 excluded_fields = () 

2714 if isinstance(outline_item, (tuple, list, PageRange)): 

2715 if isinstance(pages, bool): 

2716 if not isinstance(import_outline, bool): 

2717 excluded_fields = import_outline 

2718 import_outline = pages 

2719 pages = outline_item 

2720 self.merge( 

2721 None, 

2722 fileobj, 

2723 None, 

2724 pages, 

2725 import_outline, 

2726 excluded_fields, 

2727 ) 

2728 else: # if isinstance(outline_item, str): 

2729 self.merge( 

2730 None, 

2731 fileobj, 

2732 outline_item, 

2733 pages, 

2734 import_outline, 

2735 excluded_fields, 

2736 ) 

2737 

2738 def merge( 

2739 self, 

2740 position: Optional[int], 

2741 fileobj: Union[Path, StrByteType, PdfReader], 

2742 outline_item: Optional[str] = None, 

2743 pages: Optional[Union[PageRangeSpec, list[PageObject]]] = None, 

2744 import_outline: bool = True, 

2745 excluded_fields: Optional[Union[list[str], tuple[str, ...]]] = (), 

2746 ) -> None: 

2747 """ 

2748 Merge the pages from the given file into the output file at the 

2749 specified page number. 

2750 

2751 Args: 

2752 position: The *page number* to insert this file. File will 

2753 be inserted after the given number. 

2754 fileobj: A File Object or an object that supports the standard 

2755 read and seek methods similar to a File Object. Could also be a 

2756 string representing a path to a PDF file. 

2757 outline_item: Optionally, you may specify a string to build an outline 

2758 (aka 'bookmark') to identify the 

2759 beginning of the included file. 

2760 pages: can be a :class:`PageRange<pypdf.pagerange.PageRange>` 

2761 or a ``(start, stop[, step])`` tuple 

2762 or a list of pages to be processed 

2763 to merge only the specified range of pages from the source 

2764 document into the output document. 

2765 import_outline: You may prevent the source document's 

2766 outline (collection of outline items, previously referred to as 

2767 'bookmarks') from being imported by specifying this as ``False``. 

2768 excluded_fields: provide the list of fields/keys to be ignored 

2769 if ``/Annots`` is part of the list, the annotation will be ignored 

2770 if ``/B`` is part of the list, the articles will be ignored 

2771 

2772 Raises: 

2773 TypeError: The pages attribute is not configured properly 

2774 

2775 """ 

2776 if isinstance(fileobj, PdfDocCommon): 

2777 reader = fileobj 

2778 else: 

2779 stream, _encryption_obj = self._create_stream(fileobj) 

2780 # Create a new PdfReader instance using the stream 

2781 # (either file or BytesIO or StringIO) created above 

2782 reader = PdfReader(stream, strict=False) # type: ignore[arg-type] 

2783 

2784 if excluded_fields is None: 

2785 excluded_fields = () 

2786 # Find the range of pages to merge. 

2787 if pages is None: 

2788 pages = list(range(len(reader.pages))) 

2789 elif isinstance(pages, PageRange): 

2790 pages = list(range(*pages.indices(len(reader.pages)))) 

2791 elif isinstance(pages, list): 

2792 pass # keep unchanged 

2793 elif isinstance(pages, tuple) and len(pages) <= 3: 

2794 pages = list(range(*pages)) 

2795 elif not isinstance(pages, tuple): 

2796 raise TypeError( 

2797 '"pages" must be a tuple of (start, stop[, step]) or a list' 

2798 ) 

2799 

2800 srcpages = {} 

2801 for page in pages: 

2802 if isinstance(page, PageObject): 

2803 pg = page 

2804 else: 

2805 pg = reader.pages[page] 

2806 assert pg.indirect_reference is not None 

2807 if position is None: 

2808 # numbers in the exclude list identifies that the exclusion is 

2809 # only applicable to 1st level of cloning 

2810 srcpages[pg.indirect_reference.idnum] = self.add_page( 

2811 pg, [*list(excluded_fields), 1, "/B", 1, "/Annots"] # type: ignore[list-item] 

2812 ) 

2813 else: 

2814 srcpages[pg.indirect_reference.idnum] = self.insert_page( 

2815 pg, position, [*list(excluded_fields), 1, "/B", 1, "/Annots"] # type: ignore[list-item] 

2816 ) 

2817 position += 1 

2818 srcpages[pg.indirect_reference.idnum].original_page = pg 

2819 

2820 reader._named_destinations = ( 

2821 reader.named_destinations 

2822 ) # need for the outline processing below 

2823 

2824 arr: Any 

2825 

2826 for dest in reader._named_destinations.values(): 

2827 self._merge__process_named_dests(dest=dest, reader=reader, srcpages=srcpages) 

2828 

2829 outline_item_typ: TreeObject 

2830 if outline_item is not None: 

2831 outline_item_typ = cast( 

2832 "TreeObject", 

2833 self.add_outline_item( 

2834 TextStringObject(outline_item), 

2835 next(iter(srcpages.values())).indirect_reference, 

2836 fit=PAGE_FIT, 

2837 ).get_object(), 

2838 ) 

2839 else: 

2840 outline_item_typ = self.get_outline_root() 

2841 

2842 _ro = reader.root_object 

2843 if import_outline and Core.OUTLINES in _ro: 

2844 outline = self._get_filtered_outline( 

2845 node=_ro.get(Core.OUTLINES, None), pages=srcpages, reader=reader 

2846 ) 

2847 self._insert_filtered_outline( 

2848 outline, outline_item_typ, None 

2849 ) # TODO: use before parameter 

2850 

2851 if "/Annots" not in excluded_fields: 

2852 parent_fields: dict[int, DictionaryObject] = {} 

2853 for pag in srcpages.values(): 

2854 lst = self._insert_filtered_annotations( 

2855 annots=pag.original_page.get("/Annots", []), 

2856 page=pag, 

2857 pages=srcpages, 

2858 reader=reader, 

2859 parent_fields=parent_fields, 

2860 ) 

2861 if len(lst) > 0: 

2862 pag[NameObject("/Annots")] = lst 

2863 self.clean_page(pag) 

2864 self._set_cloned_kids(parent_fields, reader) 

2865 

2866 if "/AcroForm" in _ro and not is_null_or_none(_ro["/AcroForm"]): 

2867 if "/AcroForm" not in self._root_object: 

2868 self._root_object[NameObject("/AcroForm")] = self._add_object( 

2869 cast( 

2870 DictionaryObject, 

2871 reader.root_object["/AcroForm"], 

2872 ).clone(self, False, ("/Fields",)) 

2873 ) 

2874 arr = ArrayObject() 

2875 else: 

2876 arr = cast( 

2877 ArrayObject, 

2878 cast(DictionaryObject, self._root_object["/AcroForm"])["/Fields"], 

2879 ) 

2880 trslat = self._id_translated[id(reader)] 

2881 try: 

2882 for f in reader.root_object["/AcroForm"]["/Fields"]: # type: ignore[index] 

2883 try: 

2884 ind = IndirectObject(trslat[f.idnum], 0, self) 

2885 if ind not in arr: 

2886 arr.append(ind) 

2887 except KeyError: 

2888 # for trslat[] which mean the field has not be copied 

2889 # through the page 

2890 pass 

2891 except KeyError: # for /Acroform or /Fields are not existing 

2892 arr = self._add_object(ArrayObject()) 

2893 cast(DictionaryObject, self._root_object["/AcroForm"])[ 

2894 NameObject("/Fields") 

2895 ] = arr 

2896 

2897 if "/B" not in excluded_fields: 

2898 self.add_filtered_articles("", srcpages, reader) 

2899 

2900 def _merge__process_named_dests(self, dest: Any, reader: PdfDocCommon, srcpages: dict[int, PageObject]) -> None: 

2901 arr: Any = dest.dest_array 

2902 if "/Names" in self._root_object and dest["/Title"] in cast( 

2903 list[Any], 

2904 cast( 

2905 DictionaryObject, 

2906 cast(DictionaryObject, self._root_object["/Names"]).get("/Dests", DictionaryObject()), 

2907 ).get("/Names", DictionaryObject()), 

2908 ): 

2909 # already exists: should not duplicate it 

2910 pass 

2911 elif dest["/Page"] is None or isinstance(dest["/Page"], NullObject): 

2912 pass 

2913 elif isinstance(dest["/Page"], int): 

2914 # the page reference is a page number normally not a PDF Reference 

2915 # page numbers as int are normally accepted only in external goto 

2916 try: 

2917 p = reader.pages[dest["/Page"]] 

2918 except IndexError: 

2919 return 

2920 assert p.indirect_reference is not None 

2921 try: 

2922 arr[NumberObject(0)] = NumberObject( 

2923 srcpages[p.indirect_reference.idnum].page_number 

2924 ) 

2925 self.add_named_destination_array(dest["/Title"], arr) 

2926 except KeyError: 

2927 pass 

2928 elif dest["/Page"].indirect_reference.idnum in srcpages: 

2929 arr[NumberObject(0)] = srcpages[ 

2930 dest["/Page"].indirect_reference.idnum 

2931 ].indirect_reference 

2932 self.add_named_destination_array(dest["/Title"], arr) 

2933 

2934 def _add_articles_thread( 

2935 self, 

2936 thread: DictionaryObject, 

2937 pages: dict[int, PageObject], 

2938 reader: PdfReader, 

2939 ) -> IndirectObject: 

2940 """ 

2941 Clone the thread with only the applicable articles. 

2942 

2943 Args: 

2944 thread: Thread entry from the reader's array of threads 

2945 pages: Mapping of object numbers to page objects. 

2946 reader: The corresponding reader. 

2947 

2948 Returns: 

2949 The added thread as an indirect reference 

2950 

2951 """ 

2952 new_thread = thread.clone( 

2953 self, force_duplicate=True, ignore_fields=("/F",) 

2954 ) # use of clone to keep link between reader and writer 

2955 self.threads.append(new_thread.indirect_reference) 

2956 first_article = cast("DictionaryObject", thread["/F"]) 

2957 current_article: Optional[DictionaryObject] = first_article 

2958 new_article: Optional[DictionaryObject] = None 

2959 

2960 visited: set[int] = set() 

2961 while current_article is not None: 

2962 article_id = id(current_article) 

2963 if article_id in visited: 

2964 raise LimitReachedError("Detected cyclic article structure.") 

2965 visited.add(article_id) 

2966 

2967 page = self._get_cloned_page( 

2968 cast("PageObject", current_article["/P"]), pages, reader 

2969 ) 

2970 if page is not None: 

2971 if new_article is None: 

2972 new_article = cast( 

2973 "DictionaryObject", 

2974 self._add_object(DictionaryObject()).get_object(), 

2975 ) 

2976 new_first = new_article 

2977 new_thread[NameObject("/F")] = new_article.indirect_reference 

2978 else: 

2979 new_article2 = cast( 

2980 "DictionaryObject", 

2981 self._add_object( 

2982 DictionaryObject( 

2983 {NameObject("/V"): new_article.indirect_reference} 

2984 ) 

2985 ).get_object(), 

2986 ) 

2987 new_article[NameObject("/N")] = new_article2.indirect_reference 

2988 new_article = new_article2 

2989 new_article[NameObject("/P")] = page 

2990 new_article[NameObject("/T")] = new_thread.indirect_reference 

2991 new_article[NameObject("/R")] = current_article["/R"] 

2992 page_object = cast("PageObject", page.get_object()) 

2993 if "/B" not in page_object: 

2994 page_object[NameObject("/B")] = ArrayObject() 

2995 cast("ArrayObject", page_object["/B"]).append( 

2996 new_article.indirect_reference 

2997 ) 

2998 

2999 current_article = cast("DictionaryObject", current_article["/N"]) 

3000 if current_article == first_article: 

3001 new_article[NameObject("/N")] = new_first.indirect_reference # type: ignore[index] 

3002 new_first[NameObject("/V")] = new_article.indirect_reference # type: ignore[union-attr] 

3003 current_article = None 

3004 

3005 assert new_thread.indirect_reference is not None 

3006 return new_thread.indirect_reference 

3007 

3008 def add_filtered_articles( 

3009 self, 

3010 fltr: Union[ 

3011 Pattern[Any], str 

3012 ], # thread entry from the reader's array of threads 

3013 pages: dict[int, PageObject], 

3014 reader: PdfReader, 

3015 ) -> None: 

3016 """ 

3017 Add articles matching the defined criteria. 

3018 

3019 Args: 

3020 fltr: 

3021 pages: 

3022 reader: 

3023 

3024 """ 

3025 if isinstance(fltr, str): 

3026 fltr = re.compile(fltr) 

3027 elif not isinstance(fltr, Pattern): 

3028 fltr = re.compile("") 

3029 for p in pages.values(): 

3030 pp = p.original_page 

3031 for a in pp.get("/B", ()): 

3032 a_obj = a.get_object() 

3033 if is_null_or_none(a_obj): 

3034 continue 

3035 thr = a_obj.get("/T") 

3036 if thr is None: 

3037 continue 

3038 thr = thr.get_object() 

3039 if thr.indirect_reference.idnum not in self._id_translated[ 

3040 id(reader) 

3041 ] and fltr.search((thr.get("/I", {})).get("/Title", "")): 

3042 self._add_articles_thread(thr, pages, reader) 

3043 

3044 def _get_cloned_page( 

3045 self, 

3046 page: Union[IndirectObject, PageObject, NullObject, int, None], 

3047 pages: dict[int, PageObject], 

3048 reader: PdfReader, 

3049 ) -> Optional[IndirectObject]: 

3050 if isinstance(page, NullObject): 

3051 return None 

3052 if isinstance(page, int): 

3053 # An explicit destination may reference the target page by its 

3054 # (zero-based) index in the source document rather than by an 

3055 # indirect reference; this is what `Link(target_page_index=...)` 

3056 # produces. Resolve the index through the reader's page list so it 

3057 # can be remapped like a regular page reference. A destination that 

3058 # points past the end of the source document is dropped. 

3059 try: 

3060 page = reader.pages[page].indirect_reference 

3061 except IndexError: 

3062 return None 

3063 if isinstance(page, DictionaryObject) and page.get("/Type", "") == "/Page": 

3064 _i = page.indirect_reference 

3065 elif isinstance(page, IndirectObject): 

3066 _i = page 

3067 try: 

3068 return pages[_i.idnum].indirect_reference # type: ignore[union-attr] 

3069 except Exception: 

3070 return None 

3071 

3072 def _insert_filtered_annotations( 

3073 self, 

3074 *, 

3075 annots: Union[IndirectObject, list[PdfObject], None], 

3076 page: PageObject, 

3077 pages: dict[int, PageObject], 

3078 reader: PdfReader, 

3079 parent_fields: Optional[dict[int, DictionaryObject]] = None, 

3080 ) -> list[Destination]: 

3081 outlist = ArrayObject() 

3082 if isinstance(annots, IndirectObject): 

3083 annots = cast("list[Any]", annots.get_object()) 

3084 if annots is None: 

3085 return outlist 

3086 if not isinstance(annots, list): 

3087 logger_warning( 

3088 "Expected list of annotations, got %(annots)s of type %(annots_type)s.", 

3089 source=__name__, 

3090 annots=annots, 

3091 annots_type=annots.__class__.__name__, 

3092 ) 

3093 return outlist 

3094 for an in annots: 

3095 ano = cast("DictionaryObject", an.get_object()) 

3096 if ( 

3097 ano.get("/Subtype") != "/Link" 

3098 or "/A" not in ano 

3099 or cast("DictionaryObject", ano["/A"])["/S"] != "/GoTo" # type: ignore[comparison-overlap] 

3100 or "/Dest" in ano 

3101 ): 

3102 if ( 

3103 parent_fields is not None 

3104 and ano.get("/Subtype") == "/Widget" 

3105 and isinstance(ano.get("/Parent"), IndirectObject) 

3106 ): 

3107 outlist.append(self._clone_widget_annotation(ano, parent_fields)) 

3108 elif "/Dest" not in ano: 

3109 outlist.append(self._add_object(ano.clone(self))) 

3110 else: 

3111 d = ano["/Dest"] 

3112 if isinstance(d, str): 

3113 # it is a named dest 

3114 if str(d) in self.get_named_dest_root(): 

3115 outlist.append(ano.clone(self).indirect_reference) 

3116 else: 

3117 if not isinstance(d, ArrayObject) or not d: 

3118 continue 

3119 p = self._get_cloned_page(d[0], pages, reader) 

3120 if p is not None: 

3121 anc = ano.clone(self, ignore_fields=("/Dest",)) 

3122 anc[NameObject("/Dest")] = ArrayObject([p, *d[1:]]) 

3123 outlist.append(self._add_object(anc)) 

3124 else: 

3125 d = cast("DictionaryObject", ano["/A"]).get("/D", NullObject()) 

3126 if is_null_or_none(d): 

3127 continue 

3128 if isinstance(d, str): 

3129 # it is a named dest 

3130 if str(d) in self.get_named_dest_root(): 

3131 outlist.append(ano.clone(self).indirect_reference) 

3132 else: 

3133 if not isinstance(d, ArrayObject) or not d: 

3134 continue 

3135 p = self._get_cloned_page(d[0], pages, reader) 

3136 if p is not None: 

3137 anc = ano.clone(self, ignore_fields=("/D",)) 

3138 cast("DictionaryObject", anc["/A"])[ 

3139 NameObject("/D") 

3140 ] = ArrayObject([p, *d[1:]]) 

3141 outlist.append(self._add_object(anc)) 

3142 return outlist 

3143 

3144 def _clone_widget_annotation( 

3145 self, annotation: DictionaryObject, parent_fields: dict[int, DictionaryObject] 

3146 ) -> IndirectObject: 

3147 """ 

3148 Clone a widget annotation and link it to its parent fields. 

3149 

3150 Cloning ``/Parent`` directly would also clone the ``/Kids`` of the 

3151 parent fields, and with them the widgets and pages of sibling fields 

3152 which are not part of the merge. Instead, the parent fields are cloned 

3153 without ``/Kids`` and collected in ``parent_fields``, so that their kids 

3154 can be set by :meth:`_set_cloned_kids` once all annotations are cloned. 

3155 """ 

3156 clone = annotation.clone(self, ignore_fields=(1, "/Parent")) 

3157 source, target = annotation, clone 

3158 while isinstance(source.get("/Parent"), IndirectObject): 

3159 parent_reference = cast(IndirectObject, source.get("/Parent")) 

3160 source_parent = parent_reference.get_object() 

3161 if not isinstance(source_parent, DictionaryObject): 

3162 break 

3163 parent = source_parent.clone(self, ignore_fields=(1, "/Kids", 1, "/Parent")) 

3164 target[NameObject("/Parent")] = parent.indirect_reference 

3165 if parent_reference.idnum in parent_fields: 

3166 # The ancestors have already been linked. 

3167 break 

3168 parent_fields[parent_reference.idnum] = source_parent 

3169 source, target = source_parent, parent 

3170 assert clone.indirect_reference is not None 

3171 return clone.indirect_reference 

3172 

3173 def _set_cloned_kids(self, parent_fields: dict[int, DictionaryObject], reader: PdfReader) -> None: 

3174 """Set the ``/Kids`` of the cloned parent fields to the cloned kids, in their original order.""" 

3175 translated = self._id_translated.get(id(reader), {}) 

3176 for idnum, source_parent in parent_fields.items(): 

3177 kids = source_parent.get("/Kids", ArrayObject()) 

3178 parent = cast(DictionaryObject, self.get_object(translated[idnum])) 

3179 parent[NameObject("/Kids")] = ArrayObject( 

3180 IndirectObject(translated[kid.idnum], 0, self) 

3181 for kid in cast(ArrayObject, kids) 

3182 if isinstance(kid, IndirectObject) and kid.idnum in translated 

3183 ) 

3184 

3185 def _get_filtered_outline( 

3186 self, 

3187 *, 

3188 node: Any, 

3189 pages: dict[int, PageObject], 

3190 reader: PdfReader, 

3191 visited: Optional[set[int]] = None, 

3192 ) -> list[Destination]: 

3193 """ 

3194 Extract outline item entries that are part of the specified page set. 

3195 

3196 Returns: 

3197 A list of destination objects. 

3198 

3199 """ 

3200 if visited is None: 

3201 visited = set() 

3202 new_outline: list[Destination] = [] 

3203 if node is None: 

3204 return new_outline 

3205 node = node.get_object() 

3206 if is_null_or_none(node): 

3207 node = DictionaryObject() 

3208 

3209 if node.get("/Type", "") == "/Outlines" or "/Title" not in node: 

3210 node_id = id(node) 

3211 if node_id in visited: 

3212 logger_warning("Detected cycle in outlines.", source=__name__) 

3213 return [] 

3214 visited.add(node_id) 

3215 

3216 node = node.get("/First", None) 

3217 if node is not None: 

3218 node = node.get_object() 

3219 new_outline += self._get_filtered_outline(node=node, pages=pages, reader=reader, visited=visited) 

3220 else: 

3221 cloned_page: Union[IndirectObject, NullObject, None] 

3222 while True: 

3223 node = node.get_object() 

3224 node_id = id(node) 

3225 if node_id in visited: 

3226 logger_warning("Detected cycle in outlines.", source=__name__) 

3227 break 

3228 visited.add(node_id) 

3229 

3230 destination = cast("Destination", reader._build_outline_item(node)) 

3231 cloned_page = self._get_cloned_page(cast("PageObject", destination["/Page"]), pages, reader) 

3232 if cloned_page is None: 

3233 cloned_page = NullObject() 

3234 destination[NameObject("/Page")] = cloned_page 

3235 if "/First" in node: 

3236 destination._filtered_children = self._get_filtered_outline( 

3237 node=node["/First"], pages=pages, reader=reader, visited=visited 

3238 ) 

3239 else: 

3240 destination._filtered_children = [] 

3241 if ( 

3242 not isinstance(cloned_page, NullObject) 

3243 or len(destination._filtered_children) > 0 

3244 ): 

3245 new_outline.append(destination) 

3246 

3247 if "/Next" not in node: 

3248 break 

3249 node = node["/Next"] 

3250 return new_outline 

3251 

3252 def _clone_outline(self, dest: Destination) -> TreeObject: 

3253 n_ol = TreeObject() 

3254 self._add_object(n_ol) 

3255 n_ol[NameObject("/Title")] = TextStringObject(dest["/Title"]) 

3256 if not isinstance(dest["/Page"], NullObject): 

3257 if dest.node is not None and "/A" in dest.node: 

3258 n_ol[NameObject("/A")] = dest.node["/A"].clone(self) 

3259 else: 

3260 n_ol[NameObject("/Dest")] = dest.dest_array 

3261 # TODO: /SE 

3262 if dest.node is not None: 

3263 n_ol[NameObject("/F")] = NumberObject(dest.node.get("/F", 0)) 

3264 color = dest.node.get("/C", NullObject()).get_object() 

3265 if not isinstance(color, list): 

3266 color = [FloatObject(0.0), FloatObject(0.0), FloatObject(0.0)] 

3267 n_ol[NameObject("/C")] = ArrayObject(color) 

3268 return n_ol 

3269 

3270 def _insert_filtered_outline( 

3271 self, 

3272 outlines: list[Destination], 

3273 parent: Union[TreeObject, IndirectObject], 

3274 before: Union[TreeObject, IndirectObject, None] = None, 

3275 ) -> None: 

3276 for dest in outlines: 

3277 # TODO: can be improved to keep A and SE entries (ignored for the moment) 

3278 # with np=self.add_outline_item_destination(dest,parent,before) 

3279 if dest.get("/Type", "") == "/Outlines" or "/Title" not in dest: 

3280 np = parent 

3281 else: 

3282 np = self._clone_outline(dest) 

3283 cast(TreeObject, parent.get_object()).insert_child(np, before, self) 

3284 self._insert_filtered_outline(dest._filtered_children, np, None) 

3285 

3286 def close(self) -> None: 

3287 """Implemented for API harmonization.""" 

3288 return 

3289 

3290 def find_outline_item( 

3291 self, 

3292 outline_item: Union[IndirectObject, str], 

3293 root: Optional[OutlineType] = None, 

3294 *, 

3295 visited: Optional[set[int]] = None, 

3296 ) -> Optional[list[int]]: 

3297 if root is None: 

3298 outline = self.get_outline_root() 

3299 else: 

3300 outline = cast("TreeObject", root) 

3301 if visited is None: 

3302 visited = set() 

3303 

3304 index = 0 

3305 while outline is not None: 

3306 if ( 

3307 outline.indirect_reference == outline_item 

3308 or outline.get("/Title", None) == outline_item 

3309 ): 

3310 return [index] 

3311 

3312 outline_id = id(outline) 

3313 if outline_id in visited: 

3314 raise LimitReachedError("Detected cyclic outline hierarchy.") 

3315 visited.add(outline_id) 

3316 

3317 if "/First" in outline: 

3318 result = self.find_outline_item( 

3319 outline_item=outline_item, 

3320 root=cast(OutlineType, outline["/First"]), 

3321 visited=visited, 

3322 ) 

3323 if result: 

3324 return ([index] if "/Title" in outline else []) + result 

3325 if "/Next" in outline: 

3326 index += 1 

3327 outline = cast(TreeObject, outline["/Next"]) 

3328 else: 

3329 return None 

3330 raise PyPdfError("This line is theoretically unreachable.") # pragma: no cover 

3331 

3332 def reset_translation( 

3333 self, reader: Union[PdfReader, IndirectObject, None] = None 

3334 ) -> None: 

3335 """ 

3336 Reset the translation table between reader and the writer object. 

3337 

3338 Late cloning will create new independent objects. 

3339 

3340 Args: 

3341 reader: PdfReader or IndirectObject referencing a PdfReader object. 

3342 if set to None or omitted, all tables will be reset. 

3343 

3344 """ 

3345 if reader is None: 

3346 self._id_translated = {} 

3347 elif isinstance(reader, PdfReader): 

3348 try: 

3349 del self._id_translated[id(reader)] 

3350 except Exception: 

3351 pass 

3352 elif isinstance(reader, IndirectObject): 

3353 try: 

3354 del self._id_translated[id(reader.pdf)] 

3355 except Exception: 

3356 pass 

3357 else: 

3358 raise TypeError(f"Invalid parameter {reader}") 

3359 

3360 def set_page_label( 

3361 self, 

3362 page_index_from: int, 

3363 page_index_to: int, 

3364 style: Optional[PageLabelStyle] = None, 

3365 prefix: Optional[str] = None, 

3366 start: Optional[int] = 0, 

3367 ) -> None: 

3368 """ 

3369 Set a page label to a range of pages. 

3370 

3371 Page indexes must be given starting from 0. 

3372 Labels must have a style, a prefix or both. 

3373 If a range is not assigned any page label, a decimal label starting from 1 is applied. 

3374 

3375 Args: 

3376 page_index_from: page index of the beginning of the range starting from 0 

3377 page_index_to: page index of the beginning of the range starting from 0 

3378 style: The numbering style to be used for the numeric portion of each page label: 

3379 

3380 * ``/D`` Decimal Arabic numerals 

3381 * ``/R`` Uppercase Roman numerals 

3382 * ``/r`` Lowercase Roman numerals 

3383 * ``/A`` Uppercase letters (A to Z for the first 26 pages, 

3384 AA to ZZ for the next 26, and so on) 

3385 * ``/a`` Lowercase letters (a to z for the first 26 pages, 

3386 aa to zz for the next 26, and so on) 

3387 

3388 prefix: The label prefix for page labels in this range. 

3389 start: The value of the numeric portion for the first page label 

3390 in the range. 

3391 Subsequent pages are numbered sequentially from this value, 

3392 which must be greater than or equal to 1. 

3393 Default value: 1. 

3394 

3395 """ 

3396 if style is None and prefix is None: 

3397 raise ValueError("At least one of style and prefix must be given") 

3398 if style is not None and style not in tuple(PageLabelStyle): 

3399 raise ValueError( 

3400 f"style must be one of: {', '.join(PageLabelStyle)}, got {style!r}" 

3401 ) 

3402 if page_index_from < 0: 

3403 raise ValueError("page_index_from must be greater or equal than 0") 

3404 if page_index_to < page_index_from: 

3405 raise ValueError( 

3406 "page_index_to must be greater or equal than page_index_from" 

3407 ) 

3408 if page_index_to >= len(self.pages): 

3409 raise ValueError("page_index_to exceeds number of pages") 

3410 if start is not None and start != 0 and start < 1: 

3411 raise ValueError("If given, start must be greater or equal than one") 

3412 

3413 self._set_page_label(page_index_from, page_index_to, style, prefix, start) 

3414 

3415 def _set_page_label( 

3416 self, 

3417 page_index_from: int, 

3418 page_index_to: int, 

3419 style: Optional[PageLabelStyle] = None, 

3420 prefix: Optional[str] = None, 

3421 start: Optional[int] = 0, 

3422 ) -> None: 

3423 """ 

3424 Set a page label to a range of pages. 

3425 

3426 Page indexes must be given starting from 0. 

3427 Labels must have a style, a prefix or both. 

3428 If a range is not assigned any page label a decimal label starting from 1 is applied. 

3429 

3430 Args: 

3431 page_index_from: page index of the beginning of the range starting from 0 

3432 page_index_to: page index of the beginning of the range starting from 0 

3433 style: The numbering style to be used for the numeric portion of each page label: 

3434 /D Decimal Arabic numerals 

3435 /R Uppercase Roman numerals 

3436 /r Lowercase Roman numerals 

3437 /A Uppercase letters (A to Z for the first 26 pages, 

3438 AA to ZZ for the next 26, and so on) 

3439 /a Lowercase letters (a to z for the first 26 pages, 

3440 aa to zz for the next 26, and so on) 

3441 prefix: The label prefix for page labels in this range. 

3442 start: The value of the numeric portion for the first page label 

3443 in the range. 

3444 Subsequent pages are numbered sequentially from this value, 

3445 which must be greater than or equal to 1. Default value: 1. 

3446 

3447 """ 

3448 default_page_label = DictionaryObject() 

3449 default_page_label[NameObject("/S")] = NameObject("/D") 

3450 

3451 new_page_label = DictionaryObject() 

3452 if style is not None: 

3453 new_page_label[NameObject("/S")] = NameObject(style) 

3454 if prefix is not None: 

3455 new_page_label[NameObject("/P")] = TextStringObject(prefix) 

3456 if start != 0: 

3457 new_page_label[NameObject("/St")] = NumberObject(start) 

3458 

3459 if NameObject(CatalogAttributes.PAGE_LABELS) not in self._root_object: 

3460 nums = ArrayObject() 

3461 nums_insert(NumberObject(0), default_page_label, nums) 

3462 page_labels = TreeObject() 

3463 page_labels[NameObject("/Nums")] = nums 

3464 self._root_object[NameObject(CatalogAttributes.PAGE_LABELS)] = page_labels 

3465 

3466 page_labels = cast( 

3467 TreeObject, self._root_object[NameObject(CatalogAttributes.PAGE_LABELS)] 

3468 ) 

3469 nums = cast(ArrayObject, page_labels[NameObject("/Nums")]) 

3470 

3471 nums_insert(NumberObject(page_index_from), new_page_label, nums) 

3472 nums_clear_range(NumberObject(page_index_from), page_index_to, nums) 

3473 next_label_pos, *_ = nums_next(NumberObject(page_index_from), nums) 

3474 if next_label_pos != page_index_to + 1 and page_index_to + 1 < len(self.pages): 

3475 nums_insert(NumberObject(page_index_to + 1), default_page_label, nums) 

3476 

3477 page_labels[NameObject("/Nums")] = nums 

3478 self._root_object[NameObject(CatalogAttributes.PAGE_LABELS)] = page_labels 

3479 

3480 def _repr_mimebundle_( 

3481 self, 

3482 include: Union[Iterable[str], None] = None, 

3483 exclude: Union[Iterable[str], None] = None, 

3484 ) -> dict[str, Any]: 

3485 """ 

3486 Integration into Jupyter Notebooks. 

3487 

3488 This method returns a dictionary that maps a mime-type to its 

3489 representation. 

3490 

3491 .. seealso:: 

3492 

3493 https://ipython.readthedocs.io/en/stable/config/integrating.html 

3494 """ 

3495 pdf_data = BytesIO() 

3496 self.write(pdf_data) 

3497 data = { 

3498 "application/pdf": pdf_data, 

3499 } 

3500 

3501 if include is not None: 

3502 # Filter representations based on include list 

3503 data = {k: v for k, v in data.items() if k in include} 

3504 

3505 if exclude is not None: 

3506 # Remove representations based on exclude list 

3507 data = {k: v for k, v in data.items() if k not in exclude} 

3508 

3509 return data 

3510 

3511 

3512def _pdf_objectify(obj: Union[dict[str, Any], str, float, list[Any]]) -> PdfObject: 

3513 if isinstance(obj, PdfObject): 

3514 return obj 

3515 if isinstance(obj, dict): 

3516 to_add = DictionaryObject() 

3517 for key, value in obj.items(): 

3518 to_add[NameObject(key)] = _pdf_objectify(value) 

3519 return to_add 

3520 if isinstance(obj, str): 

3521 if obj.startswith("/"): 

3522 return NameObject(obj) 

3523 return TextStringObject(obj) 

3524 if isinstance(obj, (float, int)): 

3525 return FloatObject(obj) 

3526 if isinstance(obj, list): 

3527 return ArrayObject(_pdf_objectify(i) for i in obj) 

3528 raise NotImplementedError( 

3529 f"{type(obj)=} could not be cast to a PdfObject" 

3530 ) 

3531 

3532 

3533def _create_outline_item( 

3534 action_ref: Union[IndirectObject, None], 

3535 title: str, 

3536 color: Union[tuple[float, float, float], str, None], 

3537 italic: bool, 

3538 bold: bool, 

3539) -> TreeObject: 

3540 outline_item = TreeObject() 

3541 if action_ref is not None: 

3542 outline_item[NameObject("/A")] = action_ref 

3543 outline_item.update( 

3544 { 

3545 NameObject("/Title"): create_string_object(title), 

3546 } 

3547 ) 

3548 if color: 

3549 if isinstance(color, str): 

3550 color = hex_to_rgb(color) 

3551 outline_item.update( 

3552 {NameObject("/C"): ArrayObject([FloatObject(c) for c in color])} 

3553 ) 

3554 if italic or bold: 

3555 format_flag = 0 

3556 if italic: 

3557 format_flag += OutlineFontFlag.italic 

3558 if bold: 

3559 format_flag += OutlineFontFlag.bold 

3560 outline_item.update({NameObject("/F"): NumberObject(format_flag)}) 

3561 return outline_item