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

493 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 ItemsView, Iterator, KeysView, MutableMapping, ValuesView 

19from contextlib import ExitStack, suppress 

20from decimal import Decimal 

21from io import BytesIO, RawIOBase 

22from pathlib import Path 

23from subprocess import run 

24from tempfile import TemporaryDirectory 

25from typing import BinaryIO, Callable, Literal, TypeVar 

26from warnings import warn 

27 

28from deprecated import deprecated 

29 

30from pikepdf._augments import augment_override_cpp, augments 

31from pikepdf._core import ( 

32 AccessMode, 

33 AttachedFile, 

34 AttachedFileSpec, 

35 Attachments, 

36 NameTree, 

37 NumberTree, 

38 ObjectStreamMode, 

39 Page, 

40 Pdf, 

41 Rectangle, 

42 StreamDecodeLevel, 

43 StreamParser, 

44 Token, 

45 _ObjectMapping, 

46) 

47from pikepdf._io import atomic_overwrite, check_different_files, check_stream_is_usable 

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

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

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

51 

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

53# mypy: ignore-errors 

54 

55__all__ = [] 

56 

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

58 

59 

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

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

62 pdf = Pdf.new() 

63 pdf.pages.append(page) 

64 bio = BytesIO() 

65 pdf.save(bio) 

66 bio.seek(0) 

67 return bio.read() 

68 

69 

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

71 run( 

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

73 check=True, 

74 ) 

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

76 if not out_path.exists(): 

77 raise FileNotFoundError(out_path) 

78 return out_path 

79 

80 

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

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

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

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

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

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

87 with TemporaryDirectory() as tmp_dir: 

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

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

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

91 in_path.write_bytes(buffer) 

92 out_path = _run_mudraw(in_path, out_pattern) 

93 return out_path.read_bytes() 

94 

95 

96@augments(Object) 

97class Extend_Object: 

98 def _ipython_key_completions_(self): 

99 if isinstance(self, (Dictionary, Stream)): 

100 return self.keys() 

101 return None 

102 

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

104 if not self.same_owner_as(other): 

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

106 

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

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

109 self_keys = set(self.keys()) 

110 other_keys = set(other.keys()) 

111 

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

113 

114 del_keys = self_keys - other_keys - retain 

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

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

117 for k in del_keys: 

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

119 

120 def _type_check_write(self, filter_, decode_parms): 

121 if isinstance(filter_, list): 

122 filter_ = Array(filter_) 

123 filter_ = filter_.wrap_in_array() 

124 

125 if isinstance(decode_parms, list): 

126 decode_parms = Array(decode_parms) 

127 elif decode_parms is None: 

128 decode_parms = Array([]) 

129 else: 

130 decode_parms = decode_parms.wrap_in_array() 

131 

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

133 raise TypeError( 

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

135 ) 

136 if not all( 

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

138 ): 

139 raise TypeError( 

140 "decode_parms must be: pikepdf.Dictionary or " 

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

142 ) 

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

144 raise ValueError( 

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

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

147 ) 

148 if len(filter_) == 1: 

149 filter_ = filter_[0] 

150 if len(decode_parms) == 0: 

151 decode_parms = None 

152 elif len(decode_parms) == 1: 

153 decode_parms = decode_parms[0] 

154 return filter_, decode_parms 

155 

156 def write( 

157 self, 

158 data: bytes, 

159 *, 

160 filter: Name | Array | None = None, 

161 decode_parms: Dictionary | Array | None = None, 

162 type_check: bool = True, 

163 ): # pylint: disable=redefined-builtin 

164 if type_check and filter is not None: 

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

166 

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

168 

169 

170@augments(Pdf) 

171class Extend_Pdf: 

172 def _quick_save(self): 

173 bio = BytesIO() 

174 self.save(bio) 

175 bio.seek(0) 

176 return bio 

177 

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

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

180 data = { 

181 'application/pdf': pdf_data, 

182 } 

183 with suppress(FileNotFoundError, RuntimeError): 

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

185 return data 

186 

187 @property 

188 def docinfo(self) -> Dictionary: 

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

190 self.trailer.Info, Dictionary 

191 ): 

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

193 if not self.trailer.Info.is_indirect: 

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

195 return self.trailer.Info 

196 

197 @docinfo.setter 

