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

729 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 Generator, Iterable, Iterator, Mapping, Sequence 

34from datetime import datetime 

35from typing import ( 

36 Any, 

37 NoReturn, 

38 Optional, 

39 Union, 

40 cast, 

41) 

42 

43from ._encryption import Encryption 

44from ._page import PageObject, _VirtualList 

45from ._page_labels import index2label as page_index2page_label 

46from ._utils import ( 

47 _TraversalState, 

48 deprecation_with_replacement, 

49 logger_warning, 

50 parse_iso8824_date, 

51) 

52from .constants import CatalogAttributes as CA 

53from .constants import ( 

54 CheckboxRadioButtonAttributes, 

55 Core, 

56 GoToActionArguments, 

57 PagesAttributes, 

58 UserAccessPermissions, 

59) 

60from .constants import DocumentInformationAttributes as DI 

61from .constants import FieldDictionaryAttributes as FA 

62from .constants import PageAttributes as PG 

63from .errors import LimitReachedError, PdfReadError, PyPdfError 

64from .filters import _decompress_with_limit 

65from .generic import ( 

66 ArrayObject, 

67 BooleanObject, 

68 ByteStringObject, 

69 Destination, 

70 DictionaryObject, 

71 EncodedStreamObject, 

72 Field, 

73 Fit, 

74 FloatObject, 

75 IndirectObject, 

76 NameObject, 

77 NullObject, 

78 PdfObject, 

79 TextStringObject, 

80 TreeObject, 

81 ViewerPreferences, 

82 create_string_object, 

83 is_null_or_none, 

84) 

85from .generic._files import EmbeddedFile 

86from .types import OutlineType, PagemodeType 

87from .xmp import XmpInformation 

88 

89# TODO: Make configurable. 

90OUTLINE_MAX_ENTRIES = 100_000 

91OUTLINE_MAX_DEPTH = 100 

92PAGE_TREE_MAX_ENTRIES = 100_000 

93PAGE_TREE_MAX_DEPTH = 100 

94 

95 

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

97 if size > 8: 

98 raise PdfReadError("Invalid size in convert_to_int") 

99 d = b"\x00\x00\x00\x00\x00\x00\x00\x00" + d 

100 d = d[-8:] 

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

102 

103 

104class DocumentInformation(DictionaryObject): 

105 """ 

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

107 This class is accessible through 

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

109 

110 All text properties of the document metadata have 

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

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

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

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

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

116 as commonly accessed. 

117 """ 

118 

119 def __init__(self) -> None: 

120 DictionaryObject.__init__(self) 

121 

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

123 retval = self.get(key, None) 

124 if isinstance(retval, TextStringObject): 

125 return retval 

126 if isinstance(retval, ByteStringObject): 

127 return str(retval) 

128 return None 

129 

130 @property 

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

132 """ 

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

134 

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

136 specified. 

137 """ 

138 return ( 

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

140 if self.get(DI.TITLE) 

141 else None 

142 ) 

143 

144 @property 

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

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

147 return self.get(DI.TITLE) 

148 

149 @property 

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

151 """ 

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

153 

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

155 specified. 

156 """ 

157 return self._get_text(DI.AUTHOR) 

158 

159 @property 

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

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

162 return self.get(DI.AUTHOR) 

163 

164 @property 

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

166 """ 

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

168 

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

170 specified. 

171 """ 

172 return self._get_text(DI.SUBJECT) 

173 

174 @property 

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

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

177 return self.get(DI.SUBJECT) 

178 

179 @property 

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

181 """ 

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

183 

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

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

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

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

188 """ 

189 return self._get_text(DI.CREATOR) 

190 

191 @property 

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

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

194 return self.get(DI.CREATOR) 

195 

196 @property 

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

198 """ 

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

200 

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

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

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

204 specified. 

205 """ 

206 return self._get_text(DI.PRODUCER) 

207 

208 @property 

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

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

211 return self.get(DI.PRODUCER) 

212 

213 @property 

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

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

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

217 

218 @property 

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

220 """ 

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

222 

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

224 is the offset from UTC. 

225 """ 

226 return self.get(DI.CREATION_DATE) 

227 

228 @property 

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

230 """ 

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

232 

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

234 """ 

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

236 

237 @property 

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

239 """ 

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

241 ``ByteStringObject``. 

242 

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

244 is the offset from UTC. 

245 """ 

246 return self.get(DI.MOD_DATE) 

247 

248 @property 

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

250 """ 

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

252 

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

254 specified. 

255 """ 

256 return self._get_text(DI.KEYWORDS) 

257 

258 @property 

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

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

261 return self.get(DI.KEYWORDS) 

262 

263 

264class PdfDocCommon(ABC): 

265 """ 

266 Common functions from PdfWriter and PdfReader objects. 

267 

268 This root class is strongly abstracted. 

269 """ 

