Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/pypdf/_doc_common.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

769 statements  

1# Copyright (c) 2006, Mathieu Fenniak 

2# Copyright (c) 2007, Ashish Kulkarni <kulkarni.ashish@gmail.com> 

3# Copyright (c) 2024, Pubpub-ZZ 

4# 

5# All rights reserved. 

6# 

7# Redistribution and use in source and binary forms, with or without 

8# modification, are permitted provided that the following conditions are 

9# met: 

10# 

11# * Redistributions of source code must retain the above copyright notice, 

12# this list of conditions and the following disclaimer. 

13# * Redistributions in binary form must reproduce the above copyright notice, 

14# this list of conditions and the following disclaimer in the documentation 

15# and/or other materials provided with the distribution. 

16# * The name of the author may not be used to endorse or promote products 

17# derived from this software without specific prior written permission. 

18# 

19# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 

20# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 

21# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 

22# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 

23# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 

24# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 

25# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 

26# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 

27# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 

28# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 

29# POSSIBILITY OF SUCH DAMAGE. 

30 

31import struct 

32from abc import ABC, abstractmethod 

33from collections.abc import Iterable, Iterator, Mapping, Sequence 

34from datetime import datetime 

35from typing import ( 

36 Any, 

37 NamedTuple, 

38 NoReturn, 

39 Optional, 

40 Union, 

41 cast, 

42) 

43 

44from ._configuration import get_configuration 

45from ._encryption import Encryption 

46from ._page import PageObject, _VirtualList 

47from ._page_labels import index2label as page_index2page_label 

48from ._utils import ( 

49 _TraversalState, 

50 deprecate_no_replacement, 

51 deprecation_with_replacement, 

52 logger_warning, 

53 parse_iso8824_date, 

54) 

55from .constants import CatalogAttributes as CA 

56from .constants import ( 

57 CheckboxRadioButtonAttributes, 

58 Core, 

59 GoToActionArguments, 

60 PagesAttributes, 

61 UserAccessPermissions, 

62) 

63from .constants import DocumentInformationAttributes as DI 

64from .constants import FieldDictionaryAttributes as FA 

65from .constants import PageAttributes as PG 

66from .errors import LimitReachedError, PdfReadError, PyPdfError 

67from .filters import _decompress_with_limit 

68from .generic import ( 

69 ArrayObject, 

70 BooleanObject, 

71 ByteStringObject, 

72 Destination, 

73 DictionaryObject, 

74 EncodedStreamObject, 

75 Field, 

76 Fit, 

77 FloatObject, 

78 IndirectObject, 

79 NameObject, 

80 NullObject, 

81 PdfObject, 

82 TextStringObject, 

83 TreeObject, 

84 ViewerPreferences, 

85 create_string_object, 

86 is_null_or_none, 

87) 

88from .generic._files import EmbeddedFile 

89from .types import OutlineType, PagemodeType 

90from .xmp import XmpInformation 

91 

92 

93def convert_to_int(d: bytes, size: int) -> Union[int, tuple[Any, ...]]: 

94 if size > 8: 

95 raise PdfReadError("Invalid size in convert_to_int") 

96 d = b"\x00\x00\x00\x00\x00\x00\x00\x00" + d 

97 d = d[-8:] 

98 return cast(int, struct.unpack(">Q", d)[0]) 

99 

100 

101# Attributes a page inherits from its ancestors in the page tree. 

102_INHERITABLE_PAGE_ATTRIBUTES = ( 

103 NameObject(PG.RESOURCES), 

104 NameObject(PG.MEDIABOX), 

105 NameObject(PG.CROPBOX), 

106 NameObject(PG.ROTATE), 

107) 

108 

109 

110class _PageTreeItem(NamedTuple): 

111 """A page tree node waiting to be flattened by :meth:`PdfDocCommon._flatten`.""" 

112 

113 node: DictionaryObject 

114 #: Inherited attributes. Shared read-only between the kids of one node. 

115 inherit: dict[str, Any] 

116 indirect_reference: Optional[IndirectObject] 

117 depth: int 

118 

119 

120#: A ``/Pages`` node currently being walked: the node - carrying the attributes its 

121#: kids inherit - and the part of its ``/Kids`` which has not been reached yet. 

122_PageTreeFrame = tuple[_PageTreeItem, Iterator[PdfObject]] 

123 

124_UNSET = object() 

125 

126 

127class DocumentInformation(DictionaryObject): 

128 """ 

129 A class representing the basic document metadata provided in a PDF File. 

130 This class is accessible through 

131 :py:class:`PdfReader.metadata<pypdf.PdfReader.metadata>`. 

132 

133 All text properties of the document metadata have 

134 *two* properties, e.g. author and author_raw. The non-raw property will 

135 always return a ``TextStringObject``, making it ideal for a case where the 

136 metadata is being displayed. The raw property can sometimes return a 

137 ``ByteStringObject``, if pypdf was unable to decode the string's text 

138 encoding; this requires additional safety in the caller and therefore is not 

139 as commonly accessed. 

140 """ 

141 

142 def __init__(self) -> None: 

143 DictionaryObject.__init__(self) 

144 

145 def _get_text(self, key: str) -> Optional[str]: 

146 retval = self.get(key, None) 

147 if isinstance(retval, TextStringObject): 

148 return retval 

149 if isinstance(retval, ByteStringObject): 

150 return str(retval) 

151 return None 

152 

153 @property 

154 def title(self) -> Optional[str]: 

155 """ 

156 Read-only property accessing the document's title. 

157 

158 Returns a ``TextStringObject`` or ``None`` if the title is not 

159 specified. 

160 """ 

161 return ( 

162 self._get_text(DI.TITLE) or self.get(DI.TITLE).get_object() # type: ignore[union-attr] 

163 if self.get(DI.TITLE) 

164 else None 

165 ) 

166 

167 @property 

168 def title_raw(self) -> Optional[str]: 

169 """The "raw" version of title; can return a ``ByteStringObject``.""" 

170 return self.get(DI.TITLE) 

171 

172 @property 

173 def author(self) -> Optional[str]: 

174 """ 

175 Read-only property accessing the document's author. 

176 

177 Returns a ``TextStringObject`` or ``None`` if the author is not 

178 specified. 

179 """ 

180 return self._get_text(DI.AUTHOR) 

181 

182 @property 

183 def author_raw(self) -> Optional[str]: 

184 """The "raw" version of author; can return a ``ByteStringObject``.""" 

185 return self.get(DI.AUTHOR) 

186 

187 @property 

188 def subject(self) -> Optional[str]: 

189 """ 

190 Read-only property accessing the document's subject. 

191 

192 Returns a ``TextStringObject`` or ``None`` if the subject is not 

193 specified. 

194 """ 

195 return self._get_text(DI.SUBJECT) 

196 

197 @property 

198 def subject_raw(self) -> Optional[str]: 

199 """The "raw" version of subject; can return a ``ByteStringObject``.""" 

200 return self.get(DI.SUBJECT) 

201 

202 @property 

203 def creator(self) -> Optional[str]: 

204 """ 

205 Read-only property accessing the document's creator. 

206 

207 If the document was converted to PDF from another format, this is the 

208 name of the application (e.g. OpenOffice) that created the original 

209 document from which it was converted. Returns a ``TextStringObject`` or 

210 ``None`` if the creator is not specified. 

211 """ 

212 return self._get_text(DI.CREATOR) 

213 

214 @property 

215 def creator_raw(self) -> Optional[str]: 

216 """The "raw" version of creator; can return a ``ByteStringObject``.""" 

217 return self.get(DI.CREATOR) 

218 

219 @property 

220 def producer(self) -> Optional[str]: 

221 """ 

222 Read-only property accessing the document's producer. 

223 

224 If the document was converted to PDF from another format, this is the 

225 name of the application (for example, macOS Quartz) that converted it to 

226 PDF. Returns a ``TextStringObject`` or ``None`` if the producer is not 

227 specified. 

228 """ 

229 return self._get_text(DI.PRODUCER) 

230 

231 @property 

232 def producer_raw(self) -> Optional[str]: 

