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

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

297 statements  

1# Copyright (c) 2006, Mathieu Fenniak 

2# All rights reserved. 

3# 

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

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

6# met: 

7# 

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

9# this list of conditions and the following disclaimer. 

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

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

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

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

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

15# 

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

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

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

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

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

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

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

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

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

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

26# POSSIBILITY OF SUCH DAMAGE. 

27 

28"""Utility functions for PDF library.""" 

29__author__ = "Mathieu Fenniak" 

30__author_email__ = "biziqe@mathieu.fenniak.net" 

31 

32import functools 

33import logging 

34import re 

35import sys 

36import warnings 

37from dataclasses import dataclass 

38from datetime import datetime, timezone 

39from io import DEFAULT_BUFFER_SIZE 

40from os import SEEK_CUR 

41from re import Pattern 

42from typing import ( 

43 IO, 

44 Any, 

45 NoReturn, 

46 Optional, 

47 Union, 

48) 

49 

50if sys.version_info[:2] >= (3, 10): 

51 # Python 3.10+: https://www.python.org/dev/peps/pep-0484/ 

52 from typing import TypeAlias 

53else: 

54 from typing_extensions import TypeAlias 

55 

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

57 from typing import Self 

58else: 

59 from typing_extensions import Self 

60 

61from .errors import ( 

62 STREAM_TRUNCATED_PREMATURELY, 

63 DeprecationError, 

64 LimitReachedError, 

65 PdfStreamError, 

66) 

67 

68TransformationMatrixType: TypeAlias = tuple[ 

69 tuple[float, float, float], tuple[float, float, float], tuple[float, float, float] 

70] 

71CompressedTransformationMatrix: TypeAlias = tuple[ 

72 float, float, float, float, float, float 

73] 

74 

75StreamType = IO[Any] 

76BinaryStreamType = IO[bytes] 

77StrByteType = Union[str, StreamType] 

78 

79 

80def parse_iso8824_date(text: Optional[str]) -> Optional[datetime]: 

81 orgtext = text 

82 if not text: 

83 return None 

84 if text[0].isdigit(): 

85 text = "D:" + text 

86 if text.endswith(("Z", "z")): 

87 text += "0000" 

88 text = text.replace("z", "+").replace("Z", "+").replace("'", "") 

89 i = max(text.find("+"), text.find("-")) 

90 if i > 0 and i != len(text) - 5: 

91 text += "00" 

92 for f in ( 

93 "D:%Y", 

94 "D:%Y%m", 

95 "D:%Y%m%d", 

96 "D:%Y%m%d%H", 

97 "D:%Y%m%d%H%M", 

98 "D:%Y%m%d%H%M%S", 

99 "D:%Y%m%d%H%M%S%z", 

100 ): 

101 try: 

102 d = datetime.strptime(text, f) # noqa: DTZ007 

103 except ValueError: 

104 continue 

105 else: 

106 if text.endswith("+0000"): 

107 d = d.replace(tzinfo=timezone.utc) 

108 return d 

109 raise ValueError(f"Can not convert date: {orgtext}") 

110 

111 

112def format_iso8824_date(dt: datetime) -> str: 

113 """ 

114 Convert a datetime object to PDF date string format. 

115 

116 Converts datetime to the PDF date format D:YYYYMMDDHHmmSSOHH'mm 

117 as specified in the PDF Reference. 

118 

119 Args: 

120 dt: A datetime object to convert. 

121 

122 Returns: 

123 A date string in PDF format. 

124 """ 

125 date_str = dt.strftime("D:%Y%m%d%H%M%S") 

126 if dt.tzinfo is not None: 

127 offset = dt.utcoffset() 

128 assert offset is not None 

129 total_seconds = int(offset.total_seconds()) 

130 hours, remainder = divmod(abs(total_seconds), 3600) 

131 minutes = remainder // 60 

132 sign = "+" if total_seconds >= 0 else "-" 

133 date_str += f"{sign}{hours:02d}'{minutes:02d}'" 

134 return date_str 

135 

136 

137def _get_max_pdf_version_header(header1: str, header2: str) -> str: 

138 versions = ( 

139 "%PDF-1.3", 

140 "%PDF-1.4", 

141 "%PDF-1.5", 

142 "%PDF-1.6", 

143 "%PDF-1.7", 

144 "%PDF-2.0", 

145 ) 

