Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/pikepdf/_methods.py: 38%

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

489 statements  

1# SPDX-FileCopyrightText: 2022 James R. Barlow 

2# SPDX-License-Identifier: MPL-2.0 

3 

4"""Implement some features in Python and monkey-patch them onto C++ classes. 

5 

6In several cases the implementation of some higher levels features might as 

7well be in Python. Fortunately we can attach Python methods to C++ class 

8bindings after the fact. 

9 

10We can also move the implementation to C++ if desired. 

11""" 

12 

13from __future__ import annotations 

14 

15import datetime 

16import mimetypes 

17import shutil 

18from collections.abc import ( 

19 Callable, 

20 ItemsView, 

21 Iterator, 

22 KeysView, 

23 MutableMapping, 

24 ValuesView, 

25) 

26from contextlib import ExitStack, suppress 

27from decimal import Decimal 

28from io import BytesIO, RawIOBase 

29from pathlib import Path 

30from subprocess import run 

31from tempfile import TemporaryDirectory 

32from typing import BinaryIO, Literal, TypeVar 

33from warnings import warn 

34 

35from pikepdf._augments import augment_override_cpp, augments 

36from pikepdf._core import ( 

37 AccessMode, 

38 AttachedFile, 

39 AttachedFileSpec, 

40 Attachments, 

41 NameTree, 

42 NumberTree, 

43 ObjectStreamMode, 

44 Page, 

45 Pdf, 

46 Rectangle, 

47 StreamDecodeLevel, 

48 StreamParser, 

49 Token, 

50 _ObjectMapping, 

51) 

52from pikepdf._io import atomic_overwrite, check_different_files, check_stream_is_usable 

53from pikepdf.models import Encryption, EncryptionInfo, Outline, Permissions 

54from pikepdf.models.metadata import PdfMetadata, decode_pdf_date, encode_pdf_date 

55from pikepdf.objects import Array, Dictionary, Name, Object, Stream 

56 

57# pylint: disable=no-member,unsupported-membership-test,unsubscriptable-object 

58# mypy: ignore-errors 

59 

60__all__ = [] 

61 

62Numeric = TypeVar('Numeric', int, float, Decimal) 

63 

64 

65def _single_page_pdf(page: Page) -> bytes: 

66 """Construct a single page PDF from the provided page in memory.""" 

67 pdf = Pdf.new() 

68 pdf.pages.append(page) 

69 bio = BytesIO() 

70 pdf.save(bio) 

71 bio.seek(0) 

72 return bio.read() 

73 

74 

75def _run_mudraw(in_path: Path, out_pattern: Path) -> Path: 

76 run( 

77 ['mutool', 'draw', '-o', str(out_pattern), str(in_path)], 

78 check=True, 

79 ) 

80 out_path = out_pattern.with_name(out_pattern.name.format(1)) # Replace %d with 1 

81 if not out_path.exists(): 

82 raise FileNotFoundError(out_path) 

83 return out_path 

84 

85 

86def _mudraw(buffer: bytes | memoryview, fmt: Literal["svg"]) -> bytes: 

87 """Use mupdf draw to rasterize the PDF in the memory buffer.""" 

88 # mudraw cannot read from stdin so a temporary file is required 

89 # '-o -' does not work on macos-14 

90 # '-o <path>' can accidentally prepend numbers to dots, so use explicit %d 

91 # instead; see https://bugs.ghostscript.com/show_bug.cgi?id=708653 

92 with TemporaryDirectory() as tmp_dir: 

93 in_path = Path(tmp_dir) / 'input.pdf' 

94 out_pattern = Path(tmp_dir) / f'output%d.{fmt}' 

95 out_path = Path(tmp_dir) / f'output1.{fmt}' 

96 in_path.write_bytes(buffer) 

97 out_path = _run_mudraw(in_path, out_pattern) 

98 return out_path.read_bytes() 

99 

100 

101@augments(Object) 