233 """The "raw" version of producer; can return a ``ByteStringObject``.""" 

234 return self.get(DI.PRODUCER) 

235 

236 @property 

237 def creation_date(self) -> Optional[datetime]: 

238 """Read-only property accessing the document's creation date.""" 

239 return parse_iso8824_date(self._get_text(DI.CREATION_DATE)) 

240 

241 @property 

242 def creation_date_raw(self) -> Optional[str]: 

243 """ 

244 The "raw" version of creation date; can return a ``ByteStringObject``. 

245 

246 Typically in the format ``D:YYYYMMDDhhmmss[+Z-]hh'mm`` where the suffix 

247 is the offset from UTC. 

248 """ 

249 return self.get(DI.CREATION_DATE) 

250 

251 @property 

252 def modification_date(self) -> Optional[datetime]: 

253 """ 

254 Read-only property accessing the document's modification date. 

255 

256 The date and time the document was most recently modified. 

257 """ 

258 return parse_iso8824_date(self._get_text(DI.MOD_DATE)) 

259 

260 @property 

261 def modification_date_raw(self) -> Optional[str]: 

262 """ 

263 The "raw" version of modification date; can return a 

264 ``ByteStringObject``. 

265 

266 Typically, in the format ``D:YYYYMMDDhhmmss[+Z-]hh'mm`` where the suffix 

267 is the offset from UTC. 

268 """ 

269 return self.get(DI.MOD_DATE) 

270 

271 @property 

272 def keywords(self) -> Optional[str]: 

273 """ 

274 Read-only property accessing the document's keywords. 

275 

276 Returns a ``TextStringObject`` or ``None`` if keywords are not 

277 specified. 

278 """ 

279 return self._get_text(DI.KEYWORDS) 

280 

281 @property 

282 def keywords_raw(self) -> Optional[str]: 

283 """The "raw" version of keywords; can return a ``ByteStringObject``.""" 

284 return self.get(DI.KEYWORDS) 

285 

286 

287class PdfDocCommon(ABC): 

288 """ 

289 Common functions from PdfWriter and PdfReader objects. 

290 

291 This root class is strongly abstracted. 

292 """ 

293 

294 strict: bool = False # default 

295 

296 flattened_pages: Optional[list[PageObject]] = None 

297 

298 _encryption: Optional[Encryption] = None 

299 

300 _readonly: bool = False 

301 

302 @property 

303 @abstractmethod 

304 def root_object(self) -> DictionaryObject: 

305 ... # pragma: no cover 

306 

307 @property 

308 @abstractmethod 

309 def pdf_header(self) -> str: 

310 ... # pragma: no cover 

311 

312 @abstractmethod 

313 def get_object( 

314 self, indirect_reference: Union[int, IndirectObject] 

315 ) -> Optional[PdfObject]: 

316 ... # pragma: no cover 

317 

318 @abstractmethod 

319 def _replace_object(self, indirect: IndirectObject, obj: PdfObject) -> PdfObject: 

320 ... # pragma: no cover 

321 

322 @property 

323 @abstractmethod 

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

325 ... # pragma: no cover 

326 

327 @property 

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

329 """ 

330 Retrieve the PDF file's document information dictionary, if it exists. 

331 

332 Note that some PDF files use metadata streams instead of document 

333 information dictionaries, and these metadata streams will not be 

334 accessed by this function. 

335 """ 

336 retval = DocumentInformation() 

337 if self._info is None: 

338 return None 

339 retval.update(self._info) 

340 return retval 

341 

342 @property 

343 @abstractmethod 

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

345 ... # pragma: no cover 

346 

347 @property 

348 def viewer_preferences(self) -> Optional[ViewerPreferences]: 

349 """Returns the existing ViewerPreferences as an overloaded dictionary.""" 

350 o = self.root_object.get(CA.VIEWER_PREFERENCES, None) 

351 if o is None: 

352 return None 

353 o = o.get_object() 

354 if not isinstance(o, DictionaryObject): 

355 logger_warning( 

356 "Viewer preferences are not a dictionary: %(preferences)s", 

357 source=__name__, 

358 preferences=o, 

359 ) 

360 return None 

361 if not isinstance(o, ViewerPreferences): 

362 o = ViewerPreferences(o) 

363 if hasattr(o, "indirect_reference") and o.indirect_reference is not None: 

364 self._replace_object(o.indirect_reference, o) 

365 else: 

366 self.root_object[NameObject(CA.VIEWER_PREFERENCES)] = o 

367 return o 

368 

369 def get_num_pages(self) -> int: 

370 """ 

371 Calculate the number of pages in this PDF file. 

372 

373 Returns: 

374 The number of pages of the parsed PDF file. 

375 

376 Raises: 

377 PdfReadError: If restrictions prevent this action. 

378 

379 """ 

380 # Flattened pages will not work on an encrypted PDF; 

381 # the PDF file's page count is used in this case. Otherwise, 

382 # the original method (flattened page count) is used. 

383 if self.is_encrypted: 

384 return self.root_object["/Pages"]["/Count"] # type: ignore[no-any-return, index] 

385 if self.flattened_pages is None: 

386 self._flatten(self._readonly) 

387 assert self.flattened_pages is not None 

388 return len(self.flattened_pages) 

389 

390 def get_page(self, page_number: int) -> PageObject: 

391 """ 

392 Retrieve a page by number from this PDF file. 

393 Most of the time ``.pages[page_number]`` is preferred. 

394 

395 Args: 

396 page_number: The page number to retrieve 

397 (pages begin at zero) 

398 

399 Returns: 

400 A :class:`PageObject<pypdf._page.PageObject>` instance. 

401 

402 """ 

403 if self.flattened_pages is None: 

404 self._flatten(self._readonly) 

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

406 return self.flattened_pages[page_number] 

407 

408 def _get_page_in_node( 

409 self, 

410 page_number: int, 

411 ) -> tuple[DictionaryObject, int]: 

412 """ 

413 Retrieve the node and position within the /Kids containing the page. 

414 If page_number is greater than the number of pages, it returns the top node, -1. 

415 """ 

416 top = cast(DictionaryObject, self.root_object["/Pages"]) 

417 visited: set[int] = set() 

418 

419 def recursive_call( 

420 _node: DictionaryObject, mi: int 

421 ) -> tuple[Optional[PdfObject], int]: 

422 _node_id = id(_node) 

423 if _node_id in visited: 

424 raise LimitReachedError("Detected cycle in /Pages hierarchy when retrieving page.") 

425 visited.add(_node_id) 

426 ma = cast(int, _node.get("/Count", 1)) # default 1 for /Page types 

427 if _node.get("/Type") == "/Page": 

428 if page_number == mi: 

429 return _node, -1 

430 return None, mi + 1 

431 if (page_number - mi) >= ma: # not in nodes below 

432 if _node == top: 

433 return top, -1 

434 return None, mi + ma 

435 for _idx, kid in enumerate(cast(ArrayObject, _node["/Kids"])): 

436 kid = cast(DictionaryObject, kid.get_object()) 

437 n, i = recursive_call(kid, mi) 

438 if n is not None: # page has just been found ... 

439 if i < 0: # ... just below! 

440 return _node, _idx 

441 # ... at lower levels 

442 return n, i 

443 mi = i 

444 raise PyPdfError("Unexpectedly cannot find the node.") 

445 

446 node, idx = recursive_call(top, 0) 

447 assert isinstance(node, DictionaryObject), "mypy" 

448 return node, idx 

449 

450 @property 

451 def named_destinations(self) -> dict[str, Destination]: 

452 """A read-only dictionary which maps names to destinations.""" 

453 return self._get_named_destinations() 

454 

455 def get_named_dest_root(self) -> ArrayObject: 

456 named_dest = ArrayObject() 

457 if CA.NAMES in self.root_object and isinstance( 

458 self.root_object[CA.NAMES], DictionaryObject 

459 ): 

460 names = cast(DictionaryObject, self.root_object[CA.NAMES]) 

461 if CA.DESTS in names and isinstance(names[CA.DESTS], DictionaryObject): 

462 # §3.6.3 Name Dictionary (PDF spec 1.7) 

