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

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

857 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 ._doc_common import PdfDocCommon, convert_to_int 

53from ._encryption import Encryption, PasswordType 

54from ._utils import ( 

55 WHITESPACES_AS_BYTES, 

56 StrByteType, 

57 StreamType, 

58 logger_warning, 

59 read_non_whitespace, 

60 read_previous_line, 

61 read_until_whitespace, 

62 skip_over_comment, 

63 skip_over_whitespace, 

64) 

65from .constants import TrailerKeys as TK 

66from .errors import ( 

67 EmptyFileError, 

68 FileNotDecryptedError, 

69 LimitReachedError, 

70 PdfReadError, 

71 PdfStreamError, 

72 WrongPasswordError, 

73) 

74from .generic import ( 

75 ArrayObject, 

76 Destination, 

77 DictionaryObject, 

78 EncodedStreamObject, 

79 IndirectObject, 

80 NameObject, 

81 NullObject, 

82 NumberObject, 

83 PdfObject, 

84 StreamObject, 

85 TextStringObject, 

86 is_null_or_none, 

87 read_object, 

88) 

89from .xmp import XmpInformation 

90 

91if TYPE_CHECKING: 

92 from ._page import PageObject 

93 

94 

95class PdfReader(PdfDocCommon): 

96 """ 

97 Initialize a PdfReader object. 

98 

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

100 tables are read into memory. 

101 

102 Args: 

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

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

105 string representing a path to a PDF file. 

106 strict: Determines whether user should be warned of all 

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

108 Defaults to ``False``. 

109 password: Decrypt PDF file at initialization. If the 

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

111 Defaults to ``None``. 

112 root_object_recovery_limit: The maximum number of objects to query 

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

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

115 

116 """ 

117 

118 def __init__( 

119 self, 

120 stream: Union[StrByteType, Path], 

121 strict: bool = False, 

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

123 *, 

124 root_object_recovery_limit: Optional[int] = 10_000, 

125 ) -> None: 

126 self.strict = strict 

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

128 

129 #: Storage of parsed PDF objects. 

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

131 

132 self._startxref: int = 0 

133 self.xref_index = 0 

134 self.xref: dict[int, dict[Any, Any]] = {} 

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

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

137 self.trailer = DictionaryObject() 

138 

139 # Security parameters. 

140 self._root_object_recovery_limit = ( 

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

142 ) 

143 

144 # Map page indirect_reference number to page number 

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

146 

147 self._validated_root: Optional[DictionaryObject] = None 

148 

149 self._initialize_stream(stream) 

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

151 

152 self._override_encryption = False 

153 self._encryption: Optional[Encryption] = None 

154 if self.is_encrypted: 

155 self._handle_encryption(password) 

156 elif password is not None: 

157 raise PdfReadError("Not an encrypted file") 

158 

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

160 

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

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

163 logger_warning( 

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

165 "It may not be read correctly.", 

166 source=__name__, 

167 ) 

168 self._stream_opened = False 

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

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

171 stream = BytesIO(fh.read()) 

172 self._stream_opened = True 

173 self.read(stream) 

174 self.stream = stream 

175 

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

177 self._override_encryption = True 

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

179 # byte strings instead. Solves 

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

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

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

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

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

185 

186 # try empty password if no password provided 

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

188 if ( 

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

190 and password is not None 

191 ): 

192 # raise if password provided 

193 raise WrongPasswordError("Wrong password") 

194 self._override_encryption = False 

195 

196 def __enter__(self) -> Self: 

197 return self 

198 

199 def __exit__( 

200 self, 

201 exc_type: Optional[type[BaseException]], 

202 exc_val: Optional[BaseException], 

203 exc_tb: Optional[TracebackType], 

204 ) -> None: 

205 self.close() 

206 

207 def close(self) -> None: 

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

209 if self._stream_opened: 

210 self.stream.close() 

211 self.flattened_pages = [] 

212 self.resolved_objects = {} 

213 self.trailer = DictionaryObject() 

214 self.xref = {} 

215 self.xref_free_entry = {} 

216 self.xref_objStm = {} 

217 

218 @property 

219 def root_object(self) -> DictionaryObject: 

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

221 if self._validated_root: 

222 return self._validated_root 

223 root = self.trailer.get(TK.ROOT) 

224 if is_null_or_none(root): 

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

226 elif ( 

227 cast(DictionaryObject, cast(PdfObject, root).get_object()).get("/Type") 

228 == "/Catalog" 

229 ): 

230 self._validated_root = cast( 

231 DictionaryObject, cast(PdfObject, root).get_object() 

232 ) 

233 else: 

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

235 if self._validated_root is None: 

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

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

238 for i in range(number_of_objects): 

239 if i >= self._root_object_recovery_limit: 

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

241 try: 