102class Extend_Object: 

103 def _ipython_key_completions_(self): 

104 if isinstance(self, Dictionary | Stream): 

105 return self.keys() 

106 return None 

107 

108 def emplace(self, other: Object, retain=(Name.Parent,)): 

109 if not self.same_owner_as(other): 

110 raise TypeError("Objects must have the same owner for emplace()") 

111 

112 # .keys() returns strings, so make all strings 

113 retain = {str(k) for k in retain} 

114 self_keys = set(self.keys()) 

115 other_keys = set(other.keys()) 

116 

117 assert all(isinstance(k, str) for k in (retain | self_keys | other_keys)) 

118 

119 del_keys = self_keys - other_keys - retain 

120 for k in (k for k in other_keys if k not in retain): 

121 self[k] = other[k] # pylint: disable=unsupported-assignment-operation 

122 for k in del_keys: 

123 del self[k] # pylint: disable=unsupported-delete-operation 

124 

125 def _type_check_write(self, filter_, decode_parms): 

126 if isinstance(filter_, list): 

127 filter_ = Array(filter_) 

128 filter_ = filter_.wrap_in_array() 

129 

130 if isinstance(decode_parms, list): 

131 decode_parms = Array(decode_parms) 

132 elif decode_parms is None: 

133 decode_parms = Array([]) 

134 else: 

135 decode_parms = decode_parms.wrap_in_array() 

136 

137 if not all(isinstance(item, Name) for item in filter_): 

138 raise TypeError( 

139 "filter must be: pikepdf.Name or pikepdf.Array([pikepdf.Name])" 

140 ) 

141 if not all( 

142 (isinstance(item, Dictionary) or item is None) for item in decode_parms 

143 ): 

144 raise TypeError( 

145 "decode_parms must be: pikepdf.Dictionary or " 

146 "pikepdf.Array([pikepdf.Dictionary])" 

147 ) 

148 if len(decode_parms) != 0 and len(filter_) != len(decode_parms): 

149 raise ValueError( 

150 f"filter ({repr(filter_)}) and decode_parms " 

151 f"({repr(decode_parms)}) must be arrays of same length" 

152 ) 

153 if len(filter_) == 1: 

154 filter_ = filter_[0] 

155 if len(decode_parms) == 0: 

156 decode_parms = None 

157 elif len(decode_parms) == 1: 

158 decode_parms = decode_parms[0] 

159 return filter_, decode_parms 

160 

161 def write( 

162 self, 

163 data: bytes, 

164 *, 

165 filter: Name | Array | None = None, 

166 decode_parms: Dictionary | Array | None = None, 

167 type_check: bool = True, 

168 ): # pylint: disable=redefined-builtin 

169 if type_check and filter is not None: 

170 filter, decode_parms = self._type_check_write(filter, decode_parms) 

171 

172 self._write(data, filter=filter, decode_parms=decode_parms) 

173 

174 

175@augments(Pdf) 

176class Extend_Pdf: 

177 def _quick_save(self): 

178 bio = BytesIO() 

179 self.save(bio) 

180 bio.seek(0) 

181 return bio 

182 

183 def _repr_mimebundle_(self, include=None, exclude=None): # pylint: disable=unused-argument 

184 pdf_data = self._quick_save().read() 

185 data = { 

186 'application/pdf': pdf_data, 

187 } 

188 with suppress(FileNotFoundError, RuntimeError): 

189 data['image/svg+xml'] = _mudraw(pdf_data, 'svg').decode('utf-8') 

190 return data 

191 

192 @property 

193 def docinfo(self) -> Dictionary: 

194 if Name.Info not in self.trailer or not isinstance( 

195 self.trailer.Info, Dictionary 

196 ): 

197 self.trailer.Info = self.make_indirect(Dictionary()) 

198 if not self.trailer.Info.is_indirect: 

199 self.trailer.Info = self.make_indirect(self.trailer.Info) 