198 def docinfo(self, new_docinfo: Dictionary): 

199 if not new_docinfo.is_indirect: 

200 raise ValueError( 

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

202 ) 

203 self.trailer.Info = new_docinfo 

204 

205 @docinfo.deleter 

206 def docinfo(self): 

207 if Name.Info in self.trailer: 

208 del self.trailer.Info 

209 

210 def open_metadata( 

211 self, 

212 set_pikepdf_as_editor: bool = True, 

213 update_docinfo: bool = True, 

214 strict: bool = False, 

215 ) -> PdfMetadata: 

216 return PdfMetadata( 

217 self, 

218 pikepdf_mark=set_pikepdf_as_editor, 

219 sync_docinfo=update_docinfo, 

220 overwrite_invalid_xml=not strict, 

221 ) 

222 

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

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

225 

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

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

228 

229 def add_blank_page( 

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

231 ) -> Page: 

232 for dim in page_size: 

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

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

235 

236 page_dict = Dictionary( 

237 Type=Name.Page, 

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

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

240 Resources=Dictionary(), 

241 ) 

242 page_obj = self.make_indirect(page_dict) 

243 self._add_page(page_obj, first=False) 

244 return Page(page_obj) 

245 

246 def close(self) -> None: 

247 self._close() 

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

249 self._tmp_stream.close() 

250 

251 def __enter__(self): 

252 return self 

253 

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

255 self.close() 

256 

257 @property 

258 def allow(self) -> Permissions: 

259 results = {} 

260 for field in Permissions._fields: 

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

262 return Permissions(**results) 

263 

264 @property 

265 def encryption(self) -> EncryptionInfo: 

266 return EncryptionInfo(self._encryption_data) 

267 

268 @deprecated(version='9.10.0', reason="Use Pdf.check_pdf_syntax instead") 

269 def check(self) -> list[str]: 

270 return self.check_pdf_syntax() 

271 

272 def check_pdf_syntax(self) -> list[str]: 

273 class DiscardingParser(StreamParser): 

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

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

276 

277 def handle_object(self, *_args): 

278 pass 

279 

280 def handle_eof(self): 

281 pass 

282 

283 problems: list[str] = [] 

284 

285 self._decode_all_streams_and_discard() 

286 

287 discarding_parser = DiscardingParser() 

288 for page in self.pages: 

289 page.parse_contents(discarding_parser) 

290 

291 for warning in self.get_warnings(): 

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

293 

294 return problems 

295 

296 def save( 

297 self, 

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

299 *, 

300 static_id: bool = False, 

301 preserve_pdfa: bool = True, 

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

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

304 fix_metadata_version: bool = True, 

305 compress_streams: bool = True, 

306 stream_decode_level: StreamDecodeLevel | None = None, 

307 object_stream_mode: ObjectStreamMode = ObjectStreamMode.preserve, 

308 normalize_content: bool = False, 

309 linearize: bool = False, 

310 qdf: bool = False, 

311 progress: Callable[[int], None] = None, 

312 encryption: Encryption | bool | None = None, 

313 recompress_flate: bool = False, 

314 deterministic_id: bool = False, 

315 ) -> None: 

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

317 filename_or_stream = self._original_filename 

318 if not filename_or_stream: 

319 raise ValueError( 

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

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

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

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

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

325 "no original filename to save to." 

326 ) 

327 with ExitStack() as stack: 

328 if hasattr(filename_or_stream, 'seek'): 

329 stream = filename_or_stream 

330 check_stream_is_usable(filename_or_stream) 

331 else: 

332 if not isinstance(filename_or_stream, (str, bytes, Path)): 

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

334 filename = Path(filename_or_stream) 

335 if ( 

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

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

338 ): 

339 check_different_files(self._original_filename, filename) 

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

341 self._save( 

342 stream, 

343 static_id=static_id, 

344 preserve_pdfa=preserve_pdfa, 

345 min_version=min_version, 

346 force_version=force_version, 

347 fix_metadata_version=fix_metadata_version, 

348 compress_streams=compress_streams, 

349 stream_decode_level=stream_decode_level, 

350 object_stream_mode=object_stream_mode, 

351 normalize_content=normalize_content, 

352 linearize=linearize, 

353 qdf=qdf, 

354 progress=progress, 

355 encryption=encryption, 

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

357 recompress_flate=recompress_flate, 

358 deterministic_id=deterministic_id, 

359 ) 