146 pdf_header_indices = [] 

147 if header1 in versions: 

148 pdf_header_indices.append(versions.index(header1)) 

149 if header2 in versions: 

150 pdf_header_indices.append(versions.index(header2)) 

151 if len(pdf_header_indices) == 0: 

152 raise ValueError(f"Neither {header1!r} nor {header2!r} are proper headers") 

153 return versions[max(pdf_header_indices)] 

154 

155 

156WHITESPACES = (b"\x00", b"\t", b"\n", b"\f", b"\r", b" ") 

157WHITESPACES_AS_BYTES = b"".join(WHITESPACES) 

158WHITESPACES_AS_REGEXP = b"[" + WHITESPACES_AS_BYTES + b"]" 

159 

160 

161def read_until_whitespace(stream: StreamType, max_bytes: Optional[int] = None, strict: bool = False) -> bytes: 

162 """ 

163 Read non-whitespace characters and return them. 

164 

165 Stops upon encountering whitespace or when max_bytes is reached. 

166 

167 Args: 

168 stream: The data stream from which was read. 

169 max_bytes: The maximum number of bytes which are considered valid. One more byte 

170 will always be read from the stream. 

171 strict: Whether to raise an exception if the token exceeds max_bytes. 

172 If False, a warning is logged and only the first max_bytes are returned. 

173 

174 Returns: 

175 The data which was read. 

176 

177 """ 

178 txt = bytearray() 

179 while True: 

180 tok = stream.read(1) 

181 if tok.isspace() or tok in WHITESPACES or not tok: 

182 break 

183 if max_bytes is not None and len(txt) >= max_bytes: 

184 if strict: 

185 raise LimitReachedError(f"Token exceeds maximum length of {max_bytes} bytes.") 

186 logger_warning( 

187 "Token exceeds maximum length of %(max_bytes)d bytes.", source=__name__, max_bytes=max_bytes 

188 ) 

189 break 

190 txt += tok 

191 return bytes(txt) 

192 

193 

194def read_non_whitespace(stream: BinaryStreamType) -> bytes: 

195 """ 

196 Find and read the next non-whitespace character (ignores whitespace). 

197 

198 Args: 

199 stream: The data stream from which was read. 

200 

201 Returns: 

202 The data which was read. 

203 

204 """ 

205 tok = stream.read(1) 

206 while tok in WHITESPACES: 

207 tok = stream.read(1) 

208 return tok 

209 

210 

211def skip_over_whitespace(stream: StreamType) -> bool: 

212 """ 

213 Similar to read_non_whitespace, but return a boolean if at least one 

214 whitespace character was read. 

215 

216 Args: 

217 stream: The data stream from which was read. 

218 

219 Returns: 

220 True if one or more whitespace was skipped, otherwise return False. 

221 

222 """ 

223 tok = stream.read(1) 

224 cnt = 0 

225 while tok in WHITESPACES: 

226 cnt += 1 

227 tok = stream.read(1) 

228 return cnt > 0 

229 

230 

231def check_if_whitespace_only(value: bytes) -> bool: 

232 """ 

233 Check if the given value consists of whitespace characters only. 

234 

235 Args: 

236 value: The bytes to check. 

237 

238 Returns: 

239 True if the value only has whitespace characters, otherwise return False. 

240 

241 """ 

242 return all(b in WHITESPACES_AS_BYTES for b in value) 

243 

244 

245NEUTRAL_CHARACTER_RANGES = ( 

246 ("\x00", "\x2F"), # ASCII control codes, space, and early punctuation (!"#$%) 

247 ("\x3A", "\x40"), # ASCII operators and punctuation between digits & A (:;<=>?@) 

248 ("\u0660", "\u066D"), # Arabic-Indic digits and Arabic numeric separators 

249 ("\u06F0", "\u06F9"), # Extended Arabic-Indic (Persian) digits 

250 ("\u2000", "\u206F"), # General punctuation 

251 ("\u20A0", "\u21FF"), # Currency symbols, diacritical marks, letter-like symbols, number forms, arrows 

252) 

253 

254 

255def is_char_neutral(char: str, custom_special_characters: str = "") -> bool: 

256 """Check if a character is part of neutral character ranges""" 