200 return self.trailer.Info 

201 

202 @docinfo.setter 

203 def docinfo(self, new_docinfo: Dictionary): 

204 if not new_docinfo.is_indirect: 

205 raise ValueError( 

206 "docinfo must be an indirect object - use Pdf.make_indirect" 

207 ) 

208 self.trailer.Info = new_docinfo 

209 

210 @docinfo.deleter 

211 def docinfo(self): 

212 if Name.Info in self.trailer: 

213 del self.trailer.Info 

214 

215 def open_metadata( 

216 self, 

217 set_pikepdf_as_editor: bool = True, 

218 update_docinfo: bool = True, 

219 strict: bool = False, 

220 ) -> PdfMetadata: 

221 return PdfMetadata( 

222 self, 

223 pikepdf_mark=set_pikepdf_as_editor, 

224 sync_docinfo=update_docinfo, 

225 overwrite_invalid_xml=not strict, 

226 ) 

227 

228 def open_outline(self, max_depth: int = 15, strict: bool = False) -> Outline: 

229 return Outline(self, max_depth=max_depth, strict=strict) 

230 

231 def make_stream(self, data: bytes, d=None, **kwargs) -> Stream: 

232 return Stream(self, data, d, **kwargs) 

233 

234 def add_blank_page( 

235 self, *, page_size: tuple[Numeric, Numeric] = (612.0, 792.0) 

236 ) -> Page: 

237 for dim in page_size: 

238 if not (3 <= dim <= 14400): 

239 raise ValueError('Page size must be between 3 and 14400 PDF units') 

240 

241 page_dict = Dictionary( 

242 Type=Name.Page, 

243 MediaBox=Array([0, 0, page_size[0], page_size[1]]), 

244 Contents=self.make_stream(b''), 

245 Resources=Dictionary(), 

246 ) 

247 page_obj = self.make_indirect(page_dict) 

248 self._add_page(page_obj, first=False) 

249 return Page(page_obj) 

250 

251 def close(self) -> None: 

252 self._close() 

253 if getattr(self, '_tmp_stream', None): 

254 self._tmp_stream.close() 

255 

256 def __enter__(self): 

257 return self 

258 

259 def __exit__(self, exc_type, exc_value, traceback): 

260 self.close() 

261 

262 @property 

263 def allow(self) -> Permissions: 

264 results = {} 

265 for field in Permissions._fields: 

266 results[field] = getattr(self, '_allow_' + field) 

267 return Permissions(**results) 

268 

269 @property 

270 def encryption(self) -> EncryptionInfo: 

271 return EncryptionInfo(self._encryption_data) 

272 

273 def check_pdf_syntax( 

274 self, progress: Callable[[int], None] | None = None 

275 ) -> list[str]: 

276 class DiscardingParser(StreamParser): 

277 def __init__(self): # pylint: disable=useless-super-delegation 

278 super().__init__() # required for C++ 

279 

280 def handle_object(self, *_args): 

281 pass 

282 

283 def handle_eof(self): 

284 pass 

285 

286 problems: list[str] = [] 

287 

288 self._decode_all_streams_and_discard(progress) 

289 

290 discarding_parser = DiscardingParser() 

291 for page in self.pages: 

292 page.parse_contents(discarding_parser) 

293 

294 for warning in self.get_warnings(): 

295 problems.append("WARNING: " + warning) 

296 

297 return problems 

298 

299 def save( 

300 self, 

301 filename_or_stream: Path | str | BinaryIO | None = None, 

302 *, 

303 static_id: bool = False, 

304 preserve_pdfa: bool = True, 

305 min_version: str | tuple[str, int] = "", 

306 force_version: str | tuple[str, int] = "", 

307 fix_metadata_version: bool = True, 

308 compress_streams: bool = True, 

309 stream_decode_level: StreamDecodeLevel | None = None, 

310 object_stream_mode: ObjectStreamMode = ObjectStreamMode.preserve, 

311 normalize_content: bool = False, 

312 linearize: bool = False, 

313 qdf: bool = False, 

314 progress: Callable[[int], None] | None = None, 

315 encryption: Encryption | bool | None = None, 

316 recompress_flate: bool = False, 

317 deterministic_id: bool = False, 

318 ) -> None: 

