Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/pypdf/_reader.py: 33%

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

878 statements  

1# Copyright (c) 2006, Mathieu Fenniak 

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

3# 

4# All rights reserved. 

5# 

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

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

8# met: 

9# 

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

11# this list of conditions and the following disclaimer. 

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

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

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

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

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

17# 

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

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

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

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

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

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

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

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

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

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

28# POSSIBILITY OF SUCH DAMAGE. 

29 

30import os 

31import re 

32import sys 

33from collections.abc import Iterable 

34from io import BytesIO, UnsupportedOperation 

35from operator import itemgetter 

36from pathlib import Path 

37from types import TracebackType 

38from typing import ( 

39 TYPE_CHECKING, 

40 Any, 

41 Callable, 

42 Optional, 

43 Union, 

44 cast, 

45) 

46 

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

48 from typing import Self 

49else: 

50 from typing_extensions import Self 

51 

52from ._configuration import apply_legacy_configuration 

53from ._doc_common import PdfDocCommon, convert_to_int 

54from ._encryption import Encryption, PasswordType 

55from ._utils import ( 

56 WHITESPACES_AS_BYTES, 

57 StrByteType, 

58 StreamType, 

59 logger_warning, 

60 read_non_whitespace, 

61 read_previous_line, 

62 read_until_whitespace, 

63 skip_over_comment, 

64 skip_over_whitespace, 

65) 

66from .constants import TrailerKeys as TK 

67from .errors import ( 

68 EmptyFileError, 

69 FileNotDecryptedError, 

70 LimitReachedError, 

71 PdfReadError, 

72 PdfStreamError, 

73 WrongPasswordError, 

74) 

75from .generic import ( 

76 ArrayObject, 

77 Destination, 

78 DictionaryObject, 

79 EncodedStreamObject, 

80 IndirectObject, 

81 NameObject, 

82 NullObject, 

83 NumberObject, 

84 PdfObject, 

85 StreamObject, 

86 TextStringObject, 

87 is_null_or_none, 

88 read_object, 

89) 

90from .xmp import XmpInformation 

91 

92if TYPE_CHECKING: 

93 from ._page import PageObject 

94 

95 

96class PdfReader(PdfDocCommon): 

97 """ 

98 Initialize a PdfReader object. 

99 

100 This operation can take some time, as the PDF stream's cross-reference 

101 tables are read into memory. 

102 

103 Args: 

104 stream: A File object or an object that supports the standard read 

105 and seek methods similar to a File object. Could also be a 

106 string representing a path to a PDF file. 

107 strict: Determines whether user should be warned of all 

108 problems and also causes some correctable problems to be fatal. 

109 Defaults to ``False``. 

110 password: Decrypt PDF file at initialization. If the 

111 password is None, the file will not be decrypted. 

112 Defaults to ``None``. 

113 root_object_recovery_limit: The maximum number of objects to query 

114 for recovering the Root object in non-strict mode. To disable 

115 this security measure, pass ``None``. 

116 

117 """ 

118 

119 xref: dict[int, dict[Any, Any]] 

120 trailer: DictionaryObject 

121 

122 def __init__( 

123 self, 

124 stream: Union[StrByteType, Path], 

125 strict: bool = False, 

126 password: Union[str, bytes, None] = None, 

127 *, 

128 root_object_recovery_limit: Optional[int] = 10_000, 

129 ) -> None: 

130 self.strict = strict 

131 self.flattened_pages: Optional[list[PageObject]] = None 

132 

133 #: Storage of parsed PDF objects. 

134 self.resolved_objects: dict[tuple[Any, Any], Optional[PdfObject]] = {} 

135 

136 self._startxref: int = 0 

137 self.xref_index = 0 

138 self.xref = {} 

139 self.xref_free_entry: dict[int, dict[Any, Any]] = {} 

140 self.xref_objStm: dict[int, tuple[Any, Any]] = {} 

141 self.trailer = DictionaryObject() 

142 

143 # Security parameters. 

144 self._root_object_recovery_limit = ( 

145 root_object_recovery_limit if isinstance(root_object_recovery_limit, int) else sys.maxsize 

146 ) 

147 apply_legacy_configuration() 

148 

149 # Map page indirect_reference number to page number 

150 self._page_id2num: Optional[dict[Any, Any]] = None 

151 

152 self._validated_root: Optional[DictionaryObject] = None 

153 

154 self._initialize_stream(stream) 

155 self._known_objects: set[tuple[int, int]] = set() 

156 

157 self._override_encryption = False 

158 self._encryption: Optional[Encryption] = None 

159 if self.is_encrypted: 

160 self._handle_encryption(password) 

161 elif password is not None: 

162 raise PdfReadError("Not an encrypted file") 

163 

164 self._named_destinations_cache: Optional[dict[str, Destination]] = None 

165 self._object_stream_resolution_stack: list[int] = [] 

166 

167 def _initialize_stream(self, stream: Union[StrByteType, Path]) -> None: 

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

169 logger_warning( 

170 "PdfReader stream/file object is not in binary mode. " 

171 "It may not be read correctly.", 

172 source=__name__, 

173 ) 

174 self._stream_opened = False 

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

176 with open(stream, "rb") as fh: 

177 stream = BytesIO(fh.read()) 

178 self._stream_opened = True 

179 self.read(stream) 

180 self.stream = stream 

181 

182 def _handle_encryption(self, password: Optional[Union[str, bytes]]) -> None: 

183 self._override_encryption = True 

184 # Some documents may not have a /ID, use two empty 

185 # byte strings instead. Solves 

186 # https://github.com/py-pdf/pypdf/issues/608 

187 id_entry = self.trailer.get(TK.ID) 

188 id1_entry = id_entry[0].get_object().original_bytes if id_entry else b"" 

189 encrypt_entry = cast(DictionaryObject, self.trailer[TK.ENCRYPT].get_object()) 

190 self._encryption = Encryption.read(encrypt_entry, id1_entry) 

191 

192 # try empty password if no password provided 

193 pwd = password if password is not None else b"" 

194 if ( 

195 self._encryption.verify(pwd, strict=self.strict) == PasswordType.NOT_DECRYPTED 

196 and password is not None 

197 ): 

198 # raise if password provided 

199 raise WrongPasswordError("Wrong password") 

200 self._override_encryption = False 

201 

202 def __enter__(self) -> Self: 

203 return self 

204 

205 def __exit__( 

206 self, 

207 exc_type: Optional[type[BaseException]], 

208 exc_val: Optional[BaseException], 

209 exc_tb: Optional[TracebackType], 

210 ) -> None: 

211 self.close() 

212 

213 def close(self) -> None: 

214 """Close the stream if opened in __init__ and clear memory.""" 

215 if self._stream_opened: 

216 self.stream.close() 

217 self.flattened_pages = [] 

218 self.resolved_objects = {} 

219 self.trailer = DictionaryObject() 

220 self.xref = {} 

221 self.xref_free_entry = {} 

222 self.xref_objStm = {} 

223 

224 @property 

225 def root_object(self) -> DictionaryObject: 

226 """Provide access to "/Root". Standardized with PdfWriter.""" 

227 if self._validated_root: 

228 return self._validated_root 

229 root = self.trailer.get(TK.ROOT, NullObject()).get_object() 

230 root_dictionary: DictionaryObject 

231 if isinstance(root, DictionaryObject): 

232 root_dictionary = root 

233 else: 

234 # The catalog has to be a dictionary. Anything else cannot be used, 

235 # thus treat it like an empty one and attempt the recovery below. 

236 root_dictionary = DictionaryObject() 

237 if is_null_or_none(root): 

238 logger_warning('Cannot find "/Root" key in trailer', source=__name__) 

239 elif root_dictionary.get("/Type") == "/Catalog": 

240 self._validated_root = root_dictionary 

241 else: 

242 logger_warning("Invalid Root object in trailer", source=__name__) 

243 if self._validated_root is None: 

244 logger_warning('Searching object with "/Catalog" key', source=__name__) 

245 number_of_objects = cast(int, self.trailer.get("/Size", 0)) 

246 for i in range(number_of_objects): 

247 if i >= self._root_object_recovery_limit: 

248 raise LimitReachedError("Maximum Root object recovery limit reached.") 

249 try: 

250 obj = self.get_object(i + 1) 

251 except Exception: # to be sure to capture all errors 

252 obj = None 

253 if isinstance(obj, DictionaryObject) and obj.get("/Type") == "/Catalog": 

254 self._validated_root = obj 

