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

1486 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 

36import uuid 

37from collections.abc import Iterable, Mapping, Sequence 

38from io import BytesIO, FileIO, IOBase 

39from itertools import compress 

40from pathlib import Path 

41from re import Pattern 

42from types import TracebackType 

43from typing import ( 

44 IO, 

45 Any, 

46 Callable, 

47 Literal, 

48 Optional, 

49 Union, 

50 cast, 

51) 

52 

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

54 from typing import Self 

55else: 

56 from typing_extensions import Self 

57 

58from ._doc_common import DocumentInformation, PdfDocCommon 

59from ._encryption import EncryptAlgorithm, Encryption 

60from ._page import PageObject, Transformation 

61from ._page_labels import nums_clear_range, nums_insert, nums_next 

62from ._reader import PdfReader 

63from ._utils import ( 

64 StrByteType, 

65 StreamType, 

66 _get_max_pdf_version_header, 

67 deprecate_with_replacement, 

68 deprecation_no_replacement, 

69 logger_warning, 

70) 

71from .constants import AnnotationDictionaryAttributes as AA 

72from .constants import ( 

73 CatalogAttributes, 

74 Core, 

75 GoToActionArguments, 

76 ImageType, 

77 InteractiveFormDictEntries, 

78 OutlineFontFlag, 

79 PageLabelStyle, 

80 PagesAttributes, 

81 TypFitArguments, 

82 UserAccessPermissions, 

83) 

84from .constants import FieldDictionaryAttributes as FA 

85from .constants import PageAttributes as PG 

86from .constants import TrailerKeys as TK 

87from .errors import LimitReachedError, PdfReadError, PyPdfError 

88from .generic import ( 

89 PAGE_FIT, 

90 ArrayObject, 

91 BooleanObject, 

92 ByteStringObject, 

93 ContentStream, 

94 Destination, 

95 DictionaryObject, 

96 EmbeddedFile, 

97 Fit, 

98 FloatObject, 

99 IndirectObject, 

100 NameObject, 

101 NullObject, 

102 NumberObject, 

103 PdfObject, 

104 RectangleObject, 

105 ReferenceLink, 

106 StreamObject, 

107 TextStringObject, 

108 TreeObject, 

109 ViewerPreferences, 

110 create_string_object, 

111 extract_links, 

112 hex_to_rgb, 

113 is_null_or_none, 

114) 

115from .generic._appearance_stream import TextStreamAppearance 

116from .pagerange import PageRange, PageRangeSpec 

117from .types import ( 

118 AnnotationSubtype, 

119 BorderArrayType, 

120 LayoutType, 

121 OutlineItemType, 

122 OutlineType, 

123 PagemodeType, 

124) 

125from .xmp import XmpInformation 

126 

127ALL_DOCUMENT_PERMISSIONS = UserAccessPermissions.all() 

128 

129 

130class ObjectDeletionFlag(enum.IntFlag): 

131 NONE = 0 

132 TEXT = enum.auto() 

133 LINKS = enum.auto() 

134 ATTACHMENTS = enum.auto() 

135 OBJECTS_3D = enum.auto() 

136 ALL_ANNOTATIONS = enum.auto() 

137 XOBJECT_IMAGES = enum.auto() 

138 INLINE_IMAGES = enum.auto() 

139 DRAWING_IMAGES = enum.auto() 

140 IMAGES = XOBJECT_IMAGES | INLINE_IMAGES | DRAWING_IMAGES 

141 

142 

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

144 hash = hashlib.md5(usedforsecurity=False) 

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

146 hash.update(block) 

147 return hash.hexdigest() 

148 

149 

150class PdfWriter(PdfDocCommon): 

151 """ 

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

153 cloning a PDF file during initialization. 

154 

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

156 

157 Args: 

158 clone_from: identical to fileobj (for compatibility) 

159 

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

161 

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

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

164 

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

166 This parameter may allow loading large PDFs. 

167 

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

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

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

171 

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

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

174 

175 """ 

176 

177 def __init__( 

178 self, 

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

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

181 incremental: bool = False, 

182 full: bool = False, 

183 strict: bool = False, 

184 *, 

185 keep_initial_header: bool = False, 

186 incremental_clone_object_count_limit: Optional[int] = 500_000, 

187 incremental_clone_object_id_limit: Optional[int] = 1_000_000, 

188 ) -> None: 

189 self.strict = strict 

190 """ 

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

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

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

194 """ 

195 

196 self.incremental = incremental or full 

197 """ 

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

199 """ 

200 

201 self._objects: list[Optional[PdfObject]] = [] 

202 """ 

203 The indirect objects in the PDF. 

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

205 in clone_reader_document_root. 

206 """ 

207 

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

209 """ 

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

211 """ 

212 

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

214 """ 

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

216 This is used for compression. 

217 """ 

218 

219 self._id_translated: dict[int, dict[Union[int, Literal["PreventGC"]], Any]] = {} 

220 """List of already translated IDs. 

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

222 """ 

223 

224 self._info_obj: Optional[PdfObject] 

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

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

227 

228 self._reader: Optional[PdfReader] = None 

229 """The document being appended to, in incremental mode only.""" 

230 

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

232 """The PDF file identifier, 

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

234 

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

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

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

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

239 

240 # Security parameters. 

241 self._incremental_clone_object_count_limit = ( 

242 incremental_clone_object_count_limit 

243 if isinstance(incremental_clone_object_count_limit, int) 

244 else sys.maxsize 

245 ) 

246 self._incremental_clone_object_id_limit = ( 

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

248 ) 

249 

250 if self.incremental: 

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

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

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

254 if isinstance(fileobj, BytesIO): 

255 fileobj = PdfReader(fileobj) 

256 if not isinstance(fileobj, PdfReader): 

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

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

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

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

261 else: 

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

263 self._info_obj = self._add_object( 

264 DictionaryObject( 

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

266 ) 

267 ) 

268 

269 def _get_clone_from( 

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

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

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

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

274 fileobj == "" or clone_from is not None 

275 ): 

276 return clone_from 

277 cloning = True 

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

279 fileobj_path = Path(fileobj) 

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

281 cloning = False 

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

283 t = fileobj.tell() 

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

285 cloning = False 

286 fileobj.seek(t, 0) 

287 if cloning: 

288 clone_from = fileobj 

289 return clone_from 

290 

291 clone_from = _get_clone_from(fileobj, clone_from) 

292 # To prevent overwriting 

293 self.temp_fileobj = fileobj 

294 self.fileobj = "" 

295 self._with_as_usage = False 

296 self._cloned = False 

297 # The root of our page tree node 

298 pages = DictionaryObject( 

299 { 

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

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

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

303 } 

304 ) 

305 self.flattened_pages = [] 

306 self._encryption: Optional[Encryption] = None 

307 self._encrypt_entry: Optional[DictionaryObject] = None 

308 

309 if clone_from is not None: 

310 if not isinstance(clone_from, PdfReader): 

311 clone_from = PdfReader(clone_from) 

312 self.clone_document_from_reader(clone_from) 

313 self._cloned = True 

314 if keep_initial_header and not self.incremental: 

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

316 else: 

317 self._pages = self._add_object(pages) 

318 self._root_object = DictionaryObject( 

319 { 

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

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

322 } 

323 ) 

324 self._add_object(self._root_object) 

325 if full and not incremental: 

326 self.incremental = False 

327 if isinstance(self._ID, list): 

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

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

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

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

332 

333 # for commonality 

334 @property 

335 def is_encrypted(self) -> bool: 

336 """ 

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

338 

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

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

341 """ 

342 return False 

343 

344 @property 

345 def root_object(self) -> DictionaryObject: 

346 """ 

347 Provide direct access to PDF Structure. 

348 

349 Note: 

350 Recommended only for read access. 

351 

352 """ 

353 return self._root_object 

354 

355 @property 

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

357 """ 

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

359 

360 Returns: 

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

362 

363 """ 

364 return ( 

365 None 

366 if self._info_obj is None 

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

368 ) 

369 

370 @_info.setter 

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

372 if value is None: 

373 try: 

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

375 except (KeyError, AttributeError): 

376 pass 

377 self._info_obj = None 

378 else: 

379 if self._info_obj is None: 

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

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

382 obj.clear() 

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

384 

385 @property 

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

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

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

389 

390 @xmp_metadata.setter 

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

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

393 if value is None: 

394 if "/Metadata" in self.root_object: 

395 del self.root_object["/Metadata"] 

396 return 

397 

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

399 if not isinstance(metadata, IndirectObject): 

400 if metadata is not None: 

401 del self.root_object["/Metadata"] 

402 metadata_stream = StreamObject() 

403 stream_reference = self._add_object(metadata_stream) 

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

405 else: 

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

407 

408 if isinstance(value, XmpInformation): 

409 bytes_data = value.stream.get_data() 

410 else: 

411 bytes_data = value 

412 metadata_stream.set_data(bytes_data) 

413 

414 @property 

415 def with_as_usage(self) -> bool: 

416 deprecation_no_replacement("with_as_usage", "5.0") 

417 

418 @with_as_usage.setter 

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

420 deprecation_no_replacement("with_as_usage", "5.0") 

421 

422 def __enter__(self) -> Self: 

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

424 c: bool = self._cloned 

425 t = self.temp_fileobj 

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

427 self._cloned = c 

428 self._with_as_usage = True 

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

430 return self 

431 

432 def __exit__( 

433 self, 

434 exc_type: Optional[type[BaseException]], 

435 exc: Optional[BaseException], 

436 traceback: Optional[TracebackType], 

437 ) -> None: 

438 """Write data to the fileobj.""" 

439 if self.fileobj and not self._cloned: 

440 self.write(self.fileobj) 

441 

442 @property 

443 def pdf_header(self) -> str: 

444 """ 

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

446 

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

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

449 PDF file. 

450 

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