242 obj = self.get_object(i + 1) 

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

244 obj = None 

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

246 self._validated_root = obj 

247 logger_warning( 

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

249 source=__name__, 

250 obj_reference=obj.indirect_reference, 

251 ) 

252 break 

253 if self._validated_root is None: 

254 if not is_null_or_none(root) and "/Pages" in cast(DictionaryObject, cast(PdfObject, root).get_object()): 

255 logger_warning( 

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

257 source=__name__, 

258 root_ref=cast(PdfObject, root).indirect_reference, 

259 ) 

260 self._validated_root = cast( 

261 DictionaryObject, cast(PdfObject, root).get_object() 

262 ) 

263 else: 

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

265 return self._validated_root 

266 

267 @property 

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

269 """ 

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

271 

272 Returns: 

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

274 

275 """ 

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

277 if is_null_or_none(info): 

278 return None 

279 assert info is not None, "mypy" 

280 info = info.get_object() 

281 if not isinstance(info, DictionaryObject): 

282 raise PdfReadError( 

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

284 ) 

285 return info 

286 

287 @property 

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

289 """ 

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

291 

292 Returns: 

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

294 

295 """ 

296 id = self.trailer.get(TK.ID, None) 

297 if is_null_or_none(id): 

298 return None 

299 assert id is not None, "mypy" 

300 return cast(ArrayObject, id.get_object()) 

301 

302 @property 

303 def pdf_header(self) -> str: 

304 """ 

305 The first 8 bytes of the file. 

306 

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

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

309 """ 

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

311 # but that needs a deprecation 

312 loc = self.stream.tell() 

313 self.stream.seek(0, 0) 

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

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

316 return pdf_file_version 

317 

318 @property 

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

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

321 try: 

322 self._override_encryption = True 

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

324 finally: 

325 self._override_encryption = False 

326 

327 def _get_page_number_by_indirect( 

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

329 ) -> Optional[int]: 

330 """ 

331 Retrieve the page number from an indirect reference. 

332 

333 Args: 

334 indirect_reference: The indirect reference to locate. 

335 

336 Returns: 

337 Page number or None. 

338 

339 """ 

340 if self._page_id2num is None: 

341 self._page_id2num = { 

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

343 } 

344 

345 if is_null_or_none(indirect_reference): 

346 return None 

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

348 if isinstance(indirect_reference, int): 

349 idnum = indirect_reference 

350 else: 

351 idnum = indirect_reference.idnum 

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

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

354 

355 def _get_object_from_stream( 

356 self, indirect_reference: IndirectObject 

357 ) -> Union[int, PdfObject, str]: 

358 # indirect reference to object in object stream 

359 # read the entire object stream into memory 

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

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

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

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

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

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

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

367 stream_data = BytesIO(obj_stm.get_data()) 

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

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

370 

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

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

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

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

375 stream_data.seek(0) 

376 if n > max_n: 

377 if self.strict: 

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

379 logger_warning( 

380 "Value /N %(n)d for object %(stmnum)d exceeds maximum allowed value %(max_n)d. Limiting to %(max_n)d.", 

381 source=__name__, 

382 n=n, 

383 stmnum=stmnum, 

384 max_n=max_n, 

385 ) 

386 n = max_n 

387 

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

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

390 for _i in range(n): 

391 read_non_whitespace(stream_data) 

392 stream_data.seek(-1, 1) 

393 objnum = NumberObject.read_from_stream(stream_data) 

394 read_non_whitespace(stream_data) 

395 stream_data.seek(-1, 1) 

396 offset = NumberObject.read_from_stream(stream_data) 

397 read_non_whitespace(stream_data) 

398 stream_data.seek(-1, 1) 

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

400 

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

402 target_obj: Union[int, PdfObject, str] = NullObject() 

403 found = False 

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

405 # Skip objects already in the cache. 

406 cached = self.cache_get_indirect_object(0, obj_num) 

407 if cached is not None: 

408 if obj_num == indirect_reference.idnum: 

409 target_obj = cached 

410 found = True 

411 continue 

412 

413 stream_data.seek(first_offset + obj_offset, 0) 

414 

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

416 read_non_whitespace(stream_data) 

417 stream_data.seek(-1, 1) 

418 

419 try: 

420 obj = read_object(stream_data, self) 

421 except PdfStreamError as exc: 

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

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

424 logger_warning( 

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

426 source=__name__, 

427 index=i, 

428 obj_num=obj_num, 

429 exc=exc, 

430 ) 

431 if self.strict: # pragma: no cover 

432 raise PdfReadError( 

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

434 ) # pragma: no cover 

435 obj = NullObject() # pragma: no cover 

436 

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

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

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

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

441 if authoritative_stm == stmnum: 

442 self.cache_indirect_object(0, obj_num, obj) # type: ignore[arg-type] 