360 

361 @staticmethod 

362 def open( 

363 filename_or_stream: Path | str | BinaryIO, 

364 *, 

365 password: str | bytes = "", 

366 hex_password: bool = False, 

367 ignore_xref_streams: bool = False, 

368 suppress_warnings: bool = True, 

369 attempt_recovery: bool = True, 

370 inherit_page_attributes: bool = True, 

371 access_mode: AccessMode = AccessMode.default, 

372 allow_overwriting_input: bool = False, 

373 ) -> Pdf: 

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

375 b'%PDF-' 

376 ): 

377 warn( 

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

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

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

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

382 ) 

383 if isinstance(filename_or_stream, (int, float)): 

384 # Attempted to open with integer file descriptor? 

385 # TODO improve error 

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

387 

388 stream: RawIOBase | None = None 

389 closing_stream: bool = False 

390 original_filename: Path | None = None 

391 

392 if allow_overwriting_input: 

393 try: 

394 Path(filename_or_stream) 

395 except TypeError as error: 

396 raise ValueError( 

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

398 'to be a file path' 

399 ) from error 

400 original_filename = Path(filename_or_stream) 

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

402 stream = BytesIO() 

403 shutil.copyfileobj(pdf_file, stream) 

404 stream.seek(0) 

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

406 description = str(original_filename) 

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

408 filename_or_stream, 'seek' 

409 ): 

410 stream = filename_or_stream 

411 description = f"stream {stream}" 

412 else: 

413 stream = open(filename_or_stream, 'rb') 

414 original_filename = Path(filename_or_stream) 

415 description = str(filename_or_stream) 

416 closing_stream = True 

417 

418 try: 

419 check_stream_is_usable(stream) 

420 pdf = Pdf._open( 

421 stream, 

422 password=password, 

423 hex_password=hex_password, 

424 ignore_xref_streams=ignore_xref_streams, 

425 suppress_warnings=suppress_warnings, 

426 attempt_recovery=attempt_recovery, 

427 inherit_page_attributes=inherit_page_attributes, 

428 access_mode=access_mode, 

429 description=description, 

430 closing_stream=closing_stream, 

431 ) 

432 except Exception: 

433 if stream is not None and closing_stream: 

434 stream.close() 

435 raise 

436 pdf._tmp_stream = stream if allow_overwriting_input else None 

437 pdf._original_filename = original_filename 

438 return pdf 

439 

440 

441@augments(_ObjectMapping) 

442class Extend_ObjectMapping: 

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

444 try: 

445 return self[key] 

446 except KeyError: 

447 return default 

448 

449 @augment_override_cpp 

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

451 if isinstance(key, Name): 

452 key = str(key) 

453 return _ObjectMapping._cpp__contains__(self, key) 

454 

455 @augment_override_cpp 

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

457 if isinstance(key, Name): 

458 key = str(key) 

459 return _ObjectMapping._cpp__getitem__(self, key) 

460 

461 

462def check_is_box(obj) -> None: 

463 with suppress(AttributeError): 

464 if obj.is_rectangle: 

465 return 

466 try: 

467 pdfobj = Array(obj) 

468 if pdfobj.is_rectangle: 

469 return 

470 except Exception as e: 

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

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

473 

474 

475@augments(Page) 

476class Extend_Page: 

477 @property 

478 def mediabox(self): 

479 return self._get_mediabox(True) 

480 

481 @mediabox.setter 

482 def mediabox(self, value): 

483 check_is_box(value) 

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

485 

486 @property 

487 def artbox(self): 

488 return self._get_artbox(True, False) 

489 

490 @artbox.setter 

491 def artbox(self, value): 

492 check_is_box(value) 

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

494 

495 @property 

496 def bleedbox(self): 

497 return self._get_bleedbox(True, False) 

498 

499 @bleedbox.setter 

500 def bleedbox(self, value): 

501 check_is_box(value) 

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

503 

504 @property 

505 def cropbox(self): 

506 return self._get_cropbox(True, False) 

507 

508 @cropbox.setter 

509 def cropbox(self, value): 

510 check_is_box(value) 

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

512 

513 @property 