452 """ 

453 return self._header.decode() 

454 

455 @pdf_header.setter 

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

457 if isinstance(new_header, str): 

458 new_header = new_header.encode() 

459 self._header = new_header 

460 

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

462 if ( 

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

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

465 ): 

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

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

468 if isinstance(obj, DictionaryObject) and isinstance( 

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

470 ): 

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

472 self._objects.append(obj) 

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

474 return obj.indirect_reference 

475 

476 def get_object( 

477 self, 

478 indirect_reference: Union[int, IndirectObject], 

479 ) -> PdfObject: 

480 if isinstance(indirect_reference, int): 

481 obj = self._objects[indirect_reference - 1] 

482 elif indirect_reference.pdf != self: 

483 raise ValueError("PDF must be self") 

484 else: 

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

486 if obj is None: 

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

488 return obj 

489 

490 def _replace_object( 

491 self, 

492 indirect_reference: Union[int, IndirectObject], 

493 obj: PdfObject, 

494 ) -> PdfObject: 

495 if isinstance(indirect_reference, IndirectObject): 

496 if indirect_reference.pdf != self: 

497 raise ValueError("PDF must be self") 

498 indirect_reference = indirect_reference.idnum 

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

500 if ( 

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

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

503 ): 

504 obj = obj.clone(self) 

505 self._objects[indirect_reference - 1] = obj 

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

507 

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

509 return obj 

510 

511 def _add_page( 

512 self, 

513 page: PageObject, 

514 index: int, 

515 excluded_keys: Iterable[str] = (), 

516 ) -> PageObject: 

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

518 raise ValueError("Invalid page object") 

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

520 page_org = page 

521 excluded_keys = list(excluded_keys) 

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

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

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

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

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

527 try: # delete an already existing page 

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

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

530 ] 

531 except Exception: 

532 pass 

533 

534 page = cast( 

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

536 ) 

537 if page_org.pdf is not None: 

538 other = page_org.pdf.pdf_header 

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

540 

541 node, idx = self._get_page_in_node(index) 

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

543 

544 if idx >= 0: 

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

546 self.flattened_pages.insert(index, page) 

547 else: 

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

549 self.flattened_pages.append(page) 

550 current: Optional[PdfObject] = node 

551 recurse = 0 

552 while not is_null_or_none(current): 

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

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

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

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

557 recurse += 1 

558 if recurse > 1000: 

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

560 

561 if page_org.pdf is not None: 

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

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

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

565 # later. 

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

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

568 

569 return page 

570 

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

572 """ 

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

574 

575 The "NeedAppearances" flag indicates whether the appearance dictionary 

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

577 if the embedded appearance should be used. 

578 

579 Args: 

580 state: The actual value of the NeedAppearances flag. 

581 

582 Returns: 

583 None 

584 

585 """ 

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

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

588 try: 

589 # get the AcroForm tree 

590 if CatalogAttributes.ACRO_FORM not in self._root_object: 

591 self._root_object[ 

592 NameObject(CatalogAttributes.ACRO_FORM) 

593 ] = self._add_object(DictionaryObject()) 

594 

595 need_appearances = NameObject(InteractiveFormDictEntries.NeedAppearances) 

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

597 need_appearances 

598 ] = BooleanObject(state) 

599 except Exception as exc: # pragma: no cover 

600 logger_warning( 

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

602 source=__name__, 

603 state=state, 

604 exc=exc, 

605 ) 

606 

607 def create_viewer_preferences(self) -> ViewerPreferences: 

608 o = ViewerPreferences() 

609 self._root_object[ 

610 NameObject(CatalogAttributes.VIEWER_PREFERENCES) 

611 ] = self._add_object(o) 

612 return o 

613 

614 def add_page( 

615 self, 

616 page: PageObject, 

617 excluded_keys: Iterable[str] = (), 

618 ) -> PageObject: 

619 """ 

620 Add a page to this PDF file. 

621 

622 Recommended for advanced usage including the adequate excluded_keys. 

623 

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

625 instance. 

626 

627 Args: 

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

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

630 excluded_keys: 

631 

632 Returns: 

633 The added PageObject. 

634 

635 """ 

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

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

638 

639 def insert_page( 

640 self, 

641 page: PageObject, 

642 index: int = 0, 

643 excluded_keys: Iterable[str] = (), 

644 ) -> PageObject: 

645 """ 

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

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

648 

649 Args: 

650 page: The page to add to the document. 

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

652 excluded_keys: 

653 

654 Returns: 

655 The added PageObject. 

656 

657 """ 

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

659 if index < 0: 

660 index += len(self.flattened_pages) 

661 if index < 0: 

662 raise ValueError("Invalid index value") 

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

664 return self.add_page(page, excluded_keys) 

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

666 

667 def _get_page_number_by_indirect( 

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

669 ) -> Optional[int]: 

670 """ 

671 Generate _page_id2num. 

672 

673 Args: 

674 indirect_reference: 

675 

676 Returns: 

677 The page number or None 

678 

679 """ 

680 # To provide same function as in PdfReader 

681 if is_null_or_none(indirect_reference): 

682 return None 

683 assert indirect_reference is not None, "mypy" 

684 if isinstance(indirect_reference, int): 

685 indirect_reference = IndirectObject(indirect_reference, 0, self) 

686 obj = indirect_reference.get_object() 

687 if isinstance(obj, PageObject): 

688 return obj.page_number 

689 return None 

690 

691 def add_blank_page( 

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

693 ) -> PageObject: 

694 """ 

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

696 

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

698 

699 Args: 

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

701 space units. 

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

703 user space units. 

704 

705 Returns: 

706 The newly appended page. 

707 

708 Raises: 

709 PageSizeNotDefinedError: if width and height are not defined 

710 and previous page does not exist. 

711 

712 """ 

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

714 return self.add_page(page) 

715 

716 def insert_blank_page( 

717 self, 

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

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

720 index: int = 0, 

721 ) -> PageObject: 

722 """ 

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

724 

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

726 

727 Args: 

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

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

730 index: Position to add the page. 

731 

732 Returns: 

733 The newly inserted page. 

734 

735 Raises: 

736 PageSizeNotDefinedError: if width and height are not defined 

737 and previous page does not exist. 

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

739 """ 

740 num_pages = self.get_num_pages() 

741 if abs(index) <= num_pages: 

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

743 fixed_index = min(index, num_pages - 1) 

744 mediabox = self.pages[fixed_index].mediabox 

745 if width is None or width <= 0: 

746 width = mediabox.width 

747 if height is None or height <= 0: 

748 height = mediabox.height 

749 else: 

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

751 

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

753 self.insert_page(page, index) 

754 return page 

755 

756 @property 

757 def open_destination( 

758 self, 

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

760 return super().open_destination 

761 

762 @open_destination.setter 

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

764 if dest is None: 

765 try: 

766 del self._root_object["/OpenAction"] 

767 except KeyError: 

768 pass 

769 elif isinstance(dest, str): 

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

771 elif isinstance(dest, Destination): 

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

773 elif isinstance(dest, PageObject): 

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

775 "Opening", 

776 dest.indirect_reference 

777 if dest.indirect_reference is not None 

778 else NullObject(), 

779 PAGE_FIT, 

780 ).dest_array 

781 

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

783 """ 

784 Add JavaScript which will launch upon opening this PDF. 

785 

786 Args: 

787 javascript: Your JavaScript. 

788 

789 Example: 

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

791 

792 >>> from pypdf import PdfWriter 

793 >>> output = PdfWriter() 

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

795 

796 """ 

797 # Names / JavaScript preferred to be able to add multiple scripts 

798 if "/Names" not in self._root_object: 

799 self._root_object[NameObject(CatalogAttributes.NAMES)] = DictionaryObject() 

800 names = cast(DictionaryObject, self._root_object[CatalogAttributes.NAMES]) 

801 if "/JavaScript" not in names: 

802 names[NameObject("/JavaScript")] = DictionaryObject( 

803 {NameObject("/Names"): ArrayObject()} 

804 ) 

805 js_list = cast( 

806 ArrayObject, cast(DictionaryObject, names["/JavaScript"])["/Names"] 

807 ) 

808 # We need a name for parameterized JavaScript in the PDF file, 

809 # but it can be anything. 

810 js_list.append(create_string_object(str(uuid.uuid4()))) 

811 

812 js = DictionaryObject( 

813 { 

814 NameObject(PagesAttributes.TYPE): NameObject("/Action"), 

815 NameObject("/S"): NameObject("/JavaScript"), 

816 NameObject("/JS"): TextStringObject(f"{javascript}"), 

817 } 

818 ) 

819 js_list.append(self._add_object(js)) 

820 

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

822 """ 

823 Embed a file inside the PDF. 

824 

825 Reference: 

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

827 Section 7.11.3 

828 

829 Args: 

830 filename: The filename to display. 

831 data: The data in the file. 

832 

833 Returns: 

834 EmbeddedFile instance for the newly created embedded file. 

835 

836 """ 

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

838 

839 def append_pages_from_reader( 

840 self, 

841 reader: PdfReader, 

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

843 ) -> None: 

844 """ 

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

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

847 

848 ``append`` should be preferred. 

849 

850 Args: 

851 reader: a PdfReader object from which to copy page 

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

853 will then be updated. 

854 after_page_append: 

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

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

857 (delegates to append_pages_from_reader). The single parameter of 

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

859 document. 

860 

861 """ 

862 reader_num_pages = len(reader.pages) 

863 # Copy pages from reader to writer 

864 for reader_page_number in range(reader_num_pages): 

865 reader_page = reader.pages[reader_page_number] 

866 writer_page = self.add_page(reader_page) 

867 # Trigger callback, pass writer page as parameter 

868 if callable(after_page_append): 

869 after_page_append(writer_page) 

870 

871 def _merge_content_stream_to_page( 

872 self, 

873 page: PageObject, 

874 new_content_data: bytes, 

875 ) -> None: 

876 """ 

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

878 

879 Args: 

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

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

882 instance the commands to draw an XObject. 

883 """ 

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

885 # PDF Explained by John Whitington 

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

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

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

889 existing_content = existing_content_ref.get_object() 

890 

891 if isinstance(existing_content, ArrayObject): 

892 # Create a new StreamObject for the new_content_data 

893 new_stream_obj = StreamObject() 

894 new_stream_obj.set_data(new_content_data) 

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

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

897 if isinstance(existing_content, StreamObject): 

898 # Merge new content to existing StreamObject 

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

900 new_stream = StreamObject() 

901 new_stream.set_data(merged_data) 

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

903 else: 

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

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

906 new_stream = StreamObject() 

907 new_stream.set_data(new_content_data) 

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

909 

910 def _add_apstream_object( 

911 self, 

912 page: PageObject, 

913 appearance_stream_obj: StreamObject, 

914 object_name: str, 

915 x_offset: float, 

916 y_offset: float, 

917 ) -> None: 

918 """ 

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

920 an XObject. 

921 

922 Args: 

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

924 appearance_stream_obj: The appearance stream. 

925 object_name: The name of the appearance stream. 

926 x_offset: The horizontal offset for the appearance stream. 

927 y_offset: The vertical offset for the appearance stream. 

928 """ 

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

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

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

932 xobject_ref = self._add_object(appearance_stream_obj) 

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

934 if "/XObject" not in pg_res: 

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

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

937 if xobject_name not in pg_xo_res: 

938 pg_xo_res[xobject_name] = xobject_ref 

939 else: 

940 logger_warning( 

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

942 source=__name__, 

943 xobject_name=xobject_name, 

944 ) 

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

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

947 self._merge_content_stream_to_page(page, xobject_drawing_commands) 

948 

949 FFBITS_NUL = FA.FfBits(0) 

950 

951 def update_page_form_field_values( 

952 self, 

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

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

955 flags: FA.FfBits = FFBITS_NUL, 

956 auto_regenerate: Optional[bool] = True, 

957 flatten: bool = False, 

958 ) -> None: 

959 """ 

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

961 

962 Copy field texts and values from fields to page. 

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

964 

965 Args: 

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

967 annotations and field data will be updated. 

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

969 `None` - all pages. 

970 fields: a Python dictionary of: 

971 

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

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

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