443 

444 if obj_num == indirect_reference.idnum: 

445 target_obj = obj 

446 found = True 

447 

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

449 raise PdfReadError( 

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

451 ) # pragma: no cover 

452 return target_obj 

453 

454 def get_object( 

455 self, indirect_reference: Union[int, IndirectObject] 

456 ) -> Optional[PdfObject]: 

457 if isinstance(indirect_reference, int): 

458 indirect_reference = IndirectObject(indirect_reference, 0, self) 

459 retval = self.cache_get_indirect_object( 

460 indirect_reference.generation, indirect_reference.idnum 

461 ) 

462 if retval is not None: 

463 return retval 

464 if ( 

465 indirect_reference.generation == 0 

466 and indirect_reference.idnum in self.xref_objStm 

467 ): 

468 retval = self._get_object_from_stream(indirect_reference) # type: ignore 

469 elif ( 

470 indirect_reference.generation in self.xref 

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

472 ): 

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

474 indirect_reference.idnum, False 

475 ): 

476 return NullObject() 

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

478 self.stream.seek(start, 0) 

479 try: 

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

481 if ( 

482 idnum != indirect_reference.idnum 

483 or generation != indirect_reference.generation 

484 ): 

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

486 except Exception: 

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

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

489 else: 

490 p = self.stream.tell() 

491 self.stream.seek(0, 0) 

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

493 self.stream.seek(p, 0) 

494 m = re.search( 

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

496 buf, 

497 ) 

498 if m is not None: 

499 logger_warning( 

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

501 source=__name__, 

502 idnum=indirect_reference.idnum, 

503 generation=indirect_reference.generation, 

504 ) 

505 self.xref[indirect_reference.generation][ 

506 indirect_reference.idnum 

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

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

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

510 else: 

511 idnum = -1 

512 generation = -1 # exception will be raised below 

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

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

515 if self.strict: 

516 raise PdfReadError( 

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

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

519 "xref table not zero-indexed." 

520 ) 

521 # xref table is corrected in non-strict mode 

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

523 # some other problem 

524 raise PdfReadError( 

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

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

527 ) 

528 if self.strict: 

529 assert generation == indirect_reference.generation 

530 

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

532 if current_object in self._known_objects: 

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

534 self._known_objects.add(current_object) 

535 retval = read_object(self.stream, self) # type: ignore[assignment] 

536 self._known_objects.remove(current_object) 

537 

538 # override encryption is used for the /Encrypt dictionary 

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

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

541 if not self._encryption.is_decrypted(): 

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

543 # otherwise, decrypt here... 

544 retval = cast(PdfObject, retval) 

545 retval = self._encryption.decrypt_object( 

546 retval, indirect_reference.idnum, indirect_reference.generation, 

547 strict=self.strict, 

548 ) 

549 else: 

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

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

552 else: 

553 p = self.stream.tell() 

554 self.stream.seek(0, 0) 

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

556 self.stream.seek(p, 0) 

557 m = re.search( 

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

559 buf, 

560 ) 

561 if m is not None: 

562 logger_warning( 

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

564 source=__name__, 

565 idnum=indirect_reference.idnum, 

566 generation=indirect_reference.generation, 

567 ) 

568 if indirect_reference.generation not in self.xref: 

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

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

571 m.start(0) + 1 

572 ) 

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

574 skip_over_whitespace(self.stream) 

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

576 retval = read_object(self.stream, self) # type: ignore[assignment] 

577 

578 # override encryption is used for the /Encrypt dictionary 

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

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

581 if not self._encryption.is_decrypted(): 

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

583 # otherwise, decrypt here... 

584 retval = cast(PdfObject, retval) 

585 retval = self._encryption.decrypt_object( 

586 retval, indirect_reference.idnum, indirect_reference.generation, 

587 strict=self.strict, 

588 ) 

589 else: 

590 logger_warning( 

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

592 source=__name__, 

593 idnum=indirect_reference.idnum, 

594 generation=indirect_reference.generation, 

595 ) 

596 if self.strict: 

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

598 # For ObjStm objects, _get_object_from_stream already cached 

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

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

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

602 # always write. 

603 if not ( 

604 indirect_reference.generation == 0 

605 and indirect_reference.idnum in self.xref_objStm 

606 ): 

607 self.cache_indirect_object( 

608 indirect_reference.generation, indirect_reference.idnum, retval 

609 ) 

610 return retval 

611 

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

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

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

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

616 # tables that are off by whitespace bytes. 

617 skip_over_comment(stream) 

618 extra = skip_over_whitespace(stream) 

619 stream.seek(-1, 1) 

620 idnum = read_until_whitespace(stream) 

621 extra |= skip_over_whitespace(stream) 

622 stream.seek(-1, 1) 

623 generation = read_until_whitespace(stream) 