319 if not filename_or_stream and getattr(self, '_original_filename', None): 

320 filename_or_stream = self._original_filename 

321 if not filename_or_stream: 

322 raise ValueError( 

323 "Cannot save to original filename because the original file was " 

324 "not opening using Pdf.open(..., allow_overwriting_input=True). " 

325 "Either specify a new destination filename/file stream or open " 

326 "with allow_overwriting_input=True. If this Pdf was created using " 

327 "Pdf.new(), you must specify a destination object since there is " 

328 "no original filename to save to." 

329 ) 

330 with ExitStack() as stack: 

331 if hasattr(filename_or_stream, 'seek'): 

332 stream = filename_or_stream 

333 check_stream_is_usable(filename_or_stream) 

334 else: 

335 if not isinstance(filename_or_stream, str | bytes | Path): 

336 raise TypeError("expected str, bytes or os.PathLike object") 

337 filename = Path(filename_or_stream) 

338 if ( 

339 not getattr(self, '_tmp_stream', None) 

340 and getattr(self, '_original_filename', None) is not None 

341 ): 

342 check_different_files(self._original_filename, filename) 

343 stream = stack.enter_context(atomic_overwrite(filename)) 

344 self._save( 

345 stream, 

346 static_id=static_id, 

347 preserve_pdfa=preserve_pdfa, 

348 min_version=min_version, 

349 force_version=force_version, 

350 fix_metadata_version=fix_metadata_version, 

351 compress_streams=compress_streams, 

352 stream_decode_level=stream_decode_level, 

353 object_stream_mode=object_stream_mode, 

354 normalize_content=normalize_content, 

355 linearize=linearize, 

356 qdf=qdf, 

357 progress=progress, 

358 encryption=encryption, 

359 samefile_check=getattr(self, '_tmp_stream', None) is None, 

360 recompress_flate=recompress_flate, 

361 deterministic_id=deterministic_id, 

362 ) 

363 

364 @staticmethod 

365 def open( 

366 filename_or_stream: Path | str | BinaryIO, 

367 *, 

368 password: str | bytes = "", 

369 hex_password: bool = False, 

370 ignore_xref_streams: bool = False, 

371 suppress_warnings: bool = True, 

372 attempt_recovery: bool = True, 

373 inherit_page_attributes: bool = True, 

374 access_mode: AccessMode = AccessMode.default, 

375 allow_overwriting_input: bool = False, 

376 ) -> Pdf: 

377 if isinstance(filename_or_stream, bytes) and filename_or_stream.startswith( 

378 b'%PDF-' 

379 ): 

380 warn( 

381 "It looks like you called with Pdf.open(data) with a bytes-like object " 

382 "containing a PDF. This will probably fail because this function " 

383 "expects a filename or opened file-like object. Instead, please use " 

384 "Pdf.open(BytesIO(data))." 

385 ) 

386 if isinstance(filename_or_stream, int | float): 

387 # Attempted to open with integer file descriptor? 

388 # TODO improve error 

389 raise TypeError("expected str, bytes or os.PathLike object") 

390 

391 stream: RawIOBase | None = None 

392 closing_stream: bool = False 

393 original_filename: Path | None = None 

394 

395 if allow_overwriting_input: 

396 try: 

397 Path(filename_or_stream) 

398 except TypeError as error: 

399 raise ValueError( 

400 '"allow_overwriting_input=True" requires "open" first argument ' 

401 'to be a file path' 

402 ) from error 

403 original_filename = Path(filename_or_stream) 

404 with open(original_filename, 'rb') as pdf_file: 