975 * text values (/V) 

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

977 * font size (0 for autosize) 

978 

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

980 

981 auto_regenerate: Set/unset the need_appearances flag; 

982 the flag is unchanged if auto_regenerate is None. 

983 

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

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

986 annotation itself. 

987 

988 """ 

989 if CatalogAttributes.ACRO_FORM not in self._root_object: 

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

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

992 if InteractiveFormDictEntries.Fields not in acro_form: 

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

994 if isinstance(auto_regenerate, bool): 

995 self.set_need_appearances_writer(auto_regenerate) 

996 # Iterate through pages, update field values 

997 if page is None: 

998 page = list(self.pages) 

999 if isinstance(page, list): 

1000 for p in page: 

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

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

1003 return 

1004 if PG.ANNOTS not in page: 

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

1006 return 

1007 appearance_stream_obj: Optional[StreamObject] = None 

1008 

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

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

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

1012 continue 

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

1014 parent_annotation = annotation 

1015 else: 

1016 parent_annotation = annotation.get( 

1017 PG.PARENT, DictionaryObject() 

1018 ).get_object() 

1019 

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

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

1022 if not ( 

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

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

1025 ): 

1026 continue 

1027 if ( 

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

1029 and "/I" in parent_annotation 

1030 ): 

1031 del parent_annotation["/I"] 

1032 if flags: 

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

1034 # Set the field value 

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

1036 if isinstance(value, list): 

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

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

1039 elif isinstance(value, tuple): 

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

1041 value[0], 

1042 ) 

1043 else: 

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

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

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

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

1048 # We can find the associated appearance stream object 

1049 # within the annotation. 

1050 v = NameObject(value) 

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

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

1053 if v not in normal_ap: 

1054 v = NameObject("/Off") 

1055 appearance_stream_obj = normal_ap.get(v) 

1056 # Other cases will be updated through the for loop 

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

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

1059 elif ( 

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

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

1062 ): 

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

1064 if isinstance(value, tuple): 

1065 appearance_stream_obj = TextStreamAppearance.from_text_annotation( 

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

1067 ) 

1068 else: 

1069 appearance_stream_obj = TextStreamAppearance.from_text_annotation( 

1070 self, page, flatten, acro_form, parent_annotation, annotation 

1071 ) 

1072 # Add the appearance stream object 

1073 if AA.AP not in annotation: 

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

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

1076 ) 

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

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

1079 NameObject("/N") 

1080 ] = self._add_object(appearance_stream_obj) 

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

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

1083 self._objects[n - 1] = appearance_stream_obj 

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

1085 elif ( 

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

1087 ): # deprecated # not implemented yet 

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

1089 

1090 if appearance_stream_obj and flatten: 

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

1092 

1093 def reattach_fields( 

1094 self, page: Optional[PageObject] = None 

1095 ) -> list[DictionaryObject]: 

1096 """ 

1097 Parse annotations within the page looking for orphan fields and 

1098 reattach then into the Fields Structure. 

1099 

1100 Args: 

1101 page: page to analyze. 

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

1103 

1104 Returns: 

1105 list of reattached fields. 

1106 

1107 """ 

1108 lst = [] 

1109 if page is None: 

1110 for p in self.pages: 

1111 lst += self.reattach_fields(p) 

1112 return lst 

1113 

1114 try: 

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

1116 except KeyError: 

1117 af = DictionaryObject() 

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

1119 try: 

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

1121 except KeyError: 

1122 fields = ArrayObject() 

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

1124 

1125 if "/Annots" not in page: 

1126 return lst 

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

1128 for idx, annotation in enumerate(annotations): 

1129 is_indirect = isinstance(annotation, IndirectObject) 

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

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

1132 if ( 

1133 "indirect_reference" in annotation.__dict__ 

1134 and annotation.indirect_reference in fields 

1135 ): 

1136 continue 

1137 if not is_indirect: 

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

1139 fields.append(annotation.indirect_reference) 

1140 lst.append(annotation) 

1141 return lst 

1142 

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

1144 object_ids: set[int] = set() 

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

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

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

1148 

1149 object_count = len(object_ids) 

1150 if object_count > self._incremental_clone_object_count_limit: 

1151 raise LimitReachedError( 

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

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

1154 ) 

1155 

1156 max_object_id = max(object_ids, default=0) 

1157 if max_object_id > self._incremental_clone_object_id_limit: 

1158 raise LimitReachedError( 

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

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

1161 ) 

1162 

1163 return sorted(object_ids) 

1164 

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

1166 """ 

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

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

1169 should be considered. 

1170 

1171 Args: 

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

1173 

1174 """ 

1175 self._info_obj = None 

1176 if self.incremental: 

1177 object_ids = self._collect_incremental_clone_object_ids(reader) 

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

1179 for object_id in object_ids: 

1180 reader_object = reader.get_object(object_id) 

1181 if reader_object is not None: 

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

1183 else: 

1184 self._objects.clear() 

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

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

1187 

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

1189 if len(self._objects) > trailer_size: 

1190 if self.strict: 

1191 raise PdfReadError( 

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

1193 ) 

1194 logger_warning( 

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

1196 source=__name__, 

1197 object_count=len(self._objects), 

1198 trailer_size=trailer_size, 

1199 ) 

1200 

1201 # must be done here before rewriting 

1202 if self.incremental: 

1203 self._original_hash = [ 

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

1205 ] 

1206 

1207 try: 

1208 self._flatten() 

1209 except IndexError: 

1210 raise PdfReadError("Got index error while flattening.") 

1211 

1212 assert self.flattened_pages is not None 

1213 for p in self.flattened_pages: 

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

1215 if not self.incremental: 

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

1217 if not self.incremental: 

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

1219 NameObject("/Kids") 

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

1221 

1222 def clone_document_from_reader( 

1223 self, 

1224 reader: PdfReader, 

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

1226 ) -> None: 

1227 """ 

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

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

1230 

1231 Args: 

1232 reader: PDF file reader instance from which the clone 

1233 should be created. 

1234 after_page_append: 

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

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

1237 (delegates to append_pages_from_reader). The single parameter of 

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

1239 document. 

1240 

1241 """ 

1242 self.clone_reader_document_root(reader) 

1243 inf = reader._info 

1244 if self.incremental: 

1245 if inf is not None: 

1246 self._info_obj = cast( 

1247 IndirectObject, inf.clone(self).indirect_reference 

1248 ) 

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

1250 self._original_hash[ 

1251 self._info_obj.indirect_reference.idnum - 1 

1252 ] = self._info.hash_bin() 

1253 elif inf is not None: 

1254 self._info_obj = self._add_object( 

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

1256 ) 

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

1258 

1259 try: 

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

1261 except AttributeError: 

1262 pass 

1263 

1264 if callable(after_page_append): 

1265 for page in cast( 

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

1267 ): 

1268 after_page_append(page.get_object()) 

1269 

1270 def _compute_document_identifier(self) -> ByteStringObject: 

1271 stream = BytesIO() 

1272 self._write_pdf_structure(stream) 

1273 stream.seek(0) 

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

1275 

1276 def generate_file_identifiers(self) -> None: 

1277 """ 

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

1279 

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

1281 required. 

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

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

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

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

1286 see §14.4 "File Identifiers". 

1287 """ 

1288 if self._ID: 

1289 id1 = self._ID[0] 

1290 id2 = self._compute_document_identifier() 

1291 else: 

1292 id1 = self._compute_document_identifier() 

1293 id2 = id1 

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

1295 

1296 def encrypt( 

1297 self, 

1298 user_password: str, 

1299 owner_password: Optional[str] = None, 

1300 use_128bit: bool = True, 

1301 permissions_flag: UserAccessPermissions = ALL_DOCUMENT_PERMISSIONS, 

1302 *, 

1303 algorithm: Optional[str] = None, 

1304 ) -> None: 

1305 """ 

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

1307 

1308 Args: 

1309 user_password: The password which allows for opening 

1310 and reading the PDF file with the restrictions provided. 

1311 owner_password: The password which allows for 

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

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

1314 use_128bit: flag as to whether to use 128bit 

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

1316 By default, this flag is on. 

1317 permissions_flag: permissions as described in 

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

1319 the permission is granted. 

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

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

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

1323 10 for extraction of text and graphics. 

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

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

1326 `use_128bit` will be ignored. 

1327 

1328 """ 

1329 if self.incremental: 

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

1331 

1332 if owner_password is None: 

1333 owner_password = user_password 

1334 

1335 if algorithm is not None: 

1336 try: 

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

1338 except AttributeError: 

1339 raise ValueError(f"Algorithm '{algorithm}' NOT supported") 

1340 else: 

1341 alg = EncryptAlgorithm.RC4_128 

1342 if not use_128bit: 

1343 alg = EncryptAlgorithm.RC4_40 

1344 self.generate_file_identifiers() 

1345 assert self._ID 

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

1347 # in case call `encrypt` again 

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

1349 if self._encrypt_entry: 

1350 # replace old encrypt_entry 

1351 assert self._encrypt_entry.indirect_reference is not None 

1352 entry.indirect_reference = self._encrypt_entry.indirect_reference 

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

1354 else: 

1355 self._add_object(entry) 

1356 self._encrypt_entry = entry 

1357 

1358 def _resolve_links(self) -> None: 

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

1360 make sure they still point to the same pages. 

1361 """ 

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

1363 old_page = old_link.find_referenced_page() 

1364 if not old_page: 

1365 continue 

1366 new_page = self._merged_in_pages.get(old_page) 

1367 if new_page is None: 

1368 continue 

1369 new_link.patch_reference(self, new_page) 

1370 

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

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

1373 logger_warning( 

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

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

1376 source=__name__, 

1377 stream_name=stream.name, 

1378 ) 

1379 self._resolve_links() 

1380 

1381 if self.incremental: 

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

1383 self._reader.stream.seek(0) 

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

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

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

1387 else: 

1388 object_positions, free_objects = self._write_pdf_structure(stream) 

1389 xref_location = self._write_xref_table( 

1390 stream, object_positions, free_objects 

1391 ) 

1392 self._write_trailer(stream, xref_location) 

1393 

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

1395 """ 

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

1397 

1398 Args: 

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

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

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

1402 existing workflow. 

1403 

1404 Returns: 

1405 A tuple (bool, IO). 

1406 

1407 """ 

1408 my_file = False 

1409 

1410 if stream == "": 

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

1412 

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

1414 stream = FileIO(stream, "wb") 

1415 my_file = True 

1416 

1417 self.write_stream(stream) 

1418 

1419 if my_file: 

1420 stream.close() 

1421 else: 

1422 stream.flush() 

1423 

1424 return my_file, stream 

1425 

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

1427 """ 

1428 For analysis or debugging. 

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

1430 in the increment. 

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

1432 

1433 Returns: 

1434 List of new or modified IndirectObjects 

1435 

1436 """ 

1437 original_hash_count = len(self._original_hash) 