255 logger_warning( 

256 "Root found at %(obj_reference)r", 

257 source=__name__, 

258 obj_reference=obj.indirect_reference, 

259 ) 

260 break 

261 if self._validated_root is None: 

262 if "/Pages" in root_dictionary: 

263 logger_warning( 

264 "Possible root found at %(root_ref)r, but missing /Catalog key", 

265 source=__name__, 

266 root_ref=root_dictionary.indirect_reference, 

267 ) 

268 self._validated_root = root_dictionary 

269 else: 

270 raise PdfReadError("Cannot find Root object in pdf") 

271 return self._validated_root 

272 

273 @property 

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

275 """ 

276 Provide access to "/Info". Standardized with PdfWriter. 

277 

278 Returns: 

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

280 

281 """ 

282 info = self.trailer.get(TK.INFO, None) 

283 if is_null_or_none(info): 

284 return None 

285 assert info is not None, "mypy" 

286 info = info.get_object() 

287 if not isinstance(info, DictionaryObject): 

288 raise PdfReadError( 

289 "Trailer not found or does not point to a document information dictionary" 

290 ) 

291 return info 

292 

293 @property 

294 def _ID(self) -> Optional[ArrayObject]: 

295 """ 

296 Provide access to "/ID". Standardized with PdfWriter. 

297 

298 Returns: 

299 /ID array; None if the entry does not exist 

300 

301 """ 

302 file_identifiers = self.trailer.get(TK.ID, None) 

303 if is_null_or_none(file_identifiers): 

304 return None 

305 assert file_identifiers is not None, "mypy" 

306 return cast(ArrayObject, file_identifiers.get_object()) 

307 

308 @property 

309 def pdf_header(self) -> str: 

310 """ 

311 The first 8 bytes of the file. 

312 

313 This is typically something like ``'%PDF-1.6'`` and can be used to 

314 detect if the file is actually a PDF file and which version it is. 

315 """ 

316 # TODO: Make this return a bytes object for consistency 

317 # but that needs a deprecation 

318 loc = self.stream.tell() 

319 self.stream.seek(0, 0) 

320 pdf_file_version = self.stream.read(8).decode("utf-8", "backslashreplace") 

321 self.stream.seek(loc, 0) # return to where it was 

322 return pdf_file_version 

323 

324 @property 

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

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

327 try: 

328 # The document-level metadata stream is exempt from encryption 

329 # only if /EncryptMetadata is false (ISO 32000-2, Table 21). 

330 self._override_encryption = ( 

331 self._encryption is not None and not self._encryption.EncryptMetadata 

332 ) 

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

334 finally: 

335 self._override_encryption = False 

336 

337 def _get_page_number_by_indirect( 

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

339 ) -> Optional[int]: 

340 """ 

341 Retrieve the page number from an indirect reference. 

342 

343 Args: 

344 indirect_reference: The indirect reference to locate. 

345 

346 Returns: 

347 Page number or None. 

348 

349 """ 

350 if self._page_id2num is None: 

351 self._page_id2num = { 

352 x.indirect_reference.idnum: i for i, x in enumerate(self.pages) # type: ignore[union-attr] 

353 } 

354 

355 if is_null_or_none(indirect_reference): 

356 return None 

357 assert isinstance(indirect_reference, (int, IndirectObject)), "mypy" 

358 if isinstance(indirect_reference, int): 

359 idnum = indirect_reference 

360 else: 

361 idnum = indirect_reference.idnum 

362 assert self._page_id2num is not None, "hint for mypy" 

363 return self._page_id2num.get(idnum, None) 

364 

365 def _get_object_from_stream( 

366 self, indirect_reference: IndirectObject 

367 ) -> PdfObject: 

368 obj_id = indirect_reference.idnum 

369 if obj_id in self._object_stream_resolution_stack: 

370 start = self._object_stream_resolution_stack.index(obj_id) 

371 cycle = [*self._object_stream_resolution_stack[start:], obj_id] 

372 raise PdfReadError( 

373 "Circular object-stream reference detected: " + " -> ".join(map(str, cycle)) 

374 ) 

375 self._object_stream_resolution_stack.append(obj_id) 

376 

377 try: 

378 # indirect reference to object in object stream 

379 # read the entire object stream into memory 

380 stmnum, _idx = self.xref_objStm[indirect_reference.idnum] 

381 obj_stm: EncodedStreamObject = IndirectObject(stmnum, 0, self).get_object() # type: ignore[assignment] 

382 # This is an xref to a stream, so its type better be a stream 

383 assert cast(str, obj_stm["/Type"]) == "/ObjStm" 

384 # Parse ALL objects in this stream in one pass and cache them. 

385 # This avoids O(N²) behavior when many objects from the same stream 

386 # are resolved individually (each call would re-parse the header). 

387 stream_data = BytesIO(obj_stm.get_data()) 

388 n = int(obj_stm["/N"]) # type: ignore[call-overload] 

389 first_offset = int(obj_stm["/First"]) # type: ignore[call-overload] 

390 

391 # ObjStm header format: "objnum offset objnum offset ..." 

392 # smallest possible entry: "0 0" = 3 bytes (1 digit + 1 space + 1 digit) 

393 # using // 4 would reject a valid 3-byte single entry (3 // 4 = 0) 

394 max_n = stream_data.getbuffer().nbytes // 3 

395 stream_data.seek(0) 

396 if n > max_n: 

397 if self.strict: 

398 raise LimitReachedError(f"Value /N {n} for object {stmnum} exceeds maximum allowed value {max_n}.") 

399 logger_warning( 

400 ( 

401 "Value /N %(n)d for object %(stmnum)d exceeds maximum allowed value %(max_n)d. " 

402 "Limiting to %(max_n)d." 

403 ), 

404 source=__name__, 

405 n=n, 

406 stmnum=stmnum, 

407 max_n=max_n, 

408 ) 

409 n = max_n 

410 

411 # Phase 1: Read the index (objnum, offset) pairs from the header. 

412 obj_index: list[tuple[int, int]] = [] 

413 for _i in range(n): 

414 read_non_whitespace(stream_data) 

415 stream_data.seek(-1, 1) 

416 objnum = NumberObject.read_from_stream(stream_data) 

417 read_non_whitespace(stream_data) 

418 stream_data.seek(-1, 1) 

419 offset = NumberObject.read_from_stream(stream_data) 

420 read_non_whitespace(stream_data) 

421 stream_data.seek(-1, 1) 

422 obj_index.append((int(objnum), int(offset))) 

423 

424 # Phase 2: Parse each object and cache it. 

425 target_obj: PdfObject = NullObject() 

426 found = False 

427 for i, (obj_num, obj_offset) in enumerate(obj_index): 

428 # Skip objects already in the cache. 

429 cached = self.cache_get_indirect_object(0, obj_num) 

430 if cached is not None: 

431 if obj_num == indirect_reference.idnum: 

432 target_obj = cached 

433 found = True 

434 continue 

435 

436 stream_data.seek(first_offset + obj_offset, 0) 

437 

438 # To cope with case where the 'pointer' is on a white space 

439 read_non_whitespace(stream_data) 

440 stream_data.seek(-1, 1) 

441 

442 try: 

443 obj = read_object(stream_data, self) 

444 except PdfStreamError as exc: 

445 # Stream object cannot be read. Normally, a critical error, but 

446 # Adobe Reader doesn't complain, so continue (in strict mode?) 

447 logger_warning( 

448 "Invalid stream (index %(index)d) within object %(obj_num)d 0: %(exc)s", 

449 source=__name__, 

450 index=i, 

451 obj_num=obj_num, 

452 exc=exc, 

453 ) 

454 if self.strict: # pragma: no cover 

455 raise PdfReadError( 

456 f"Cannot read object stream: {exc}" 

457 ) from exc # pragma: no cover 

458 obj = NullObject() # pragma: no cover 

459 

460 # Only cache if this stream is the authoritative source for the object. 

461 # Incremental updates may override objects originally in the stream; 

462 # caching those stale versions would shadow the newer xref entry. 

463 authoritative_stm, _idx = self.xref_objStm.get(obj_num, (None, None)) 

464 if authoritative_stm == stmnum: 

465 self.cache_indirect_object(0, obj_num, obj) 

466 

467 if obj_num == indirect_reference.idnum: 

468 target_obj = obj 

469 found = True 

470 

471 if not found and self.strict: # pragma: no cover 

472 raise PdfReadError( 

473 "This is a fatal error in strict mode." 

474 ) # pragma: no cover 

475 return target_obj 

476 finally: 