405 stream = BytesIO() 

406 shutil.copyfileobj(pdf_file, stream) 

407 stream.seek(0) 

408 # description = f"memory copy of {original_filename}" 

409 description = str(original_filename) 

410 elif hasattr(filename_or_stream, 'read') and hasattr( 

411 filename_or_stream, 'seek' 

412 ): 

413 stream = filename_or_stream 

414 description = f"stream {stream}" 

415 else: 

416 stream = open(filename_or_stream, 'rb') 

417 original_filename = Path(filename_or_stream) 

418 description = str(filename_or_stream) 

419 closing_stream = True 

420 

421 try: 

422 check_stream_is_usable(stream) 

423 pdf = Pdf._open( 

424 stream, 

425 password=password, 

426 hex_password=hex_password, 

427 ignore_xref_streams=ignore_xref_streams, 

428 suppress_warnings=suppress_warnings, 

429 attempt_recovery=attempt_recovery, 

430 inherit_page_attributes=inherit_page_attributes, 

431 access_mode=access_mode, 

432 description=description, 

433 closing_stream=closing_stream, 

434 ) 

435 except Exception: 

436 if stream is not None and closing_stream: 

437 stream.close() 

438 raise 

439 pdf._tmp_stream = stream if allow_overwriting_input else None 

440 pdf._original_filename = original_filename 

441 return pdf 

442 

443 

444@augments(_ObjectMapping) 

445class Extend_ObjectMapping: 

446 def get(self, key, default=None) -> Object: 

447 try: 

448 return self[key] 

449 except KeyError: 

450 return default 

451 

452 @augment_override_cpp 

453 def __contains__(self, key: Name | str) -> bool: 

454 if isinstance(key, Name): 

455 key = str(key) 

456 return _ObjectMapping._cpp__contains__(self, key) 

457 

458 @augment_override_cpp 

459 def __getitem__(self, key: Name | str) -> Object: 

460 if isinstance(key, Name): 

461 key = str(key) 

462 return _ObjectMapping._cpp__getitem__(self, key) 

463 

464 

465def check_is_box(obj) -> None: 

466 with suppress(AttributeError): 

467 if obj.is_rectangle: 

468 return 

469 try: 

470 pdfobj = Array(obj) 

471 if pdfobj.is_rectangle: 

472 return 

473 except Exception as e: 

474 raise ValueError("object is not a rectangle") from e 

475 raise ValueError("object is not a rectangle") 

476 

477 

478@augments(Page) 

479class Extend_Page: 

480 @property 

481 def mediabox(self): 

482 return self._get_mediabox(True) 

483 

484 @mediabox.setter 

485 def mediabox(self, value): 

486 check_is_box(value) 

487 self.obj['/MediaBox'] = value 

488 

489 @property 

490 def artbox(self): 

491 return self._get_artbox(True, False) 

492 

493 @artbox.setter 

494 def artbox(self, value): 

495 check_is_box(value) 

496 self.obj['/ArtBox'] = value 

497 

498 @property 

499 def bleedbox(self): 

500 return self._get_bleedbox(True, False) 

501 

502 @bleedbox.setter 

503 def bleedbox(self, value): 

504 check_is_box(value) 

505 self.obj['/BleedBox'] = value 

506 

507 @property 

508 def cropbox(self): 

509 return self._get_cropbox(True, False) 

510 

511 @cropbox.setter 

512 def cropbox(self, value): 

513 check_is_box(value) 

514 self.obj['/CropBox'] = value 

515 

516 @property 

517 def trimbox(self): 

518 return self._get_trimbox(True, False) 

519 

520 @trimbox.setter 

521 def trimbox(self, value): 

522 check_is_box(value) 

523 self.obj['/TrimBox'] = value 

524 

525 @property 

526 def images(self) -> _ObjectMapping: 

527 return self._images 

528 

529 @property 

530 def form_xobjects(self) -> _ObjectMapping: 