1438 return [ 

1439 cast(IndirectObject, obj).indirect_reference 

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

1441 if ( 

1442 obj is not None 

1443 and ( 

1444 i >= original_hash_count 

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

1446 ) 

1447 ) 

1448 ] 

1449 

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

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

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

1453 object_positions = {} 

1454 object_blocks = [] 

1455 current_start = -1 

1456 current_stop = -2 

1457 original_hash_count = len(self._original_hash) 

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

1459 if obj is not None and ( 

1460 i >= original_hash_count 

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

1462 ): 

1463 idnum = i + 1 

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

1465 # first write new/modified object 

1466 object_positions[idnum] = stream.tell() 

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

1468 """ encryption is not operational 

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

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

1471 """ 

1472 obj.write_to_stream(stream) 

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

1474 

1475 # prepare xref 

1476 if idnum != current_stop: 

1477 if current_start > 0: 

1478 object_blocks.append( 

1479 [current_start, current_stop - current_start] 

1480 ) 

1481 current_start = idnum 

1482 current_stop = idnum + 1 

1483 assert current_start > 0, "for pytest only" 

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

1485 # write incremented xref 

1486 xref_location = stream.tell() 

1487 xr_id = len(self._objects) + 1 

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

1489 init_data = { 

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

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

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

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

1494 NameObject("/Index"): ArrayObject( 

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

1496 ), 

1497 NameObject("/W"): ArrayObject( 

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

1499 ), 

1500 "__streamdata__": b"", 

1501 } 

1502 if self._info is not None and ( 

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

1504 >= len(self._original_hash) 

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

1506 != self._original_hash[ 

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

1508 ] 

1509 ): 

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

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

1512 if self._ID: 

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

1514 xr = StreamObject.initialize_from_dictionary(init_data) 

1515 xr.set_data( 

1516 b"".join( 

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

1518 ) 

1519 ) 

1520 xr.write_to_stream(stream) 

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

1522 

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

1524 object_positions = [] 

1525 free_objects = [] 

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

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

1528 

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

1530 if obj is not None: 

1531 object_positions.append(stream.tell()) 

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

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

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

1535 obj.write_to_stream(stream) 

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

1537 else: 

1538 object_positions.append(-1) 

1539 free_objects.append(idnum) 

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

1541 return object_positions, free_objects 

1542 

1543 def _write_xref_table( 

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

1545 ) -> int: 

1546 xref_location = stream.tell() 

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

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

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

1550 free_idx = 1 

1551 for offset in object_positions: 

1552 if offset > 0: 

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

1554 else: 

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

1556 free_idx += 1 

1557 return xref_location 

1558 

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

1560 """ 

1561 Write the PDF trailer to the stream. 

1562 

1563 To quote the PDF specification: 

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

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

1566 """ 

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

1568 trailer = DictionaryObject( 

1569 { 

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

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

1572 } 

1573 ) 

1574 if self._info is not None: 

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

1576 if self._ID is not None: 

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

1578 if self._encrypt_entry: 

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

1580 trailer.write_to_stream(stream) 

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

1582 

1583 @property 

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

1585 """ 

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

1587 

1588 Args: 

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

1590 

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

1592 information dictionaries, and these metadata streams will not be 

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

1594 

1595 """ 

1596 return super().metadata 

1597 

1598 @metadata.setter 

1599 def metadata( 

1600 self, 

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

1602 ) -> None: 

1603 if value is None: 

1604 self._info = None 

1605 else: 

1606 if self._info is not None: 

1607 self._info.clear() 

1608 

1609 self.add_metadata(value) 

1610 

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

1612 """ 

1613 Add custom metadata to the output. 

1614 

1615 Args: 

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

1617 and each value is your new metadata. 

1618 

1619 """ 

1620 args = {} 

1621 if isinstance(infos, PdfObject): 

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

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

1624 if isinstance(value, PdfObject): 

1625 value = value.get_object() 

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

1627 if self._info is None: 

1628 self._info = DictionaryObject() 

1629 self._info.update(args) 

1630 

1631 _UNSET = object() 

1632 

1633 def compress_identical_objects( 

1634 self, 

1635 remove_identicals: Any = _UNSET, 

1636 remove_orphans: Any = _UNSET, 

1637 *, 

1638 remove_duplicates: bool = True, 

1639 remove_unreferenced: bool = True, 

1640 ) -> None: 

1641 """ 

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

1643 This will make objects common to multiple pages. 

1644 Recommended to be used just before writing output. 

1645 

1646 Args: 

1647 remove_identicals: Deprecated. 

1648 remove_orphans: Deprecated. 

1649 remove_duplicates: Remove duplicate objects. 

1650 remove_unreferenced: Remove unreferenced objects. 

1651 

1652 """ 

1653 if remove_identicals != self._UNSET: 

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

1655 assert isinstance(remove_identicals, bool) 

1656 remove_duplicates = remove_identicals 

1657 if remove_orphans != self._UNSET: 

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

1659 assert isinstance(remove_orphans, bool) 

1660 remove_unreferenced = remove_orphans 

1661 

1662 def replace_in_obj( 

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

1664 ) -> None: 

1665 if isinstance(obj, DictionaryObject): 

1666 key_val = obj.items() 

1667 elif isinstance(obj, ArrayObject): 

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

1669 else: 

1670 return 

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

1672 for k, v in key_val: 

1673 if isinstance(v, IndirectObject): 

1674 unreferenced[v.idnum - 1] = False 

1675 if v in crossref: 

1676 obj[k] = crossref[v] 

1677 else: 

1678 """The filtering on DictionaryObject and ArrayObject only 

1679 will be performed within replace_in_obj""" 

1680 replace_in_obj(v, crossref) 

1681 

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

1683 self._idnum_hash = {} 

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

1685 # look for similar objects 

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

1687 if is_null_or_none(obj): 

1688 continue 

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

1690 assert isinstance(obj.indirect_reference, IndirectObject) 

1691 h = obj.hash_value() 

1692 if remove_duplicates and h in self._idnum_hash: 

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

1694 self._objects[idx] = None 

1695 else: 

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

1697 

1698 # generate the dict converting others to 1st 

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

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

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

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

1703 

1704 # replace reference to merged objects 

1705 for obj in self._objects: 

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

1707 replace_in_obj(obj, cnv_rev) 

1708 

1709 if remove_unreferenced: 

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

1711 

1712 if not is_null_or_none(self._info): 

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

1714 

1715 try: 

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

1717 except AttributeError: 

1718 pass 

1719 

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

1721 self._objects[i] = None 

1722 

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

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

1725 ref = IndirectObject(idnum, 0, self) 

1726 assert ref.get_object() == obj 

1727 return ref 

1728 

1729 def get_outline_root(self) -> TreeObject: 

1730 if Core.OUTLINES in self._root_object: 

1731 # Entries in the catalog dictionary 

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

1733 if not isinstance(outline, TreeObject): 

1734 t = TreeObject(outline) 

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

1736 outline = t 

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

1738 outline_ref = IndirectObject(idnum, 0, self) 

1739 assert outline_ref.get_object() == outline 

1740 else: 

1741 outline = TreeObject() 

1742 outline.update({}) 

1743 outline_ref = self._add_object(outline) 

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

1745 

1746 return outline 

1747 

1748 def get_threads_root(self) -> ArrayObject: 

1749 """ 

1750 The list of threads. 

1751 

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

1753 

1754 Returns: 

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

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

1757 

1758 """ 

1759 if Core.THREADS in self._root_object: 

1760 # Entries in the catalog dictionary 

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

1762 else: 

1763 threads = ArrayObject() 

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

1765 return threads 

1766 

1767 @property 

1768 def threads(self) -> ArrayObject: 

1769 """ 

1770 Read-only property for the list of threads. 

1771 

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

1773 

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

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

1776 """ 

1777 return self.get_threads_root() 

1778 

1779 def add_outline_item_destination( 

1780 self, 

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

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

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

1784 is_open: bool = True, 

1785 ) -> IndirectObject: 

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

1787 if isinstance(page_destination, PageObject): 

1788 return self.add_outline_item_destination( 

1789 Destination( 

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

1791 cast(IndirectObject, page_destination.indirect_reference), 

1792 Fit.fit(), 

1793 ) 

1794 ) 

1795 

1796 if parent is None: 

1797 parent = self.get_outline_root() 

1798 

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

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

1801 page_destination_ref = self._add_object(page_destination) 

1802 if before is not None: 

1803 before = before.indirect_reference 

1804 parent.insert_child( 

1805 page_destination_ref, 

1806 before, 

1807 self, 

1808 page_destination.inc_parent_counter_outline, 

1809 ) 

1810 if "/Count" not in page_destination: 

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

1812 

1813 return page_destination_ref 

1814 

1815 def add_outline_item_dict( 

1816 self, 

1817 outline_item: OutlineItemType, 

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

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

1820 is_open: bool = True, 

1821 ) -> IndirectObject: 

1822 outline_item_object = TreeObject() 

1823 outline_item_object.update(outline_item) 

1824 

1825 """code currently unreachable 

1826 if "/A" in outline_item: 

1827 action = DictionaryObject() 

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

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

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

1831 action_ref = self._add_object(action) 

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

1833 """ 

1834 return self.add_outline_item_destination( 

1835 outline_item_object, parent, before, is_open 

1836 ) 

1837 

1838 def add_outline_item( 

1839 self, 

1840 title: str, 

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

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

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

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

1845 bold: bool = False, 

1846 italic: bool = False, 

1847 fit: Fit = PAGE_FIT, 

1848 is_open: bool = True, 

1849 ) -> IndirectObject: 

1850 """ 

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

1852 

1853 Args: 

1854 title: Title to use for this outline item. 

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

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

1857 outline items. 

1858 before: 

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

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

1861 bold: Outline item font is bold 

1862 italic: Outline item font is italic 

1863 fit: The fit of the destination page. 

1864 

1865 Returns: 

1866 The added outline item as an indirect object. 

1867 

1868 """ 

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

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

1871 if fit is not None and page_number is None: 

1872 page_number = fit 

1873 return self.add_outline_item( 

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

1875 ) 

1876 if page_number is None: 

1877 action_ref = None 

1878 else: 

1879 if isinstance(page_number, IndirectObject): 

1880 page_ref = page_number 

1881 elif isinstance(page_number, PageObject): 

1882 page_ref = page_number.indirect_reference 

1883 elif isinstance(page_number, int): 

1884 try: 

1885 page_ref = self.pages[page_number].indirect_reference 

1886 except IndexError: 

1887 page_ref = NumberObject(page_number) 

1888 if page_ref is None: 

1889 logger_warning( 

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

1891 source=__name__, 

1892 page_number=page_number, 

1893 ) 

1894 page_ref = NullObject() 

1895 dest = Destination( 

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

1897 page_ref, 

1898 fit, 

1899 ) 

1900 

1901 action_ref = self._add_object( 

1902 DictionaryObject( 

1903 { 

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

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

1906 } 

1907 ) 

1908 ) 

1909 outline_item = self._add_object( 

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

1911 ) 

1912 

1913 if parent is None: 