514 def trimbox(self): 

515 return self._get_trimbox(True, False) 

516 

517 @trimbox.setter 

518 def trimbox(self, value): 

519 check_is_box(value) 

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

521 

522 @property 

523 def images(self) -> _ObjectMapping: 

524 return self._images 

525 

526 @property 

527 def form_xobjects(self) -> _ObjectMapping: 

528 return self._form_xobjects 

529 

530 @property 

531 def resources(self) -> Dictionary: 

532 if Name.Resources not in self.obj: 

533 self.obj.Resources = Dictionary() 

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

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

536 return self.obj.Resources 

537 

538 def add_resource( 

539 self, 

540 res: Object, 

541 res_type: Name, 

542 name: Name | None = None, 

543 *, 

544 prefix: str = '', 

545 replace_existing: bool = True, 

546 ) -> Name: 

547 resources = self.resources 

548 if res_type not in resources: 

549 resources[res_type] = Dictionary() 

550 

551 if name is not None and prefix: 

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

553 if name is None: 

554 name = Name.random(prefix=prefix) 

555 

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

557 if not isinstance(res_dict, Dictionary): 

558 continue 

559 if name in res_dict: 

560 if replace_existing: 

561 del res_dict[name] 

562 else: 

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

564 

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

566 return name 

567 

568 def _over_underlay( 

569 self, 

570 other, 

571 rect: Rectangle | None, 

572 under: bool, 

573 push_stack: bool, 

574 shrink: bool, 

575 expand: bool, 

576 ) -> Name: 

577 formx = None 

578 if isinstance(other, Page): 

579 formx = other.as_form_xobject() 

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

581 formx = Page(other).as_form_xobject() 

582 elif ( 

583 isinstance(other, Stream) 

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

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

586 ): 

587 formx = other 

588 

589 if formx is None: 

590 raise TypeError( 

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

592 ) 

593 

594 if rect is None: 

595 rect = Rectangle(self.trimbox) 

596 

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

598 cs = self.calc_form_xobject_placement( 

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

600 ) 

601 

602 if push_stack: 

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

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

605 

606 self.contents_add(cs, prepend=under) 

607 self.contents_coalesce() 

608 return formx_placed_name 

609 

610 def add_overlay( 

611 self, 

612 other: Object | Page, 

613 rect: Rectangle | None = None, 

614 *, 

615 push_stack: bool = True, 

616 shrink: bool = True, 

617 expand: bool = True, 

618 ) -> Name: 

619 return self._over_underlay( 

620 other, 

621 rect, 

622 under=False, 

623 push_stack=push_stack, 

624 expand=expand, 

625 shrink=shrink, 

626 ) 

627 

628 def add_underlay( 

629 self, 

630 other: Object | Page, 

631 rect: Rectangle | None = None, 

632 *, 

633 shrink: bool = True, 

634 expand: bool = True, 

635 ) -> Name: 

636 return self._over_underlay( 

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

638 ) 

639 

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

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

642 

643 def __getattr__(self, name): 

644 return getattr(self.obj, name) 

645 

646 @augment_override_cpp 

647 def __setattr__(self, name, value): 

648 if hasattr(self.__class__, name): 

649 object.__setattr__(self, name, value) 

650 else: 

651 setattr(self.obj, name, value) 

652 

653 @augment_override_cpp 

654 def __delattr__(self, name): 

655 if hasattr(self.__class__, name): 

656 object.__delattr__(self, name) 

657 else: 

658 delattr(self.obj, name) 

659 

660 def __getitem__(self, key): 

661 return self.obj[key] 

662 

663 def __setitem__(self, key, value): 

664 self.obj[key] = value 

665 

666 def __delitem__(self, key): 

667 del self.obj[key] 

668 

669 def __contains__(self, key): 

670 return key in self.obj 

671 

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

673 try: 

674 return self[key] 

675 except KeyError: 

676 return default 

677 

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

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

680 

681 def __repr__(self): 

682 return ( 

683 repr(self.obj) 

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

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

686 ) 

687 

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

689 data = {} 

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

691 if include: 

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

693 if exclude: 

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

695 pagedata = _single_page_pdf(self) 

696 if 'application/pdf' in bundle: 

697 data['application/pdf'] = pagedata 

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