477 self._object_stream_resolution_stack.pop() 

478 

479 def get_object( 

480 self, indirect_reference: Union[int, IndirectObject] 

481 ) -> Optional[PdfObject]: 

482 if isinstance(indirect_reference, int): 

483 indirect_reference = IndirectObject(indirect_reference, 0, self) 

484 retval = self.cache_get_indirect_object( 

485 indirect_reference.generation, indirect_reference.idnum 

486 ) 

487 if retval is not None: 

488 return retval 

489 if ( 

490 indirect_reference.generation == 0 

491 and indirect_reference.idnum in self.xref_objStm 

492 ): 

493 retval = self._get_object_from_stream(indirect_reference) 

494 elif ( 

495 indirect_reference.generation in self.xref 

496 and indirect_reference.idnum in self.xref[indirect_reference.generation] 

497 ): 

498 if self.xref_free_entry.get(indirect_reference.generation, {}).get( 

499 indirect_reference.idnum, False 

500 ): 

501 return NullObject() 

502 start = self.xref[indirect_reference.generation][indirect_reference.idnum] 

503 self.stream.seek(start, 0) 

504 try: 

505 idnum, generation = self.read_object_header(self.stream) 

506 if ( 

507 idnum != indirect_reference.idnum 

508 or generation != indirect_reference.generation 

509 ): 

510 raise PdfReadError("Not matching, we parse the file for it") 

511 except Exception: 

512 if hasattr(self.stream, "getbuffer"): 

513 buf = bytes(self.stream.getbuffer()) 

514 else: 

515 p = self.stream.tell() 

516 self.stream.seek(0, 0) 

517 buf = self.stream.read(-1) 

518 self.stream.seek(p, 0) 

519 m = re.search( 

520 rf"\s{indirect_reference.idnum}\s+{indirect_reference.generation}\s+obj".encode(), 

521 buf, 

522 ) 

523 if m is not None: 

524 logger_warning( 

525 "Object ID %(idnum)d,%(generation)d ref repaired", 

526 source=__name__, 

527 idnum=indirect_reference.idnum, 

528 generation=indirect_reference.generation, 

529 ) 

530 self.xref[indirect_reference.generation][ 

531 indirect_reference.idnum 

532 ] = (m.start(0) + 1) 

533 self.stream.seek(m.start(0) + 1) 

534 idnum, generation = self.read_object_header(self.stream) 

535 else: 

536 idnum = -1 

537 generation = -1 # exception will be raised below 

538 if idnum != indirect_reference.idnum and self.xref_index: 

539 # xref table probably had bad indexes due to not being zero-indexed 

540 if self.strict: 

541 raise PdfReadError( 

542 f"Expected object ID ({indirect_reference.idnum} {indirect_reference.generation}) " 

543 f"does not match actual ({idnum} {generation}); " 

544 "xref table not zero-indexed." 

545 ) 

546 # xref table is corrected in non-strict mode 

547 elif idnum != indirect_reference.idnum and self.strict: 

548 # some other problem 

549 raise PdfReadError( 

550 f"Expected object ID ({indirect_reference.idnum} {indirect_reference.generation}) " 

551 f"does not match actual ({idnum} {generation})." 

552 ) 

553 if self.strict: 

554 assert generation == indirect_reference.generation 

555 

556 current_object = (indirect_reference.idnum, indirect_reference.generation) 

557 if current_object in self._known_objects: 

558 raise LimitReachedError(f"Detected loop with self reference for {indirect_reference!r}.") 

559 self._known_objects.add(current_object) 

560 retval = read_object(self.stream, self) 

561 self._known_objects.remove(current_object) 

562 

563 # override encryption is used for the /Encrypt dictionary 

564 if not self._override_encryption and self._encryption is not None: 

565 # if we don't have the encryption key: 

566 if not self._encryption.is_decrypted(): 

567 raise FileNotDecryptedError("File has not been decrypted") 

568 # otherwise, decrypt here... 

569 retval = self._encryption.decrypt_object( 

570 retval, indirect_reference.idnum, indirect_reference.generation, 

571 strict=self.strict, 

572 ) 

573 else: 

574 if hasattr(self.stream, "getbuffer"): 

575 buf = bytes(self.stream.getbuffer()) 

576 else: 

577 p = self.stream.tell() 

578 self.stream.seek(0, 0) 

579 buf = self.stream.read(-1) 

580 self.stream.seek(p, 0) 

581 m = re.search( 

582 rf"\s{indirect_reference.idnum}\s+{indirect_reference.generation}\s+obj".encode(), 

583 buf, 

584 ) 

585 if m is not None: 

586 logger_warning( 

587 "Object %(idnum)d %(generation)d found", 

588 source=__name__, 

589 idnum=indirect_reference.idnum, 

590 generation=indirect_reference.generation, 

591 ) 

592 if indirect_reference.generation not in self.xref: 

593 self.xref[indirect_reference.generation] = {} 

594 self.xref[indirect_reference.generation][indirect_reference.idnum] = ( 

595 m.start(0) + 1 

596 ) 

597 self.stream.seek(m.end(0) + 1) 

598 skip_over_whitespace(self.stream) 

599 self.stream.seek(-1, 1) 

600 retval = read_object(self.stream, self) 

601 

602 # override encryption is used for the /Encrypt dictionary 

603 if not self._override_encryption and self._encryption is not None: 

604 # if we don't have the encryption key: 

605 if not self._encryption.is_decrypted(): 

606 raise FileNotDecryptedError("File has not been decrypted") 

607 # otherwise, decrypt here... 

608 retval = self._encryption.decrypt_object( 

609 retval, indirect_reference.idnum, indirect_reference.generation, 

610 strict=self.strict, 

611 ) 

612 else: 

613 logger_warning( 

614 "Object %(idnum)d %(generation)d not defined.", 

615 source=__name__, 

616 idnum=indirect_reference.idnum, 

617 generation=indirect_reference.generation, 

618 ) 

619 if self.strict: 

620 raise PdfReadError("Could not find object.") 

621 # For ObjStm objects, _get_object_from_stream already cached 

622 # the result during batch parsing; skip the redundant cache write 

623 # to avoid "Overwriting cache" warnings. For non-ObjStm objects 

624 # (including encrypted ones that need decrypted values cached), 

625 # always write. 

626 if not ( 

627 indirect_reference.generation == 0 

628 and indirect_reference.idnum in self.xref_objStm 

629 ): 

630 self.cache_indirect_object( 

631 indirect_reference.generation, indirect_reference.idnum, retval 

632 ) 

633 return retval 

634 

635 def read_object_header(self, stream: StreamType) -> tuple[int, int]: 

636 # Should never be necessary to read out whitespace, since the 

637 # cross-reference table should put us in the right spot to read the 

638 # object header. In reality some files have stupid cross-reference 

639 # tables that are off by whitespace bytes. 

640 skip_over_comment(stream) 

641 extra = skip_over_whitespace(stream) 

642 stream.seek(-1, 1) 

643 idnum = read_until_whitespace(stream, max_bytes=IndirectObject._MAXIMUM_PART_LENGTH, strict=self.strict) 

644 extra |= skip_over_whitespace(stream) 

645 stream.seek(-1, 1) 

646 generation = read_until_whitespace(stream, max_bytes=IndirectObject._MAXIMUM_PART_LENGTH, strict=self.strict) 

647 extra |= skip_over_whitespace(stream) 

648 stream.seek(-1, 1) 

649 

650 # although it's not used, it might still be necessary to read 

651 _obj = stream.read(3) 

652 

653 read_non_whitespace(stream) 

654 stream.seek(-1, 1) 

655 if extra and self.strict: 

656 logger_warning( 

657 "Superfluous whitespace found in object header %(idnum)r %(generation)r", 

658 source=__name__, 

659 idnum=idnum, 

660 generation=generation, 

661 ) 

662 

663 try: 

664 return int(idnum), int(generation) 

665 except (ValueError, OverflowError) as exception: 

666 # Only raise a ValueError here as other types would break future processing. 

667 raise ValueError( 

668 f"Invalid indirect object reference ({idnum!r} {generation!r} R): {exception}" 

669 ) from exception 

670 

671 def cache_get_indirect_object( 

672 self, generation: int, idnum: int 

673 ) -> Optional[PdfObject]: 

674 try: 

675 return self.resolved_objects.get((generation, idnum)) 

676 except RecursionError as exception: # pragma: no cover 

677 raise PdfReadError("Maximum recursion depth reached.") from exception # pragma: no cover 

678 