1914 parent = self.get_outline_root() 

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

1916 

1917 def add_outline(self) -> None: 

1918 raise NotImplementedError( 

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

1920 ) 

1921 

1922 def add_named_destination_array( 

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

1924 ) -> None: 

1925 named_dest = self.get_named_dest_root() 

1926 i = 0 

1927 while i < len(named_dest): 

1928 if title < named_dest[i]: 

1929 named_dest.insert(i, destination) 

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

1931 return 

1932 i += 2 

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

1934 return 

1935 

1936 def add_named_destination_object( 

1937 self, 

1938 page_destination: PdfObject, 

1939 ) -> IndirectObject: 

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

1941 self.add_named_destination_array( 

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

1943 ) 

1944 

1945 return page_destination_ref 

1946 

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

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

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

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

1951 count = len(kids) 

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

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

1954 return kids[page_number] 

1955 

1956 def add_named_destination( 

1957 self, 

1958 title: str, 

1959 page_number: int, 

1960 ) -> IndirectObject: 

1961 page_ref = self._get_page_reference(page_number) 

1962 dest = DictionaryObject() 

1963 dest.update( 

1964 { 

1965 NameObject(GoToActionArguments.D): ArrayObject( 

1966 [page_ref, NameObject(TypFitArguments.FIT_H), NumberObject(826)] 

1967 ), 

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

1969 } 

1970 ) 

1971 

1972 dest_ref = self._add_object(dest) 

1973 if not isinstance(title, TextStringObject): 

1974 title = TextStringObject(str(title)) 

1975 

1976 self.add_named_destination_array(title, dest_ref) 

1977 return dest_ref 

1978 

1979 def remove_links(self) -> None: 

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

1981 for page in self.pages: 

1982 self.remove_objects_from_page(page, ObjectDeletionFlag.ALL_ANNOTATIONS) 

1983 

1984 def remove_annotations( 

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

1986 ) -> None: 

1987 """ 

1988 Remove annotations by annotation subtype. 

1989 

1990 Args: 

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

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

1993 "/Movie", "/Screen", ... 

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

1995 

1996 """ 

1997 for page in self.pages: 

1998 self._remove_annots_from_page(page, subtypes) 

1999 

2000 def _remove_annots_from_page( 

2001 self, 

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

2003 subtypes: Optional[Iterable[str]], 

2004 ) -> None: 

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

2006 if PG.ANNOTS in page: 

2007 i = 0 

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

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

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

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

2012 if isinstance(an, IndirectObject): 

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

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

2015 else: 

2016 i += 1 

2017 

2018 def remove_objects_from_page( 

2019 self, 

2020 page: Union[PageObject, DictionaryObject], 

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

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

2023 ) -> None: 

2024 """ 

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

2026 

2027 Args: 

2028 page: Page object to clean up. 

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

2030 or a list of ObjectDeletionFlag 

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

2032 This is a Python dictionary with the following properties: 

2033 

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

2035 

2036 """ 

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

2038 for to_d in to_delete: 

2039 self.remove_objects_from_page(page, to_d) 

2040 return None 

2041 assert isinstance(to_delete, ObjectDeletionFlag) 

2042 

2043 if to_delete & ObjectDeletionFlag.LINKS: 

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

2045 if to_delete & ObjectDeletionFlag.ATTACHMENTS: 

2046 return self._remove_annots_from_page( 

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

2048 ) 

2049 if to_delete & ObjectDeletionFlag.OBJECTS_3D: 

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

2051 if to_delete & ObjectDeletionFlag.ALL_ANNOTATIONS: 

2052 return self._remove_annots_from_page(page, None) 

2053 

2054 jump_operators = [] 

2055 if to_delete & ObjectDeletionFlag.DRAWING_IMAGES: 

2056 jump_operators = [ 

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

2058 b"W", b"W*", 

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

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

2061 b"sh" 

2062 ] 

2063 if to_delete & ObjectDeletionFlag.TEXT: 

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

2065 

2066 if not isinstance(page, PageObject): 

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

2068 if "/Contents" in page: 

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

2070 

2071 images, forms = self._remove_objects_from_page__clean_forms( 

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

2073 ) 

2074 

2075 self._remove_objects_from_page__clean( 

2076 content=content, images=images, forms=forms, 

2077 jump_operators=jump_operators, to_delete=to_delete, 

2078 text_filters=text_filters 

2079 ) 

2080 page.replace_contents(content) 

2081 return None 

2082 

2083 def _remove_objects_from_page__clean( 

2084 self, 

2085 content: ContentStream, 

2086 images: list[str], 

2087 forms: list[str], 

2088 jump_operators: list[bytes], 

2089 to_delete: ObjectDeletionFlag, 

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

2091 ) -> None: 

2092 font_id = None 

2093 font_ids_to_delete = [] 

2094 if text_filters and to_delete & ObjectDeletionFlag.TEXT: 

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

2096 

2097 i = 0 

2098 while i < len(content.operations): 

2099 operands, operator = content.operations[i] 

2100 if operator == b"Tf": 

2101 font_id = operands[0] 

2102 if ( 

2103 ( 

2104 operator == b"INLINE IMAGE" 

2105 and (to_delete & ObjectDeletionFlag.INLINE_IMAGES) 

2106 ) 

2107 or (operator in jump_operators) 

2108 or ( 

2109 operator == b"Do" 

2110 and (to_delete & ObjectDeletionFlag.XOBJECT_IMAGES) 

2111 and (operands[0] in images) 

2112 ) 

2113 ): 

2114 if ( 

2115 not to_delete & ObjectDeletionFlag.TEXT 

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

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

2118 ): 

2119 del content.operations[i] 

2120 else: 

2121 i += 1 

2122 else: 

2123 i += 1 

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

2125 

2126 def _remove_objects_from_page__clean_forms( 

2127 self, 

2128 elt: DictionaryObject, 

2129 stack: list[DictionaryObject], 

2130 jump_operators: list[bytes], 

2131 to_delete: ObjectDeletionFlag, 

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

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

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

2135 if (elt in stack) or ( 

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

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

2138 for x in stack 

2139 ) 

2140 ): 

2141 # to prevent infinite looping 

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

2143 try: 

2144 d = cast( 

2145 dict[Any, Any], 

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

2147 ) 

2148 except KeyError: 

2149 d = {} 

2150 images = [] 

2151 forms = [] 

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

2153 o = v.get_object() 

2154 try: 

2155 content: Any = None 

2156 if ( 

2157 to_delete & ObjectDeletionFlag.XOBJECT_IMAGES 

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

2159 ): 

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

2161 images.append(k) 

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

2163 forms.append(k) 

2164 if isinstance(o, ContentStream): 

2165 content = o 

2166 else: 

2167 content = ContentStream(o, self) 

2168 content.update( 

2169 { 

2170 k1: v1 

2171 for k1, v1 in o.items() 

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

2173 } 

2174 ) 

2175 try: 

2176 content.indirect_reference = o.indirect_reference 

2177 except AttributeError: # pragma: no cover 

2178 pass 

2179 stack.append(elt) 

2180 

2181 # clean subforms 

2182 self._remove_objects_from_page__clean_forms( 

2183 elt=content, stack=stack, jump_operators=jump_operators, to_delete=to_delete, 

2184 text_filters=text_filters, 

2185 ) 

2186 if content is not None: 

2187 if isinstance(v, IndirectObject): 

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

2189 else: 

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

2191 # where streams must be indirected. 

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

2193 except (TypeError, KeyError): 

2194 pass 

2195 for im in images: 

2196 del d[im] # for clean-up 

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

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

2199 e = ContentStream(elt, self) 

2200 e.update(elt.items()) 

2201 elt = e 

2202 # clean the content 

2203 self._remove_objects_from_page__clean( 

2204 content=elt, images=images, forms=forms, jump_operators=jump_operators, 

2205 to_delete=to_delete, text_filters=text_filters 

2206 ) 

2207 return images, forms 

2208 

2209 def remove_images( 

2210 self, 

2211 to_delete: ImageType = ImageType.ALL, 

2212 ) -> None: 

2213 """ 

2214 Remove images from this output. 

2215 

2216 Args: 

2217 to_delete: The type of images to be deleted 

2218 (default = all images types) 

2219 

2220 """ 

2221 if isinstance(to_delete, bool): 

2222 to_delete = ImageType.ALL 

2223 

2224 i = ObjectDeletionFlag.NONE 

2225 

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

2227 if to_delete & ImageType[image]: 

2228 i |= ObjectDeletionFlag[image] 

2229 

2230 for page in self.pages: 

2231 self.remove_objects_from_page(page, i) 

2232 

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

2234 """ 

2235 Remove text from the PDF. 

2236 

2237 Args: 

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

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

2240 """ 

2241 if not font_names: 

2242 font_names = [] 

2243 

2244 for page in self.pages: 

2245 resource_ids_to_remove = [] 

2246 

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

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

2249 if font_names: 

2250 # Recursively loop through page objects to gather font info 

2251 def get_font_info( 

2252 obj: Any, 

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

2254 key: Optional[str] = None 

2255 ) -> dict[str, Any]: 

2256 if font_info is None: 

2257 font_info = {} 

2258 if isinstance(obj, IndirectObject): 

2259 obj = obj.get_object() 

2260 if isinstance(obj, dict): 

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

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

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

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

2265 if normalized_font_name not in font_info: 

2266 font_info[normalized_font_name] = { 

2267 "normalized_font_name": normalized_font_name, 

2268 "resource_ids": [], 

2269 } 

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

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

2272 for k in obj: 

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

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

2275 for child_obj in obj: 

2276 font_info = get_font_info(child_obj, font_info) 

2277 return font_info 

2278 

2279 # Add relevant resource names for removal 

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

2281 for font_name in font_names: 

2282 if font_name in font_info: 

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

2284 

2285 text_filters = {} 

2286 if font_names: 

2287 text_filters["font_ids"] = resource_ids_to_remove 

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

2289 

2290 def add_uri( 

2291 self, 

2292 page_number: int, 

2293 uri: str, 

2294 rect: RectangleObject, 

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

2296 ) -> None: 

2297 """ 

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

2299 

2300 Args: 

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

2302 uri: URI of resource to link to. 

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

2304 array of four integers specifying the clickable rectangular area 

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

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

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

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

2309 drawn if this argument is omitted. 

2310 

2311 """ 

2312 page_link = self._get_page_reference(page_number) 

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

2314 

2315 border_arr: BorderArrayType 

2316 if border is not None: 

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

2318 if len(border) == 4: 

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

2320 border_arr.append(dash_pattern) 

2321 else: 

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

2323 

2324 if isinstance(rect, str): 

2325 rect = NumberObject(rect) 

2326 elif isinstance(rect, RectangleObject): 

2327 pass 

2328 else: 

2329 rect = RectangleObject(rect) 

2330 

2331 lnk2 = DictionaryObject() 

2332 lnk2.update( 

2333 { 

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

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

2336 } 

2337 ) 

2338 lnk = DictionaryObject() 