699 with suppress(FileNotFoundError, RuntimeError): 

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

701 return data 

702 

703 

704@augments(Token) 

705class Extend_Token: 

706 def __repr__(self): 

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

708 

709 

710@augments(Rectangle) 

711class Extend_Rectangle: 

712 def __repr__(self): 

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

714 

715 def __hash__(self): 

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

717 

718 def to_bbox(self) -> Rectangle: 

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

720 

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

722 at the origin (0, 0). 

723 

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

725 XObjects. 

726 """ 

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

728 

729 

730@augments(Attachments) 

731class Extend_Attachments(MutableMapping): 

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

733 filespec = self._get_filespec(k) 

734 if filespec is None: 

735 raise KeyError(k) 

736 return filespec 

737 

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

739 if isinstance(v, bytes): 

740 return self._attach_data(k, v) 

741 if not v.filename: 

742 v.filename = k 

743 return self._add_replace_filespec(k, v) 

744 

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

746 return self._remove_filespec(k) 

747 

748 def __len__(self): 

749 return len(self._get_all_filespecs()) 

750 

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

752 yield from self._get_all_filespecs() 

753 

754 def __repr__(self): 

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

756 

757 

758@augments(AttachedFileSpec) 

759class Extend_AttachedFileSpec: 

760 @staticmethod 

761 def from_filepath( 

762 pdf: Pdf, 

763 path: Path | str, 

764 *, 

765 description: str = '', 

766 relationship: Name | None = Name.Unspecified, 

767 ): 

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

769 if mime is None: 

770 mime = '' 

771 if not isinstance(path, Path): 

772 path = Path(path) 

773 

774 stat = path.stat() 

775 return AttachedFileSpec( 

776 pdf, 

777 path.read_bytes(), 

778 description=description, 

779 filename=str(path.name), 

780 mime_type=mime, 

781 creation_date=encode_pdf_date( 

782 datetime.datetime.fromtimestamp(stat.st_ctime) 

783 ), 

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

785 relationship=relationship, 

786 ) 

787 

788 @property 

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

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

791 

792 @relationship.setter 

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

794 if value is None: 

795 del self.obj[Name.AFRelationship] 

796 else: 

797 self.obj[Name.AFRelationship] = value 

798 

799 def __repr__(self): 

800 if self.filename: 

801 return ( 

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

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

804 ) 

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

806 

807 

808@augments(AttachedFile) 

809class Extend_AttachedFile: 

810 @property 

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

812 if not self._creation_date: 

813 return None 

814 return decode_pdf_date(self._creation_date) 

815 

816 @creation_date.setter 

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

818 self._creation_date = encode_pdf_date(value) 

819 

820 @property 

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

822 if not self._mod_date: 

823 return None 

824 return decode_pdf_date(self._mod_date) 

825 

826 @mod_date.setter 

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

828 self._mod_date = encode_pdf_date(value) 

829 

830 def read_bytes(self) -> bytes: 

831 return self.obj.read_bytes() 

832 

833 def __repr__(self): 

834 return ( 

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

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

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

838 ) 

839 

840 

841@augments(NameTree) 

842class Extend_NameTree: 

843 def keys(self): 

844 return KeysView(self._as_map()) 

845 

846 def values(self): 

847 return ValuesView(self._as_map()) 

848 

849 def items(self): 

850 return ItemsView(self._as_map()) 

851 

852 get = MutableMapping.get 

853 pop = MutableMapping.pop 

854 popitem = MutableMapping.popitem 

855 clear = MutableMapping.clear 

856 update = MutableMapping.update 

857 setdefault = MutableMapping.setdefault 

858 

859 

860MutableMapping.register(NameTree) 

861 

862 

863@augments(NumberTree) 

864class Extend_NumberTree: 

865 def keys(self): 

866 return KeysView(self._as_map()) 

867 

868 def values(self): 

869 return ValuesView(self._as_map()) 

870 

871 def items(self): 

872 return ItemsView(self._as_map()) 

873 

874 get = MutableMapping.get 

875 pop = MutableMapping.pop 

876 popitem = MutableMapping.popitem 

877 clear = MutableMapping.clear 

878 update = MutableMapping.update 

879 setdefault = MutableMapping.setdefault 

880 

881 

882MutableMapping.register(NumberTree)