531 return self._form_xobjects 

532 

533 @property 

534 def resources(self) -> Dictionary: 

535 if Name.Resources not in self.obj: 

536 self.obj.Resources = Dictionary() 

537 elif not isinstance(self.obj.Resources, Dictionary): 

538 raise TypeError("Page /Resources exists but is not a dictionary") 

539 return self.obj.Resources 

540 

541 def add_resource( 

542 self, 

543 res: Object, 

544 res_type: Name, 

545 name: Name | None = None, 

546 *, 

547 prefix: str = '', 

548 replace_existing: bool = True, 

549 ) -> Name: 

550 resources = self.resources 

551 if res_type not in resources: 

552 resources[res_type] = Dictionary() 

553 

554 if name is not None and prefix: 

555 raise ValueError("Must specify one of name= or prefix=") 

556 if name is None: 

557 name = Name.random(prefix=prefix) 

558 

559 for res_dict in resources.as_dict().values(): 

560 if not isinstance(res_dict, Dictionary): 

561 continue 

562 if name in res_dict: 

563 if replace_existing: 

564 del res_dict[name] 

565 else: 

566 raise ValueError(f"Name {name} already exists in page /Resources") 

567 

568 resources[res_type][name] = res.with_same_owner_as(self.obj) 

569 return name 

570 

571 def _over_underlay( 

572 self, 

573 other, 

574 rect: Rectangle | None, 

575 under: bool, 

576 push_stack: bool, 

577 shrink: bool, 

578 expand: bool, 

579 ) -> Name: 

580 formx = None 

581 if isinstance(other, Page): 

582 formx = other.as_form_xobject() 

583 elif isinstance(other, Dictionary) and other.get(Name.Type) == Name.Page: 

584 formx = Page(other).as_form_xobject() 

585 elif ( 

586 isinstance(other, Stream) 

587 and other.get(Name.Type) == Name.XObject 

588 and other.get(Name.Subtype) == Name.Form 

589 ): 

590 formx = other 

591 

592 if formx is None: 

593 raise TypeError( 

594 "other object is not something we can convert to Form XObject" 

595 ) 

596 

597 if rect is None: 

598 rect = Rectangle(self.trimbox) 

599 

600 formx_placed_name = self.add_resource(formx, Name.XObject) 

601 cs = self.calc_form_xobject_placement( 

602 formx, formx_placed_name, rect, allow_shrink=shrink, allow_expand=expand 

603 ) 

604 

605 if push_stack: 

606 self.contents_add(b'q\n', prepend=True) # prepend q 

607 self.contents_add(b'Q\n', prepend=False) # i.e. append Q 

608 

609 self.contents_add(cs, prepend=under) 

610 self.contents_coalesce() 

611 return formx_placed_name 

612 

613 def add_overlay( 

614 self, 

615 other: Object | Page, 

616 rect: Rectangle | None = None, 

617 *, 

618 push_stack: bool = True, 

619 shrink: bool = True, 

620 expand: bool = True, 

621 ) -> Name: 

622 return self._over_underlay( 

623 other, 

624 rect, 

625 under=False, 

626 push_stack=push_stack, 

627 expand=expand, 

628 shrink=shrink, 

629 ) 

630 

631 def add_underlay( 

632 self, 

633 other: Object | Page, 

634 rect: Rectangle | None = None, 

635 *, 

636 shrink: bool = True, 

637 expand: bool = True, 

638 ) -> Name: 

639 return self._over_underlay( 

640 other, rect, under=True, push_stack=False, expand=expand, shrink=shrink 

641 ) 

642 

643 def contents_add(self, contents: Stream | bytes, *, prepend: bool = False): 

644 return self._contents_add(contents, prepend=prepend) 

645 

646 def __getattr__(self, name): 

647 return getattr(self.obj, name) 

648 

649 @augment_override_cpp 

650 def __setattr__(self, name, value): 