679 def cache_indirect_object( 

680 self, generation: int, idnum: int, obj: Optional[PdfObject] 

681 ) -> Optional[PdfObject]: 

682 if (generation, idnum) in self.resolved_objects: 

683 msg = "Overwriting cache for %(generation)d %(idnum)d" 

684 values = {"generation": generation, "idnum": idnum} 

685 if self.strict: 

686 raise PdfReadError(msg % values) 

687 logger_warning(msg, source=__name__, **values) 

688 self.resolved_objects[(generation, idnum)] = obj 

689 if obj is not None: 

690 obj.indirect_reference = IndirectObject(idnum, generation, self) 

691 return obj 

692 

693 def _replace_object(self, indirect_reference: IndirectObject, obj: PdfObject) -> PdfObject: 

694 # function reserved for future development 

695 if indirect_reference.pdf != self: 

696 raise ValueError("Cannot update PdfReader with external object") 

697 if (indirect_reference.generation, indirect_reference.idnum) not in self.resolved_objects: 

698 raise ValueError("Cannot find referenced object") 

699 self.resolved_objects[(indirect_reference.generation, indirect_reference.idnum)] = obj 

700 obj.indirect_reference = indirect_reference 

701 return obj 

702 

703 def read(self, stream: StreamType) -> None: 

704 """ 

705 Read and process the PDF stream, extracting necessary data. 

706 

707 Args: 

708 stream: The PDF file stream. 

709 

710 """ 

711 self._basic_validation(stream) 

712 self._find_eof_marker(stream) 

713 startxref = self._find_startxref_pos(stream) 

714 if startxref < 0: 

715 # A negative offset cannot point into the file. Treat it like the 

716 # zero case (#3157) so the xref table is repaired instead of 

717 # leaking a ValueError from a negative seek. 

718 logger_warning( 

719 "Negative startxref pointer (%(startxref)d), treating it as zero.", 

720 source=__name__, 

721 startxref=startxref, 

722 ) 

723 startxref = 0 

724 self._startxref = startxref 

725 

726 # check and eventually correct the startxref only if not strict 

727 xref_issue_nr = self._get_xref_issues(stream, startxref) 

728 if xref_issue_nr != 0: 

729 if self.strict and xref_issue_nr: 

730 raise PdfReadError("Broken xref table") 

731 logger_warning( 

732 "incorrect startxref pointer(%(xref_issue_nr)d)", 

733 source=__name__, 

734 xref_issue_nr=xref_issue_nr, 

735 ) 

736 

737 # read all cross-reference tables and their trailers 

738 self._read_xref_tables_and_trailers(stream, startxref, xref_issue_nr) 

739 

740 # if not zero-indexed, verify that the table is correct; change it if necessary 

741 if self.xref_index and not self.strict: 

742 loc = stream.tell() 

743 for gen, xref_entry in self.xref.items(): 

744 if gen == 65535: 

745 continue 

746 xref_k = sorted( 

747 xref_entry.keys() 

748 ) # ensure ascending to prevent damage 

749 for xref_id in xref_k: 

750 stream.seek(xref_entry[xref_id], 0) 

751 try: 

752 pid, _pgen = self.read_object_header(stream) 

753 except ValueError: 

754 self._rebuild_xref_table(stream) 

755 break 

756 if pid == xref_id - self.xref_index: 

757 # fixing index item per item is required for revised PDF. 

758 self.xref[gen][pid] = self.xref[gen][xref_id] 

759 del self.xref[gen][xref_id] 

760 # if not, then either it's just plain wrong, or the 

761 # non-zero-index is actually correct 

762 stream.seek(loc, 0) # return to where it was 

763 

764 # remove wrong objects (not pointing to correct structures) - cf #2326 

765 if not self.strict: 

766 loc = stream.tell() 

767 for gen, xref_entry in self.xref.items(): 

768 if gen == 65535: 

769 continue 

770 ids = list(xref_entry.keys()) 

771 for xref_id in ids: 

772 stream.seek(xref_entry[xref_id], 0) 

773 try: 

774 self.read_object_header(stream) 

775 except ValueError: 

776 logger_warning( 

777 "Ignoring wrong pointing object %(id)d %(gen)d (offset %(offset)d)", 

778 source=__name__, 

779 id=xref_id, 

780 gen=gen, 

781 offset=xref_entry[xref_id], 

782 ) 

783 del xref_entry[xref_id] # we can delete the id, we are parsing ids 

784 stream.seek(loc, 0) # return to where it was 

785 

786 def _basic_validation(self, stream: StreamType) -> None: 

787 """Ensure the stream is valid and not empty.""" 

788 stream.seek(0, os.SEEK_SET) 

789 try: 

790 header_byte = stream.read(5) 

791 except UnicodeDecodeError as exception: 

792 raise UnsupportedOperation("cannot read header") from exception 

793 if header_byte == b"": 

794 raise EmptyFileError("Cannot read an empty file") 

795 if header_byte != b"%PDF-": 

796 if self.strict: 

797 raise PdfReadError( 

798 f"PDF starts with '{header_byte.decode('utf8')}', " 

799 "but '%PDF-' expected" 

800 ) 

801 logger_warning("invalid pdf header: %(header_byte)r", source=__name__, header_byte=header_byte) 

802 stream.seek(0, os.SEEK_END) 

803 

804 def _find_eof_marker(self, stream: StreamType) -> None: 

805 """ 

806 Jump to the %%EOF marker. 

807 

808 According to the specs, the %%EOF marker should be at the very end of 

809 the file. Hence for standard-compliant PDF documents this function will 

810 read only the last part (DEFAULT_BUFFER_SIZE). 

811 

812 Producers that omit the line break before the marker (``startxref 

813 256%%EOF`` or ``256%%EOF``) are accepted as well (#4127). 

814 """ 

815 header_size = 8 # to parse whole file, Header is e.g. '%PDF-1.6' 

816 line = b"" 

817 first = True 

818 # Bytes of `line` end at this position. An inline %%EOF therefore 

819 # begins at end_position - len(line) + marker_index. 

820 end_position = stream.tell() 

821 while True: 

822 marker_index = line.find(b"%%EOF") 

823 if marker_index == 0: 

824 break 

825 if marker_index > 0 and first: 

826 # Only the trailing line is allowed to carry an inline marker. 

827 # Earlier lines keep the historical "starts with %%EOF" rule so 

828 # a %%EOF buried in stream data is not treated as the trailer. 

829 # Leading whitespace in front of the marker is ignored. 

830 if line[:marker_index].lstrip(WHITESPACES_AS_BYTES): 

831 logger_warning( 

832 "EOF marker not at start of line", 

833 source=__name__, 

834 ) 

835 stream.seek(end_position - len(line) + marker_index) 

836 break 

837 if line != b"" and first: 

838 if any( 

839 line.strip().endswith(tr) for tr in (b"%%EO", b"%%E", b"%%", b"%") 

840 ): 

841 # Consider the file as truncated while 

842 # having enough confidence to carry on. 

843 logger_warning("EOF marker seems truncated", source=__name__) 

844 break 

845 first = False 

846 if b"startxref" in line: 

847 logger_warning( 

848 "CAUTION: startxref found while searching for %%EOF. " 

849 "The file might be truncated and some data might not be read.", 

850 source=__name__, 

851 ) 

852 if stream.tell() < header_size: 

853 if self.strict: 

854 raise PdfReadError("EOF marker not found") 

855 logger_warning("EOF marker not found", source=__name__) 

856 end_position = stream.tell() 

857 line = read_previous_line(stream) 

858 

859 def _find_startxref_pos(self, stream: StreamType) -> int: 

860 """ 

861 Find startxref entry - the location of the xref table. 

862 

863 Args: 

864 stream: 

865 

866 Returns: 

867 The bytes offset 

868 

869 """ 

870 line = read_previous_line(stream) 

871 # Some producers append further %%EOF markers below the one that 

872 # closes the last revision (#4008). _find_eof_marker() stops at the 

873 # very last of them, so the line above it is another marker rather 

874 # than the offset. Skip that trailing run to reach the real offset; 

875 # the revision being read is unchanged, only the marker padding is 

876 # ignored. 

877 duplicate_markers = 0 

878 while line.startswith(b"%%EOF") and stream.tell() > 0: 

879 if duplicate_markers == self._MAX_STARTXREF_RECOVERY_LINES: 

880 break 

881 line = read_previous_line(stream) 

882 duplicate_markers += 1 

883 if duplicate_markers: 

884 logger_warning( 

885 "Duplicate %%EOF marker(s) found, skipping them", source=__name__ 

886 ) 