463 dests = cast(DictionaryObject, names[CA.DESTS]) 

464 if CA.NAMES in dests: 

465 # §7.9.6, entries in a name tree node dictionary 

466 named_dest = cast(ArrayObject, dests[CA.NAMES]) 

467 else: 

468 named_dest = ArrayObject() 

469 dests[NameObject(CA.NAMES)] = named_dest 

470 elif hasattr(self, "_add_object"): 

471 dests = DictionaryObject() 

472 dests_ref = self._add_object(dests) 

473 names[NameObject(CA.DESTS)] = dests_ref 

474 dests[NameObject(CA.NAMES)] = named_dest 

475 

476 elif hasattr(self, "_add_object"): 

477 names = DictionaryObject() 

478 names_ref = self._add_object(names) 

479 self.root_object[NameObject(CA.NAMES)] = names_ref 

480 dests = DictionaryObject() 

481 dests_ref = self._add_object(dests) 

482 names[NameObject(CA.DESTS)] = dests_ref 

483 dests[NameObject(CA.NAMES)] = named_dest 

484 

485 return named_dest 

486 

487 ## common 

488 def _get_named_destinations( 

489 self, 

490 *, 

491 tree: Optional[DictionaryObject] = None, 

492 retval: Optional[dict[str, Destination]] = None, 

493 visited: Optional[set[int]] = None, 

494 ) -> dict[str, Destination]: 

495 """ 

496 Retrieve the named destinations present in the document. 

497 

498 Args: 

499 tree: The current tree. 

500 retval: The previously retrieved destinations for nested calls. 

501 visited: Already known/visited objects. 

502 

503 Returns: 

504 A dictionary which maps names to destinations. 

505 

506 """ 

507 if visited is None: 

508 visited = set() 

509 if retval is None: 

510 retval = {} 

511 catalog = self.root_object 

512 

513 # get the name tree 

514 candidate: Optional[PdfObject] = None 

515 if CA.DESTS in catalog: 

516 candidate = catalog[CA.DESTS].get_object() 

517 elif CA.NAMES in catalog: 

518 names = catalog[CA.NAMES].get_object() 

519 if isinstance(names, DictionaryObject) and CA.DESTS in names: 

520 candidate = names[CA.DESTS].get_object() 

521 if candidate is not None and not isinstance(candidate, DictionaryObject): 

522 logger_warning( 

523 "Destination tree is not a dictionary: %(tree)s", 

524 source=__name__, 

525 tree=candidate, 

526 ) 

527 return retval 

528 if candidate is not None: 

529 tree = candidate 

530 

531 if is_null_or_none(tree): 

532 return retval 

533 assert tree is not None, "mypy" 

534 

535 tree_id = id(tree) 

536 if tree_id in visited: 

537 logger_warning("Detected cycle in destination tree.", source=__name__) 

538 return retval 

539 visited.add(tree_id) 

540 

541 if PagesAttributes.KIDS in tree: 

542 kids = tree[PagesAttributes.KIDS].get_object() 

543 if not isinstance(kids, ArrayObject): 

544 logger_warning( 

545 "Destination tree kids are not an array: %(kids)s", 

546 source=__name__, 

547 kids=kids, 

548 ) 

549 return retval 

550 # recurse down the tree 

551 for kid in kids: 

552 self._get_named_destinations(tree=kid.get_object(), retval=retval, visited=visited) 

553 # §7.9.6, entries in a name tree node dictionary 

554 elif CA.NAMES in tree: # /Kids and /Names are exclusives (§7.9.6) 

555 names = tree[CA.NAMES].get_object() 

556 if not isinstance(names, ArrayObject): 

557 logger_warning( 

558 "Destination tree names are not an array: %(names)s", 

559 source=__name__, 

560 names=names, 

561 ) 

562 return retval 

563 i = 0 

564 while i < len(names): 

565 key = names[i].get_object() 

566 i += 1 

567 if not isinstance(key, (bytes, str)): 

568 continue 

569 try: 

570 value = names[i].get_object() 

571 except IndexError: 

572 break 

573 i += 1 

574 if isinstance(value, DictionaryObject): 

575 if "/D" in value: 

576 value = value["/D"] 

577 else: 

578 continue 

579 dest = self._build_destination(key, value) 

580 if dest is not None: 

581 retval[cast(str, dest["/Title"])] = dest 

582 # Remain backwards-compatible. 

583 retval[str(key)] = dest 

584 else: # case where /Dests is in the document's catalog dictionary (PDF 1.7 specs, §2 about PDF 1.1) 

585 for k__, v__ in tree.items(): 

586 val = v__.get_object() 

587 if isinstance(val, DictionaryObject): 

588 if "/D" in val: 

589 val = val["/D"].get_object() 

590 else: 

591 continue 

592 dest = self._build_destination(k__, val) 

593 if dest is not None: 

594 retval[k__] = dest 

595 return retval 

596 

597 # A select group of relevant field attributes. For the complete list, 

598 # see §12.3.2 of the PDF 1.7 or PDF 2.0 specification. 

599 

600 def get_fields( 

601 self, 

602 tree: Optional[DictionaryObject] = None, 

603 retval: Optional[dict[Any, Any]] = None, 

604 fileobj: Optional[Any] = None, 

605 stack: Any = _UNSET, 

606 *, 

607 _visited: Optional[set[int]] = None, 

608 _depth: int = 0, 

609 _traversal_state: Optional[_TraversalState] = None, 

610 ) -> Optional[dict[str, Any]]: 

611 """ 

612 Extract field data if this PDF contains interactive form fields. 

613 

614 The *tree*, *retval*, *visited*, *depth* parameters are for internal use. 

615 

616 Args: 

617 tree: Current object to parse. 

618 retval: In-progress list of fields. 

619 fileobj: A file object (usually a text file) to write 

620 a report to on all interactive form fields found. 

621 stack: The stack to use. Deprecated. 

622 _visited: Set of already visited IDs. Callers should usually omit this. 

623 _depth: The current field depth. 

624 _traversal_state: The current traversal state. 

625 

626 Returns: 

627 A dictionary where each key is a field name, and each 

628 value is a :class:`Field<pypdf.generic.Field>` object. By 

629 default, the mapping name is used for keys. 

630 ``None`` if form data could not be located. 

631 

632 """ 

633 if stack is not _UNSET: 

634 deprecate_no_replacement(name="stack", removed_in="7.0.0") 

635 if retval is not None: 

636 if _visited is None: 

637 _visited = set() 

638 _visited.update(set(map(id, stack or []))) 

639 

640 configuration = get_configuration() 

641 if _depth >= configuration.form_fields_maximum_depth: 

642 raise LimitReachedError("Reached maximum allowed form field depth.") 

643 if _traversal_state is None: 

644 _traversal_state = _TraversalState() 

645 

646 def _check_field_limit(_increment: int) -> None: 

647 _traversal_state.entry_count += _increment 

648 if _traversal_state.entry_count > configuration.form_fields_maximum_entries: 

649 raise LimitReachedError("Exceeded maximum number of fields allowed.") 

650 

651 field_attributes = FA.attributes_dict() 

652 field_attributes.update(CheckboxRadioButtonAttributes.attributes_dict()) 

653 if retval is None: 

654 retval = {} 

655 catalog = self.root_object 

656 _visited = set() 

657 # get the AcroForm tree 

658 if CA.ACRO_FORM in catalog: 

659 entry = catalog[CA.ACRO_FORM] 

660 acro_form = None if entry is None else entry.get_object() 

661 if acro_form is not None and not isinstance( 

662 acro_form, DictionaryObject 

663 ): 

664 logger_warning( 

665 "AcroForm is not a dictionary: %(acro_form)s", 

666 source=__name__, 

667 acro_form=acro_form, 

668 ) 

669 return None 

670 tree = acro_form 

671 else: 

672 return None 

673 if tree is None: 

674 return retval 

675 assert _visited is not None 

676 if "/Fields" in tree: 

677 fields = tree["/Fields"].get_object() 

678 if not isinstance(fields, ArrayObject): 