624 extra |= skip_over_whitespace(stream) 

625 stream.seek(-1, 1) 

626 

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

628 _obj = stream.read(3) 

629 

630 read_non_whitespace(stream) 

631 stream.seek(-1, 1) 

632 if extra and self.strict: 

633 logger_warning( 

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

635 source=__name__, 

636 idnum=idnum, 

637 generation=generation, 

638 ) 

639 return int(idnum), int(generation) 

640 

641 def cache_get_indirect_object( 

642 self, generation: int, idnum: int 

643 ) -> Optional[PdfObject]: 

644 try: 

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

646 except RecursionError: 

647 raise PdfReadError("Maximum recursion depth reached.") 

648 

649 def cache_indirect_object( 

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

651 ) -> Optional[PdfObject]: 

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

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

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

655 if self.strict: 

656 raise PdfReadError(msg % values) 

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

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

659 if obj is not None: 

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

661 return obj 

662 

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

664 # function reserved for future development 

665 if indirect_reference.pdf != self: 

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

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

668 raise ValueError("Cannot find referenced object") 

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

670 obj.indirect_reference = indirect_reference 

671 return obj 

672 

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

674 """ 

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

676 

677 Args: 

678 stream: The PDF file stream. 

679 

680 """ 

681 self._basic_validation(stream) 

682 self._find_eof_marker(stream) 

683 startxref = self._find_startxref_pos(stream) 

684 self._startxref = startxref 

685 

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

687 xref_issue_nr = self._get_xref_issues(stream, startxref) 

688 if xref_issue_nr != 0: 

689 if self.strict and xref_issue_nr: 

690 raise PdfReadError("Broken xref table") 

691 logger_warning( 

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

693 source=__name__, 

694 xref_issue_nr=xref_issue_nr, 

695 ) 

696 

697 # read all cross-reference tables and their trailers 

698 self._read_xref_tables_and_trailers(stream, startxref, xref_issue_nr) 

699 

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

701 if self.xref_index and not self.strict: 

702 loc = stream.tell() 

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

704 if gen == 65535: 

705 continue 

706 xref_k = sorted( 

707 xref_entry.keys() 

708 ) # ensure ascending to prevent damage 

709 for id in xref_k: 

710 stream.seek(xref_entry[id], 0) 

711 try: 

712 pid, _pgen = self.read_object_header(stream) 

713 except ValueError: 

714 self._rebuild_xref_table(stream) 

715 break 

716 if pid == id - self.xref_index: 

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

718 self.xref[gen][pid] = self.xref[gen][id] 

719 del self.xref[gen][id] 

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

721 # non-zero-index is actually correct 

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

723 

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

725 if not self.strict: 

726 loc = stream.tell() 

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

728 if gen == 65535: 

729 continue 

730 ids = list(xref_entry.keys()) 

731 for id in ids: 

732 stream.seek(xref_entry[id], 0) 

733 try: 

734 self.read_object_header(stream) 

735 except ValueError: 

736 logger_warning( 

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

738 source=__name__, 

739 id=id, 

740 gen=gen, 

741 offset=xref_entry[id], 

742 ) 

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

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

745 

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

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

748 stream.seek(0, os.SEEK_SET) 

749 try: 

750 header_byte = stream.read(5) 

751 except UnicodeDecodeError: 

752 raise UnsupportedOperation("cannot read header") 

753 if header_byte == b"": 

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

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

756 if self.strict: 

757 raise PdfReadError( 

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

759 "but '%PDF-' expected" 

760 ) 

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

762 stream.seek(0, os.SEEK_END) 

763 

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

765 """ 

766 Jump to the %%EOF marker. 

767 

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

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

770 read only the last part (DEFAULT_BUFFER_SIZE). 

771 """ 

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

773 line = b"" 

774 first = True 

775 while not line.startswith(b"%%EOF"): 

776 if line != b"" and first: 

777 if any( 

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

779 ): 

780 # Consider the file as truncated while 

781 # having enough confidence to carry on. 

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

783 break 

784 first = False 

785 if b"startxref" in line: 

786 logger_warning( 

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

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

789 source=__name__, 

790 ) 

791 if stream.tell() < HEADER_SIZE: 

792 if self.strict: 

793 raise PdfReadError("EOF marker not found") 

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

795 line = read_previous_line(stream) 

796 

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

798 """ 

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

800 

801 Args: 

802 stream: 

803 

804 Returns: 

805 The bytes offset 

806 

807 """ 

808 line = read_previous_line(stream) 

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

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

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

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

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

814 # ignored. 

815 duplicate_markers = 0 

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

817 if duplicate_markers == self._MAX_STARTXREF_RECOVERY_LINES: 

818 break 

819 line = read_previous_line(stream) 

820 duplicate_markers += 1 

821 if duplicate_markers: 