270 

271 strict: bool = False # default 

272 

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

274 

275 _encryption: Optional[Encryption] = None 

276 

277 _readonly: bool = False 

278 

279 @property 

280 @abstractmethod 

281 def root_object(self) -> DictionaryObject: 

282 ... # pragma: no cover 

283 

284 @property 

285 @abstractmethod 

286 def pdf_header(self) -> str: 

287 ... # pragma: no cover 

288 

289 @abstractmethod 

290 def get_object( 

291 self, indirect_reference: Union[int, IndirectObject] 

292 ) -> Optional[PdfObject]: 

293 ... # pragma: no cover 

294 

295 @abstractmethod 

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

297 ... # pragma: no cover 

298 

299 @property 

300 @abstractmethod 

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

302 ... # pragma: no cover 

303 

304 @property 

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

306 """ 

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

308 

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

310 information dictionaries, and these metadata streams will not be 

311 accessed by this function. 

312 """ 

313 retval = DocumentInformation() 

314 if self._info is None: 

315 return None 

316 retval.update(self._info) 

317 return retval 

318 

319 @property 

320 @abstractmethod 

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

322 ... # pragma: no cover 

323 

324 @property 

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

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

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

328 if o is None: 

329 return None 

330 o = o.get_object() 

331 if not isinstance(o, DictionaryObject): 

332 logger_warning( 

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

334 source=__name__, 

335 preferences=o, 

336 ) 

337 return None 

338 if not isinstance(o, ViewerPreferences): 

339 o = ViewerPreferences(o) 

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

341 self._replace_object(o.indirect_reference, o) 

342 else: 

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

344 return o 

345 

346 def get_num_pages(self) -> int: 

347 """ 

348 Calculate the number of pages in this PDF file. 

349 

350 Returns: 

351 The number of pages of the parsed PDF file. 

352 

353 Raises: 

354 PdfReadError: If restrictions prevent this action. 

355 

356 """ 

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

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

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

360 if self.is_encrypted: 

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

362 if self.flattened_pages is None: 

363 self._flatten(self._readonly) 

364 assert self.flattened_pages is not None 

365 return len(self.flattened_pages) 

366 

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

368 """ 

369 Retrieve a page by number from this PDF file. 

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

371 

372 Args: 

373 page_number: The page number to retrieve 

374 (pages begin at zero) 

375 

376 Returns: 

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

378 

379 """ 

380 if self.flattened_pages is None: 

381 self._flatten(self._readonly) 

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

383 return self.flattened_pages[page_number] 

384 

385 def _get_page_in_node( 

386 self, 

387 page_number: int, 

388 ) -> tuple[DictionaryObject, int]: 

389 """ 

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

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

392 """ 

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

394 visited: set[int] = set() 

395 

396 def recursive_call( 

397 _node: DictionaryObject, mi: int 

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

399 _node_id = id(_node) 

400 if _node_id in visited: 

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

402 visited.add(_node_id) 

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

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

405 if page_number == mi: 

406 return _node, -1 

407 return None, mi + 1 

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

409 if _node == top: 

410 return top, -1 

411 return None, mi + ma 

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

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

414 n, i = recursive_call(kid, mi) 

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

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

417 return _node, _idx 

418 # ... at lower levels 

419 return n, i 

420 mi = i 

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

422 

423 node, idx = recursive_call(top, 0) 

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

425 return node, idx 

426 

427 @property 

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

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

430 return self._get_named_destinations() 

431 

432 def get_named_dest_root(self) -> ArrayObject: 

433 named_dest = ArrayObject() 

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

435 self.root_object[CA.NAMES], DictionaryObject 

436 ): 

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

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

439 # §3.6.3 Name Dictionary (PDF spec 1.7) 

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

441 dests_ref = dests.indirect_reference 

442 if CA.NAMES in dests: 

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

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

445 else: 

446 named_dest = ArrayObject() 

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

448 elif hasattr(self, "_add_object"): 

449 dests = DictionaryObject() 

450 dests_ref = self._add_object(dests) 

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

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

453 

454 elif hasattr(self, "_add_object"): 

455 names = DictionaryObject() 

456 names_ref = self._add_object(names) 

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

458 dests = DictionaryObject() 

459 dests_ref = self._add_object(dests) 

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

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

462 

463 return named_dest 

464 

465 ## common 

466 def _get_named_destinations( 

467 self, 

468 *, 

469 tree: Optional[DictionaryObject] = None, 

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

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

472 ) -> dict[str, Destination]: 

473 """ 

474 Retrieve the named destinations present in the document. 

475 

476 Args: 

477 tree: The current tree. 

478 retval: The previously retrieved destinations for nested calls. 

479 visited: Already known/visited objects. 

480 

481 Returns: 

482 A dictionary which maps names to destinations. 

483 

484 """ 