257 if any(start <= char <= end for start, end in NEUTRAL_CHARACTER_RANGES): 

258 return True 

259 

260 return bool(custom_special_characters and char in custom_special_characters) 

261 

262 

263RTL_CHARACTER_RANGES = ( 

264 ("\u0590", "\u08FF"), # Hebrew, Arabic, Syriac, Thaana, N'Ko, etc. 

265 ("\uFB1D", "\uFDFF"), # Hebrew & Arabic Presentation Forms-A 

266 ("\uFE70", "\uFEFF"), # Arabic Presentation Forms-B 

267) 

268 

269 

270def is_char_rtl(char: str, custom_rtl_min: str = "", custom_rtl_max: str = "") -> bool: 

271 """Check if a character is part of RTL character ranges""" 

272 if any(start <= char <= end for start, end in RTL_CHARACTER_RANGES): 

273 return True 

274 

275 return bool(custom_rtl_min and custom_rtl_max and (custom_rtl_min <= char <= custom_rtl_max)) 

276 

277 

278def skip_over_comment(stream: StreamType) -> None: 

279 tok = stream.read(1) 

280 stream.seek(-1, 1) 

281 if tok == b"%": 

282 while tok not in (b"\n", b"\r"): 

283 tok = stream.read(1) 

284 if tok == b"": 

285 raise PdfStreamError("File ended unexpectedly.") 

286 

287 

288def skip_over_comments(stream: StreamType, *, limit: Optional[int] = None) -> None: 

289 """ 

290 Skip consecutive leading comments and the whitespace following each one. 

291 

292 The stream must be positioned at the first comment, if any. If the next 

293 character is not ``%``, the stream position is unchanged. If only whitespace 

294 follows the last comment before EOF, leave the stream at the last whitespace 

295 byte, preserving the existing read_object behaviour for a missing object. 

296 

297 Args: 

298 stream: The stream to read from. 

299 limit: The maximum number of comments to skip, or ``None`` for no limit. 

300 

301 Raises: 

302 LimitReachedError: If another comment follows after ``limit`` comments; 

303 the stream remains at its ``%`` without reading its body. 

304 PdfStreamError: If a comment ends before a line ending. 

305 

306 """ 

307 tok = stream.read(1) 

308 stream.seek(-len(tok), 1) 

309 comment_count = 0 

310 while tok == b"%": 

311 if limit is not None and comment_count >= limit: 

312 raise LimitReachedError(f"Maximum number of leading comments reached: {limit}.") 

313 skip_over_comment(stream) 

314 tok = read_non_whitespace(stream) 

315 stream.seek(-1, 1) 

316 comment_count += 1 

317 

318 

319def read_until_regex(*, stream: StreamType, regex: Pattern[bytes], length: int = sys.maxsize) -> bytes: 

320 """ 

321 Read until the regular expression pattern matched (ignore the match). 

322 Treats EOF on the underlying stream as the end of the token to be matched. 

323 

324 Args: 

325 stream: The stream to read from. 

326 regex: The pattern to search for. 

327 length: The (approximated) maximum number of bytes to read before raising an exception. 

328 

329 Returns: 

330 The read bytes. 

331 

332 """ 

333 parts: list[bytes] = [] 

334 total_length = 0 

335 tail = b"" 

336 chunk_size = 16 

337 while True: 

338 token = stream.read(chunk_size) 

339 if not token: 

340 return b"".join(parts) 

341 token_length = len(token) 

342 if (current_length := total_length + token_length) >= length: 

343 raise LimitReachedError( 

344 f"Read stream length of {current_length} exceeds maximum allowed length of {length}." 

345 ) 

346 

347 # Search overlap of previous tail + new chunk to catch 

348 # multi-byte regex matches spanning chunk boundaries. 

349 current_buffer = tail + token 

350 search_match = regex.search(current_buffer) 

351 parts.append(token) 

352 if search_match is not None: 

353 overlap = len(tail) 

354 actual_start = total_length - overlap + search_match.start() 

355 stream.seek(actual_start - total_length - token_length, 1) 

356 return b"".join(parts)[:actual_start] 

357 total_length += token_length 

358 

359 # Fixed overlap: 16 bytes is sufficient for the short 

360 # delimiter patterns used in PDF parsing. 