822 logger_warning( 

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

824 ) 

825 try: 

826 startxref = int(line) 

827 except ValueError: 

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

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

830 raise PdfReadError("startxref not found") 

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

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

833 else: 

834 line = read_previous_line(stream) 

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

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

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

838 # Some producers append a broken trailing cross-reference 

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

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

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

842 if self.strict: 

843 raise PdfReadError("startxref not found") 

844 startxref = self._find_previous_startxref_pos(stream) 

845 return startxref 

846 

847 # Upper bound on the number of lines _find_previous_startxref_pos scans 

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

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

850 # backwards scan. 

851 _MAX_STARTXREF_RECOVERY_LINES = 1000 

852 

853 @classmethod 

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

855 """ 

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

857 backwards from the current position. 

858 

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

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

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

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

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

864 

865 Args: 

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

867 trailing pointer. 

868 

869 Returns: 

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

871 

872 """ 

873 offset: Optional[int] = None 

874 for _ in range(cls._MAX_STARTXREF_RECOVERY_LINES): 

875 if stream.tell() <= 0: 

876 break 

877 line = read_previous_line(stream) 

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

879 try: 

880 offset = int(line) 

881 except ValueError: 

882 offset = None 

883 continue 

884 if len(line) > 9: 

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

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

887 if offset is not None: 

888 logger_warning( 

889 "found startxref pointing to a previous revision after " 

890 "a corrupt one", 

891 source=__name__, 

892 ) 

893 return offset 

894 break 

895 raise PdfReadError("startxref not found") 

896 

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

898 cache = {} 

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

900 if object_number in cache: 

901 # Always use the first match. 

902 continue 

903 cache[object_number] = (object_start, generation_number) 

904 return cache 

905 

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

907 # standard cross-reference table 

908 ref = stream.read(3) 

909 if ref != b"ref": 

910 raise PdfReadError("xref table read error") 

911 read_non_whitespace(stream) 

912 stream.seek(-1, 1) 

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

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

915 while True: 

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

917 if first_time and num != 0: 

918 self.xref_index = num 

919 if self.strict: 

920 logger_warning( 

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

922 source=__name__, 

923 ) 

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

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

926 first_time = False 

927 read_non_whitespace(stream) 

928 stream.seek(-1, 1) 

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

930 if not isinstance(size, int): 

931 logger_warning( 

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

933 source=__name__, 

934 ) 

935 self._rebuild_xref_table(stream) 

936 stream.read() 

937 return 

938 read_non_whitespace(stream) 

939 stream.seek(-1, 1) 

940 cnt = 0 

941 while cnt < size: 

942 line = stream.read(20) 

943 if not line: 

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

945 

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

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

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

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

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

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

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

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

954 line = stream.read(20) 

955 

956 # On the other hand, some malformed PDF files 

957 # use a single character EOL without a preceding 

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

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

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

961 # text "trailer"): 

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

963 stream.seek(-1, 1) 

964 

965 try: 

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

967 entry_type_b = line[17:18] 

968 

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

970 except Exception: 

971 if hasattr(stream, "getbuffer"): 

972 buf = bytes(stream.getbuffer()) 

973 else: 

974 p = stream.tell() 

975 stream.seek(0, 0) 

976 buf = stream.read(-1) 

977 stream.seek(p) 

978 

979 if recovery_cache is None: 

980 recovery_cache = self._load_recovery_cache(buf) 

981 

982 if num not in recovery_cache: 

983 logger_warning( 

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

985 source=__name__, 

986 num=num, 

987 ) 

988 generation = 65535 

989 offset = -1 

990 entry_type_b = b"f" 

991 else: 

992 logger_warning( 

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

994 source=__name__, 

995 num=num, 

996 ) 

997 generation, offset = recovery_cache[num] 

998 entry_type_b = b"n" 

999 

1000 if generation not in self.xref: 

1001 self.xref[generation] = {} 

1002 self.xref_free_entry[generation] = {} 

1003 if num in self.xref[generation]: 

1004 # It really seems like we should allow the last 

1005 # xref table in the file to override previous 

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

1007 # any existing key is already set correctly. 

1008 pass 

1009 else: 

1010 if entry_type_b == b"n": 

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

1012 try: 

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

1014 except Exception: 

1015 pass 

1016 try: 

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

1018 except Exception: 

1019 pass 

1020 cnt += 1 

1021 num += 1 

1022 read_non_whitespace(stream) 

1023 stream.seek(-1, 1) 

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

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

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

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

1028 stream.seek(-1, 1) 

1029 skip_over_comment(stream) 

1030 read_non_whitespace(stream) 

1031 stream.seek(-1, 1) 

1032 stream.seek(-1, 1) 

1033 trailer_tag = stream.read(7) 

1034 if trailer_tag != b"trailer": 