485 if visited is None: 

486 visited = set() 

487 if retval is None: 

488 retval = {} 

489 catalog = self.root_object 

490 

491 # get the name tree 

492 if CA.DESTS in catalog: 

493 tree = cast(DictionaryObject, catalog[CA.DESTS]) 

494 elif CA.NAMES in catalog: 

495 names = cast(DictionaryObject, catalog[CA.NAMES]) 

496 if CA.DESTS in names: 

497 tree = cast(DictionaryObject, names[CA.DESTS]) 

498 

499 if is_null_or_none(tree): 

500 return retval 

501 assert tree is not None, "mypy" 

502 

503 tree_id = id(tree) 

504 if tree_id in visited: 

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

506 return retval 

507 visited.add(tree_id) 

508 

509 if PagesAttributes.KIDS in tree: 

510 # recurse down the tree 

511 for kid in cast(ArrayObject, tree[PagesAttributes.KIDS]): 

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

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

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

515 names = cast(DictionaryObject, tree[CA.NAMES]) 

516 i = 0 

517 while i < len(names): 

518 key = names[i].get_object() 

519 i += 1 

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

521 continue 

522 try: 

523 value = names[i].get_object() 

524 except IndexError: 

525 break 

526 i += 1 

527 if isinstance(value, DictionaryObject): 

528 if "/D" in value: 

529 value = value["/D"] 

530 else: 

531 continue 

532 dest = self._build_destination(key, value) 

533 if dest is not None: 

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

535 # Remain backwards-compatible. 

536 retval[str(key)] = dest 

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

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

539 val = v__.get_object() 

540 if isinstance(val, DictionaryObject): 

541 if "/D" in val: 

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

543 else: 

544 continue 

545 dest = self._build_destination(k__, val) 

546 if dest is not None: 

547 retval[k__] = dest 

548 return retval 

549 

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

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

552 