651 if hasattr(self.__class__, name): 

652 object.__setattr__(self, name, value) 

653 else: 

654 setattr(self.obj, name, value) 

655 

656 @augment_override_cpp 

657 def __delattr__(self, name): 

658 if hasattr(self.__class__, name): 

659 object.__delattr__(self, name) 

660 else: 

661 delattr(self.obj, name) 

662 

663 def __getitem__(self, key): 

664 return self.obj[key] 

665 

666 def __setitem__(self, key, value): 

667 self.obj[key] = value 

668 

669 def __delitem__(self, key): 

670 del self.obj[key] 

671 

672 def __contains__(self, key): 

673 return key in self.obj 

674 

675 def get(self, key, default=None): 

676 try: 

677 return self[key] 

678 except KeyError: 

679 return default 

680 

681 def emplace(self, other: Page, retain=(Name.Parent,)): 

682 return self.obj.emplace(other.obj, retain=retain) 

683 

684 def __repr__(self): 

685 return ( 

686 repr(self.obj) 

687 .replace('Dictionary', 'Page', 1) 

688 .replace('(Type="/Page")', '', 1) 

689 ) 

690 

691 def _repr_mimebundle_(self, include=None, exclude=None): 

692 data = {} 

693 bundle = {'application/pdf', 'image/svg+xml'} 

694 if include: 

695 bundle = {k for k in bundle if k in include} 

696 if exclude: 

697 bundle = {k for k in bundle if k not in exclude} 

698 pagedata = _single_page_pdf(self) 

699 if 'application/pdf' in bundle: 

700 data['application/pdf'] = pagedata 

701 if 'image/svg+xml' in bundle: 

702 with suppress(FileNotFoundError, RuntimeError): 

703 data['image/svg+xml'] = _mudraw(pagedata, 'svg').decode('utf-8') 

704 return data 

705 

706 

707@augments(Token) 

708class Extend_Token: 

709 def __repr__(self): 

710 return f'pikepdf.Token({self.type_}, {self.raw_value})' 

711 

712 

713@augments(Rectangle) 

714class Extend_Rectangle: 

715 def __repr__(self): 

716 return f'pikepdf.Rectangle({self.llx}, {self.lly}, {self.urx}, {self.ury})' 

717 

718 def __hash__(self): 

719 return hash((self.llx, self.lly, self.urx, self.ury)) 

720 

721 def to_bbox(self) -> Rectangle: 

722 """Returns the origin-centred bounding box that encloses this rectangle. 

723 

724 Create a new rectangle with the same width and height as this one, but located 

725 at the origin (0, 0). 

726 

727 Bounding boxes represent independent coordinate systems, such as for Form 

728 XObjects. 

729 """ 

730 return Rectangle(0, 0, self.width, self.height) 

731 

732 

733@augments(Attachments) 

734class Extend_Attachments(MutableMapping): 

735 def __getitem__(self, k: str) -> AttachedFileSpec: 

736 filespec = self._get_filespec(k) 

737 if filespec is None: 

738 raise KeyError(k) 

739 return filespec 

740 

741 def __setitem__(self, k: str, v: AttachedFileSpec | bytes) -> None: 

742 if isinstance(v, bytes): 

743 return self._attach_data(k, v) 

744 if not v.filename: 

745 v.filename = k 

746 return self._add_replace_filespec(k, v) 

747 

748 def __delitem__(self, k: str) -> None: 

749 return self._remove_filespec(k) 

750 

751 def __len__(self): 

752 return len(self._get_all_filespecs()) 

753 

754 def __iter__(self) -> Iterator[str]: 

755 yield from self._get_all_filespecs() 

756 

757 def __repr__(self): 

758 return f"<pikepdf._core.Attachments: {list(self)}>" 

759 

760 

761@augments(AttachedFileSpec) 

762class Extend_AttachedFileSpec: 

763 @staticmethod 