1035 # more xrefs! 

1036 stream.seek(-7, 1) 

1037 else: 

1038 break 

1039 

1040 def _read_xref_tables_and_trailers( 

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

1042 ) -> None: 

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

1044 self.xref = {} 

1045 self.xref_free_entry = {} 

1046 self.xref_objStm = {} 

1047 self.trailer = DictionaryObject() 

1048 visited_xref_offsets: set[int] = set() 

1049 while startxref is not None: 

1050 # Detect circular /Prev references in the xref chain 

1051 if startxref in visited_xref_offsets: 

1052 logger_warning( 

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

1054 source=__name__, 

1055 startxref=startxref, 

1056 ) 

1057 break 

1058 visited_xref_offsets.add(startxref) 

1059 # load the xref table 

1060 stream.seek(startxref, 0) 

1061 x = stream.read(1) 

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

1063 x = stream.read(1) 

1064 if x == b"x": 

1065 startxref = self._read_xref(stream) 

1066 elif xref_issue_nr: 

1067 try: 

1068 self._rebuild_xref_table(stream) 

1069 break 

1070 except Exception: 

1071 xref_issue_nr = 0 

1072 elif x.isdigit(): 

1073 try: 

1074 xrefstream = self._read_pdf15_xref_stream(stream) 

1075 except Exception as e: 

1076 if TK.ROOT in self.trailer: 

1077 logger_warning( 

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

1079 source=__name__, 

1080 args=e.args, 

1081 ) 

1082 break 

1083 raise PdfReadError(f"Trailer cannot be read: {e!s}") 

1084 self._process_xref_stream(xrefstream) 

1085 if "/Prev" in xrefstream: 

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

1087 else: 

1088 break 

1089 else: 

1090 startxref = self._read_xref_other_error(stream, startxref) 

1091 

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

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

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

1095 

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

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

1098 for key in self._XREF_STREAM_TRAILER_KEYS: 

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

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

1101 if "/XRefStm" in xrefstream: 

1102 p = self.stream.tell() 

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

1104 self._read_pdf15_xref_stream(self.stream) 

1105 self.stream.seek(p, 0) 

1106 

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

1108 self._read_standard_xref_table(stream) 

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

1110 return None 

1111 stream.seek(-1, 1) 

1112 read_non_whitespace(stream) 

1113 stream.seek(-1, 1) 

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

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

1116 if key not in self.trailer: 

1117 self.trailer[key] = value 

1118 if "/XRefStm" in new_trailer: 

1119 p = stream.tell() 

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

1121 try: 

1122 self._read_pdf15_xref_stream(stream) 

1123 except Exception: 

1124 logger_warning( 

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

1126 source=__name__, 

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

1128 ) 

1129 stream.seek(p, 0) 

1130 if "/Prev" in new_trailer: 

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

1132 return None 

1133 

1134 def _read_xref_other_error( 

1135 self, stream: StreamType, startxref: int 

1136 ) -> Optional[int]: 

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

1138 if startxref == 0: 

1139 if self.strict: 

1140 raise PdfReadError( 

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

1142 ) 

1143 logger_warning( 

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

1145 source=__name__, 

1146 ) 

1147 return None 

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

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

1150 # off-by-one before. 

1151 stream.seek(-11, 1) 

1152 tmp = stream.read(20) 

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

1154 if xref_loc != -1: 

1155 startxref -= 10 - xref_loc 

1156 return startxref 

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

1158 stream.seek(startxref, 0) 

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

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

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

1162 startxref += look 

1163 return startxref 

1164 # no xref table found at specified location 

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

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

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

1168 try: 

1169 self._rebuild_xref_table(stream) 

1170 return None 

1171 except Exception: 

1172 raise PdfReadError("Cannot rebuild xref") 

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

1174 

1175 def _sanitize_pdf15_xref_stream_index_pairs( 

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

1177 ) -> list[int]: 

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

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

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

1181 if min_entry_bytes == 0: 

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

1183 if self.strict: 

1184 raise PdfStreamError(message) 

1185 logger_warning(message, source=__name__) 

1186 return [] 

1187 

1188 # maximum number of entries that could physically fit 

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

1190 

1191 result = [] 

1192 total = 0 

1193 

1194 for index, pair_value in enumerate(index_pairs): 

1195 pair_value_int = int(pair_value) 

1196 

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

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

1199 if index % 2 == 1: 

1200 if total + pair_value_int > max_entries: 

1201 if self.strict: 

1202 raise LimitReachedError( 

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

1204 ) 

1205 new_v = max(0, max_entries - total) 

1206 logger_warning( 

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

1208 source=__name__, 

1209 old_count=pair_value_int, 

1210 new_count=new_v, 

1211 ) 

1212 pair_value_int = new_v 

1213 

