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)