679 logger_warning( 

680 "AcroForm /Fields is not an array: %(fields)s", 

681 source=__name__, 

682 fields=fields, 

683 ) 

684 return retval 

685 _check_field_limit(len(fields)) 

686 for f in fields: 

687 field = f.get_object() 

688 self._build_field( 

689 field=field, retval=retval, fileobj=fileobj, field_attributes=field_attributes, 

690 visited=_visited, depth=_depth, traversal_state=_traversal_state, 

691 ) 

692 elif any(attr in tree for attr in field_attributes): 

693 # Tree is a field 

694 _check_field_limit(1) 

695 self._build_field( 

696 field=tree, retval=retval, fileobj=fileobj, field_attributes=field_attributes, 

697 visited=_visited, depth=_depth, traversal_state=_traversal_state, 

698 ) 

699 return retval 

700 

701 def _get_qualified_field_name( 

702 self, 

703 *, 

704 parent: DictionaryObject, 

705 visited: Optional[set[int]] = None 

706 ) -> str: 

707 if visited is None: 

708 visited = set() 

709 parent_id = id(parent) 

710 if parent_id in visited: 

711 raise LimitReachedError("Detected cycle in /Parent hierarchy when retrieving qualified field name.") 

712 visited.add(parent_id) 

713 

714 if "/TM" in parent: 

715 return cast(str, parent["/TM"]) 

716 if "/Parent" in parent: 

717 return ( 

718 self._get_qualified_field_name( 

719 parent=cast(DictionaryObject, parent["/Parent"]), 

720 visited=visited, 

721 ) 

722 + "." 

723 + cast(str, parent.get("/T", "")) 

724 ) 

725 return cast(str, parent.get("/T", "")) 

726 

727 @staticmethod 

728 def _normal_appearance(appearance: Any) -> DictionaryObject: 

729 """Return the /N normal-appearance sub-dictionary of an /AP entry.""" 

730 appearance = appearance.get_object() 

731 if not isinstance(appearance, DictionaryObject): 

732 raise PdfReadError(f"Expected appearance dictionary, got {appearance!r}") 

733 normal = appearance.get("/N") 

734 if normal is not None: 

735 normal = normal.get_object() 

736 if not isinstance(normal, DictionaryObject): 

737 raise PdfReadError(f"Expected /N appearance dictionary, got {normal!r}") 

738 return normal 

739 

740 def _build_field( 

741 self, 

742 *, 

743 field: Union[TreeObject, DictionaryObject], 

744 retval: dict[Any, Any], 

745 fileobj: Any, 

746 field_attributes: Any, 

747 visited: set[int], 

748 depth: int, 

749 traversal_state: _TraversalState, 

750 ) -> None: 

751 if not isinstance(field, DictionaryObject): 

752 logger_warning( 

753 "Form field is not a dictionary: %(field)s", 

754 source=__name__, 

755 field=field, 

756 ) 

757 return 

758 if all(attr not in field for attr in ("/T", "/TM")): 

759 return 

760 key = self._get_qualified_field_name(parent=field) 

761 if fileobj: 

762 self._write_field(fileobj, field, field_attributes) 

763 fileobj.write("\n") 

764 retval[key] = Field(field) 

765 obj = retval[key].indirect_reference.get_object() # to get the full object 

766 if obj.get(FA.FT, "") == "/Ch" and obj.get(NameObject(FA.Opt)): 

767 retval[key][NameObject("/_States_")] = obj[NameObject(FA.Opt)] 

768 if obj.get(FA.FT, "") == "/Btn" and "/AP" in obj: 

769 # Checkbox 

770 normal = self._normal_appearance(obj["/AP"]) 

771 retval[key][NameObject("/_States_")] = ArrayObject(list(normal.keys())) 

772 if "/Off" not in retval[key]["/_States_"]: 

773 retval[key][NameObject("/_States_")].append(NameObject("/Off")) 

774 elif obj.get(FA.FT, "") == "/Btn" and obj.get(FA.Ff, 0) & FA.FfBits.Radio != 0: 

775 states: list[str] = [] 

776 retval[key][NameObject("/_States_")] = ArrayObject(states) 

777 for k in obj.get(FA.Kids, {}): 

778 k = k.get_object() 

779 if "/AP" not in k: 

780 raise PdfReadError(f"Button field kid missing /AP: {k!r}") 

781 normal = self._normal_appearance(k["/AP"]) 

782 for s in list(normal.keys()): 

783 if s not in states: 

784 states.append(s) 

785 retval[key][NameObject("/_States_")] = ArrayObject(states) 

786 if ( 

787 obj.get(FA.Ff, 0) & FA.FfBits.NoToggleToOff != 0 

788 and "/Off" in retval[key]["/_States_"] 

789 ): 

790 del retval[key]["/_States_"][retval[key]["/_States_"].index("/Off")] 

791 # at last for order 

792 self._check_kids( 

793 tree=field, retval=retval, fileobj=fileobj, visited=visited, depth=depth, traversal_state=traversal_state 

794 ) 

795 

796 def _check_kids( 

797 self, 

798 *, 

799 tree: Union[TreeObject, DictionaryObject], 

800 retval: Any, 

801 fileobj: Any, 

802 visited: set[int], 

803 depth: int, 

804 traversal_state: _TraversalState, 

805 ) -> None: 

806 tree_id = id(tree) 

807 if tree_id in visited: 

808 logger_warning( 

809 "%(field_name)s already parsed", 

810 source=__name__, 

811 field_name=self._get_qualified_field_name(parent=tree), 

812 ) 

813 return 

814 visited.add(tree_id) 

815 if PagesAttributes.KIDS in tree: 

816 # recurse down the tree 

817 for kid in tree[PagesAttributes.KIDS]: # type: ignore[attr-defined] 

818 kid = kid.get_object() 

819 self.get_fields( 

820 tree=kid, retval=retval, fileobj=fileobj, 

821 _visited=visited, _depth=depth + 1, _traversal_state=traversal_state 

822 ) 

823 

824 def _write_field(self, fileobj: Any, field: Any, field_attributes: Any) -> None: 

825 field_attributes_tuple = FA.attributes() 

826 field_attributes_tuple = ( 

827 field_attributes_tuple + CheckboxRadioButtonAttributes.attributes() 

828 ) 

829 

830 for attr in field_attributes_tuple: 

831 if attr in ( 

832 FA.Kids, 

833 FA.AA, 

834 ): 

835 continue 

836 attr_name = field_attributes[attr] 

837 try: 

838 if attr == FA.FT: 

839 # Make the field type value clearer 

840 types = { 

841 "/Btn": "Button", 

842 "/Tx": "Text", 

843 "/Ch": "Choice", 

844 "/Sig": "Signature", 

845 } 

846 if field[attr] in types: 

847 fileobj.write(f"{attr_name}: {types[field[attr]]}\n") 

848 elif attr == FA.Parent: 

849 # Let's just write the name of the parent 

850 try: 

851 name = field[attr][FA.TM] 

852 except KeyError: 

853 name = field[attr][FA.T] 

854 fileobj.write(f"{attr_name}: {name}\n") 

855 else: 

856 fileobj.write(f"{attr_name}: {field[attr]}\n") 

857 except KeyError: 

858 # Field attribute is N/A or unknown, so don't write anything 

859 pass 

860 

861 def get_form_text_fields(self, full_qualified_name: bool = False) -> dict[str, Any]: 

862 """ 

863 Retrieve form fields from the document with textual data. 

864 

865 Args: 

866 full_qualified_name: to get full name 

867 

868 Returns: 

869 A dictionary. The key is the name of the form field, 

870 the value is the content of the field. 

871 

872 If the document contains multiple form fields with the same name, the 

873 second and following will get the suffix .2, .3, ... 

874 

875 """ 

876 

877 def indexed_key(k: str, fields: dict[Any, Any]) -> str: 

878 if k not in fields: 

879 return k 

880 return ( 

881 k 

882 + "." 

883 + str(sum(1 for kk in fields if kk.startswith(k + ".")) + 2) 

884 ) 

885 

886 # Retrieve document form fields 

887 form_fields = self.get_fields() 

888 if form_fields is None: 