1214 total += pair_value_int 

1215 

1216 result.append(pair_value_int) 

1217 

1218 return result 

1219 

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

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

1222 stream.seek(-1, 1) 

1223 stream_idnum, stream_generation = self.read_object_header(stream) 

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

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

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

1227 self.cache_indirect_object(stream_generation, stream_idnum, xref_stream) 

1228 

1229 # Index pairs specify the subsections in the dictionary. 

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

1231 if "/Size" not in xref_stream: 

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

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

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

1235 

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

1237 assert len(entry_sizes) >= 3 

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

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

1240 index_pairs = self._sanitize_pdf15_xref_stream_index_pairs( 

1241 index_pairs=index_pairs, entry_sizes=entry_sizes, xref_stream=xref_stream 

1242 ) 

1243 

1244 stream_data = BytesIO(xref_stream.get_data()) 

1245 

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

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

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

1249 if entry_sizes[i] > 0: 

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

1251 return convert_to_int(d, entry_sizes[i]) 

1252 

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

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

1255 if i == 0: 

1256 return 1 # First value defaults to 1 

1257 return 0 

1258 

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

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

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

1262 

1263 # Iterate through each subsection 

1264 self._read_xref_subsections(index_pairs, get_entry, used_before) 

1265 return xref_stream 

1266 

1267 @staticmethod 

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

1269 """ 

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

1271 

1272 Args: 

1273 stream: 

1274 startxref: 

1275 

1276 Returns: 

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

1278 

1279 """ 

1280 if startxref == 0: 

1281 return 4 

1282 

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

1284 line = stream.read(1) 

1285 if line == b"j": 

1286 line = stream.read(1) 

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

1288 return 1 

1289 line = stream.read(4) 

1290 if line != b"xref": 

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

1292 line = b"" 

1293 while line in b"0123456789 \t": 

1294 line = stream.read(1) 

1295 if line == b"": 

1296 return 2 

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

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

1299 return 3 

1300 return 0 

1301 

1302 @classmethod 

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

1304 index = 0 

1305 ord_0 = ord("0") 

1306 ord_9 = ord("9") 

1307 while True: 

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

1309 if index == -1: 

1310 return 

1311 

1312 index_before_space = index - 1 

1313 

1314 # Skip whitespace backwards 

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

1316 index_before_space -= 1 

1317 

1318 # Read generation number 

1319 generation_end = index_before_space + 1 

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

1321 index_before_space -= 1 

1322 generation_start = index_before_space + 1 

1323 

1324 # Skip whitespace 

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

1326 index_before_space -= 1 

1327 

1328 # Read object number 

1329 object_end = index_before_space + 1 

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

1331 index_before_space -= 1 

1332 object_start = index_before_space + 1 

1333 

1334 # Validate 

1335 if object_start < object_end and generation_start < generation_end: 

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

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

1338 

1339 yield object_number, generation_number, object_start 

1340 

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

1342 

1343 @classmethod 

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

1345 index = 0 

1346 data_length = len(data) 

1347 while True: 

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

1349 if index == -1: 

1350 return 

1351 

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

1353 

1354 # Skip whitespace 

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

1356 index_after_trailer += 1 

1357 

1358 # Must be dictionary start 

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

1360 yield index_after_trailer # offset of '<<' 

1361 

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

1363 

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

1365 self.xref = {} 

1366 stream.seek(0, 0) 

1367 stream_data = stream.read(-1) 

1368 

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

1370 if generation_number not in self.xref: 

1371 self.xref[generation_number] = {} 

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

1373 

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

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

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

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

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

1379 for generation_number in self.xref: 

1380 for object_number in self.xref[generation_number]: 

1381 # get_object in manual 

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

1383 stream.seek(object_start, 0) 

1384 try: 

1385 _ = self.read_object_header(stream) 

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

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

1388 if object_type == "/XRef": 

1389 trailer = DictionaryObject() 

1390 for key in self._XREF_STREAM_TRAILER_KEYS: 

1391 if key in obj: 

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

1393 xref_stream_trailers.append((object_start, trailer)) 

1394 continue 

1395 if object_type != "/ObjStm": 

1396 continue 

1397 object_stream = BytesIO(obj.get_data()) 

1398 actual_count = 0 

1399 while True: 

1400 current = read_until_whitespace(object_stream) 

1401 if not current.isdigit(): 

1402 break 

1403 inner_object_number = int(current) 

1404 skip_over_whitespace(object_stream) 

1405 object_stream.seek(-1, 1) 

1406 current = read_until_whitespace(object_stream) 

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

1408 break # pragma: no cover 

1409 inner_generation_number = int(current) 

1410 self.xref_objStm[inner_object_number] = (object_number, inner_generation_number) 

1411 actual_count += 1 

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

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