361 tail = token[-16:] 

362 if chunk_size < 8192: 

363 chunk_size <<= 1 

364 

365 

366def read_block_backwards(stream: BinaryStreamType, to_read: int) -> bytes: 

367 """ 

368 Given a stream at position X, read a block of size to_read ending at position X. 

369 

370 This changes the stream's position to the beginning of where the block was 

371 read. 

372 

373 Args: 

374 stream: 

375 to_read: 

376 

377 Returns: 

378 The data which was read. 

379 

380 """ 

381 if stream.tell() < to_read: 

382 raise PdfStreamError("Could not read malformed PDF file") 

383 # Seek to the start of the block we want to read. 

384 stream.seek(-to_read, SEEK_CUR) 

385 read = stream.read(to_read) 

386 # Seek to the start of the block we read after reading it. 

387 stream.seek(-to_read, SEEK_CUR) 

388 return read 

389 

390 

391def read_previous_line(stream: StreamType) -> bytes: 

392 """ 

393 Given a byte stream with current position X, return the previous line. 

394 

395 All characters between the first CR/LF byte found before X 

396 (or, the start of the file, if no such byte is found) and position X 

397 After this call, the stream will be positioned one byte after the 

398 first non-CRLF character found beyond the first CR/LF byte before X, 

399 or, if no such byte is found, at the beginning of the stream. 

400 

401 Args: 

402 stream: StreamType: 

403 

404 Returns: 

405 The data which was read. 

406 

407 """ 

408 line_content = [] 

409 found_crlf = False 

410 if stream.tell() == 0: 

411 raise PdfStreamError(STREAM_TRUNCATED_PREMATURELY) 

412 while True: 

413 to_read = min(DEFAULT_BUFFER_SIZE, stream.tell()) 

414 if to_read == 0: 

415 break 

416 # Read the block. After this, our stream will be one 

417 # beyond the initial position. 

418 block = read_block_backwards(stream, to_read) 

419 idx = len(block) - 1 

420 if not found_crlf: 

421 # We haven't found our first CR/LF yet. 

422 # Read off characters until we hit one. 

423 while idx >= 0 and block[idx] not in b"\r\n": 

424 idx -= 1 

425 if idx >= 0: 

426 found_crlf = True 

427 if found_crlf: 

428 # We found our first CR/LF already (on this block or 

429 # a previous one). 

430 # Our combined line is the remainder of the block 

431 # plus any previously read blocks. 

432 line_content.append(block[idx + 1 :]) 

433 # Continue to read off any more CRLF characters. 

434 while idx >= 0 and block[idx] in b"\r\n": 

435 idx -= 1 

436 else: 

437 # Didn't find CR/LF yet - add this block to our 

438 # previously read blocks and continue. 

439 line_content.append(block) 

440 if idx >= 0: 

441 # We found the next non-CRLF character. 

442 # Set the stream position correctly, then break 

443 stream.seek(idx + 1, SEEK_CUR) 

444 break 

445 # Join all the blocks in the line (which are in reverse order) 

446 return b"".join(line_content[::-1]) 

447 

448 

449def matrix_multiply( 

450 a: TransformationMatrixType, b: TransformationMatrixType 

451) -> TransformationMatrixType: 

452 return tuple( # type: ignore[return-value] 

453 tuple(sum(float(i) * float(j) for i, j in zip(row, col)) for col in zip(*b)) 

454 for row in a 

455 ) 

456 

457 

458def mark_location(stream: StreamType) -> None: 

459 """Create text file showing current location in context.""" 

460 # Mainly for debugging 

461 radius = 5000 

462 stream.seek(-radius, 1) 

463 with open("pypdf_pdfLocation.txt", "wb") as output_fh: 

464 output_fh.write(stream.read(radius)) 

465 output_fh.write(b"HERE") 

466 output_fh.write(stream.read(radius)) 

467 stream.seek(-radius, 1) 

468 

469 

470def deprecate(msg: str, stacklevel: int = 3) -> None: 

471 warnings.warn(msg, DeprecationWarning, stacklevel=stacklevel) 

472 

473 

474def deprecation(msg: str) -> NoReturn: 

475 raise DeprecationError(msg) 

476 

477 

478def deprecate_with_replacement(old_name: str, new_name: str, removed_in: str) -> None: 