2339 lnk.update( 

2340 { 

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

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

2343 NameObject(AA.P): page_link, 

2344 NameObject(AA.Rect): rect, 

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

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

2347 NameObject("/A"): lnk2, 

2348 } 

2349 ) 

2350 lnk_ref = self._add_object(lnk) 

2351 

2352 if PG.ANNOTS in page_ref: 

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

2354 else: 

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

2356 

2357 _valid_layouts = ( 

2358 "/NoLayout", 

2359 "/SinglePage", 

2360 "/OneColumn", 

2361 "/TwoColumnLeft", 

2362 "/TwoColumnRight", 

2363 "/TwoPageLeft", 

2364 "/TwoPageRight", 

2365 ) 

2366 

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

2368 try: 

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

2370 except KeyError: 

2371 return None 

2372 

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

2374 """ 

2375 Set the page layout. 

2376 

2377 Args: 

2378 layout: The page layout to be used. 

2379 

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

2381 :widths: 50 200 

2382 

2383 * - /NoLayout 

2384 - Layout explicitly not specified 

2385 * - /SinglePage 

2386 - Show one page at a time 

2387 * - /OneColumn 

2388 - Show one column at a time 

2389 * - /TwoColumnLeft 

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

2391 * - /TwoColumnRight 

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

2393 * - /TwoPageLeft 

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

2395 * - /TwoPageRight 

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

2397 

2398 """ 

2399 if not isinstance(layout, NameObject): 

2400 if layout not in self._valid_layouts: 

2401 logger_warning( 

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

2403 source=__name__, 

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

2405 ) 

2406 layout = NameObject(layout) 

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

2408 

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

2410 """ 

2411 Set the page layout. 

2412 

2413 Args: 

2414 layout: The page layout to be used 

2415 

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

2417 :widths: 50 200 

2418 

2419 * - /NoLayout 

2420 - Layout explicitly not specified 

2421 * - /SinglePage 

2422 - Show one page at a time 

2423 * - /OneColumn 

2424 - Show one column at a time 

2425 * - /TwoColumnLeft 

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

2427 * - /TwoColumnRight 

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

2429 * - /TwoPageLeft 

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

2431 * - /TwoPageRight 

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

2433 

2434 """ 

2435 self._set_page_layout(layout) 

2436 

2437 @property 

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

2439 """ 

2440 Page layout property. 

2441 

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

2443 :widths: 50 200 

2444 

2445 * - /NoLayout 

2446 - Layout explicitly not specified 

2447 * - /SinglePage 

2448 - Show one page at a time 

2449 * - /OneColumn 

2450 - Show one column at a time 

2451 * - /TwoColumnLeft 

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

2453 * - /TwoColumnRight 

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

2455 * - /TwoPageLeft 

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

2457 * - /TwoPageRight 

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

2459 """ 

2460 return self._get_page_layout() 

2461 

2462 @page_layout.setter 

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

2464 self._set_page_layout(layout) 

2465 

2466 _valid_modes = ( 

2467 "/UseNone", 

2468 "/UseOutlines", 

2469 "/UseThumbs", 

2470 "/FullScreen", 

2471 "/UseOC", 

2472 "/UseAttachments", 

2473 ) 

2474 

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

2476 try: 

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

2478 except KeyError: 

2479 return None 

2480 

2481 @property 

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

2483 """ 

2484 Page mode property. 

2485 

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

2487 :widths: 50 200 

2488 

2489 * - /UseNone 

2490 - Do not show outline or thumbnails panels 

2491 * - /UseOutlines 

2492 - Show outline (aka bookmarks) panel 

2493 * - /UseThumbs 

2494 - Show page thumbnails panel 

2495 * - /FullScreen 

2496 - Fullscreen view 

2497 * - /UseOC 

2498 - Show Optional Content Group (OCG) panel 

2499 * - /UseAttachments 

2500 - Show attachments panel 

2501 """ 

2502 return self._get_page_mode() 

2503 

2504 @page_mode.setter 

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

2506 if isinstance(mode, NameObject): 

2507 mode_name: NameObject = mode 

2508 else: 

2509 if mode not in self._valid_modes: 

2510 logger_warning( 

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

2512 source=__name__, 

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

2514 ) 

2515 mode_name = NameObject(mode) 

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

2517 

2518 def add_annotation( 

2519 self, 

2520 page_number: Union[int, PageObject], 

2521 annotation: dict[str, Any], 

2522 ) -> DictionaryObject: 

2523 """ 

2524 Add a single annotation to the page. 

2525 The added annotation must be a new annotation. 

2526 It cannot be recycled. 

2527 

2528 Args: 

2529 page_number: PageObject or page index. 

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

2531 

2532 Returns: 

2533 The inserted object. 

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

2535 

2536 """ 

2537 page = page_number 

2538 if isinstance(page, int): 

2539 page = self.pages[page] 

2540 elif not isinstance(page, PageObject): 

2541 raise TypeError("page: invalid type") 

2542 

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

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

2545 

2546 if page.annotations is None: 

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

2548 assert page.annotations is not None 

2549 

2550 # Internal link annotations need the correct object type for the 

2551 # destination 

2552 if to_add.get("/Subtype") == "/Link" and "/Dest" in to_add: 

2553 tmp = cast(dict[Any, Any], to_add[NameObject("/Dest")]) 

2554 dest = Destination( 

2555 NameObject("/LinkName"), 

2556 tmp["target_page_index"], 

2557 Fit( 

2558 fit_type=tmp["fit"], fit_args=dict(tmp)["fit_args"] 

2559 ), # I have no clue why this dict-hack is necessary 

2560 ) 

2561 to_add[NameObject("/Dest")] = dest.dest_array 

2562 

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

2564 

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

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

2567 NameObject("/Popup") 

2568 ] = to_add.indirect_reference 

2569 

2570 return to_add 

2571 

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

2573 """ 

2574 Perform some clean up in the page. 

2575 Currently: convert NameObject named destination to TextStringObject 

2576 (required for names/dests list) 

2577 

2578 Args: 

2579 page: 

2580 

2581 Returns: 

2582 The cleaned PageObject 

2583 

2584 """ 

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

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

2587 a_obj = a.get_object() 

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

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

2590 if isinstance(d, NameObject): 

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

2592 elif act is not None: 

2593 act = act.get_object() 

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

2595 if isinstance(d, NameObject): 

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

2597 return page 

2598 

2599 def _create_stream( 

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

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

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

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

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

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

2606 # BytesIO stream. 

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

2608 encryption_obj = None 

2609 stream: IOBase 

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

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

2612 stream = BytesIO(f.read()) 

2613 elif isinstance(fileobj, PdfReader): 

2614 if fileobj._encryption: 

2615 encryption_obj = fileobj._encryption 

2616 orig_tell = fileobj.stream.tell() 

2617 fileobj.stream.seek(0) 

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

2619 

2620 # reset the stream to its original location 

2621 fileobj.stream.seek(orig_tell) 

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

2623 fileobj.seek(0) 

2624 filecontent = fileobj.read() 

2625 stream = BytesIO(filecontent) 

2626 else: 

2627 raise NotImplementedError( 

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

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

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

2631 "Passing a PdfReader directly works as well." 

2632 ) 

2633 return stream, encryption_obj 

2634 

2635 def append( 

2636 self, 

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

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

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

2640 import_outline: bool = True, 

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

2642 ) -> None: 

2643 """ 

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

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

2646 position. 

2647 

2648 Args: 

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

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

2651 string representing a path to a PDF file. 

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

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

2654 included file. 

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

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

2657 or a list of pages to be processed 

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

2659 document into the output document. 

2660 import_outline: You may prevent the source document's 

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

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

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

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

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

2666 

2667 """ 

2668 if excluded_fields is None: 

2669 excluded_fields = () 

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

2671 if isinstance(pages, bool): 

2672 if not isinstance(import_outline, bool): 

2673 excluded_fields = import_outline 

2674 import_outline = pages 

2675 pages = outline_item 

2676 self.merge( 

2677 None, 

2678 fileobj, 

2679 None, 

2680 pages, 

2681 import_outline, 

2682 excluded_fields, 

2683 ) 

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

2685 self.merge( 

2686 None, 

2687 fileobj, 

2688 outline_item, 

2689 pages, 

2690 import_outline, 

2691 excluded_fields, 

2692 ) 

2693 

2694 def merge( 

2695 self, 

2696 position: Optional[int], 

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

2698 outline_item: Optional[str] = None, 

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

2700 import_outline: bool = True, 

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

2702 ) -> None: 

2703 """ 

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

2705 specified page number. 

2706 

2707 Args: 

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

2709 be inserted after the given number. 

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

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

2712 string representing a path to a PDF file. 

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

2714 (aka 'bookmark') to identify the 

2715 beginning of the included file. 

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

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

2718 or a list of pages to be processed 

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

2720 document into the output document. 

2721 import_outline: You may prevent the source document's 

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

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

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

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

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

2727 

2728 Raises: 

2729 TypeError: The pages attribute is not configured properly 

2730 

2731 """ 

2732 if isinstance(fileobj, PdfDocCommon): 

2733 reader = fileobj 

2734 else: 

2735 stream, _encryption_obj = self._create_stream(fileobj) 

2736 # Create a new PdfReader instance using the stream 

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

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

2739 

2740 if excluded_fields is None: 

2741 excluded_fields = () 

2742 # Find the range of pages to merge. 

2743 if pages is None: 

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

2745 elif isinstance(pages, PageRange): 

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

2747 elif isinstance(pages, list): 

2748 pass # keep unchanged 

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

2750 pages = list(range(*pages)) 

2751 elif not isinstance(pages, tuple): 

2752 raise TypeError( 

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

2754 ) 

2755 

2756 srcpages = {} 

2757 for page in pages: 

2758 if isinstance(page, PageObject): 

2759 pg = page 

2760 else: 

2761 pg = reader.pages[page] 

2762 assert pg.indirect_reference is not None 

2763 if position is None: 

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

2765 # only applicable to 1st level of cloning 

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

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

2768 ) 

2769 else: 

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

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

2772 ) 

2773 position += 1 

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

2775 

2776 reader._named_destinations = ( 

2777 reader.named_destinations 

2778 ) # need for the outline processing below 

2779 

2780 arr: Any 

2781 

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

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

2784 

2785 outline_item_typ: TreeObject 

2786 if outline_item is not None: 

2787 outline_item_typ = cast( 

2788 "TreeObject", 

2789 self.add_outline_item( 

2790 TextStringObject(outline_item), 

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

2792 fit=PAGE_FIT, 

2793 ).get_object(), 

2794 ) 

2795 else: 

2796 outline_item_typ = self.get_outline_root() 

2797 

2798 _ro = reader.root_object 

2799 if import_outline and Core.OUTLINES in _ro: 

2800 outline = self._get_filtered_outline( 

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

2802 ) 

2803 self._insert_filtered_outline( 

2804 outline, outline_item_typ, None 

2805 ) # TODO: use before parameter 

2806 

2807 if "/Annots" not in excluded_fields: 