1414 logger_warning( # pragma: no cover 

1415 ( 

1416 "found %(actual_count)d objects within " 

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

1418 "whereas %(expected)d expected" 

1419 ), 

1420 source=__name__, 

1421 actual_count=actual_count, 

1422 object_number=object_number, 

1423 generation_number=generation_number, 

1424 expected=expected_count, 

1425 ) 

1426 except Exception: # could be multiple causes 

1427 pass 

1428 

1429 stream.seek(0, 0) 

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

1431 for position in self._find_pdf_trailers(stream_data): 

1432 stream.seek(position, 0) 

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

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

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

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

1437 self.trailer[key] = value 

1438 

1439 def _read_xref_subsections( 

1440 self, 

1441 idx_pairs: list[int], 

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

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

1444 ) -> None: 

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

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

1447 # The subsections must increase 

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

1449 # The first entry is the type 

1450 xref_type = get_entry(0) 

1451 # The rest of the elements depend on the xref_type 

1452 if xref_type == 0: 

1453 # linked list of free objects 

1454 next_free_object = get_entry(1) # noqa: F841 

1455 next_generation = get_entry(2) # noqa: F841 

1456 elif xref_type == 1: 

1457 # objects that are in use but are not compressed 

1458 byte_offset = get_entry(1) 

1459 generation = get_entry(2) 

1460 if generation not in self.xref: 

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

1462 if not used_before(num, generation): 

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

1464 elif xref_type == 2: 

1465 # compressed objects 

1466 objstr_num = get_entry(1) 

1467 obstr_idx = get_entry(2) 

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

1469 if not used_before(num, generation): 

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

1471 elif self.strict: 

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

1473 

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

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

1476 i = 0 

1477 while i + 1 < len(array): 

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

1479 i += 2 

1480 

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

1482 """ 

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

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

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

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

1487 password is correct. 

1488 

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

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

1491 this library. 

1492 

1493 Args: 

1494 password: The password to match. 

1495 

1496 Returns: 

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

1498 owner password or the user password. 

1499 

1500 """ 

1501 if not self._encryption: 

1502 raise PdfReadError("Not encrypted file") 

1503 # TODO: raise Exception for wrong password 

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

1505 

1506 @property 

1507 def is_encrypted(self) -> bool: 

1508 """ 

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

1510 

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

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

1513 """ 

1514 return TK.ENCRYPT in self.trailer 

1515 

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

1517 """ 

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

1519 

1520 Args: 

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

1522 

1523 Returns: 

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

1525 

1526 """ 

1527 catalog = self.root_object 

1528 

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

1530 catalog["/AcroForm"], DictionaryObject 

1531 ): 

1532 return None 

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

1534 if "/Fields" not in acroform: 

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

1536 return None 

1537 

1538 interim = DictionaryObject() 

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

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

1541 self.cache_indirect_object( 

1542 0, 

1543 max(i for (g, i) in self.resolved_objects if g == 0) + 1, 

1544 interim, 

1545 ) 

1546 arr = ArrayObject() 

1547 arr.append(interim.indirect_reference) 

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

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

1550 obj = o.get_object() 

1551 if "/Parent" in obj: 

1552 logger_warning( 

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

1554 source=__name__, 

1555 obj_ref=obj.indirect_reference, 

1556 ) 

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

1558 return interim 

1559 

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

1561 """ 

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

1563 

1564 Args: 

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

1566 

1567 Returns: 

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

1569 

1570 """ 

1571 catalog = self.root_object 

1572 

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

1574 catalog["/AcroForm"], DictionaryObject 

1575 ): 

1576 return None 

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

1578 if "/Fields" not in acroform: 

1579 return None 

1580 

1581 interim = cast( 

1582 DictionaryObject, 

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

1584 ) 

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

1586 return interim 

1587 

1588 def _repr_mimebundle_( 

1589 self, 

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

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

1592 ) -> dict[str, Any]: 

1593 """ 

1594 Integration into Jupyter Notebooks. 

1595 

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

1597 representation. 

1598 

1599 .. seealso:: 

1600 

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

1602 """ 

1603 self.stream.seek(0) 

1604 pdf_data = self.stream.read() 

1605 data = { 

1606 "application/pdf": pdf_data, 

1607 } 

1608 

1609 if include is not None: 

1610 # Filter representations based on include list 

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

1612 

1613 if exclude is not None: 

1614 # Remove representations based on exclude list 

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

1616 

1617 return data 

1618 

1619 def _get_named_destinations( 

1620 self, 

1621 *, 

1622 tree: Optional[DictionaryObject] = None, 

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

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

1625 ) -> dict[str, Destination]: 

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

1627 static, but not in the writer. 

1628 """ 

1629 if tree or retval: 

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

1631 

1632 if self._named_destinations_cache is None: 

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

1634 return self._named_destinations_cache