889 return {} 

890 ff = {} 

891 for field, value in form_fields.items(): 

892 if value.get("/FT") == "/Tx": 

893 if full_qualified_name: 

894 ff[field] = value.get("/V") 

895 else: 

896 ff[indexed_key(cast(str, value.get("/T", field)), ff)] = value.get("/V") 

897 return ff 

898 

899 def get_pages_showing_field( 

900 self, field: Union[Field, PdfObject, IndirectObject] 

901 ) -> list[PageObject]: 

902 """ 

903 Provides list of pages where the field is called. 

904 

905 Args: 

906 field: Field Object, PdfObject or IndirectObject referencing a Field 

907 

908 Returns: 

909 List of pages: 

910 - Empty list: 

911 The field has no widgets attached 

912 (either hidden field or ancestor field). 

913 - Single page list: 

914 Page where the widget is present 

915 (most common). 

916 - Multi-page list: 

917 Field with multiple kids widgets 

918 (example: radio buttons, field repeated on multiple pages). 

919 

920 """ 

921 try: 

922 # to cope with all types 

923 field = cast(DictionaryObject, field.indirect_reference.get_object()) # type: ignore[union-attr] 

924 except Exception as exc: 

925 raise ValueError("Field type is invalid") from exc 

926 if is_null_or_none(field.get_inherited(key="/FT", default=None)): 

927 raise ValueError("Field is not valid") 

928 ret = [] 

929 if field.get("/Subtype", "") == "/Widget": 

930 if "/P" in field: 

931 ret = [field["/P"].get_object()] 

932 else: 

933 ret = [ 

934 p 

935 for p in self.pages 

936 if field.indirect_reference in p.get("/Annots", "") 

937 ] 

938 else: 

939 kids = field.get("/Kids", ()) 

940 for k in kids: 

941 k = k.get_object() 

942 if (k.get("/Subtype", "") == "/Widget") and ("/T" not in k): 

943 # Kid that is just a widget, not a field: 

944 if "/P" in k: 

945 ret += [k["/P"].get_object()] 

946 else: 

947 ret += [ 

948 p 

949 for p in self.pages 

950 if k.indirect_reference in p.get("/Annots", "") 

951 ] 

952 return [ 

953 x 

954 if isinstance(x, PageObject) 

955 else (self.pages[self._get_page_number_by_indirect(x.indirect_reference)]) # type: ignore[index, union-attr] 

956 for x in ret 

957 ] 

958 

959 @property 