2808 for pag in srcpages.values(): 

2809 lst = self._insert_filtered_annotations( 

2810 pag.original_page.get("/Annots", []), pag, srcpages, reader 

2811 ) 

2812 if len(lst) > 0: 

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

2814 self.clean_page(pag) 

2815 

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

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

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

2819 cast( 

2820 DictionaryObject, 

2821 reader.root_object["/AcroForm"], 

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

2823 ) 

2824 arr = ArrayObject() 

2825 else: 

2826 arr = cast( 

2827 ArrayObject, 

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

2829 ) 

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

2831 try: 

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

2833 try: 

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

2835 if ind not in arr: 

2836 arr.append(ind) 

2837 except KeyError: 

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

2839 # through the page 

2840 pass 

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

2842 arr = self._add_object(ArrayObject()) 

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

2844 NameObject("/Fields") 

2845 ] = arr 

2846 

2847 if "/B" not in excluded_fields: 

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

2849 

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

2851 arr: Any = dest.dest_array 

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

2853 list[Any], 

2854 cast( 

2855 DictionaryObject, 

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

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

2858 ): 

2859 # already exists: should not duplicate it 

2860 pass 

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

2862 pass 

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

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

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

2866 try: 

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

2868 except IndexError: 

2869 return 

2870 assert p.indirect_reference is not None 

2871 try: 

2872 arr[NumberObject(0)] = NumberObject( 

2873 srcpages[p.indirect_reference.idnum].page_number 

2874 ) 

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

2876 except KeyError: 

2877 pass 

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

2879 arr[NumberObject(0)] = srcpages[ 

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

2881 ].indirect_reference 

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

2883 

2884 def _add_articles_thread( 

2885 self, 

2886 thread: DictionaryObject, 

2887 pages: dict[int, PageObject], 

2888 reader: PdfReader, 

2889 ) -> IndirectObject: 

2890 """ 

2891 Clone the thread with only the applicable articles. 

2892 

2893 Args: 

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

2895 pages: Mapping of object numbers to page objects. 

2896 reader: The corresponding reader. 

2897 

2898 Returns: 

2899 The added thread as an indirect reference 

2900 

2901 """ 

2902 new_thread = thread.clone( 

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

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

2905 self.threads.append(new_thread.indirect_reference) 

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

2907 current_article: Optional[DictionaryObject] = first_article 

2908 new_article: Optional[DictionaryObject] = None 

2909 

2910 visited: set[int] = set() 

2911 while current_article is not None: 

2912 article_id = id(current_article) 

2913 if article_id in visited: 

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

2915 visited.add(article_id) 

2916 

2917 page = self._get_cloned_page( 

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

2919 ) 

2920 if page is not None: 

2921 if new_article is None: 

2922 new_article = cast( 

2923 "DictionaryObject", 

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

2925 ) 

2926 new_first = new_article 

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

2928 else: 

2929 new_article2 = cast( 

2930 "DictionaryObject", 

2931 self._add_object( 

2932 DictionaryObject( 

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

2934 ) 

2935 ).get_object(), 

2936 ) 

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

2938 new_article = new_article2 

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

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

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

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

2943 if "/B" not in page_object: 

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

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

2946 new_article.indirect_reference 

2947 ) 

2948 

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

2950 if current_article == first_article: 

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

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

2953 current_article = None 

2954 

2955 assert new_thread.indirect_reference is not None 

2956 return new_thread.indirect_reference 

2957 