887 try: 

888 startxref = int(line) 

889 except ValueError as exception: # pragma: no cover 

890 # 'startxref' may be on the same line as the location 

891 if not line.startswith(b"startxref"): 

892 raise PdfReadError("startxref not found") from exception 

893 startxref = int(line[9:].strip()) 

894 logger_warning("startxref on same line as offset", source=__name__) 

895 else: 

896 line = read_previous_line(stream) 

897 if not line.startswith(b"startxref"): 

898 # The 'startxref' keyword expected just above the offset is 

899 # missing or corrupt (for example a truncated 'tartxref'). 

900 # Some producers append a broken trailing cross-reference 

901 # pointer while an earlier, intact 'startxref' from a previous 

902 # revision is still present. Recovering from this violates the 

903 # standard, so only attempt it in non-strict mode (#3238). 

904 if self.strict: 

905 raise PdfReadError("startxref not found") 

906 startxref = self._find_previous_startxref_pos(stream) 

907 return startxref 

908 

909 # Upper bound on the number of lines _find_previous_startxref_pos scans 

910 # backwards while recovering a corrupt trailing startxref pointer. Kept 

911 # fixed and non-configurable so a crafted file cannot trigger an unbounded 

912 # backwards scan. 

913 _MAX_STARTXREF_RECOVERY_LINES = 1000 

914 

915 @classmethod 

916 def _find_previous_startxref_pos(cls, stream: StreamType) -> int: 

917 """ 

918 Recover the most recent intact ``startxref`` pointer by scanning 

919 backwards from the current position. 

920 

921 This is used as a fallback when the ``startxref`` keyword belonging to 

922 the final ``%%EOF`` is corrupt (#3238). The offset always appears on 

923 the line directly below the keyword, so the value read immediately 

924 before encountering ``startxref`` (while moving backwards) is returned. 

925 At most ``_MAX_STARTXREF_RECOVERY_LINES`` lines are inspected. 

926 

927 Args: 

928 stream: The PDF byte stream, positioned just above the corrupt 

929 trailing pointer. 

930 

931 Returns: 

932 The bytes offset of the recovered ``startxref``. 

933 

934 """ 

935 offset: Optional[int] = None 

936 for _ in range(cls._MAX_STARTXREF_RECOVERY_LINES): 

937 if stream.tell() <= 0: 

938 break 

939 line = read_previous_line(stream) 

940 if not line.startswith(b"startxref"): 

941 try: 

942 offset = int(line) 

943 except ValueError: 

944 offset = None 

945 continue 

946 if len(line) > 9: 

947 # 'startxref' on the same line as the offset 

948 return int(line[9:].strip()) 

949 if offset is not None: 

950 logger_warning( 

951 "found startxref pointing to a previous revision after " 

952 "a corrupt one", 

953 source=__name__, 

954 ) 

955 return offset 

956 break 

957 raise PdfReadError("startxref not found") 

958 

959 def _load_recovery_cache(self, data: bytes) -> dict[int, tuple[int, int]]: 

960 cache = {} 

961 for object_number, generation_number, object_start in self._find_pdf_objects(data): 

962 if object_number in cache: 

963 # Always use the first match. 

964 continue 

965 cache[object_number] = (object_start, generation_number) 

966 return cache 

967 

968 def _read_standard_xref_table(self, stream: StreamType) -> None: 

969 # standard cross-reference table 

970 ref = stream.read(3) 

971 if ref != b"ref": 

972 raise PdfReadError("xref table read error") 

973 read_non_whitespace(stream) 

974 stream.seek(-1, 1) 

975 first_time = True # check if the first time looking at the xref table 

976 recovery_cache: Optional[dict[int, tuple[int, int]]] = None 

977 while True: 

978 num = cast(int, read_object(stream, self)) 

979 if first_time and num != 0: 

980 self.xref_index = num 

981 if self.strict: 

982 logger_warning( 

983 "Xref table not zero-indexed. ID numbers for objects will be corrected.", 

984 source=__name__, 

985 ) 

986 # if table not zero indexed, could be due to error from when PDF was created 

987 # which will lead to mismatched indices later on, only warned and corrected if self.strict==True 

988 first_time = False 

989 read_non_whitespace(stream) 

990 stream.seek(-1, 1) 

991 size = cast(int, read_object(stream, self)) 

992 if not isinstance(size, int): 

993 logger_warning( 

994 "Invalid/Truncated xref table. Rebuilding it.", 

995 source=__name__, 

996 ) 

997 self._rebuild_xref_table(stream) 

998 stream.read() 

999 return 

1000 read_non_whitespace(stream) 

1001 stream.seek(-1, 1) 

1002 cnt = 0 

1003 while cnt < size: 

1004 line = stream.read(20) 

1005 if not line: 

1006 raise PdfReadError("Unexpected empty line in Xref table.") 

1007 

1008 # It's very clear in section 3.4.3 of the PDF spec 

1009 # that all cross-reference table lines are a fixed 

1010 # 20 bytes (as of PDF 1.7). However, some files have 

1011 # 21-byte entries (or more) due to the use of \r\n 

1012 # (CRLF) EOL's. Detect that case, and adjust the line 

1013 # until it does not begin with a \r (CR) or \n (LF). 

1014 while line[0] in b"\x0D\x0A": 

1015 stream.seek(-20 + 1, 1) 

1016 line = stream.read(20) 

1017 if len(line) != 20: 

1018 raise PdfReadError("Unexpected EOF in Xref table.") 

1019 

1020 # On the other hand, some malformed PDF files 

1021 # use a single character EOL without a preceding 

1022 # space. Detect that case, and seek the stream 

1023 # back one character (0-9 means we've bled into 

1024 # the next xref entry, t means we've bled into the 

1025 # text "trailer"): 

1026 if line[-1] in b"0123456789t": 

1027 stream.seek(-1, 1) 

1028 

1029 try: 

1030 offset_b, generation_b = line[:16].split(b" ") 

1031 entry_type_b = line[17:18] 

1032 

1033 offset, generation = int(offset_b), int(generation_b) 

1034 except Exception: 

1035 if hasattr(stream, "getbuffer"): 

1036 buf = bytes(stream.getbuffer()) 

1037 else: 

1038 p = stream.tell() 

1039 stream.seek(0, 0) 

1040 buf = stream.read(-1) 

1041 stream.seek(p) 

1042 

1043 if recovery_cache is None: 

1044 recovery_cache = self._load_recovery_cache(buf) 

1045 

1046 if num not in recovery_cache: 

1047 logger_warning( 

1048 "entry %(num)d in Xref table invalid; object not found", 

1049 source=__name__, 

1050 num=num, 

1051 ) 

1052 generation = 65535 

1053 offset = -1 

1054 entry_type_b = b"f" 

1055 else: 

1056 logger_warning( 

1057 "entry %(num)d in Xref table invalid but object found", 

1058 source=__name__, 

1059 num=num, 

1060 ) 

1061 generation, offset = recovery_cache[num] 

1062 entry_type_b = b"n" 

1063 

1064 if generation not in self.xref: 

1065 self.xref[generation] = {} 

1066 self.xref_free_entry[generation] = {} 

1067 if num in self.xref[generation]: 

1068 # It really seems like we should allow the last 

1069 # xref table in the file to override previous 

1070 # ones. Since we read the file backwards, assume 

1071 # any existing key is already set correctly. 

1072 pass 

1073 else: 

1074 if entry_type_b == b"n": 

1075 self.xref[generation][num] = offset 

1076 try: 

1077 self.xref_free_entry[generation][num] = entry_type_b == b"f" 

1078 except Exception: 

1079 pass 

1080 try: 

1081 self.xref_free_entry[65535][num] = entry_type_b == b"f" 

1082 except Exception: 

1083 pass 

1084 cnt += 1 

1085 num += 1 

1086 read_non_whitespace(stream) 

1087 stream.seek(-1, 1) 

1088 # Skip any PDF comments between xref entries and the trailer 

1089 # keyword. Some PDF producers (e.g. Vectorizer.AI) insert 

1090 # comments here which are legal per the PDF spec (§7.2.3). 

1091 while stream.read(1) == b"%": 

1092 stream.seek(-1, 1) 

1093 skip_over_comment(stream) 

1094 read_non_whitespace(stream) 

1095 stream.seek(-1, 1) 

1096 stream.seek(-1, 1) 

1097 trailer_tag = stream.read(7) 

1098 if trailer_tag != b"trailer": 

1099 # more xrefs! 