764 def from_filepath( 

765 pdf: Pdf, 

766 path: Path | str, 

767 *, 

768 description: str = '', 

769 relationship: Name | None = Name.Unspecified, 

770 ): 

771 mime, _ = mimetypes.guess_type(str(path)) 

772 if mime is None: 

773 mime = '' 

774 if not isinstance(path, Path): 

775 path = Path(path) 

776 

777 stat = path.stat() 

778 return AttachedFileSpec( 

779 pdf, 

780 path.read_bytes(), 

781 description=description, 

782 filename=str(path.name), 

783 mime_type=mime, 

784 creation_date=encode_pdf_date( 

785 datetime.datetime.fromtimestamp(stat.st_ctime) 

786 ), 

787 mod_date=encode_pdf_date(datetime.datetime.fromtimestamp(stat.st_mtime)), 

788 relationship=relationship, 

789 ) 

790 

791 @property 

792 def relationship(self) -> Name | None: 

793 return self.obj.get(Name.AFRelationship) 

794 

795 @relationship.setter 

796 def relationship(self, value: Name | None): 

797 if value is None: 

798 del self.obj[Name.AFRelationship] 

799 else: 

800 self.obj[Name.AFRelationship] = value 

801 

802 def __repr__(self): 

803 if self.filename: 

804 return ( 

805 f"<pikepdf._core.AttachedFileSpec for {self.filename!r}, " 

806 f"description {self.description!r}>" 

807 ) 

808 return f"<pikepdf._core.AttachedFileSpec description {self.description!r}>" 

809 

810 

811@augments(AttachedFile) 

812class Extend_AttachedFile: 

813 @property 

814 def creation_date(self) -> datetime.datetime | None: 

815 if not self._creation_date: 

816 return None 

817 return decode_pdf_date(self._creation_date) 

818 

819 @creation_date.setter 

820 def creation_date(self, value: datetime.datetime): 

821 self._creation_date = encode_pdf_date(value) 

822 

823 @property 

824 def mod_date(self) -> datetime.datetime | None: 

825 if not self._mod_date: 

826 return None 

827 return decode_pdf_date(self._mod_date) 

828 

829 @mod_date.setter 

830 def mod_date(self, value: datetime.datetime): 

831 self._mod_date = encode_pdf_date(value) 

832 

833 def read_bytes(self) -> bytes: 

834 return self.obj.read_bytes() 

835 

836 def __repr__(self): 

837 return ( 

838 f'<pikepdf._core.AttachedFile objid={self.obj.objgen} size={self.size} ' 

839 f'mime_type={self.mime_type} creation_date={self.creation_date} ' 

840 f'mod_date={self.mod_date}>' 

841 ) 

842 

843 

844@augments(NameTree) 

845class Extend_NameTree: 

846 def keys(self): 

847 return KeysView(self._as_map()) 

848 

849 def values(self): 

850 return ValuesView(self._as_map()) 

851 

852 def items(self): 

853 return ItemsView(self._as_map()) 

854 

855 get = MutableMapping.get 

856 pop = MutableMapping.pop 

857 popitem = MutableMapping.popitem 

858 clear = MutableMapping.clear 

859 update = MutableMapping.update 

860 setdefault = MutableMapping.setdefault 

861 

862 

863MutableMapping.register(NameTree) 

864 

865 

866@augments(NumberTree) 

867class Extend_NumberTree: 

868 def keys(self): 

869 return KeysView(self._as_map()) 

870 

871 def values(self): 

872 return ValuesView(self._as_map()) 

873 

874 def items(self): 

875 return ItemsView(self._as_map()) 

876 

877 get = MutableMapping.get 

878 pop = MutableMapping.pop 

879 popitem = MutableMapping.popitem 

880 clear = MutableMapping.clear 

881 update = MutableMapping.update 

882 setdefault = MutableMapping.setdefault 

883 

884 

885MutableMapping.register(NumberTree)