2958 def add_filtered_articles( 

2959 self, 

2960 fltr: Union[ 

2961 Pattern[Any], str 

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

2963 pages: dict[int, PageObject], 

2964 reader: PdfReader, 

2965 ) -> None: 

2966 """ 

2967 Add articles matching the defined criteria. 

2968 

2969 Args: 

2970 fltr: 

2971 pages: 

2972 reader: 

2973 

2974 """ 

2975 if isinstance(fltr, str): 

2976 fltr = re.compile(fltr) 

2977 elif not isinstance(fltr, Pattern): 

2978 fltr = re.compile("") 

2979 for p in pages.values(): 

2980 pp = p.original_page 

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

2982 a_obj = a.get_object() 

2983 if is_null_or_none(a_obj): 

2984 continue 

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

2986 if thr is None: 

2987 continue 

2988 thr = thr.get_object() 

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

2990 id(reader) 

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

2992 self._add_articles_thread(thr, pages, reader) 

2993 

2994 def _get_cloned_page( 

2995 self, 

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

2997 pages: dict[int, PageObject], 

2998 reader: PdfReader, 

2999 ) -> Optional[IndirectObject]: 

3000 if isinstance(page, NullObject): 

3001 return None 

3002 if isinstance(page, int): 

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

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

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

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

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

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

3009 try: 

3010 page = reader.pages[page].indirect_reference 

3011 except IndexError: 

3012 return None 

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

3014 _i = page.indirect_reference 

3015 elif isinstance(page, IndirectObject): 

3016 _i = page 

3017 try: 

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

3019 except Exception: 

3020 return None 

3021 

3022 def _insert_filtered_annotations( 

3023 self, 

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

3025 page: PageObject, 

3026 pages: dict[int, PageObject], 

3027 reader: PdfReader, 

3028 ) -> list[Destination]: 

3029 outlist = ArrayObject() 

3030 if isinstance(annots, IndirectObject): 

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

3032 if annots is None: 

3033 return outlist 

3034 if not isinstance(annots, list): 

3035 logger_warning( 

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

3037 source=__name__, 

3038 annots=annots, 

3039 annots_type=annots.__class__.__name__, 

3040 ) 

3041 return outlist 

3042 for an in annots: 

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

3044 if ( 

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

3046 or "/A" not in ano 

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

3048 or "/Dest" in ano 

3049 ): 

3050 if "/Dest" not in ano: 

3051 outlist.append(self._add_object(ano.clone(self))) 

3052 else: 

3053 d = ano["/Dest"] 

3054 if isinstance(d, str): 

3055 # it is a named dest 

3056 if str(d) in self.get_named_dest_root(): 

3057 outlist.append(ano.clone(self).indirect_reference) 

3058 else: 

3059 if not isinstance(d, ArrayObject) or not d: 

3060 continue 

3061 p = self._get_cloned_page(d[0], pages, reader) 

3062 if p is not None: 

3063 anc = ano.clone(self, ignore_fields=("/Dest",)) 

3064 anc[NameObject("/Dest")] = ArrayObject([p, *d[1:]]) 

3065 outlist.append(self._add_object(anc)) 

3066 else: 

3067 d = cast("DictionaryObject", ano["/A"]).get("/D", NullObject()) 

3068 if is_null_or_none(d): 

3069 continue 

3070 if isinstance(d, str): 

3071 # it is a named dest 

3072 if str(d) in self.get_named_dest_root(): 

3073 outlist.append(ano.clone(self).indirect_reference) 

3074 else: 

3075 if not isinstance(d, ArrayObject) or not d: 

3076 continue 

3077 p = self._get_cloned_page(d[0], pages, reader) 

3078 if p is not None: 

3079 anc = ano.clone(self, ignore_fields=("/D",)) 

3080 cast("DictionaryObject", anc["/A"])[ 

3081 NameObject("/D") 

3082 ] = ArrayObject([p, *d[1:]]) 

3083 outlist.append(self._add_object(anc)) 

3084 return outlist 

3085 

3086 def _get_filtered_outline( 

3087 self, 

3088 *, 

3089 node: Any, 

3090 pages: dict[int, PageObject], 

3091 reader: PdfReader, 

3092 visited: Optional[set[int]] = None, 

3093 ) -> list[Destination]: 

3094 """ 

3095 Extract outline item entries that are part of the specified page set. 

3096 

3097 Returns: 

3098 A list of destination objects. 

3099 

3100 """ 

3101 if visited is None: 

3102 visited = set() 

3103 new_outline: list[Destination] = [] 

3104 if node is None: 

3105 return new_outline 

3106 node = node.get_object() 

3107 if is_null_or_none(node): 

3108 node = DictionaryObject() 

3109 

3110 if node.get("/Type", "") == "/Outlines" or "/Title" not in node: 

3111 node_id = id(node) 

3112 if node_id in visited: 

3113 logger_warning("Detected cycle in outlines.", source=__name__) 

3114 return [] 

3115 visited.add(node_id) 

3116 

3117 node = node.get("/First", None) 

3118 if node is not None: 

3119 node = node.get_object() 

3120 new_outline += self._get_filtered_outline(node=node, pages=pages, reader=reader, visited=visited) 

3121 else: 

3122 cloned_page: Union[IndirectObject, NullObject, None] 

3123 while True: 

3124 node = node.get_object() 

3125 node_id = id(node) 

3126 if node_id in visited: 

3127 logger_warning("Detected cycle in outlines.", source=__name__) 

3128 break 

3129 visited.add(node_id) 

3130 

3131 destination = cast("Destination", reader._build_outline_item(node)) 

3132 cloned_page = self._get_cloned_page(cast("PageObject", destination["/Page"]), pages, reader) 

3133 if cloned_page is None: 

3134 cloned_page = NullObject() 

3135 destination[NameObject("/Page")] = cloned_page 

3136 if "/First" in node: 

3137 destination._filtered_children = self._get_filtered_outline( 

3138 node=node["/First"], pages=pages, reader=reader, visited=visited 

3139 ) 

3140 else: 

3141 destination._filtered_children = [] 

3142 if ( 

3143 not isinstance(cloned_page, NullObject) 

3144 or len(destination._filtered_children) > 0 

3145 ): 

3146 new_outline.append(destination) 

3147 

3148 if "/Next" not in node: 

3149 break 

3150 node = node["/Next"] 

3151 return new_outline 

3152 

3153 def _clone_outline(self, dest: Destination) -> TreeObject: 

3154 n_ol = TreeObject() 

3155 self._add_object(n_ol) 

3156 n_ol[NameObject("/Title")] = TextStringObject(dest["/Title"]) 

3157 if not isinstance(dest["/Page"], NullObject): 

3158 if dest.node is not None and "/A" in dest.node: 

3159 n_ol[NameObject("/A")] = dest.node["/A"].clone(self) 

3160 else: 

3161 n_ol[NameObject("/Dest")] = dest.dest_array 

3162 # TODO: /SE 

3163 if dest.node is not None: 

3164 n_ol[NameObject("/F")] = NumberObject(dest.node.get("/F", 0)) 

3165 color = dest.node.get("/C", NullObject()).get_object() 

3166 if not isinstance(color, list): 

3167 color = [FloatObject(0.0), FloatObject(0.0), FloatObject(0.0)] 

3168 n_ol[NameObject("/C")] = ArrayObject(color) 

3169 return n_ol 

3170 

3171 def _insert_filtered_outline( 

3172 self, 

3173 outlines: list[Destination], 

3174 parent: Union[TreeObject, IndirectObject], 

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

3176 ) -> None: 

3177 for dest in outlines: 

3178 # TODO: can be improved to keep A and SE entries (ignored for the moment) 

3179 # with np=self.add_outline_item_destination(dest,parent,before) 

3180 if dest.get("/Type", "") == "/Outlines" or "/Title" not in dest: 

3181 np = parent 

3182 else: 

3183 np = self._clone_outline(dest) 

3184 cast(TreeObject, parent.get_object()).insert_child(np, before, self) 

3185 self._insert_filtered_outline(dest._filtered_children, np, None) 

3186 

3187 def close(self) -> None: 

3188 """Implemented for API harmonization.""" 

3189 return 

3190 

3191 def find_outline_item( 

3192 self, 

3193 outline_item: dict[str, Any], 

3194 root: Optional[OutlineType] = None, 

3195 ) -> Optional[list[int]]: 

3196 if root is None: 

3197 o = self.get_outline_root() 

3198 else: 

3199 o = cast("TreeObject", root) 

3200 

3201 i = 0 

3202 while o is not None: 

3203 if ( 

3204 o.indirect_reference == outline_item 

3205 or o.get("/Title", None) == outline_item 

3206 ): 

3207 return [i] 

3208 if "/First" in o: 

3209 res = self.find_outline_item( 

3210 outline_item, cast(OutlineType, o["/First"]) 

3211 ) 

3212 if res: 

3213 return ([i] if "/Title" in o else []) + res 

3214 if "/Next" in o: 

3215 i += 1 

3216 o = cast(TreeObject, o["/Next"]) 

3217 else: 

3218 return None 

3219 raise PyPdfError("This line is theoretically unreachable.") # pragma: no cover 

3220 

3221 def reset_translation( 

3222 self, reader: Union[PdfReader, IndirectObject, None] = None 

3223 ) -> None: 

3224 """ 

3225 Reset the translation table between reader and the writer object. 

3226 

3227 Late cloning will create new independent objects. 

3228 

3229 Args: 

3230 reader: PdfReader or IndirectObject referencing a PdfReader object. 

3231 if set to None or omitted, all tables will be reset. 

3232 

3233 """ 

3234 if reader is None: 

3235 self._id_translated = {} 

3236 elif isinstance(reader, PdfReader): 

3237 try: 

3238 del self._id_translated[id(reader)] 

3239 except Exception: 

3240 pass 

3241 elif isinstance(reader, IndirectObject): 

3242 try: 

3243 del self._id_translated[id(reader.pdf)] 

3244 except Exception: 

3245 pass 

3246 else: 

3247 raise TypeError(f"Invalid parameter {reader}") 

3248 

3249 def set_page_label( 

3250 self, 

3251 page_index_from: int, 

3252 page_index_to: int, 

3253 style: Optional[PageLabelStyle] = None, 

3254 prefix: Optional[str] = None, 

3255 start: Optional[int] = 0, 

3256 ) -> None: 

3257 """ 

3258 Set a page label to a range of pages. 

3259 

3260 Page indexes must be given starting from 0. 

3261 Labels must have a style, a prefix or both. 

3262 If a range is not assigned any page label, a decimal label starting from 1 is applied. 

3263 

3264 Args: 

3265 page_index_from: page index of the beginning of the range starting from 0 

3266 page_index_to: page index of the beginning of the range starting from 0 

3267 style: The numbering style to be used for the numeric portion of each page label: 

3268 

3269 * ``/D`` Decimal Arabic numerals 

3270 * ``/R`` Uppercase Roman numerals 

3271 * ``/r`` Lowercase Roman numerals 

3272 * ``/A`` Uppercase letters (A to Z for the first 26 pages, 

3273 AA to ZZ for the next 26, and so on) 

3274 * ``/a`` Lowercase letters (a to z for the first 26 pages, 

3275 aa to zz for the next 26, and so on) 

3276 

3277 prefix: The label prefix for page labels in this range. 

3278 start: The value of the numeric portion for the first page label 

3279 in the range. 

3280 Subsequent pages are numbered sequentially from this value, 

3281 which must be greater than or equal to 1. 

3282 Default value: 1. 

3283 

3284 """ 

3285 if style is None and prefix is None: 

3286 raise ValueError("At least one of style and prefix must be given") 

3287 if style is not None and style not in tuple(PageLabelStyle): 

3288 raise ValueError( 

3289 f"style must be one of: {', '.join(PageLabelStyle)}, got {style!r}" 

3290 ) 

3291 if page_index_from < 0: 

3292 raise ValueError("page_index_from must be greater or equal than 0") 

3293 if page_index_to < page_index_from: 

3294 raise ValueError( 

3295 "page_index_to must be greater or equal than page_index_from" 

3296 ) 

3297 if page_index_to >= len(self.pages): 

3298 raise ValueError("page_index_to exceeds number of pages") 

3299 if start is not None and start != 0 and start < 1: 

3300 raise ValueError("If given, start must be greater or equal than one") 

3301 

3302 self._set_page_label(page_index_from, page_index_to, style, prefix, start) 

3303 

3304 def _set_page_label( 

3305 self, 

3306 page_index_from: int, 

3307 page_index_to: int, 

3308 style: Optional[PageLabelStyle] = None, 

3309 prefix: Optional[str] = None, 

3310 start: Optional[int] = 0, 

3311 ) -> None: 

3312 """ 

3313 Set a page label to a range of pages. 

3314 

3315 Page indexes must be given starting from 0. 

3316 Labels must have a style, a prefix or both. 

3317 If a range is not assigned any page label a decimal label starting from 1 is applied. 

3318 

3319 Args: 

3320 page_index_from: page index of the beginning of the range starting from 0 

3321 page_index_to: page index of the beginning of the range starting from 0 

3322 style: The numbering style to be used for the numeric portion of each page label: 

3323 /D Decimal Arabic numerals 

3324 /R Uppercase Roman numerals 

3325 /r Lowercase Roman numerals 

3326 /A Uppercase letters (A to Z for the first 26 pages, 

3327 AA to ZZ for the next 26, and so on) 

3328 /a Lowercase letters (a to z for the first 26 pages, 

3329 aa to zz for the next 26, and so on) 

3330 prefix: The label prefix for page labels in this range. 

3331 start: The value of the numeric portion for the first page label 

3332 in the range. 

3333 Subsequent pages are numbered sequentially from this value, 

3334 which must be greater than or equal to 1. Default value: 1. 

3335 

3336 """ 

3337 default_page_label = DictionaryObject() 

3338 default_page_label[NameObject("/S")] = NameObject("/D") 

3339 

3340 new_page_label = DictionaryObject() 

3341 if style is not None: 

3342 new_page_label[NameObject("/S")] = NameObject(style) 

3343 if prefix is not None: 

3344 new_page_label[NameObject("/P")] = TextStringObject(prefix) 

3345 if start != 0: 

3346 new_page_label[NameObject("/St")] = NumberObject(start) 

3347 

3348 if NameObject(CatalogAttributes.PAGE_LABELS) not in self._root_object: 

3349 nums = ArrayObject() 

3350 nums_insert(NumberObject(0), default_page_label, nums) 

3351 page_labels = TreeObject() 

3352 page_labels[NameObject("/Nums")] = nums 

3353 self._root_object[NameObject(CatalogAttributes.PAGE_LABELS)] = page_labels 

3354 

3355 page_labels = cast( 

3356 TreeObject, self._root_object[NameObject(CatalogAttributes.PAGE_LABELS)] 

3357 ) 

3358 nums = cast(ArrayObject, page_labels[NameObject("/Nums")]) 

3359 

3360 nums_insert(NumberObject(page_index_from), new_page_label, nums) 

3361 nums_clear_range(NumberObject(page_index_from), page_index_to, nums) 

3362 next_label_pos, *_ = nums_next(NumberObject(page_index_from), nums) 

3363 if next_label_pos != page_index_to + 1 and page_index_to + 1 < len(self.pages): 

3364 nums_insert(NumberObject(page_index_to + 1), default_page_label, nums) 

3365 

3366 page_labels[NameObject("/Nums")] = nums 

3367 self._root_object[NameObject(CatalogAttributes.PAGE_LABELS)] = page_labels 

3368 

3369 def _repr_mimebundle_( 

3370 self, 

3371 include: Union[Iterable[str], None] = None, 

3372 exclude: Union[Iterable[str], None] = None, 

3373 ) -> dict[str, Any]: 

3374 """ 

3375 Integration into Jupyter Notebooks. 

3376 

3377 This method returns a dictionary that maps a mime-type to its 

3378 representation. 

3379 

3380 .. seealso:: 

3381 

3382 https://ipython.readthedocs.io/en/stable/config/integrating.html 

3383 """ 

3384 pdf_data = BytesIO() 

3385 self.write(pdf_data) 

3386 data = { 

3387 "application/pdf": pdf_data, 

3388 } 

3389 

3390 if include is not None: 

3391 # Filter representations based on include list 

3392 data = {k: v for k, v in data.items() if k in include} 

3393 

3394 if exclude is not None: 

3395 # Remove representations based on exclude list 

3396 data = {k: v for k, v in data.items() if k not in exclude} 

3397 

3398 return data 

3399 

3400 

3401def _pdf_objectify(obj: Union[dict[str, Any], str, float, list[Any]]) -> PdfObject: 

3402 if isinstance(obj, PdfObject): 

3403 return obj 

3404 if isinstance(obj, dict): 

3405 to_add = DictionaryObject() 

3406 for key, value in obj.items(): 

3407 to_add[NameObject(key)] = _pdf_objectify(value) 

3408 return to_add 

3409 if isinstance(obj, str): 

3410 if obj.startswith("/"): 

3411 return NameObject(obj) 

3412 return TextStringObject(obj) 

3413 if isinstance(obj, (float, int)): 

3414 return FloatObject(obj) 

3415 if isinstance(obj, list): 

3416 return ArrayObject(_pdf_objectify(i) for i in obj) 

3417 raise NotImplementedError( 

3418 f"{type(obj)=} could not be cast to a PdfObject" 

3419 ) 

3420 

3421 

3422def _create_outline_item( 

3423 action_ref: Union[IndirectObject, None], 

3424 title: str, 

3425 color: Union[tuple[float, float, float], str, None], 

3426 italic: bool, 

3427 bold: bool, 

3428) -> TreeObject: 

3429 outline_item = TreeObject() 

3430 if action_ref is not None: 

3431 outline_item[NameObject("/A")] = action_ref 

3432 outline_item.update( 

3433 { 

3434 NameObject("/Title"): create_string_object(title), 

3435 } 

3436 ) 

3437 if color: 

3438 if isinstance(color, str): 

3439 color = hex_to_rgb(color) 

3440 outline_item.update( 

3441 {NameObject("/C"): ArrayObject([FloatObject(c) for c in color])} 

3442 ) 

3443 if italic or bold: 

3444 format_flag = 0 

3445 if italic: 

3446 format_flag += OutlineFontFlag.italic 

3447 if bold: 

3448 format_flag += OutlineFontFlag.bold 

3449 outline_item.update({NameObject("/F"): NumberObject(format_flag)}) 

3450 return outline_item