479 """Issue a warning that a feature will be removed, but has a replacement.""" 

480 deprecate( 

481 f"{old_name} is deprecated and will be removed in pypdf {removed_in}. Use {new_name} instead.", 

482 4, 

483 ) 

484 

485 

486def deprecation_with_replacement(old_name: str, new_name: str, removed_in: str) -> NoReturn: 

487 """Raise an exception that a feature was already removed, but has a replacement.""" 

488 deprecation( 

489 f"{old_name} is deprecated and was removed in pypdf {removed_in}. Use {new_name} instead." 

490 ) 

491 

492 

493def deprecate_no_replacement(name: str, removed_in: str) -> None: 

494 """Issue a warning that a feature will be removed without replacement.""" 

495 deprecate(f"{name} is deprecated and will be removed in pypdf {removed_in}.", 4) 

496 

497 

498def deprecation_no_replacement(name: str, removed_in: str) -> NoReturn: 

499 """Raise an exception that a feature was already removed without replacement.""" 

500 deprecation(f"{name} is deprecated and was removed in pypdf {removed_in}.") 

501 

502 

503def logger_error(message: str, *, source: str, **values: Any) -> None: 

504 """ 

505 Use this instead of logger.error directly. 

506 

507 That allows people to overwrite it more easily. 

508 

509 See the docs on when to use which: 

510 https://pypdf.readthedocs.io/en/latest/user/suppress-warnings.html 

511 """ 

512 if values: 

513 logging.getLogger(source).error(message, values) 

514 else: 

515 logging.getLogger(source).error(message) 

516 

517 

518def logger_warning(message: str, *, source: str, **values: Any) -> None: 

519 """ 

520 Use this instead of logger.warning directly. 

521 

522 That allows people to overwrite it more easily. 

523 

524 ## Exception, warnings.warn, logger_warning 

525 - Exceptions should be used if the user should write code that deals with 

526 an error case, e.g. the PDF being completely broken. 

527 - warnings.warn should be used if the user needs to fix their code, e.g. 

528 DeprecationWarnings 

529 - logger_warning should be used if the user needs to know that an issue was 

530 handled by pypdf, e.g. a non-compliant PDF being read in a way that 

531 pypdf could apply a robustness fix to still read it. This applies mainly 

532 to strict=False mode. 

533 """ 

534 if values: 

535 logging.getLogger(source).warning(message, values) 

536 else: 

537 # Keep parity with logger_error and support plain warning messages. 

538 # Passing an empty dict to logging is not equivalent to passing no args: 

539 # plain messages would fail while being formatted. 

540 logging.getLogger(source).warning(message) 

541 

542 

543def rename_kwargs( 

544 func_name: str, kwargs: dict[str, Any], aliases: dict[str, str], fail: bool = False 

545) -> None: 

546 """ 

547 Helper function to deprecate arguments. 

548 

549 Args: 

550 func_name: Name of the function to be deprecated 

551 kwargs: 

552 aliases: 

553 fail: 

554 

555 """ 

556 for old_term, new_term in aliases.items(): 

557 if old_term in kwargs: 

558 if fail: 

559 raise DeprecationError( 

560 f"{old_term} is deprecated as an argument. Use {new_term} instead" 

561 ) 

562 if new_term in kwargs: 

563 raise TypeError( 

564 f"{func_name} received both {old_term} and {new_term} as " 

565 f"an argument. {old_term} is deprecated. " 

566 f"Use {new_term} instead." 

567 ) 

568 kwargs[new_term] = kwargs.pop(old_term) 

569 warnings.warn( 

570 message=( 

571 f"{old_term} is deprecated as an argument. Use {new_term} instead" 

572 ), 

573 category=DeprecationWarning, 

574 stacklevel=3, 

575 ) 

576 

577 

578def _human_readable_bytes(bytes: int) -> str: 

579 if bytes < 10**3: 

580 return f"{bytes} Byte" 

581 if bytes < 10**6: 

582 return f"{bytes / 10**3:.1f} kB" 

583 if bytes < 10**9: 

584 return f"{bytes / 10**6:.1f} MB" 

585 return f"{bytes / 10**9:.1f} GB" 

586 

587 

588# The following class has been copied from Django: 

