Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/pypdf/generic/_files.py: 27%
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
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
1from __future__ import annotations
3import bisect
4from functools import cached_property
5from typing import TYPE_CHECKING, cast
7from pypdf._utils import format_iso8824_date, logger_warning, parse_iso8824_date
8from pypdf.constants import CatalogAttributes as CA
9from pypdf.constants import FileSpecificationDictionaryEntries
10from pypdf.constants import PageAttributes as PG
11from pypdf.errors import PdfReadError, PyPdfError
12from pypdf.generic import (
13 ArrayObject,
14 ByteStringObject,
15 DecodedStreamObject,
16 DictionaryObject,
17 NameObject,
18 NullObject,
19 NumberObject,
20 StreamObject,
21 TextStringObject,
22 is_null_or_none,
23)
25if TYPE_CHECKING:
26 import datetime
27 from collections.abc import Iterator
29 from pypdf._writer import PdfWriter
32class EmbeddedFile:
33 """
34 Container holding the information on an embedded file.
36 Attributes are evaluated lazily if possible.
38 Further information on embedded files can be found in section 7.11 of the PDF 2.0 specification.
39 """
40 def __init__(self, name: str, pdf_object: DictionaryObject, parent: ArrayObject | None = None) -> None:
41 """
42 Args:
43 name: The (primary) name as provided in the name tree.
44 pdf_object: The corresponding PDF object to allow retrieving further data.
45 parent: The parent list.
46 """
47 self._name = name
48 self.pdf_object = pdf_object
49 self._parent = parent
51 @property
52 def name(self) -> str:
53 """
54 The (primary) name of the embedded file as provided in the name tree.
56 .. warning::
58 This value can contain arbitrary characters. Please make sure to sanitize it before
59 using it to write the file content to the disk for example.
60 """
61 return self._name
63 @classmethod
64 def _create_new(cls, writer: PdfWriter, name: str, content: str | bytes) -> EmbeddedFile:
65 """
66 Create a new embedded file and add it to the PdfWriter.
68 Args:
69 writer: The PdfWriter instance to add the embedded file to.
70 name: The filename to display.
71 content: The data in the file.
73 Returns:
74 EmbeddedFile instance for the newly created embedded file.
75 """
76 # Convert string content to bytes if needed
77 if isinstance(content, str):
78 content = content.encode("latin-1")
80 # Create the file entry (the actual embedded file stream)
81 file_entry = DecodedStreamObject()
82 file_entry.set_data(content)
83 file_entry.update({NameObject(PG.TYPE): NameObject("/EmbeddedFile")})
85 # Create the /EF entry
86 ef_entry = DictionaryObject()
87 ef_entry.update({NameObject("/F"): writer._add_object(file_entry)})
89 # Create the filespec dictionary
90 from pypdf.generic import create_string_object # noqa: PLC0415
91 filespec = DictionaryObject()
92 filespec_reference = writer._add_object(filespec)
93 name_object = cast(TextStringObject, create_string_object(name))
94 filespec.update(
95 {
96 NameObject(PG.TYPE): NameObject("/Filespec"),
97 NameObject(FileSpecificationDictionaryEntries.F): name_object,
98 NameObject(FileSpecificationDictionaryEntries.EF): ef_entry,
99 }
100 )
102 # Add the name and filespec to the names array.
103 # We use the inverse order for insertion, as this allows us to re-use the
104 # same index.
105 names_array = cls._get_names_array(writer)
106 insertion_index = cls._get_insertion_index(names_array, name_object)
107 names_array.insert(insertion_index, filespec_reference)
108 names_array.insert(insertion_index, name_object)
110 # Return an EmbeddedFile instance
111 return cls(name=name, pdf_object=filespec, parent=names_array)
113 @classmethod
114 def _get_names_array(cls, writer: PdfWriter) -> ArrayObject:
115 """Get the names array for embedded files, possibly creating and flattening it."""
116 if CA.NAMES not in writer.root_object:
117 # Add the /Names entry to the catalog.
118 writer.root_object[NameObject(CA.NAMES)] = writer._add_object(DictionaryObject())
120 names_dict = cast(DictionaryObject, writer.root_object[CA.NAMES])
121 if "/EmbeddedFiles" not in names_dict:
122 # We do not yet have an entry for embedded files. Create and return it.
123 names = ArrayObject()
124 embedded_files_names_dictionary = DictionaryObject(
125 {NameObject(CA.NAMES): names}
126 )
127 names_dict[NameObject("/EmbeddedFiles")] = writer._add_object(embedded_files_names_dictionary)
128 return names
130 # We have an existing embedded files entry.
131 embedded_files_names_tree = cast(DictionaryObject, names_dict["/EmbeddedFiles"])
132 if "/Names" in embedded_files_names_tree:
133 # Simple case: We already have a flat list.
134 return cast(ArrayObject, embedded_files_names_tree[NameObject(CA.NAMES)])
135 if "/Kids" not in embedded_files_names_tree:
136 # Invalid case: This is no name tree.
137 raise PdfReadError("Got neither Names nor Kids in embedded files tree.")
139 # Complex case: Convert a /Kids-based name tree to a /Names-based one.
140 # /Name-based ones are much easier to handle and allow us to simplify the
141 # actual insertion logic by only having to consider one case.
142 names = ArrayObject()
143 kids = cast(ArrayObject, embedded_files_names_tree["/Kids"].get_object())
144 embedded_files_names_dictionary = DictionaryObject(
145 {NameObject(CA.NAMES): names}
146 )
147 names_dict[NameObject("/EmbeddedFiles")] = writer._add_object(embedded_files_names_dictionary)
148 for kid in kids:
149 # Write the flattened file entries. As we do not change the actual files,
150 # this should not have any impact on references to them.
151 # There might be further (nested) kids here.
152 # Wait for an example before evaluating an implementation.
153 for name in kid.get_object().get("/Names", []):
154 names.append(name)
155 return names
157 @classmethod
158 def _get_insertion_index(cls, names_array: ArrayObject, name: str) -> int:
159 keys = [names_array[i].encode("utf-8") for i in range(0, len(names_array), 2)]
160 name_bytes = name.encode("utf-8")
162 start = bisect.bisect_left(keys, name_bytes)
163 end = bisect.bisect_right(keys, name_bytes)
165 if start != end:
166 return end * 2
167 if start == 0:
168 return 0
169 if start == (key_count := len(keys)):
170 return key_count * 2
171 return end * 2
173 @property
174 def alternative_name(self) -> str | None:
175 """
176 Retrieve the alternative name (as per the file specification dictionary).
178 .. warning::
180 This value can contain arbitrary characters. Please make sure to sanitize it before
181 using it to write the file content to the disk for example.
182 """
183 for key in [FileSpecificationDictionaryEntries.UF, FileSpecificationDictionaryEntries.F]:
184 # PDF 2.0 reference, table 43:
185 # > A PDF reader shall use the value of the UF key, when present, instead of the F key.
186 if key in self.pdf_object:
187 value = self.pdf_object[key].get_object()
188 if not is_null_or_none(value):
189 return cast(str, value)
190 return None
192 @alternative_name.setter
193 def alternative_name(self, value: TextStringObject | None) -> None:
194 """Set the alternative name (as per the file specification dictionary)."""
195 if value is None:
196 if FileSpecificationDictionaryEntries.UF in self.pdf_object:
197 self.pdf_object[NameObject(FileSpecificationDictionaryEntries.UF)] = NullObject()
198 if FileSpecificationDictionaryEntries.F in self.pdf_object:
199 self.pdf_object[NameObject(FileSpecificationDictionaryEntries.F)] = NullObject()
200 else:
201 self.pdf_object[NameObject(FileSpecificationDictionaryEntries.UF)] = value
202 self.pdf_object[NameObject(FileSpecificationDictionaryEntries.F)] = value
204 @property
205 def description(self) -> str | None:
206 """Retrieve the description."""
207 value = self.pdf_object.get(FileSpecificationDictionaryEntries.DESC)
208 if is_null_or_none(value):
209 return None
210 return value
212 @description.setter
213 def description(self, value: TextStringObject | None) -> None:
214 """Set the description."""
215 if value is None:
216 self.pdf_object[NameObject(FileSpecificationDictionaryEntries.DESC)] = NullObject()
217 else:
218 self.pdf_object[NameObject(FileSpecificationDictionaryEntries.DESC)] = value
220 @property
221 def associated_file_relationship(self) -> str:
222 """Retrieve the relationship of the referring document to this embedded file."""
223 return cast(
224 NameObject,
225 self.pdf_object.get("/AFRelationship", NameObject("/Unspecified")),
226 )
228 @associated_file_relationship.setter
229 def associated_file_relationship(self, value: NameObject) -> None:
230 """Set the relationship of the referring document to this embedded file."""
231 self.pdf_object[NameObject("/AFRelationship")] = value
233 @property
234 def _embedded_file(self) -> StreamObject:
235 """Retrieve the actual embedded file stream."""
236 if "/EF" not in self.pdf_object:
237 raise PdfReadError(f"/EF entry not found: {self.pdf_object}")
238 ef = cast(DictionaryObject, self.pdf_object["/EF"])
239 for key in [FileSpecificationDictionaryEntries.UF, FileSpecificationDictionaryEntries.F]:
240 if key in ef:
241 return cast(StreamObject, ef[key].get_object())
242 raise PdfReadError(f"No /(U)F key found in file dictionary: {ef}")
244 @property
245 def _params(self) -> DictionaryObject:
246 """Retrieve the file-specific parameters."""
247 return cast(DictionaryObject, self._embedded_file.get("/Params", DictionaryObject()).get_object())
249 @cached_property
250 def _ensure_params(self) -> DictionaryObject:
251 """Ensure the /Params dictionary exists and return it."""
252 embedded_file = self._embedded_file
253 if "/Params" not in embedded_file:
254 embedded_file[NameObject("/Params")] = DictionaryObject()
255 return cast(DictionaryObject, embedded_file["/Params"])
257 @property
258 def subtype(self) -> str | None:
259 """Retrieve the subtype. This is a MIME media type, prefixed by a slash."""
260 value = self._embedded_file.get("/Subtype")
261 if is_null_or_none(value):
262 return None
263 return value
265 @subtype.setter
266 def subtype(self, value: NameObject | None) -> None:
267 """Set the subtype. This should be a MIME media type, prefixed by a slash."""
268 embedded_file = self._embedded_file
269 if value is None:
270 embedded_file[NameObject("/Subtype")] = NullObject()
271 else:
272 embedded_file[NameObject("/Subtype")] = value
274 @property
275 def content(self) -> bytes:
276 """Retrieve the actual file content."""
277 return self._embedded_file.get_data()
279 @content.setter
280 def content(self, value: str | bytes) -> None:
281 """Set the file content."""
282 if isinstance(value, str):
283 value = value.encode("latin-1")
284 self._embedded_file.set_data(value)
286 @property
287 def size(self) -> int | None:
288 """Retrieve the size of the uncompressed file in bytes."""
289 value = self._params.get("/Size")
290 if is_null_or_none(value):
291 return None
292 return value
294 @size.setter
295 def size(self, value: NumberObject | None) -> None:
296 """Set the size of the uncompressed file in bytes."""
297 params = self._ensure_params
298 if value is None:
299 params[NameObject("/Size")] = NullObject()
300 else:
301 params[NameObject("/Size")] = value
303 @property
304 def creation_date(self) -> datetime.datetime | None:
305 """Retrieve the file creation datetime."""
306 return parse_iso8824_date(self._params.get("/CreationDate"))
308 @creation_date.setter
309 def creation_date(self, value: datetime.datetime | None) -> None:
310 """Set the file creation datetime."""
311 params = self._ensure_params
312 if value is None:
313 params[NameObject("/CreationDate")] = NullObject()
314 else:
315 date_str = format_iso8824_date(value)
316 params[NameObject("/CreationDate")] = TextStringObject(date_str)
318 @property
319 def modification_date(self) -> datetime.datetime | None:
320 """Retrieve the datetime of the last file modification."""
321 return parse_iso8824_date(self._params.get("/ModDate"))
323 @modification_date.setter
324 def modification_date(self, value: datetime.datetime | None) -> None:
325 """Set the datetime of the last file modification."""
326 params = self._ensure_params
327 if value is None:
328 params[NameObject("/ModDate")] = NullObject()
329 else:
330 date_str = format_iso8824_date(value)
331 params[NameObject("/ModDate")] = TextStringObject(date_str)
333 @property
334 def checksum(self) -> bytes | None:
335 """Retrieve the MD5 checksum of the (uncompressed) file."""
336 value = self._params.get("/CheckSum")
337 if is_null_or_none(value):
338 return None
339 return value
341 @checksum.setter
342 def checksum(self, value: ByteStringObject | None) -> None:
343 """Set the MD5 checksum of the (uncompressed) file."""
344 params = self._ensure_params
345 if value is None:
346 params[NameObject("/CheckSum")] = NullObject()
347 else:
348 params[NameObject("/CheckSum")] = value
350 @property
351 def _names(self) -> Iterator[str]:
352 yield self.name
353 if self.alternative_name is not None and self.alternative_name != self.name:
354 yield self.alternative_name
356 def delete(self) -> None:
357 """Delete the file from the document."""
358 if not self._parent:
359 raise PyPdfError("Parent required to delete file from document.")
360 if self.pdf_object in self._parent:
361 index = self._parent.index(self.pdf_object)
362 elif (
363 (indirect_reference := getattr(self.pdf_object, "indirect_reference", None)) is not None
364 and indirect_reference in self._parent
365 ):
366 index = self._parent.index(indirect_reference)
367 else:
368 raise PyPdfError("File not found in parent object.")
369 self._parent.pop(index) # Reference.
370 self._parent.pop(index - 1) # Name.
371 self.pdf_object = DictionaryObject() # Invalidate.
373 def __repr__(self) -> str:
374 return f"<{self.__class__.__name__} name={self.name!r}>"
376 @staticmethod
377 def _report_malformed(message: str, strict: bool) -> None:
378 """Raise an exception in strict mode, warn otherwise."""
379 if strict:
380 raise PdfReadError(message)
381 logger_warning(message, source=__name__)
383 @classmethod
384 def _load_from_names(cls, names: ArrayObject) -> Iterator[EmbeddedFile]:
385 """
386 Convert the given name tree into class instances.
388 Args:
389 names: The name tree to load the data from.
391 Returns:
392 Iterable of class instances for the files found.
393 """
394 # This is a name tree of the format [name_1, reference_1, name_2, reference_2, ...]
395 for i, name in enumerate(names):
396 if not isinstance(name, str):
397 # Skip plain strings and retrieve them as `direct_name` by index.
398 file_dictionary = name.get_object()
399 direct_name = names[i - 1].get_object()
400 yield EmbeddedFile(name=direct_name, pdf_object=file_dictionary, parent=names)
402 @classmethod
403 def _load(cls, catalog: DictionaryObject, strict: bool = False) -> Iterator[EmbeddedFile]:
404 """
405 Load the embedded files for the given document catalog.
407 This method and its signature are considered internal API and thus not exposed publicly for now.
409 Args:
410 catalog: The document catalog to load from.
411 strict: Whether to raise an exception on malformed name trees
412 instead of issuing a warning and skipping them.
414 Returns:
415 Iterable of class instances for the files found.
416 """
417 if "/Names" not in catalog:
418 return
419 names = catalog["/Names"]
420 if isinstance(names, NullObject):
421 # A null value is equivalent to an omitted entry.
422 return
423 if not isinstance(names, DictionaryObject):
424 cls._report_malformed(f"Names tree is not a dictionary: {names}", strict=strict)
425 return
426 if "/EmbeddedFiles" not in names:
427 return
428 container = names["/EmbeddedFiles"]
429 if isinstance(container, NullObject):
430 return
431 if not isinstance(container, DictionaryObject):
432 cls._report_malformed(f"Embedded files entry is not a dictionary: {container}", strict=strict)
433 return
435 if "/Kids" in container:
436 kids = container["/Kids"].get_object()
437 if isinstance(kids, NullObject):
438 kids = ArrayObject()
439 elif not isinstance(kids, ArrayObject):
440 cls._report_malformed(f"Embedded files /Kids is not an array: {kids}", strict=strict)
441 return
442 for kid in kids:
443 # There might be further (nested) kids here.
444 # Wait for an example before evaluating an implementation.
445 kid = kid.get_object()
446 if not isinstance(kid, DictionaryObject):
447 cls._report_malformed(f"Embedded files kid is not a dictionary: {kid}", strict=strict)
448 continue
449 if "/Names" not in kid:
450 continue
451 kid_names = kid["/Names"].get_object()
452 if isinstance(kid_names, NullObject):
453 continue
454 if not isinstance(kid_names, ArrayObject):
455 cls._report_malformed(f"Embedded files name list is not an array: {kid_names}", strict=strict)
456 continue
457 yield from cls._load_from_names(kid_names)
458 if "/Names" in container:
459 container_names = container["/Names"].get_object()
460 if isinstance(container_names, NullObject):
461 return
462 if not isinstance(container_names, ArrayObject):
463 cls._report_malformed(f"Embedded files name list is not an array: {container_names}", strict=strict)
464 return
465 yield from cls._load_from_names(container_names)