553 def get_fields( 

554 self, 

555 tree: Optional[DictionaryObject] = None, 

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

557 fileobj: Optional[Any] = None, 

558 stack: Optional[list[PdfObject]] = None, 

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

560 """ 

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

562 

563 The *tree*, *retval*, *stack* parameters are for recursive use. 

564 

565 Args: 

566 tree: Current object to parse. 

567 retval: In-progress list of fields. 

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

569 a report to on all interactive form fields found. 

570 stack: List of already parsed objects. 

571 

572 Returns: 

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

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

575 default, the mapping name is used for keys. 

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

577 

578 """ 

579 field_attributes = FA.attributes_dict() 

580 field_attributes.update(CheckboxRadioButtonAttributes.attributes_dict()) 

581 if retval is None: 

582 retval = {} 

583 catalog = self.root_object 

584 stack = [] 

585 # get the AcroForm tree 

586 if CA.ACRO_FORM in catalog: 

587 tree = cast(Optional[DictionaryObject], catalog[CA.ACRO_FORM]) 

588 else: 

589 return None 

590 if tree is None: 

591 return retval 

592 assert stack is not None 

593 if "/Fields" in tree: 

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

595 if not isinstance(fields, ArrayObject): 

596 logger_warning( 

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

598 source=__name__, 

599 fields=fields, 

600 ) 

601 return retval 

602 for f in fields: 

603 field = f.get_object() 

604 self._build_field(field, retval, fileobj, field_attributes, stack) 

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

606 # Tree is a field 

607 self._build_field(tree, retval, fileobj, field_attributes, stack) 

608 return retval 

609 

610 def _get_qualified_field_name( 

611 self, 

612 *, 

613 parent: DictionaryObject, 

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

615 ) -> str: 

616 if visited is None: 

617 visited = set() 

618 parent_id = id(parent) 

619 if parent_id in visited: 

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

621 visited.add(parent_id) 

622 

623 if "/TM" in parent: 

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

625 if "/Parent" in parent: 

626 return ( 

627 self._get_qualified_field_name( 

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

629 visited=visited, 

630 ) 

631 + "." 

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

633 ) 

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

635 

636 @staticmethod 

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

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

639 appearance = appearance.get_object() 

640 if not isinstance(appearance, DictionaryObject): 

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

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

643 if normal is not None: 

644 normal = normal.get_object() 

645 if not isinstance(normal, DictionaryObject): 

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

647 return normal 

648 

649 def _build_field( 

650 self, 

651 field: Union[TreeObject, DictionaryObject], 

652 retval: dict[Any, Any], 

653 fileobj: Any, 

654 field_attributes: Any, 

655 stack: list[PdfObject], 

656 ) -> None: 

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

658 return 

659 key = self._get_qualified_field_name(parent=field) 

660 if fileobj: 

661 self._write_field(fileobj, field, field_attributes) 

662 fileobj.write("\n") 

663 retval[key] = Field(field) 

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

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

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

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

668 # Checkbox 

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

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

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

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

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

674 states: list[str] = [] 

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

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

677 k = k.get_object() 

678 if "/AP" not in k: 

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

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

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

682 if s not in states: 

683 states.append(s) 

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

685 if ( 

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

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

688 ): 

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

690 # at last for order 

691 self._check_kids(field, retval, fileobj, stack) 

692 

693 def _check_kids( 

694 self, 

695 tree: Union[TreeObject, DictionaryObject], 

696 retval: Any, 

697 fileobj: Any, 

698 stack: list[PdfObject], 

699 ) -> None: 

700 if tree in stack: 

701 logger_warning( 

702 "%(field_name)s already parsed", 

703 source=__name__, 

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

705 ) 

706 return 

707 stack.append(tree) 

708 if PagesAttributes.KIDS in tree: 

709 # recurse down the tree 

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

711 kid = kid.get_object() 

712 self.get_fields(kid, retval, fileobj, stack) 

713 

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

715 field_attributes_tuple = FA.attributes() 

716 field_attributes_tuple = ( 

717 field_attributes_tuple + CheckboxRadioButtonAttributes.attributes() 

718 ) 

719 

720 for attr in field_attributes_tuple: 

721 if attr in ( 

722 FA.Kids, 

723 FA.AA, 

724 ): 

725 continue 

726 attr_name = field_attributes[attr] 

727 try: 

728 if attr == FA.FT: 

729 # Make the field type value clearer 

730 types = { 

731 "/Btn": "Button", 

732 "/Tx": "Text", 

733 "/Ch": "Choice", 

734 "/Sig": "Signature", 

735 } 

736 if field[attr] in types: 

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

738 elif attr == FA.Parent: 

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

740 try: 

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

742 except KeyError: 

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

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

745 else: 

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

747 except KeyError: 

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

749 pass 

750 

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

752 """ 

753 Retrieve form fields from the document with textual data. 

754 

755 Args: 

756 full_qualified_name: to get full name 

757 

758 Returns: 

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

760 the value is the content of the field. 

761 

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

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

764 

765 """ 

766 

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

768 if k not in fields: 

769 return k 

770 return ( 

771 k 

772 + "." 

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

774 ) 

775 

776 # Retrieve document form fields 

777 form_fields = self.get_fields() 

778 if form_fields is None: 

779 return {} 

780 ff = {} 

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

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

783 if full_qualified_name: 

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

785 else: 

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

787 return ff 

788 

789 def get_pages_showing_field( 

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

791 ) -> list[PageObject]: 

792 """ 

793 Provides list of pages where the field is called. 

794 

795 Args: 

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

797 

798 Returns: 

799 List of pages: 

800 - Empty list: 

801 The field has no widgets attached 

802 (either hidden field or ancestor field). 

803 - Single page list: 

804 Page where the widget is present 

805 (most common). 

806 - Multi-page list: 

807 Field with multiple kids widgets 

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

809 

810 """ 

811 try: 

812 # to cope with all types 

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

814 except Exception as exc: 

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

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

817 raise ValueError("Field is not valid") 

818 ret = [] 

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

820 if "/P" in field: 

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

822 else: 

823 ret = [ 

824 p 

825 for p in self.pages 

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

827 ] 

828 else: 

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

830 for k in kids: 

831 k = k.get_object() 

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

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

834 if "/P" in k: 

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

836 else: 

837 ret += [ 

838 p 

839 for p in self.pages 

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

841 ] 

842 return [ 

843 x 

844 if isinstance(x, PageObject) 

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

846 for x in ret 

847 ] 

848 

849 @property 

850 def open_destination( 

851 self, 

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

853 """ 

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

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

856 or is not set. 

857 

858 Raises: 

859 Exception: If a destination is invalid. 

860 

861 """ 

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

863 return None 

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

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

866 oa = oa.decode() 

867 if isinstance(oa, str): 

868 return create_string_object(oa) 

869 if isinstance(oa, ArrayObject): 

870 try: 

871 page, typ, *array = oa 

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

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

874 except Exception as exc: 

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

876 else: 

877 return None 

878 

879 @open_destination.setter 

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

881 raise NotImplementedError("No setter for open_destination") 

882 

883 @property 

884 def outline(self) -> OutlineType: 

885 """ 

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

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

888 'bookmarks'). 

889 """ 

890 return self._get_outline() 

891 

892 def _get_outline( 

893 self, 

894 *, 

895 node: Optional[DictionaryObject] = None, 

896 outline: Optional[Any] = None, 

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

898 depth: int = 0, 

899 traversal_state: Optional[_TraversalState] = None 

900 ) -> OutlineType: 

901 if traversal_state is None: 

902 traversal_state = _TraversalState() 

903 

904 if outline is None: 

905 outline = [] 

906 catalog = self.root_object 

907 

908 # get the outline dictionary and named destinations 

909 if Core.OUTLINES in catalog: 

910 lines = cast(DictionaryObject, catalog[Core.OUTLINES]) 

911 

912 if isinstance(lines, NullObject): 

913 return outline 

914 

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

916 if not is_null_or_none(lines) and "/First" in lines: 

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

918 self._named_destinations = self._get_named_destinations() 

919 

920 if node is None: 

921 return outline 

922 

923 if depth > OUTLINE_MAX_DEPTH: 

924 raise LimitReachedError(f"Maximum outline depth reached: {depth} > {OUTLINE_MAX_DEPTH}.") 

925 

926 # see if there are any more outline items 

927 if visited is None: 

928 visited = set() 

929 while True: 

930 node_id = id(node) 

931 if node_id in visited: 

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

933 break 

934 visited.add(node_id) 

935 traversal_state.entry_count += 1 

936 if traversal_state.entry_count > OUTLINE_MAX_ENTRIES: 

937 raise LimitReachedError( 

938 f"Maximum outline entry limit reached: {traversal_state.entry_count} > {OUTLINE_MAX_ENTRIES}." 

939 ) 

940 

941 if not isinstance(node, DictionaryObject): 

942 logger_warning( 

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

944 source=__name__, 

945 node=node, 

946 ) 

947 break 

948 

949 outline_obj = self._build_outline_item(node) 

950 if outline_obj: 

951 outline.append(outline_obj) 

952 

953 # check for sub-outline 

954 if "/First" in node: 

955 sub_outline: list[Any] = [] 

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

957 inner_visited = visited.copy() 

958 self._get_outline( 

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

960 outline=sub_outline, 

961 visited=inner_visited, 

962 depth=depth + 1, 

963 traversal_state=traversal_state, 

964 ) 

965 if sub_outline: 

966 outline.append(sub_outline) 

967 

968 if "/Next" not in node: 

969 break 

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

971 

972 return outline 

973 

974 @property 

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

976 """ 

977 Read-only property for the list of threads. 

978 

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

980 

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

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

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

984 None if there are no articles. 

985 

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

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

988 author, and creation date. 

989 """ 

990 catalog = self.root_object 

991 if Core.THREADS in catalog: 

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

993 return None 

994 

995 @abstractmethod 

996 def _get_page_number_by_indirect( 

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

998 ) -> Optional[int]: 

999 ... # pragma: no cover 

1000 

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

1002 """ 

1003 Retrieve page number of a given PageObject. 

1004 

1005 Args: 

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

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

1008 

1009 Returns: 

1010 The page number or None if page is not found 

1011 

1012 """ 

1013 return self._get_page_number_by_indirect(page.indirect_reference) 

1014 

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

1016 """ 

1017 Retrieve page number of a given Destination object. 

1018 

1019 Args: 

1020 destination: The destination to get page number. 

1021 

1022 Returns: 

1023 The page number or None if page is not found 

1024 

1025 """ 

1026 return self._get_page_number_by_indirect(destination.page) 

1027 

1028 def _build_destination( 

1029 self, 

1030 title: Union[str, bytes], 

1031 array: Optional[ArrayObject], 

1032 ) -> Destination: 

1033 page, typ = None, None 

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

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

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

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

1038 page = NullObject() 

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

1040 page, typ, *array = array # type: ignore[assignment] 

1041 try: 

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

1043 except PdfReadError: 

1044 logger_warning("Unknown destination: %(title)r %(array)s", source=__name__, title=title, array=array) 

1045 if self.strict: 

1046 raise 

1047 # create a link to first Page 

1048 tmp = self.pages[0].indirect_reference 

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

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

1051 

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

1053 dest, title, outline_item = None, None, None 

1054 

1055 # title required for valid outline 

1056 # §12.3.3, entries in an outline item dictionary 

1057 try: 

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

1059 except KeyError: 

1060 if self.strict: 

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

1062 title = "" 

1063 

1064 if "/A" in node: 

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

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

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

1068 if action_type == "/GoTo": 

1069 if GoToActionArguments.D in action: 

1070 dest = action[GoToActionArguments.D] 

1071 elif self.strict: 

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

1073 elif "/Dest" in node: 

1074 # Destination, PDF 1.7 and PDF 2.0 §12.3.2 

1075 dest = node["/Dest"] 

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

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

1078 dest = dest["/D"] 

1079 

1080 if isinstance(dest, ArrayObject): 

1081 outline_item = self._build_destination(title, dest) 

1082 elif isinstance(dest, str): 

1083 # named destination, addresses NameObject Issue #193 

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

1085 try: 

1086 outline_item = self._build_destination( 

1087 title, self._named_destinations[dest].dest_array 

1088 ) 

1089 except KeyError: 

1090 # named destination not found in Name Dict 

1091 outline_item = self._build_destination(title, None) 

1092 elif dest is None: 

1093 # outline item not required to have destination or action 

1094 # PDFv1.7 Table 153 

1095 outline_item = self._build_destination(title, dest) 

1096 else: 

1097 if self.strict: 

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

1099 logger_warning( 

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

1101 source=__name__, 

1102 dest=dest, 

1103 ) 

1104 outline_item = self._build_destination(title, None) 

1105 

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

1107 if outline_item: 

1108 if "/C" in node: 

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

1110 color = node["/C"] 

1111 if isinstance(color, list): 

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

1113 else: 

1114 logger_warning( 

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

1116 source=__name__, 

1117 color=color, 

1118 ) 

1119 if "/F" in node: 

1120 # specifies style characteristics bold and/or italic 

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

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

1123 if "/Count" in node: 

1124 # absolute value = num. visible children 

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

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

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

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

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

1130 ) 

1131 outline_item.node = node 

1132 try: 

1133 outline_item.indirect_reference = node.indirect_reference 

1134 except AttributeError: 

1135 pass 

1136 return outline_item 

1137 

1138 @property 

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

1140 """ 

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

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

1143 

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

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

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

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

1148 

1149 Note: 

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

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

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

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

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

1155 PdfWriter. 

1156 

1157 """ 

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

1159 

1160 @property 

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

1162 """ 

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

1164 

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

1166 appear in the document. 

1167 """ 

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

1169 

1170 @property 

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

1172 """ 

1173 Get the page layout currently being used. 

1174 

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

1176 :widths: 50 200 

1177 

1178 * - /NoLayout 

1179 - Layout explicitly not specified 

1180 * - /SinglePage 

1181 - Show one page at a time 

1182 * - /OneColumn 

1183 - Show one column at a time 

1184 * - /TwoColumnLeft 

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

1186 * - /TwoColumnRight 

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

1188 * - /TwoPageLeft 

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

1190 * - /TwoPageRight 

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

1192 """ 

1193 try: 

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

1195 except KeyError: 

1196 return None 

1197 

1198 @property 

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

1200 """ 

1201 Get the page mode currently being used. 

1202 

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

1204 :widths: 50 200 

1205 

1206 * - /UseNone 

1207 - Do not show outline or thumbnails panels 

1208 * - /UseOutlines 

1209 - Show outline (aka bookmarks) panel 

1210 * - /UseThumbs 

1211 - Show page thumbnails panel 

1212 * - /FullScreen 

1213 - Fullscreen view 

1214 * - /UseOC 

1215 - Show Optional Content Group (OCG) panel 

1216 * - /UseAttachments 

1217 - Show attachments panel 

1218 """ 

1219 try: 

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

1221 except KeyError: 

1222 return None 

1223 

1224 def _flatten( 

1225 self, 

1226 list_only: bool = False, 

1227 pages: Union[DictionaryObject, PageObject, None] = None, 

1228 inherit: Optional[dict[str, Any]] = None, 

1229 indirect_reference: Optional[IndirectObject] = None, 

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

1231 depth: int = 0, 

1232 traversal_state: Optional[_TraversalState] = None, 

1233 ) -> None: 

1234 """ 

1235 Process the document pages to ease searching. 

1236 

1237 Attributes of a page may inherit from ancestor nodes 

1238 in the page tree. Flattening means moving 

1239 any inheritance data into descendant nodes, 

1240 effectively removing the inheritance dependency. 

1241 

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

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

1244 and making the file less editable. 

1245 

1246 Args: 

1247 list_only: Will only list the pages within _flatten_pages. 

1248 pages: 

1249 inherit: 

1250 indirect_reference: Used recursively to flatten the /Pages object. 

1251 visited: Set of id() values on the active page-tree traversal path. 

1252 Detects multi-hop cycles such as A→B→C→A that the single-parent 

1253 check misses. 

1254 depth: Current page-tree traversal depth. 

1255 traversal_state: State shared across the complete traversal. 

1256 

1257 """ 

1258 inheritable_page_attributes = ( 

1259 NameObject(PG.RESOURCES), 

1260 NameObject(PG.MEDIABOX), 

1261 NameObject(PG.CROPBOX), 

1262 NameObject(PG.ROTATE), 

1263 ) 

1264 if inherit is None: 

1265 inherit = {} 

1266 if visited is None: 

1267 visited = set() 

1268 if traversal_state is None: 

1269 traversal_state = _TraversalState() 

1270 if depth > PAGE_TREE_MAX_DEPTH: 

1271 raise LimitReachedError(f"Maximum page tree depth reached: {depth} > {PAGE_TREE_MAX_DEPTH}.") 

1272 if is_null_or_none(pages): 

1273 # Fix issue 327: set flattened_pages attribute only for 

1274 # decrypted file 

1275 catalog = self.root_object 

1276 pages = catalog.get("/Pages").get_object() # type: ignore[union-attr] 

1277 if not isinstance(pages, DictionaryObject): 

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

1279 self.flattened_pages = [] 

1280 assert pages is not None, "mypy" 

1281 

1282 if PagesAttributes.TYPE in pages: 

1283 t = cast(str, pages[PagesAttributes.TYPE]) 

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

1285 elif PagesAttributes.KIDS not in pages: 

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

1287 if self.strict and not any( 

1288 key in pages for key in (PG.CONTENTS, PG.MEDIABOX, PG.PARENT) 

1289 ): 

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

1291 t = "/Page" 

1292 else: 

1293 t = "/Pages" 

1294 

1295 if t == "/Pages": 

1296 for attr in inheritable_page_attributes: 

1297 if attr in pages: 

1298 inherit[attr] = pages[attr] 

1299 pages_reference = getattr(pages, "indirect_reference", object()) 

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

1301 if isinstance(kids, NullObject): 

1302 kids = ArrayObject() 

1303 elif not isinstance(kids, ArrayObject): 

1304 raise PdfReadError( 

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

1306 ) 

1307 for page in kids: 

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

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

1310 

1311 additional_arguments = {} 

1312 if isinstance(page, IndirectObject): 

1313 additional_arguments["indirect_reference"] = page 

1314 obj = page.get_object() 

1315 if obj: 

1316 # damaged file may have invalid child in /Pages 

1317 obj_id = id(obj) 

1318 if obj_id in visited: 

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

1320 traversal_state.entry_count += 1 

1321 if traversal_state.entry_count > PAGE_TREE_MAX_ENTRIES: 

1322 raise LimitReachedError( 

1323 "Maximum page tree entry limit reached: " 

1324 f"{traversal_state.entry_count} > {PAGE_TREE_MAX_ENTRIES}." 

1325 ) 

1326 visited.add(obj_id) 

1327 try: 

1328 self._flatten( 

1329 list_only, 

1330 obj, 

1331 inherit.copy(), 

1332 visited=visited, 

1333 depth=depth + 1, 

1334 traversal_state=traversal_state, 

1335 **additional_arguments, 

1336 ) 

1337 finally: 

1338 visited.remove(obj_id) 

1339 elif t == "/Page": 

1340 page_obj = PageObject(self, indirect_reference) 

1341 if not list_only: 

1342 page_obj.update(pages) 

1343 for attr_in, value in inherit.items(): 

1344 # if the page has its own value, it does not inherit the 

1345 # parent's value 

1346 if attr_in not in page_obj: 

1347 page_obj[attr_in] = value 

1348 

1349 # TODO: Could flattened_pages be None at this point? 

1350 self.flattened_pages.append(page_obj) # type: ignore[union-attr] 

1351 

1352 def remove_page( 

1353 self, 

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

1355 clean: bool = False, 

1356 ) -> None: 

1357 """ 

1358 Remove page from pages list. 

1359 

1360 Args: 

1361 page: 

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

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

1364 only the first one will be removed. 

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

1366 

1367 clean: replace PageObject with NullObject to prevent annotations 

1368 or destinations to reference a detached page. 

1369 

1370 """ 

1371 if self.flattened_pages is None: 

1372 self._flatten(self._readonly) 

1373 assert self.flattened_pages is not None 

1374 if isinstance(page, IndirectObject): 

1375 p = page.get_object() 

1376 if not isinstance(p, PageObject): 

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

1378 return 

1379 page = p 

1380 

1381 if not isinstance(page, int): 

1382 try: 

1383 page = self.flattened_pages.index(page) 

1384 except ValueError: 

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

1386 return 

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

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

1389 return 

1390 

1391 ind = self.pages[page].indirect_reference 

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

1393 # concrete _VirtualList does implement deletion. 

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

1395 if clean and ind is not None: 

1396 self._replace_object(ind, NullObject()) 

1397 

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

1399 """ 

1400 Used to ease development. 

1401 

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

1403 

1404 Args: 

1405 num: The object number of the indirect object. 

1406 gen: The generation number of the indirect object. 

1407 

1408 Returns: 

1409 A PdfObject 

1410 

1411 """ 

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

1413 

1414 def decode_permissions( 

1415 self, permissions_code: int 

1416 ) -> NoReturn: # pragma: no cover 

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

1418 deprecation_with_replacement( 

1419 old_name="decode_permissions", 

1420 new_name="user_access_permissions", 

1421 removed_in="5.0.0", 

1422 ) 

1423 

1424 @property 

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

1426 """ 

1427 Get the user access permissions for encrypted documents. 

1428 Returns None if not encrypted. 

1429 

1430 .. warning:: 

1431 

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

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

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

1435 Check :attr:`are_permissions_valid` to verify. 

1436 """ 

1437 if self._encryption is None: 

1438 return None 

1439 return UserAccessPermissions(self._encryption.P) 

1440 

1441 @property 

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

1443 """ 

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

1445 

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

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

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

1449 permissions may have been tampered with). 

1450 

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

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

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

1454 """ 

1455 if self._encryption is None: 

1456 return None 

1457 if not self._encryption.is_decrypted(): 

1458 return None 

1459 return self._encryption._are_permissions_valid 

1460 

1461 @property 

1462 @abstractmethod 

1463 def is_encrypted(self) -> bool: 

1464 """ 

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

1466 

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

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

1469 """ 

1470 ... # pragma: no cover 

1471 

1472 @property 

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

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

1475 catalog = self.root_object 

1476 

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

1478 return None 

1479 

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

1481 

1482 if "/XFA" in tree: 

1483 fields = cast(ArrayObject, tree["/XFA"]) 

1484 i = iter(fields) 

1485 for f in i: 

1486 tag = f 

1487 f = next(i) 

1488 if isinstance(f, IndirectObject): 

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

1490 if field: 

1491 es = _decompress_with_limit(field._data) 

1492 retval[tag] = es 

1493 return retval 

1494 

1495 @property 

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

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

1498 return LazyDict( 

1499 { 

1500 name: (self._get_attachment_list, name) 

1501 for name in self._list_attachments() 

1502 } 

1503 ) 

1504 

1505 @property 

1506 def attachment_list(self) -> Generator[EmbeddedFile, None, None]: 

1507 """Iterable of attachment objects.""" 

1508 yield from EmbeddedFile._load(self.root_object) 

1509 

1510 def _list_attachments(self) -> list[str]: 

1511 """ 

1512 Retrieves the list of filenames of file attachments. 

1513 

1514 Returns: 

1515 list of filenames 

1516 

1517 """ 

1518 names = [] 

1519 for entry in self.attachment_list: 

1520 names.append(entry.name) 

1521 if (name := entry.alternative_name) != entry.name and name: 

1522 names.append(name) 

1523 return names 

1524 

1525 def _get_attachment_list(self, name: str) -> list[bytes]: 

1526 out = self._get_attachments(name)[name] 

1527 if isinstance(out, list): 

1528 return out 

1529 return [out] 

1530 

1531 def _get_attachments( 

1532 self, filename: Optional[str] = None 

1533 ) -> dict[str, Union[bytes, list[bytes]]]: 

1534 """ 

1535 Retrieves all or selected file attachments of the PDF as a dictionary of file names 

1536 and the file data as a bytestring. 

1537 

1538 Args: 

1539 filename: If filename is None, then a dictionary of all attachments 

1540 will be returned, where the key is the filename and the value 

1541 is the content. Otherwise, a dictionary with just a single key 

1542 - the filename - and its content will be returned. 

1543 

1544 Returns: 

1545 dictionary of filename -> Union[bytestring or List[ByteString]] 

1546 If the filename exists multiple times a list of the different versions will be provided. 

1547 

1548 """ 

1549 attachments: dict[str, Union[bytes, list[bytes]]] = {} 

1550 for entry in self.attachment_list: 

1551 names = set() 

1552 alternative_name = entry.alternative_name 

1553 if filename is not None: 

1554 if filename in {entry.name, alternative_name}: 

1555 name = entry.name if filename == entry.name else alternative_name 

1556 names.add(name) 

1557 else: 

1558 continue 

1559 else: 

1560 names = {entry.name, alternative_name} 

1561 

1562 for name in names: 

1563 if name is None: 

1564 continue 

1565 if name in attachments: 

1566 if not isinstance(attachments[name], list): 

1567 attachments[name] = [attachments[name]] # type:ignore 

1568 attachments[name].append(entry.content) # type:ignore 

1569 else: 

1570 attachments[name] = entry.content 

1571 return attachments 

1572 

1573 @abstractmethod 

1574 def _repr_mimebundle_( 

1575 self, 

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

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

1578 ) -> dict[str, Any]: 

1579 """ 

1580 Integration into Jupyter Notebooks. 

1581 

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

1583 representation. 

1584 

1585 .. seealso:: 

1586 

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

1588 """ 

1589 ... # pragma: no cover 

1590 

1591 

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

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

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

1595 

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

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

1598 return func(arg) 

1599 

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

1601 return iter(self._raw_dict) 

1602 

1603 def __len__(self) -> int: 

1604 return len(self._raw_dict) 

1605 

1606 def __str__(self) -> str: 

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