589# https://github.com/django/django/blob/adae619426b6f50046b3daaa744db52989c9d6db/django/utils/functional.py#L51-L65 

590# It received some modifications to comply with our own coding standards. 

591# 

592# Original license: 

593# 

594# --------------------------------------------------------------------------------- 

595# Copyright (c) Django Software Foundation and individual contributors. 

596# All rights reserved. 

597# 

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

599# are permitted provided that the following conditions are met: 

600# 

601# 1. Redistributions of source code must retain the above copyright notice, 

602# this list of conditions and the following disclaimer. 

603# 

604# 2. Redistributions in binary form must reproduce the above copyright 

605# notice, this list of conditions and the following disclaimer in the 

606# documentation and/or other materials provided with the distribution. 

607# 

608# 3. Neither the name of Django nor the names of its contributors may be used 

609# to endorse or promote products derived from this software without 

610# specific prior written permission. 

611# 

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

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

614# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 

615# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 

616# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 

617# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 

618# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 

619# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 

620# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 

621# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 

622# --------------------------------------------------------------------------------- 

623class classproperty: # noqa: N801 

624 """ 

625 Decorator that converts a method with a single cls argument into a property 

626 that can be accessed directly from the class. 

627 """ 

628 

629 def __init__(self, method=None) -> None: # type: ignore # noqa: ANN001 

630 self.fget = method 

631 

632 def __get__(self, instance, cls=None) -> Any: # type: ignore # noqa: ANN001 

633 return self.fget(cls) 

634 

635 def getter(self, method) -> Self: # type: ignore # noqa: ANN001 

636 self.fget = method 

637 return self 

638 

639 

640@dataclass 

641class File: 

642 from .generic import IndirectObject # noqa: PLC0415 

643 

644 name: str = "" 

645 """ 

646 Filename as identified within the PDF file. 

647 """ 

648 data: bytes = b"" 

649 """ 

650 Data as bytes. 

651 """ 

652 indirect_reference: Optional[IndirectObject] = None 

653 """ 

654 Reference to the object storing the stream. 

655 """ 

656 

657 def __str__(self) -> str: 

658 return f"{self.__class__.__name__}(name={self.name}, data: {_human_readable_bytes(len(self.data))})" 

659 

660 def __repr__(self) -> str: 

661 return self.__str__()[:-1] + f", hash: {hash(self.data)})" 

662 

663 

664@functools.total_ordering 

665class Version: 

666 COMPONENT_PATTERN = re.compile(r"^(\d+)(.*)$") 

667 

668 def __init__(self, version_str: str) -> None: 

669 self.version_str = version_str 

670 self.components = self._parse_version(version_str) 

671 

672 def _parse_version(self, version_str: str) -> list[tuple[int, str]]: 

673 components = version_str.split(".") 

674 parsed_components = [] 

675 for component in components: 

676 match = Version.COMPONENT_PATTERN.match(component) 

677 if not match: 

678 parsed_components.append((0, component)) 

679 continue 

680 integer_prefix = match.group(1) 

681 suffix = match.group(2) 

682 if integer_prefix is None: 

683 integer_prefix = 0 

684 parsed_components.append((int(integer_prefix), suffix)) 

685 return parsed_components 

686 

687 def __eq__(self, other: object) -> bool: 

688 if not isinstance(other, Version): 

689 return False 

690 return self.components == other.components 

691 

692 def __hash__(self) -> int: 

693 # Convert to tuple as lists cannot be hashed. 

694 return hash((self.__class__, tuple(self.components))) 

695 

696 def __lt__(self, other: Any) -> bool: 

697 if not isinstance(other, Version): 

698 raise ValueError(f"Version cannot be compared against {type(other)}") 

699 

700 for self_component, other_component in zip(self.components, other.components): 

701 self_value, self_suffix = self_component 

702 other_value, other_suffix = other_component 

703 

704 if self_value < other_value: 

705 return True 

706 if self_value > other_value: 

707 return False 

708 

709 if self_suffix < other_suffix: 

710 return True 

711 if self_suffix > other_suffix: 

712 return False 

713 

714 return len(self.components) < len(other.components) 

715 

716 

717@dataclass 

718class _TraversalState: 

719 """Sometimes we need mutable objects which just count something.""" 

720 entry_count: int = 0 

721 has_logged: bool = False