960 def open_destination( 

961 self, 

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

963 """ 

964 Property to access the opening destination (``/OpenAction`` entry in 

965 the PDF catalog). It returns ``None`` if the entry does not exist 

966 or is not set. 

967 

968 Raises: 

969 Exception: If a destination is invalid. 

970 

971 """ 

972 if "/OpenAction" not in self.root_object: 

973 return None 

974 oa: Any = self.root_object["/OpenAction"] 

975 if isinstance(oa, bytes): # pragma: no cover 

976 oa = oa.decode() 

977 if isinstance(oa, str): 

978 return create_string_object(oa) 

979 if isinstance(oa, ArrayObject): 

980 try: 

981 page, typ, *array = oa 

982 fit = Fit(typ, tuple(array)) 

983 return Destination("OpenAction", page, fit) 

984 except Exception as exc: 

985 raise Exception(f"Invalid Destination {oa}: {exc}") from exc 

986 else: 

987 return None 

988 

989 @open_destination.setter 

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

991 raise NotImplementedError("No setter for open_destination") 

992 

993 @property 

994 def outline(self) -> OutlineType: 

995 """ 

996 Read-only property for the outline present in the document 

997 (i.e., a collection of 'outline items' which are also known as 

998 'bookmarks'). 

999 """ 

1000 return self._get_outline() 

1001 

1002 def _get_outline( 

1003 self, 

1004 *, 

1005 node: Optional[DictionaryObject] = None, 

1006 outline: Optional[Any] = None, 

1007 visited: Optional[set[int]] = None, 

1008 depth: int = 0, 

1009 traversal_state: Optional[_TraversalState] = None 

1010 ) -> OutlineType: 

1011 if traversal_state is None: 

1012 traversal_state = _TraversalState() 

1013 

1014 if outline is None: 

1015 outline = [] 

1016 catalog = self.root_object 

1017 

1018 # get the outline dictionary and named destinations 

1019 if Core.OUTLINES in catalog: 

1020 lines = catalog[Core.OUTLINES] 

1021 

1022 # A reference to an object which is missing or free in the xref table 

1023 # resolves to None. 

1024 if is_null_or_none(lines): 

1025 return outline 

1026 

1027 if not isinstance(lines, DictionaryObject): 

1028 logger_warning( 

1029 "Outlines are not a dictionary: %(lines)s", 

1030 source=__name__, 

1031 lines=lines, 

1032 ) 

1033 return outline 

1034 

1035 # §12.3.3 Document outline, entries in the outline dictionary 

1036 if "/First" in lines: 

1037 node = cast(DictionaryObject, lines["/First"]) 

1038 self._named_destinations = self._get_named_destinations() 

1039 

1040 if node is None: 

1041 return outline 

1042 

1043 configuration = get_configuration() 

1044 if depth > configuration.outline_maximum_depth: 

1045 raise LimitReachedError(f"Maximum outline depth reached: {depth} > {configuration.outline_maximum_depth}.") 

1046 

1047 # see if there are any more outline items 

1048 if visited is None: 

1049 visited = set() 

1050 while True: 

1051 node_id = id(node) 

1052 if node_id in visited: 

1053 logger_warning("Detected cycle in outline structure for %(node)s", source=__name__, node=node) 

1054 break 

1055 visited.add(node_id) 

1056 traversal_state.entry_count += 1 

1057 if traversal_state.entry_count > configuration.outline_maximum_entries: 

1058 raise LimitReachedError( 

1059 f"Maximum outline entry limit reached: " 

1060 f"{traversal_state.entry_count} > {configuration.outline_maximum_entries}." 

1061 ) 

1062 

1063 if not isinstance(node, DictionaryObject): 

1064 logger_warning( 

1065 "Outline node is not a dictionary: %(node)s", 

1066 source=__name__, 

1067 node=node, 

1068 ) 

1069 break 

1070 

1071 outline_obj = self._build_outline_item(node) 

1072 if outline_obj: 

1073 outline.append(outline_obj) 

1074 

1075 # check for sub-outline 

1076 if "/First" in node: 

1077 sub_outline: list[Any] = [] 

1078 # Pass a copy to allow multiple outer entries to reference the same inner one. 

1079 inner_visited = visited.copy() 

1080 self._get_outline( 

1081 node=cast(DictionaryObject, node["/First"]), 

1082 outline=sub_outline, 

1083 visited=inner_visited, 

1084 depth=depth + 1, 

1085 traversal_state=traversal_state, 

1086 ) 

1087 if sub_outline: 

1088 outline.append(sub_outline) 

1089 

1090 if "/Next" not in node: 

1091 break 

1092 node = cast(DictionaryObject, node["/Next"]) 

1093 

1094 return outline 

1095 

1096 @property 

1097 def threads(self) -> Optional[ArrayObject]: 

1098 """ 

1099 Read-only property for the list of threads. 

1100 

1101 See §12.4.3 from the PDF 1.7 or 2.0 specification. 

1102 

1103 It is an array of dictionaries with "/F" (the first bead in the thread) 

1104 and "/I" (a thread information dictionary containing information about 

1105 the thread, such as its title, author, and creation date) properties or 

1106 None if there are no articles. 

1107 

1108 Since PDF 2.0 it can also contain an indirect reference to a metadata 

1109 stream containing information about the thread, such as its title, 

1110 author, and creation date. 

1111 """ 

1112 catalog = self.root_object 

1113 if Core.THREADS in catalog: 

1114 return cast("ArrayObject", catalog[Core.THREADS]) 

1115 return None 

1116 

1117 @abstractmethod 

1118 def _get_page_number_by_indirect( 

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

1120 ) -> Optional[int]: 

1121 ... # pragma: no cover 

1122 

1123 def get_page_number(self, page: PageObject) -> Optional[int]: 

1124 """ 

1125 Retrieve page number of a given PageObject. 

1126 

1127 Args: 

1128 page: The page to get page number. Should be 

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

1130 

1131 Returns: 

1132 The page number or None if page is not found 

1133 

1134 """ 

1135 return self._get_page_number_by_indirect(page.indirect_reference) 

1136 

1137 def get_destination_page_number(self, destination: Destination) -> Optional[int]: 

1138 """ 

1139 Retrieve page number of a given Destination object. 

1140 

1141 Args: 

1142 destination: The destination to get page number. 

1143 

1144 Returns: 

1145 The page number or None if page is not found 

1146 

1147 """ 

1148 return self._get_page_number_by_indirect(destination.page) 

1149 

1150 def _build_destination( 

1151 self, 

1152 title: Union[str, bytes], 

1153 array: Optional[ArrayObject], 

1154 ) -> Destination: 

1155 page, typ = None, None 

1156 # A valid destination is an array of at least a page and a fit type. 

1157 # Anything else (a name, a bare number, a NullObject, None, ...) cannot 

1158 # be unpacked below, so treat it as a missing destination. 

1159 if not isinstance(array, ArrayObject) or len(array) < 2: 

1160 page = NullObject() 

1161 return Destination(title, page, Fit.fit()) 

1162 page, typ, *fit_args = array 

1163 try: 

1164 return Destination(title, page, Fit(fit_type=typ, fit_args=fit_args)) 

1165 except PdfReadError: 

1166 logger_warning( 

1167 "Unknown destination: %(title)r %(fit_args)s", 

1168 source=__name__, 

1169 title=title, 

1170 fit_args=fit_args, 

1171 ) 

1172 if self.strict: 

1173 raise 

1174 # create a link to first Page 

1175 tmp = self.pages[0].indirect_reference 

1176 indirect_reference = NullObject() if tmp is None else tmp 

1177 return Destination(title, indirect_reference, Fit.fit()) 

1178 

1179 def _build_outline_item(self, node: DictionaryObject) -> Optional[Destination]: 

1180 dest, title, outline_item = None, None, None 

1181 

1182 # title required for valid outline 

1183 # §12.3.3, entries in an outline item dictionary 

1184 if "/Title" in node: 

1185 title = cast("str", node["/Title"]) 

1186 else: 

1187 if self.strict: 

1188 raise PdfReadError(f"Outline Entry Missing /Title attribute: {node!r}") 

1189 title = "" 

1190 

1191 if "/A" in node: 

1192 # Action, PDF 1.7 and PDF 2.0 §12.6 (only type GoTo supported) 

1193 action = cast(DictionaryObject, node["/A"]) 

1194 if GoToActionArguments.S in action: 

1195 action_type = cast(NameObject, action[GoToActionArguments.S]) 

1196 if action_type == "/GoTo": 

1197 if GoToActionArguments.D in action: 

1198 dest = action[GoToActionArguments.D] 

1199 elif self.strict: 

1200 raise PdfReadError(f"Outline Action Missing /D attribute: {node!r}") 

1201 elif self.strict: 

1202 raise PdfReadError(f"Outline Action Missing /S attribute: {node!r}") 

1203 elif "/Dest" in node: 

1204 # Destination, PDF 1.7 and PDF 2.0 §12.3.2 

1205 dest = node["/Dest"] 

1206 # if array was referenced in another object, will be a dict w/ key "/D" 

1207 if isinstance(dest, DictionaryObject) and "/D" in dest: 

1208 dest = dest["/D"] 

1209 

1210 if isinstance(dest, ArrayObject): 

1211 outline_item = self._build_destination(title, dest) 

1212 elif isinstance(dest, str): 

1213 # named destination, addresses NameObject Issue #193 

1214 # TODO: Keep named destination instead of replacing it? 

1215 try: 

1216 outline_item = self._build_destination( 

1217 title, self._named_destinations[dest].dest_array 

1218 ) 

1219 except KeyError: 

1220 # named destination not found in Name Dict 

1221 outline_item = self._build_destination(title, None) 

1222 elif dest is None: 

1223 # outline item not required to have destination or action 

1224 # PDFv1.7 Table 153 

1225 outline_item = self._build_destination(title, dest) 

1226 else: 

1227 if self.strict: 

1228 raise PdfReadError(f"Unexpected destination {dest!r}") 

1229 logger_warning( 

1230 "Removed unexpected destination %(dest)r from destination", 

1231 source=__name__, 

1232 dest=dest, 

1233 ) 

1234 outline_item = self._build_destination(title, None) 

1235 

1236 # if outline item created, add color, format, and child count if present 

1237 if outline_item: 

1238 if "/C" in node: 

1239 # Color of outline item font in (R, G, B) with values ranging 0.0-1.0 

1240 color = node["/C"] 

1241 if isinstance(color, list): 

1242 outline_item[NameObject("/C")] = ArrayObject(FloatObject(c) for c in color) 

1243 else: 

1244 logger_warning( 

1245 "Ignoring non-array outline color %(color)r", 

1246 source=__name__, 

1247 color=color, 

1248 ) 

1249 if "/F" in node: 

1250 # specifies style characteristics bold and/or italic 

1251 # with 1=italic, 2=bold, 3=both 

1252 outline_item[NameObject("/F")] = node["/F"] 

1253 if "/Count" in node: 

1254 # absolute value = num. visible children 

1255 # with positive = open/unfolded, negative = closed/folded 

1256 outline_item[NameObject("/Count")] = node["/Count"] 

1257 # if count is 0 we will consider it as open (to have available is_open) 

1258 outline_item[NameObject("/%is_open%")] = BooleanObject( 

1259 node.get("/Count", 0) >= 0 

1260 ) 

1261 outline_item.node = node 

1262 try: 

1263 outline_item.indirect_reference = node.indirect_reference 

1264 except AttributeError: 

1265 pass 

1266 return outline_item 

1267 

1268 @property 

1269 def pages(self) -> Sequence[PageObject]: 

1270 """ 

1271 Property that emulates a list of :class:`PageObject<pypdf._page.PageObject>`. 

1272 This property allows to get a page or a range of pages. 

1273 

1274 The returned object supports indexing, slicing, ``len()``, iteration and 

1275 (for PdfWriter) ``del``, but it is not a :class:`list` - pages are looked 

1276 up on demand rather than materialised up front, so list-only operations 

1277 such as ``append()`` or concatenation with ``+`` are not available. 

1278 

1279 Note: 

1280 For PdfWriter only: Provides the capability to remove a page/range of 

1281 page from the list (using the del operator). Remember: Only the page 

1282 entry is removed, as the objects beneath can be used elsewhere. A 

1283 solution to completely remove them - if they are not used anywhere - is 

1284 to write to a buffer/temporary file and then load it into a new 

1285 PdfWriter. 

1286 

1287 """ 

1288 return _VirtualList(self.get_num_pages, self.get_page) 

1289 

1290 @property 

1291 def page_labels(self) -> list[str]: 

1292 """ 

1293 A list of labels for the pages in this document. 

1294 

1295 This property is read-only. The labels are in the order that the pages 

1296 appear in the document. 

1297 """ 

1298 return [page_index2page_label(self, i) for i in range(len(self.pages))] 

1299 

1300 @property 

1301 def page_layout(self) -> Optional[str]: 

1302 """ 

1303 Get the page layout currently being used. 

1304 

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

1306 :widths: 50 200 

1307 

1308 * - /NoLayout 

1309 - Layout explicitly not specified 

1310 * - /SinglePage 

1311 - Show one page at a time 

1312 * - /OneColumn 

1313 - Show one column at a time 

1314 * - /TwoColumnLeft 

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

1316 * - /TwoColumnRight 

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

1318 * - /TwoPageLeft 

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

1320 * - /TwoPageRight 

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

1322 """ 

1323 try: 

1324 return cast(NameObject, self.root_object[CA.PAGE_LAYOUT]) 

1325 except KeyError: 

1326 return None 

1327 

1328 @property 

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

1330 """ 

1331 Get the page mode currently being used. 

1332 

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

1334 :widths: 50 200 

1335 

1336 * - /UseNone 

1337 - Do not show outline or thumbnails panels 

1338 * - /UseOutlines 

1339 - Show outline (aka bookmarks) panel 

1340 * - /UseThumbs 

1341 - Show page thumbnails panel 

1342 * - /FullScreen 

1343 - Fullscreen view 

1344 * - /UseOC 

1345 - Show Optional Content Group (OCG) panel 

1346 * - /UseAttachments 

1347 - Show attachments panel 

1348 """ 

1349 try: 

1350 return self.root_object["/PageMode"] # type: ignore[return-value] 

1351 except KeyError: 

1352 return None 

1353 

1354 def _flatten(self, list_only: bool = False) -> None: 

1355 """ 

1356 Process the document pages to ease searching. 

1357 

1358 Attributes of a page may inherit from ancestor nodes 

1359 in the page tree. Flattening means moving 

1360 any inheritance data into descendant nodes, 

1361 effectively removing the inheritance dependency. 

1362 The result is stored in ``self.flattened_pages``. 

1363 

1364 Note: It is distinct from another use of "flattening" applied to PDFs. 

1365 Flattening a PDF also means combining all the contents into one single layer 

1366 and making the file less editable. 

1367 

1368 The page tree is walked with an explicit stack instead of recursion, so the 

1369 traversal is bound by ``Configuration.page_tree_maximum_depth`` and 

1370 ``Configuration.page_tree_maximum_entries`` only, not by the interpreter 

1371 recursion limit. 

1372 

1373 Args: 

1374 list_only: If True, the page's own entries are not copied into the 

1375 generated :class:`PageObject`. Attributes inherited from ancestor 

1376 nodes are applied either way. Note that a page reached through an 

1377 indirect reference is already populated by :class:`PageObject` 

1378 itself, so this only has an effect for ``/Kids`` entries that are 

1379 inline dictionaries. 

1380 

1381 """ 

1382 configuration = get_configuration() 

1383 self.flattened_pages = None 

1384 pages = self.root_object.get("/Pages", NullObject()).get_object() 

1385 if not isinstance(pages, DictionaryObject): 

1386 raise PdfReadError("Invalid object in /Pages") 

1387 

1388 flattened_pages: list[PageObject] = [] 

1389 traversal_state = _TraversalState() 

1390 # id() values of the nodes on the path from the root to the current node. 

1391 # Detects multi-hop cycles such as A→B→C→A that the single-parent check misses. 

1392 ancestor_ids: set[int] = set() 

1393 # The /Pages nodes of that path whose /Kids are not exhausted yet. 

1394 stack: list[_PageTreeFrame] = [] 

1395 item: Optional[_PageTreeItem] = _PageTreeItem( 

1396 node=pages, inherit={}, indirect_reference=None, depth=0 

1397 ) 

1398 while item is not None: 

1399 if item.depth > configuration.page_tree_maximum_depth: 

1400 raise LimitReachedError( 

1401 f"Maximum page tree depth reached: {item.depth} > {configuration.page_tree_maximum_depth}." 

1402 ) 

1403 

1404 # Get the current node_type 

1405 node = item.node 

1406 if PagesAttributes.TYPE in node: 

1407 node_type = cast(str, node[PagesAttributes.TYPE]) 

1408 # if the page tree node has no /Type, consider as a page if /Kids is also missing 

1409 elif PagesAttributes.KIDS not in node: 

1410 # Without /Type, only accept it as a page if it carries a structural page key. 

1411 if self.strict and not any(key in node for key in (PG.CONTENTS, PG.MEDIABOX, PG.PARENT)): 

1412 raise PdfReadError(f"Non-page object reached through /Kids: {node!r}") 

1413 node_type = "/Page" 

1414 else: 

1415 node_type = "/Pages" 

1416 

1417 # Flatten that type of node 

1418 if node_type == "/Pages": 

1419 ancestor_ids.add(id(node)) 

1420 stack.append(self._enter_page_tree_node(item)) 

1421 elif node_type == "/Page": 

1422 flattened_pages.append(self._flatten_leaf_page(item, list_only)) 

1423 

1424 item = self._pop_next_page_tree_kid(stack, ancestor_ids, traversal_state) 

1425 

1426 # Assigned only here, so that a failed traversal does not leave a partial page list behind. 

1427 self.flattened_pages = flattened_pages 

1428 

1429 def _enter_page_tree_node(self, item: _PageTreeItem) -> _PageTreeFrame: 

1430 """ 

1431 Start walking an intermediate ``/Pages`` node: propagate its inheritable 

1432 attributes and pair it with an iterator over its ``/Kids``. 

1433 """ 

1434 pages = item.node 

1435 # Copied before being modified, as the kids of the enclosing node share it. 

1436 inherit = dict(item.inherit) 

1437 for attr in _INHERITABLE_PAGE_ATTRIBUTES: 

1438 if attr in pages: 

1439 inherit[attr] = pages[attr] 

1440 

1441 kids = pages.get(PagesAttributes.KIDS, ArrayObject()).get_object() 

1442 if isinstance(kids, NullObject): 

1443 kids = ArrayObject() 

1444 elif not isinstance(kids, ArrayObject): 

1445 raise PdfReadError(f"Expected /Kids to be an array, got {type(kids).__name__}.") 

1446 

1447 return item._replace(inherit=inherit), iter(kids) 

1448 

1449 def _pop_next_page_tree_kid( 

1450 self, 

1451 stack: list[_PageTreeFrame], 

1452 ancestor_ids: set[int], 

1453 traversal_state: _TraversalState, 

1454 ) -> Optional[_PageTreeItem]: 

1455 """ 

1456 Return the next page tree node to flatten, leaving the ``/Pages`` nodes whose 

1457 ``/Kids`` are exhausted, or ``None`` when the traversal is complete. 

1458 

1459 Cyclic references and the configured page-tree entry limit are enforced here. 

1460 """ 

1461 configuration = get_configuration() 

1462 while stack: 

1463 parent, kids = stack[-1] 

1464 pages_reference = getattr(parent.node, "indirect_reference", object()) 

1465 for page in kids: 

1466 if getattr(page, "indirect_reference", object()) == pages_reference: 

1467 raise PdfReadError("Detected cyclic page references.") 

1468 

1469 obj = page.get_object() 

1470 if not isinstance(obj, DictionaryObject): 

1471 if not is_null_or_none(obj): 

1472 logger_warning( 

1473 "Ignoring page tree entry that is not a dictionary: %(entry)s", 

1474 source=__name__, 

1475 entry=obj, 

1476 ) 

1477 # damaged file may have invalid child in /Pages 

1478 continue 

1479 if not obj: 

1480 # An empty dictionary would otherwise be read as a blank page. 

1481 continue 

1482 if id(obj) in ancestor_ids: 

1483 raise PdfReadError("Detected cyclic page references.") 

1484 traversal_state.entry_count += 1 

1485 if traversal_state.entry_count > configuration.page_tree_maximum_entries: 

1486 raise LimitReachedError( 

1487 "Maximum page tree entry limit reached: " 

1488 f"{traversal_state.entry_count} > {configuration.page_tree_maximum_entries}." 

1489 ) 

1490 return _PageTreeItem( 

1491 node=obj, 

1492 inherit=parent.inherit, 

1493 indirect_reference=page if isinstance(page, IndirectObject) else None, 

1494 depth=parent.depth + 1, 

1495 ) 

1496 stack.pop() 

1497 ancestor_ids.discard(id(parent.node)) 

1498 return None 

1499 

1500 def _flatten_leaf_page(self, item: _PageTreeItem, list_only: bool) -> PageObject: 

1501 """ 

1502 Build the :class:`PageObject` for a leaf ``/Page`` node. 

1503 

1504 ``list_only`` suppresses copying the page's own entries; the inherited 

1505 attributes are applied regardless. When the node has an indirect reference, 

1506 :class:`PageObject` has already copied those entries in its constructor, 

1507 so the flag only matters for inline ``/Kids`` dictionaries. 

1508 """ 

1509 page_obj = PageObject(self, item.indirect_reference) 

1510 if not list_only: 

1511 page_obj.update(item.node) 

1512 for attr, value in item.inherit.items(): 

1513 # if the page has its own value, it does not inherit the parent's value 

1514 if attr not in page_obj: 

1515 page_obj[attr] = value 

1516 

1517 return page_obj 

1518 

1519 def remove_page( 

1520 self, 

1521 page: Union[int, PageObject, IndirectObject], 

1522 clean: bool = False, 

1523 ) -> None: 

1524 """ 

1525 Remove page from pages list. 

1526 

1527 Args: 

1528 page: 

1529 * :class:`int`: Page number to be removed. 

1530 * :class:`~pypdf._page.PageObject`: page to be removed. If the page appears many times 

1531 only the first one will be removed. 

1532 * :class:`~pypdf.generic.IndirectObject`: Reference to page to be removed. 

1533 

1534 clean: replace PageObject with NullObject to prevent annotations 

1535 or destinations to reference a detached page. 

1536 

1537 """ 

1538 if self.flattened_pages is None: 

1539 self._flatten(self._readonly) 

1540 assert self.flattened_pages is not None 

1541 if isinstance(page, IndirectObject): 

1542 p = page.get_object() 

1543 if not isinstance(p, PageObject): 

1544 logger_warning("IndirectObject is not referencing a page", source=__name__) 

1545 return 

1546 page = p 

1547 

1548 if not isinstance(page, int): 

1549 try: 

1550 page = self.flattened_pages.index(page) 

1551 except ValueError: 

1552 logger_warning("Cannot find page in pages", source=__name__) 

1553 return 

1554 if not (0 <= page < len(self.flattened_pages)): 

1555 logger_warning("Page number is out of range", source=__name__) 

1556 return 

1557 

1558 ind = self.pages[page].indirect_reference 

1559 # `pages` is typed as a Sequence because it is not a list, but the 

1560 # concrete _VirtualList does implement deletion. 

1561 del cast(_VirtualList, self.pages)[page] 

1562 if clean and ind is not None: 

1563 self._replace_object(ind, NullObject()) 

1564 

1565 def _get_indirect_object(self, num: int, gen: int) -> Optional[PdfObject]: 

1566 """ 

1567 Used to ease development. 

1568 

1569 This is equivalent to generic.IndirectObject(num,gen,self).get_object() 

1570 

1571 Args: 

1572 num: The object number of the indirect object. 

1573 gen: The generation number of the indirect object. 

1574 

1575 Returns: 

1576 A PdfObject 

1577 

1578 """ 

1579 return IndirectObject(num, gen, self).get_object() 

1580 

1581 def decode_permissions( 

1582 self, permissions_code: int 

1583 ) -> NoReturn: # pragma: no cover 

1584 """Take the permissions as an integer, return the allowed access.""" 

1585 deprecation_with_replacement( 

1586 old_name="decode_permissions", 

1587 new_name="user_access_permissions", 

1588 removed_in="5.0.0", 

1589 ) 

1590 

1591 @property 

1592 def user_access_permissions(self) -> Optional[UserAccessPermissions]: 

1593 """ 

1594 Get the user access permissions for encrypted documents. 

1595 Returns None if not encrypted. 

1596 

1597 .. warning:: 

1598 

1599 For AES-256 encrypted documents (R=5/R=6), the returned 

1600 permissions are derived from the ``/P`` field, which is 

1601 only trustworthy if the ``/Perms`` integrity check passed. 

1602 Check :attr:`are_permissions_valid` to verify. 

1603 """ 

1604 if self._encryption is None: 

1605 return None 

1606 return UserAccessPermissions(self._encryption.P) 

1607 

1608 @property 

1609 def are_permissions_valid(self) -> Optional[bool]: 

1610 """ 

1611 Whether the ``/Perms`` integrity check passed for this document. 

1612 

1613 For AES-256 encrypted documents (R=5/R=6), the ``/Perms`` field 

1614 is an encrypted copy of the permissions that can be verified 

1615 independently. Returns ``False`` if this check fails (the ``/P`` 

1616 permissions may have been tampered with). 

1617 

1618 Returns ``None`` if the document is not encrypted or has not yet 

1619 been decrypted via :meth:`decrypt()<pypdf.PdfReader.decrypt>`. 

1620 Returns ``True`` for non-AES-256 encryption (no ``/Perms`` to check). 

1621 """ 

1622 if self._encryption is None: 

1623 return None 

1624 if not self._encryption.is_decrypted(): 

1625 return None 

1626 return self._encryption._are_permissions_valid 

1627 

1628 @property 

1629 @abstractmethod 

1630 def is_encrypted(self) -> bool: 

1631 """ 

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

1633 

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

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

1636 """ 

1637 ... # pragma: no cover 

1638 

1639 @property 

1640 def xfa(self) -> Optional[dict[str, Any]]: 

1641 retval: dict[str, Any] = {} 

1642 catalog = self.root_object 

1643 

1644 if "/AcroForm" not in catalog or not catalog["/AcroForm"]: 

1645 return None 

1646 

1647 tree = cast(TreeObject, catalog["/AcroForm"]) 

1648 

1649 if "/XFA" in tree: 

1650 fields = tree["/XFA"].get_object() 

1651 if not isinstance(fields, ArrayObject): 

1652 logger_warning( 

1653 "XFA entry is not an array: %(fields)s", 

1654 source=__name__, 

1655 fields=fields, 

1656 ) 

1657 return retval 

1658 i = iter(fields) 

1659 for f in i: 

1660 tag = f 

1661 f = next(i, None) 

1662 if isinstance(f, IndirectObject): 

1663 field = cast(Optional[EncodedStreamObject], f.get_object()) 

1664 if field: 

1665 es = _decompress_with_limit(field._data) 

1666 retval[tag] = es 

1667 return retval 

1668 

1669 @property 

1670 def attachments(self) -> Mapping[str, list[bytes]]: 

1671 """Mapping of attachment filenames to their content.""" 

1672 entries: dict[str, list[EmbeddedFile]] = {} 

1673 

1674 for entry in self.attachment_list: 

1675 for name in entry._names: 

1676 entries.setdefault(name, []).append(entry) 

1677 

1678 return LazyDict( 

1679 { 

1680 name: (self._get_attachment_contents, attachment_entries) 

1681 for name, attachment_entries in entries.items() 

1682 } 

1683 ) 

1684 

1685 @classmethod 

1686 def _get_attachment_contents(cls, entries: list[EmbeddedFile]) -> list[bytes]: 

1687 return [entry.content for entry in entries] 

1688 

1689 @property 

1690 def attachment_list(self) -> Iterator[EmbeddedFile]: 

1691 """Iterable of attachment objects.""" 

1692 yield from EmbeddedFile._load(self.root_object, strict=self.strict) 

1693 

1694 @abstractmethod 

1695 def _repr_mimebundle_( 

1696 self, 

1697 include: Union[Iterable[str], None] = None, 

1698 exclude: Union[Iterable[str], None] = None, 

1699 ) -> dict[str, Any]: 

1700 """ 

1701 Integration into Jupyter Notebooks. 

1702 

1703 This method returns a dictionary that maps a mime-type to its 

1704 representation. 

1705 

1706 .. seealso:: 

1707 

1708 https://ipython.readthedocs.io/en/stable/config/integrating.html 

1709 """ 

1710 ... # pragma: no cover 

1711 

1712 

1713class LazyDict(Mapping[Any, Any]): 

1714 def __init__(self, *args: Any, **kwargs: Any) -> None: 

1715 self._raw_dict = dict(*args, **kwargs) 

1716 

1717 def __getitem__(self, key: str) -> Any: 

1718 func, arg = self._raw_dict.__getitem__(key) 

1719 return func(arg) 

1720 

1721 def __iter__(self) -> Iterator[Any]: 

1722 return iter(self._raw_dict) 

1723 

1724 def __len__(self) -> int: 

1725 return len(self._raw_dict) 

1726 

1727 def __str__(self) -> str: 

1728 return f"LazyDict(keys={list(self.keys())})"