1100 stream.seek(-7, 1) 

1101 else: 

1102 break 

1103 

1104 def _read_xref_tables_and_trailers( 

1105 self, stream: StreamType, startxref: Optional[int], xref_issue_nr: int 

1106 ) -> None: 

1107 """Read the cross-reference tables and trailers in the PDF stream.""" 

1108 self.xref = {} 

1109 self.xref_free_entry = {} 

1110 self.xref_objStm = {} 

1111 self.trailer = DictionaryObject() 

1112 visited_xref_offsets: set[int] = set() 

1113 while startxref is not None: 

1114 # Detect circular /Prev references in the xref chain 

1115 if startxref in visited_xref_offsets: 

1116 logger_warning( 

1117 "Circular xref chain detected at offset %(startxref)d, stopping", 

1118 source=__name__, 

1119 startxref=startxref, 

1120 ) 

1121 break 

1122 visited_xref_offsets.add(startxref) 

1123 # load the xref table 

1124 stream.seek(startxref, 0) 

1125 x = stream.read(1) 

1126 if x in b"\r\n": 

1127 x = stream.read(1) 

1128 if x == b"x": 

1129 startxref = self._read_xref(stream) 

1130 elif xref_issue_nr: 

1131 try: 

1132 self._rebuild_xref_table(stream) 

1133 break 

1134 except Exception: 

1135 xref_issue_nr = 0 

1136 elif x.isdigit(): 

1137 try: 

1138 xrefstream = self._read_pdf15_xref_stream(stream) 

1139 except Exception as exception: # pragma: no cover 

1140 if TK.ROOT in self.trailer: 

1141 logger_warning( 

1142 "Previous trailer cannot be read: %(args)s", 

1143 source=__name__, 

1144 args=exception.args, 

1145 ) 

1146 break 

1147 raise PdfReadError(f"Trailer cannot be read: {exception!s}") from exception # pragma: no cover 

1148 self._process_xref_stream(xrefstream) 

1149 if "/Prev" in xrefstream: 

1150 startxref = cast(int, xrefstream["/Prev"]) 

1151 else: 

1152 break 

1153 else: 

1154 startxref = self._read_xref_other_error(stream, startxref) 

1155 

1156 # The trailer keys a PDF 1.5+ cross-reference stream carries in place of a 

1157 # `trailer` keyword (PDF 2.0 specification, table 17). 

1158 _XREF_STREAM_TRAILER_KEYS = (TK.ROOT, TK.ENCRYPT, TK.INFO, TK.ID, TK.SIZE) 

1159 

1160 def _process_xref_stream(self, xrefstream: DictionaryObject) -> None: 

1161 """Process and handle the xref stream.""" 

1162 for key in self._XREF_STREAM_TRAILER_KEYS: 

1163 if key in xrefstream and key not in self.trailer: 

1164 self.trailer[NameObject(key)] = xrefstream.raw_get(key) 

1165 if "/XRefStm" in xrefstream: 

1166 p = self.stream.tell() 

1167 self.stream.seek(cast(int, xrefstream["/XRefStm"]) + 1, 0) 

1168 self._read_pdf15_xref_stream(self.stream) 

1169 self.stream.seek(p, 0) 

1170 

1171 def _read_xref(self, stream: StreamType) -> Optional[int]: 

1172 self._read_standard_xref_table(stream) 

1173 if stream.read(1) == b"": 

1174 return None 

1175 stream.seek(-1, 1) 

1176 read_non_whitespace(stream) 

1177 stream.seek(-1, 1) 

1178 new_trailer = cast(dict[str, Any], read_object(stream, self)) 

1179 for key, value in new_trailer.items(): 

1180 if key not in self.trailer: 

1181 self.trailer[key] = value 

1182 if "/XRefStm" in new_trailer: 

1183 p = stream.tell() 

1184 stream.seek(cast(int, new_trailer["/XRefStm"]) + 1, 0) 

1185 try: 

1186 self._read_pdf15_xref_stream(stream) 

1187 except Exception: 

1188 logger_warning( 

1189 "XRef object at %(xref_stm)d can not be read, some object may be missing", 

1190 source=__name__, 

1191 xref_stm=int(new_trailer["/XRefStm"]), 

1192 ) 

1193 stream.seek(p, 0) 

1194 if "/Prev" in new_trailer: 

1195 return cast(int, new_trailer["/Prev"]) 

1196 return None 

1197 

1198 def _read_xref_other_error( 

1199 self, stream: StreamType, startxref: int 

1200 ) -> Optional[int]: 

1201 # some PDFs have /Prev=0 in the trailer, instead of no /Prev 

1202 if startxref == 0: 

1203 if self.strict: 

1204 raise PdfReadError( 

1205 "/Prev=0 in the trailer (try opening with strict=False)" 

1206 ) 

1207 logger_warning( 

1208 "/Prev=0 in the trailer - assuming there is no previous xref table", 

1209 source=__name__, 

1210 ) 

1211 return None 

1212 # bad xref character at startxref. Let's see if we can find 

1213 # the xref table nearby, as we've observed this error with an 

1214 # off-by-one before. 

1215 stream.seek(-11, 1) 

1216 tmp = stream.read(20) 

1217 xref_loc = tmp.find(b"xref") 

1218 if xref_loc != -1: 

1219 startxref -= 10 - xref_loc 

1220 return startxref 

1221 # No explicit xref table, try finding a cross-reference stream. 

1222 stream.seek(startxref, 0) 

1223 for look in range(25): # value extended to cope with more linearized files 

1224 if stream.read(1).isdigit(): 

1225 # This is not a standard PDF, consider adding a warning 

1226 startxref += look 

1227 return startxref 

1228 # no xref table found at specified location 

1229 if "/Root" in self.trailer and not self.strict: 

1230 # if Root has been already found, just raise warning 

1231 logger_warning("Invalid parent xref., rebuild xref", source=__name__) 

1232 try: 

1233 self._rebuild_xref_table(stream) 

1234 return None 

1235 except Exception as exception: # pragma: no cover 

1236 raise PdfReadError("Cannot rebuild xref") from exception # pragma: no cover 

1237 raise PdfReadError("Could not find xref table at specified location") 

1238 

1239 def _sanitize_pdf15_xref_stream_index_pairs( 

1240 self, index_pairs: list[int], entry_sizes: list[int], xref_stream: StreamObject 

1241 ) -> list[int]: 

1242 # `entry_sizes` holds the byte widths for the entries. Summing determines the total number of bytes per entry. 

1243 # We expect up to 3 values. `min_entry_bytes` will be the smallest plausible size of one xref entry. 

1244 min_entry_bytes = sum(int(entry_sizes[i]) for i in range(min(len(entry_sizes), 3))) 

1245 if min_entry_bytes == 0: 

1246 message = "Cross-reference stream encodes no entry data." 

1247 if self.strict: 

1248 raise PdfStreamError(message) 

1249 logger_warning(message, source=__name__) 

1250 return [] 

1251 

1252 # maximum number of entries that could physically fit 

1253 max_entries = len(xref_stream.get_data()) // min_entry_bytes + 1 

1254 

1255 result = [] 

1256 total = 0 

1257 

1258 for index, pair_value in enumerate(index_pairs): 

1259 pair_value_int = int(pair_value) 

1260 

1261 # `index_pairs` has the format `[start0, count0, start1, count1, ...]` 

1262 # Only modify the counts here, but keep the start values. 

1263 if index % 2 == 1: 

1264 if total + pair_value_int > max_entries: 

1265 if self.strict: 

1266 raise LimitReachedError( 

1267 f"Total XRef entries {total + pair_value_int} exceed maximum allowed value {max_entries}." 

1268 ) 

1269 new_v = max(0, max_entries - total) 

1270 logger_warning( 

1271 "Clamping XRef count from %(old_count)d to %(new_count)d to fit stream size.", 

1272 source=__name__, 

1273 old_count=pair_value_int, 

1274 new_count=new_v, 

1275 ) 

1276 pair_value_int = new_v 

1277 

1278 total += pair_value_int 

1279 

1280 result.append(pair_value_int) 

1281 

1282 return result 

1283 

1284 def _read_pdf15_xref_stream(self, stream: StreamType) -> StreamObject: 

1285 """Read the cross-reference stream for PDF 1.5+.""" 

1286 stream.seek(-1, 1) 

1287 stream_idnum, stream_generation = self.read_object_header(stream) 

1288 xref_stream = cast(StreamObject, read_object(stream, self)) 

1289 if cast(str, xref_stream["/Type"]) != "/XRef": 

1290 raise PdfReadError(f"Unexpected type {xref_stream['/Type']!r}") 

1291 self.cache_indirect_object(stream_generation, stream_idnum, xref_stream) 

1292 

1293 # Index pairs specify the subsections in the dictionary. 

1294 # If none, create one subsection that spans everything. 

1295 if "/Size" not in xref_stream: 

1296 # According to table 17 of the PDF 2.0 specification, this key is required. 

1297 raise PdfReadError(f"Size missing from XRef stream {xref_stream!r}!") 

1298 index_pairs = xref_stream.get("/Index", [0, xref_stream["/Size"]]) 

1299 

1300 entry_sizes = cast(list[int], xref_stream.get("/W")) 

1301 assert len(entry_sizes) >= 3 

1302 if self.strict and len(entry_sizes) > 3: 

1303 raise PdfReadError(f"Too many entry sizes: {entry_sizes}") 

1304 index_pairs = self._sanitize_pdf15_xref_stream_index_pairs( 

1305 index_pairs=index_pairs, entry_sizes=entry_sizes, xref_stream=xref_stream 

1306 ) 

1307 

1308 stream_data = BytesIO(xref_stream.get_data()) 

1309 

1310 def get_entry(i: int) -> Union[int, tuple[int, ...]]: 

1311 # Reads the correct number of bytes for each entry. See the 

1312 # discussion of the W parameter in PDF spec table 17. 

1313 if entry_sizes[i] > 0: 

1314 d = stream_data.read(entry_sizes[i]) 

1315 return convert_to_int(d, entry_sizes[i]) 

1316 

1317 # PDF Spec Table 17: A value of zero for an element in the 

1318 # W array indicates...the default value shall be used 

1319 if i == 0: 

1320 return 1 # First value defaults to 1 

1321 return 0 

1322 

1323 def used_before(num: int, generation: Union[int, tuple[int, ...]]) -> bool: 

1324 # We move backwards through the xrefs, don't replace any. 

1325 return num in self.xref.get(generation, []) or num in self.xref_objStm # type: ignore[arg-type] 

1326 

1327 # Iterate through each subsection 

1328 self._read_xref_subsections(index_pairs, get_entry, used_before) 

1329 return xref_stream 

1330 

1331 @staticmethod 

1332 def _get_xref_issues(stream: StreamType, startxref: int) -> int: 

1333 """ 

1334 Return an int which indicates an issue. 0 means there is no issue. 

1335 

1336 Args: 

1337 stream: 

1338 startxref: 

1339 

1340 Returns: 

1341 0 means no issue, other values represent specific issues. 

1342 

1343 """ 

1344 if startxref == 0: 

1345 return 4 

1346 

1347 stream.seek(startxref - 1, 0) # -1 to check character before 

1348 line = stream.read(1) 

1349 if line == b"j": 

1350 line = stream.read(1) 

1351 if line not in b"\r\n \t": 

1352 return 1 

1353 line = stream.read(4) 

1354 if line != b"xref": 

1355 # not a xref so check if it is an XREF object 

1356 line = b"" 

1357 while line in b"0123456789 \t": 

1358 line = stream.read(1) 

1359 if line == b"": 

1360 return 2 

1361 line += stream.read(2) # 1 char already read, +2 to check "obj" 

1362 if line.lower() != b"obj": 

1363 return 3 

1364 return 0 

1365 

1366 @classmethod 

1367 def _find_pdf_objects(cls, data: bytes) -> Iterable[tuple[int, int, int]]: 

1368 index = 0 

1369 ord_0 = ord("0") 

1370 ord_9 = ord("9") 

1371 while True: 

1372 index = data.find(b" obj", index) 

1373 if index == -1: 

1374 return 

1375 

1376 index_before_space = index - 1 

1377 

1378 # Skip whitespace backwards 

1379 while index_before_space >= 0 and data[index_before_space] in WHITESPACES_AS_BYTES: 

1380 index_before_space -= 1 

1381 

1382 # Read generation number 

1383 generation_end = index_before_space + 1 

1384 while index_before_space >= 0 and ord_0 <= data[index_before_space] <= ord_9: 

1385 index_before_space -= 1 

1386 generation_start = index_before_space + 1 

1387 

1388 # Skip whitespace 

1389 while index_before_space >= 0 and data[index_before_space] in WHITESPACES_AS_BYTES: 

1390 index_before_space -= 1 

1391 

1392 # Read object number 

1393 object_end = index_before_space + 1 

1394 while index_before_space >= 0 and ord_0 <= data[index_before_space] <= ord_9: 

1395 index_before_space -= 1 

1396 object_start = index_before_space + 1 

1397 

1398 # Validate 

1399 if object_start < object_end and generation_start < generation_end: 

1400 object_number = int(data[object_start:object_end]) 

1401 generation_number = int(data[generation_start:generation_end]) 

1402 

1403 yield object_number, generation_number, object_start 

1404 

1405 index += 4 # len(b" obj") 

1406 

1407 @classmethod 

1408 def _find_pdf_trailers(cls, data: bytes) -> Iterable[int]: 

1409 index = 0 

1410 data_length = len(data) 

1411 while True: 

1412 index = data.find(b"trailer", index) 

1413 if index == -1: 

1414 return 

1415 

1416 index_after_trailer = index + 7 # len(b"trailer") 

1417 

1418 # Skip whitespace 

1419 while index_after_trailer < data_length and data[index_after_trailer] in WHITESPACES_AS_BYTES: 

1420 index_after_trailer += 1 

1421 

1422 # Must be dictionary start 

1423 if index_after_trailer + 1 < data_length and data[index_after_trailer:index_after_trailer+2] == b"<<": 

1424 yield index_after_trailer # offset of '<<' 

1425 

1426 index += 7 # len(b"trailer") 

1427 

1428 def _rebuild_xref_table(self, stream: StreamType) -> None: 

1429 self.xref = {} 

1430 stream.seek(0, 0) 

1431 stream_data = stream.read(-1) 

1432 

1433 for object_number, generation_number, object_start in self._find_pdf_objects(stream_data): 

1434 if generation_number not in self.xref: 

1435 self.xref[generation_number] = {} 

1436 self.xref[generation_number][object_number] = object_start 

1437 

1438 logger_warning("parsing for Object Streams", source=__name__) 

1439 # PDF 1.5+ files may carry the trailer keys inside a cross-reference 

1440 # stream instead of behind a `trailer` keyword. Collect them here, 

1441 # keyed by their offset, to merge them below in file order. 

1442 xref_stream_trailers: list[tuple[int, DictionaryObject]] = [] 

1443 for generation_number in self.xref: 

1444 for object_number in self.xref[generation_number]: 

1445 # get_object in manual 

1446 object_start = self.xref[generation_number][object_number] 

1447 stream.seek(object_start, 0) 

1448 try: 

1449 _ = self.read_object_header(stream) 

1450 obj = cast(StreamObject, read_object(stream, self)) 

1451 object_type = obj.get("/Type", "") 

1452 if object_type == "/XRef": 

1453 trailer = DictionaryObject() 

1454 for key in self._XREF_STREAM_TRAILER_KEYS: 

1455 if key in obj: 

1456 trailer[NameObject(key)] = obj.raw_get(key) 

1457 xref_stream_trailers.append((object_start, trailer)) 

1458 continue 

1459 if object_type != "/ObjStm": 

1460 continue 

1461 object_stream = BytesIO(obj.get_data()) 

1462 actual_count = 0 

1463 while True: 

1464 current = read_until_whitespace( 

1465 object_stream, max_bytes=IndirectObject._MAXIMUM_PART_LENGTH, strict=self.strict 

1466 ) 

1467 if not current.isdigit(): 

1468 break 

1469 inner_object_number = int(current) 

1470 skip_over_whitespace(object_stream) 

1471 object_stream.seek(-1, 1) 

1472 current = read_until_whitespace( 

1473 object_stream, max_bytes=IndirectObject._MAXIMUM_PART_LENGTH, strict=self.strict 

1474 ) 

1475 if not current.isdigit(): # pragma: no cover 

1476 break # pragma: no cover 

1477 inner_offset = int(current) 

1478 self.xref_objStm[inner_object_number] = (object_number, inner_offset) 

1479 actual_count += 1 

1480 expected_count = cast(int, obj["/N"]) 

1481 if actual_count != expected_count: # pragma: no cover 

1482 logger_warning( # pragma: no cover 

1483 ( 

1484 "found %(actual_count)d objects within " 

1485 "Object(%(object_number)d,%(generation_number)d) " 

1486 "whereas %(expected)d expected" 

1487 ), 

1488 source=__name__, 

1489 actual_count=actual_count, 

1490 object_number=object_number, 

1491 generation_number=generation_number, 

1492 expected=expected_count, 

1493 ) 

1494 except LimitReachedError: 

1495 # Do not let the broad recovery below bypass the token-length limit 

1496 # when strict parsing is enabled. 

1497 raise 

1498 except Exception: # could be multiple causes 

1499 pass 

1500 

1501 stream.seek(0, 0) 

1502 trailers: list[tuple[int, dict[Any, Any]]] = list(xref_stream_trailers) 

1503 for position in self._find_pdf_trailers(stream_data): 

1504 stream.seek(position, 0) 

1505 trailers.append((position, cast(dict[Any, Any], read_object(stream, self)))) 

1506 # Here, we are parsing the file from start to end, the new data have to erase the existing. 

1507 for _, new_trailer in sorted(trailers, key=itemgetter(0)): 

1508 for key, value in new_trailer.items(): 

1509 self.trailer[key] = value 

1510 

1511 def _read_xref_subsections( 

1512 self, 

1513 idx_pairs: list[int], 

1514 get_entry: Callable[[int], Union[int, tuple[int, ...]]], 

1515 used_before: Callable[[int, Union[int, tuple[int, ...]]], bool], 

1516 ) -> None: 

1517 """Read and process the subsections of the xref.""" 

1518 for start, size in self._pairs(idx_pairs): 

1519 # The subsections must increase 

1520 for num in range(start, start + size): 

1521 # The first entry is the type 

1522 xref_type = get_entry(0) 

1523 # The rest of the elements depend on the xref_type 

1524 if xref_type == 0: 

1525 # linked list of free objects 

1526 next_free_object = get_entry(1) # noqa: F841 

1527 next_generation = get_entry(2) # noqa: F841 

1528 elif xref_type == 1: 

1529 # objects that are in use but are not compressed 

1530 byte_offset = get_entry(1) 

1531 generation = get_entry(2) 

1532 if generation not in self.xref: 

1533 self.xref[generation] = {} # type: ignore[index] 

1534 if not used_before(num, generation): 

1535 self.xref[generation][num] = byte_offset # type: ignore[index] 

1536 elif xref_type == 2: 

1537 # compressed objects 

1538 objstr_num = get_entry(1) 

1539 obstr_idx = get_entry(2) 

1540 generation = 0 # PDF spec table 18, generation is 0 

1541 if not used_before(num, generation): 

1542 self.xref_objStm[num] = (objstr_num, obstr_idx) 

1543 elif self.strict: 

1544 raise PdfReadError(f"Unknown xref type: {xref_type}") 

1545 

1546 def _pairs(self, array: list[int]) -> Iterable[tuple[int, int]]: 

1547 """Iterate over pairs in the array.""" 

1548 i = 0 

1549 while i + 1 < len(array): 

1550 yield array[i], array[i + 1] 

1551 i += 2 

1552 

1553 def decrypt(self, password: Union[str, bytes]) -> PasswordType: 

1554 """ 

1555 When using an encrypted / secured PDF file with the PDF Standard 

1556 encryption handler, this function will allow the file to be decrypted. 

1557 It checks the given password against the document's user password and 

1558 owner password, and then stores the resulting decryption key if either 

1559 password is correct. 

1560 

1561 It does not matter which password was matched. Both passwords provide 

1562 the correct decryption key that will allow the document to be used with 

1563 this library. 

1564 

1565 Args: 

1566 password: The password to match. 

1567 

1568 Returns: 

1569 An indicator if the document was decrypted and whether it was the 

1570 owner password or the user password. 

1571 

1572 """ 

1573 if not self._encryption: 

1574 raise PdfReadError("Not encrypted file") 

1575 # TODO: raise Exception for wrong password 

1576 return self._encryption.verify(password, strict=self.strict) 

1577 

1578 @property 

1579 def is_encrypted(self) -> bool: 

1580 """ 

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

1582 

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

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

1585 """ 

1586 return TK.ENCRYPT in self.trailer 

1587 

1588 def add_form_topname(self, name: str) -> Optional[DictionaryObject]: 

1589 """ 

1590 Add a top level form that groups all form fields below it. 

1591 

1592 Args: 

1593 name: text string of the "/T" Attribute of the created object 

1594 

1595 Returns: 

1596 The created object. ``None`` means no object was created. 

1597 

1598 """ 

1599 catalog = self.root_object 

1600 

1601 if "/AcroForm" not in catalog or not isinstance( 

1602 catalog["/AcroForm"], DictionaryObject 

1603 ): 

1604 return None 

1605 acroform = cast(DictionaryObject, catalog[NameObject("/AcroForm")]) 

1606 if "/Fields" not in acroform: 

1607 # TODO: No error but this may be extended for XFA Forms 

1608 return None 

1609 

1610 interim = DictionaryObject() 

1611 interim[NameObject("/T")] = TextStringObject(name) 

1612 interim[NameObject("/Kids")] = acroform[NameObject("/Fields")] 

1613 # Allocate the new object_id beyond the source-file range and all cached object IDs. 

1614 object_id = max( 

1615 cast(int, self.trailer.get("/Size", 0)) - 1, 

1616 max((i for entries in self.xref.values() for i in entries), default=0), 

1617 max(self.xref_objStm, default=0), 

1618 max((i for _, i in self.resolved_objects), default=0), 

1619 ) + 1 

1620 self.cache_indirect_object(0, object_id, interim) 

1621 arr = ArrayObject() 

1622 arr.append(interim.indirect_reference) 

1623 acroform[NameObject("/Fields")] = arr 

1624 for o in cast(ArrayObject, interim["/Kids"]): 

1625 obj = o.get_object() 

1626 if "/Parent" in obj: 

1627 logger_warning( 

1628 "Top Level Form Field %(obj_ref)s has a non-expected parent", 

1629 source=__name__, 

1630 obj_ref=obj.indirect_reference, 

1631 ) 

1632 obj[NameObject("/Parent")] = interim.indirect_reference 

1633 return interim 

1634 

1635 def rename_form_topname(self, name: str) -> Optional[DictionaryObject]: 

1636 """ 

1637 Rename top level form field that all form fields below it. 

1638 

1639 Args: 

1640 name: text string of the "/T" field of the created object 

1641 

1642 Returns: 

1643 The modified object. ``None`` means no object was modified. 

1644 

1645 """ 

1646 catalog = self.root_object 

1647 

1648 if "/AcroForm" not in catalog or not isinstance( 

1649 catalog["/AcroForm"], DictionaryObject 

1650 ): 

1651 return None 

1652 acroform = cast(DictionaryObject, catalog[NameObject("/AcroForm")]) 

1653 if "/Fields" not in acroform: 

1654 return None 

1655 

1656 interim = cast( 

1657 DictionaryObject, 

1658 cast(ArrayObject, acroform[NameObject("/Fields")])[0].get_object(), 

1659 ) 

1660 interim[NameObject("/T")] = TextStringObject(name) 

1661 return interim 

1662 

1663 def _repr_mimebundle_( 

1664 self, 

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

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

1667 ) -> dict[str, Any]: 

1668 """ 

1669 Integration into Jupyter Notebooks. 

1670 

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

1672 representation. 

1673 

1674 .. seealso:: 

1675 

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

1677 """ 

1678 self.stream.seek(0) 

1679 pdf_data = self.stream.read() 

1680 data = { 

1681 "application/pdf": pdf_data, 

1682 } 

1683 

1684 if include is not None: 

1685 # Filter representations based on include list 

1686 data = {k: v for k, v in data.items() if k in include} 

1687 

1688 if exclude is not None: 

1689 # Remove representations based on exclude list 

1690 data = {k: v for k, v in data.items() if k not in exclude} 

1691 

1692 return data 

1693 

1694 def _get_named_destinations( 

1695 self, 

1696 *, 

1697 tree: Optional[DictionaryObject] = None, 

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

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

1700 ) -> dict[str, Destination]: 

1701 """Override from PdfDocCommon. In the reader we can assume this is 

1702 static, but not in the writer. 

1703 """ 

1704 if tree or retval: 

1705 return super()._get_named_destinations(tree=tree, retval=retval, visited=visited) 

1706 

1707 if self._named_destinations_cache is None: 

1708 self._named_destinations_cache = super()._get_named_destinations() 

1709 return self._named_destinations_cache