_files.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. from __future__ import annotations
  2. import bisect
  3. from functools import cached_property
  4. from typing import TYPE_CHECKING, cast
  5. from pypdf._utils import format_iso8824_date, parse_iso8824_date
  6. from pypdf.constants import CatalogAttributes as CA
  7. from pypdf.constants import FileSpecificationDictionaryEntries
  8. from pypdf.constants import PageAttributes as PG
  9. from pypdf.errors import PdfReadError, PyPdfError
  10. from pypdf.generic import (
  11. ArrayObject,
  12. ByteStringObject,
  13. DecodedStreamObject,
  14. DictionaryObject,
  15. NameObject,
  16. NullObject,
  17. NumberObject,
  18. StreamObject,
  19. TextStringObject,
  20. is_null_or_none,
  21. )
  22. if TYPE_CHECKING:
  23. import datetime
  24. from collections.abc import Generator
  25. from pypdf._writer import PdfWriter
  26. class EmbeddedFile:
  27. """
  28. Container holding the information on an embedded file.
  29. Attributes are evaluated lazily if possible.
  30. Further information on embedded files can be found in section 7.11 of the PDF 2.0 specification.
  31. """
  32. def __init__(self, name: str, pdf_object: DictionaryObject, parent: ArrayObject | None = None) -> None:
  33. """
  34. Args:
  35. name: The (primary) name as provided in the name tree.
  36. pdf_object: The corresponding PDF object to allow retrieving further data.
  37. parent: The parent list.
  38. """
  39. self._name = name
  40. self.pdf_object = pdf_object
  41. self._parent = parent
  42. @property
  43. def name(self) -> str:
  44. """The (primary) name of the embedded file as provided in the name tree."""
  45. return self._name
  46. @classmethod
  47. def _create_new(cls, writer: PdfWriter, name: str, content: str | bytes) -> EmbeddedFile:
  48. """
  49. Create a new embedded file and add it to the PdfWriter.
  50. Args:
  51. writer: The PdfWriter instance to add the embedded file to.
  52. name: The filename to display.
  53. content: The data in the file.
  54. Returns:
  55. EmbeddedFile instance for the newly created embedded file.
  56. """
  57. # Convert string content to bytes if needed
  58. if isinstance(content, str):
  59. content = content.encode("latin-1")
  60. # Create the file entry (the actual embedded file stream)
  61. file_entry = DecodedStreamObject()
  62. file_entry.set_data(content)
  63. file_entry.update({NameObject(PG.TYPE): NameObject("/EmbeddedFile")})
  64. # Create the /EF entry
  65. ef_entry = DictionaryObject()
  66. ef_entry.update({NameObject("/F"): writer._add_object(file_entry)})
  67. # Create the filespec dictionary
  68. from pypdf.generic import create_string_object # noqa: PLC0415
  69. filespec = DictionaryObject()
  70. filespec_reference = writer._add_object(filespec)
  71. name_object = cast(TextStringObject, create_string_object(name))
  72. filespec.update(
  73. {
  74. NameObject(PG.TYPE): NameObject("/Filespec"),
  75. NameObject(FileSpecificationDictionaryEntries.F): name_object,
  76. NameObject(FileSpecificationDictionaryEntries.EF): ef_entry,
  77. }
  78. )
  79. # Add the name and filespec to the names array.
  80. # We use the inverse order for insertion, as this allows us to re-use the
  81. # same index.
  82. names_array = cls._get_names_array(writer)
  83. insertion_index = cls._get_insertion_index(names_array, name_object)
  84. names_array.insert(insertion_index, filespec_reference)
  85. names_array.insert(insertion_index, name_object)
  86. # Return an EmbeddedFile instance
  87. return cls(name=name, pdf_object=filespec, parent=names_array)
  88. @classmethod
  89. def _get_names_array(cls, writer: PdfWriter) -> ArrayObject:
  90. """Get the names array for embedded files, possibly creating and flattening it."""
  91. if CA.NAMES not in writer.root_object:
  92. # Add the /Names entry to the catalog.
  93. writer.root_object[NameObject(CA.NAMES)] = writer._add_object(DictionaryObject())
  94. names_dict = cast(DictionaryObject, writer.root_object[CA.NAMES])
  95. if "/EmbeddedFiles" not in names_dict:
  96. # We do not yet have an entry for embedded files. Create and return it.
  97. names = ArrayObject()
  98. embedded_files_names_dictionary = DictionaryObject(
  99. {NameObject(CA.NAMES): names}
  100. )
  101. names_dict[NameObject("/EmbeddedFiles")] = writer._add_object(embedded_files_names_dictionary)
  102. return names
  103. # We have an existing embedded files entry.
  104. embedded_files_names_tree = cast(DictionaryObject, names_dict["/EmbeddedFiles"])
  105. if "/Names" in embedded_files_names_tree:
  106. # Simple case: We already have a flat list.
  107. return cast(ArrayObject, embedded_files_names_tree[NameObject(CA.NAMES)])
  108. if "/Kids" not in embedded_files_names_tree:
  109. # Invalid case: This is no name tree.
  110. raise PdfReadError("Got neither Names nor Kids in embedded files tree.")
  111. # Complex case: Convert a /Kids-based name tree to a /Names-based one.
  112. # /Name-based ones are much easier to handle and allow us to simplify the
  113. # actual insertion logic by only having to consider one case.
  114. names = ArrayObject()
  115. kids = cast(ArrayObject, embedded_files_names_tree["/Kids"].get_object())
  116. embedded_files_names_dictionary = DictionaryObject(
  117. {NameObject(CA.NAMES): names}
  118. )
  119. names_dict[NameObject("/EmbeddedFiles")] = writer._add_object(embedded_files_names_dictionary)
  120. for kid in kids:
  121. # Write the flattened file entries. As we do not change the actual files,
  122. # this should not have any impact on references to them.
  123. # There might be further (nested) kids here.
  124. # Wait for an example before evaluating an implementation.
  125. for name in kid.get_object().get("/Names", []):
  126. names.append(name)
  127. return names
  128. @classmethod
  129. def _get_insertion_index(cls, names_array: ArrayObject, name: str) -> int:
  130. keys = [names_array[i].encode("utf-8") for i in range(0, len(names_array), 2)]
  131. name_bytes = name.encode("utf-8")
  132. start = bisect.bisect_left(keys, name_bytes)
  133. end = bisect.bisect_right(keys, name_bytes)
  134. if start != end:
  135. return end * 2
  136. if start == 0:
  137. return 0
  138. if start == (key_count := len(keys)):
  139. return key_count * 2
  140. return end * 2
  141. @property
  142. def alternative_name(self) -> str | None:
  143. """Retrieve the alternative name (file specification)."""
  144. for key in [FileSpecificationDictionaryEntries.UF, FileSpecificationDictionaryEntries.F]:
  145. # PDF 2.0 reference, table 43:
  146. # > A PDF reader shall use the value of the UF key, when present, instead of the F key.
  147. if key in self.pdf_object:
  148. value = self.pdf_object[key].get_object()
  149. if not is_null_or_none(value):
  150. return cast(str, value)
  151. return None
  152. @alternative_name.setter
  153. def alternative_name(self, value: TextStringObject | None) -> None:
  154. """Set the alternative name (file specification)."""
  155. if value is None:
  156. if FileSpecificationDictionaryEntries.UF in self.pdf_object:
  157. self.pdf_object[NameObject(FileSpecificationDictionaryEntries.UF)] = NullObject()
  158. if FileSpecificationDictionaryEntries.F in self.pdf_object:
  159. self.pdf_object[NameObject(FileSpecificationDictionaryEntries.F)] = NullObject()
  160. else:
  161. self.pdf_object[NameObject(FileSpecificationDictionaryEntries.UF)] = value
  162. self.pdf_object[NameObject(FileSpecificationDictionaryEntries.F)] = value
  163. @property
  164. def description(self) -> str | None:
  165. """Retrieve the description."""
  166. value = self.pdf_object.get(FileSpecificationDictionaryEntries.DESC)
  167. if is_null_or_none(value):
  168. return None
  169. return value
  170. @description.setter
  171. def description(self, value: TextStringObject | None) -> None:
  172. """Set the description."""
  173. if value is None:
  174. self.pdf_object[NameObject(FileSpecificationDictionaryEntries.DESC)] = NullObject()
  175. else:
  176. self.pdf_object[NameObject(FileSpecificationDictionaryEntries.DESC)] = value
  177. @property
  178. def associated_file_relationship(self) -> str:
  179. """Retrieve the relationship of the referring document to this embedded file."""
  180. return self.pdf_object.get("/AFRelationship", "/Unspecified")
  181. @associated_file_relationship.setter
  182. def associated_file_relationship(self, value: NameObject) -> None:
  183. """Set the relationship of the referring document to this embedded file."""
  184. self.pdf_object[NameObject("/AFRelationship")] = value
  185. @property
  186. def _embedded_file(self) -> StreamObject:
  187. """Retrieve the actual embedded file stream."""
  188. if "/EF" not in self.pdf_object:
  189. raise PdfReadError(f"/EF entry not found: {self.pdf_object}")
  190. ef = cast(DictionaryObject, self.pdf_object["/EF"])
  191. for key in [FileSpecificationDictionaryEntries.UF, FileSpecificationDictionaryEntries.F]:
  192. if key in ef:
  193. return cast(StreamObject, ef[key].get_object())
  194. raise PdfReadError(f"No /(U)F key found in file dictionary: {ef}")
  195. @property
  196. def _params(self) -> DictionaryObject:
  197. """Retrieve the file-specific parameters."""
  198. return self._embedded_file.get("/Params", DictionaryObject()).get_object()
  199. @cached_property
  200. def _ensure_params(self) -> DictionaryObject:
  201. """Ensure the /Params dictionary exists and return it."""
  202. embedded_file = self._embedded_file
  203. if "/Params" not in embedded_file:
  204. embedded_file[NameObject("/Params")] = DictionaryObject()
  205. return cast(DictionaryObject, embedded_file["/Params"])
  206. @property
  207. def subtype(self) -> str | None:
  208. """Retrieve the subtype. This is a MIME media type, prefixed by a slash."""
  209. value = self._embedded_file.get("/Subtype")
  210. if is_null_or_none(value):
  211. return None
  212. return value
  213. @subtype.setter
  214. def subtype(self, value: NameObject | None) -> None:
  215. """Set the subtype. This should be a MIME media type, prefixed by a slash."""
  216. embedded_file = self._embedded_file
  217. if value is None:
  218. embedded_file[NameObject("/Subtype")] = NullObject()
  219. else:
  220. embedded_file[NameObject("/Subtype")] = value
  221. @property
  222. def content(self) -> bytes:
  223. """Retrieve the actual file content."""
  224. return self._embedded_file.get_data()
  225. @content.setter
  226. def content(self, value: str | bytes) -> None:
  227. """Set the file content."""
  228. if isinstance(value, str):
  229. value = value.encode("latin-1")
  230. self._embedded_file.set_data(value)
  231. @property
  232. def size(self) -> int | None:
  233. """Retrieve the size of the uncompressed file in bytes."""
  234. value = self._params.get("/Size")
  235. if is_null_or_none(value):
  236. return None
  237. return value
  238. @size.setter
  239. def size(self, value: NumberObject | None) -> None:
  240. """Set the size of the uncompressed file in bytes."""
  241. params = self._ensure_params
  242. if value is None:
  243. params[NameObject("/Size")] = NullObject()
  244. else:
  245. params[NameObject("/Size")] = value
  246. @property
  247. def creation_date(self) -> datetime.datetime | None:
  248. """Retrieve the file creation datetime."""
  249. return parse_iso8824_date(self._params.get("/CreationDate"))
  250. @creation_date.setter
  251. def creation_date(self, value: datetime.datetime | None) -> None:
  252. """Set the file creation datetime."""
  253. params = self._ensure_params
  254. if value is None:
  255. params[NameObject("/CreationDate")] = NullObject()
  256. else:
  257. date_str = format_iso8824_date(value)
  258. params[NameObject("/CreationDate")] = TextStringObject(date_str)
  259. @property
  260. def modification_date(self) -> datetime.datetime | None:
  261. """Retrieve the datetime of the last file modification."""
  262. return parse_iso8824_date(self._params.get("/ModDate"))
  263. @modification_date.setter
  264. def modification_date(self, value: datetime.datetime | None) -> None:
  265. """Set the datetime of the last file modification."""
  266. params = self._ensure_params
  267. if value is None:
  268. params[NameObject("/ModDate")] = NullObject()
  269. else:
  270. date_str = format_iso8824_date(value)
  271. params[NameObject("/ModDate")] = TextStringObject(date_str)
  272. @property
  273. def checksum(self) -> bytes | None:
  274. """Retrieve the MD5 checksum of the (uncompressed) file."""
  275. value = self._params.get("/CheckSum")
  276. if is_null_or_none(value):
  277. return None
  278. return value
  279. @checksum.setter
  280. def checksum(self, value: ByteStringObject | None) -> None:
  281. """Set the MD5 checksum of the (uncompressed) file."""
  282. params = self._ensure_params
  283. if value is None:
  284. params[NameObject("/CheckSum")] = NullObject()
  285. else:
  286. params[NameObject("/CheckSum")] = value
  287. def delete(self) -> None:
  288. """Delete the file from the document."""
  289. if not self._parent:
  290. raise PyPdfError("Parent required to delete file from document.")
  291. if self.pdf_object in self._parent:
  292. index = self._parent.index(self.pdf_object)
  293. elif (
  294. (indirect_reference := getattr(self.pdf_object, "indirect_reference", None)) is not None
  295. and indirect_reference in self._parent
  296. ):
  297. index = self._parent.index(indirect_reference)
  298. else:
  299. raise PyPdfError("File not found in parent object.")
  300. self._parent.pop(index) # Reference.
  301. self._parent.pop(index - 1) # Name.
  302. self.pdf_object = DictionaryObject() # Invalidate.
  303. def __repr__(self) -> str:
  304. return f"<{self.__class__.__name__} name={self.name!r}>"
  305. @classmethod
  306. def _load_from_names(cls, names: ArrayObject) -> Generator[EmbeddedFile]:
  307. """
  308. Convert the given name tree into class instances.
  309. Args:
  310. names: The name tree to load the data from.
  311. Returns:
  312. Iterable of class instances for the files found.
  313. """
  314. # This is a name tree of the format [name_1, reference_1, name_2, reference_2, ...]
  315. for i, name in enumerate(names):
  316. if not isinstance(name, str):
  317. # Skip plain strings and retrieve them as `direct_name` by index.
  318. file_dictionary = name.get_object()
  319. direct_name = names[i - 1].get_object()
  320. yield EmbeddedFile(name=direct_name, pdf_object=file_dictionary, parent=names)
  321. @classmethod
  322. def _load(cls, catalog: DictionaryObject) -> Generator[EmbeddedFile]:
  323. """
  324. Load the embedded files for the given document catalog.
  325. This method and its signature are considered internal API and thus not exposed publicly for now.
  326. Args:
  327. catalog: The document catalog to load from.
  328. Returns:
  329. Iterable of class instances for the files found.
  330. """
  331. try:
  332. container = cast(
  333. DictionaryObject,
  334. cast(DictionaryObject, catalog["/Names"])["/EmbeddedFiles"],
  335. )
  336. except KeyError:
  337. return
  338. if "/Kids" in container:
  339. for kid in cast(ArrayObject, container["/Kids"].get_object()):
  340. # There might be further (nested) kids here.
  341. # Wait for an example before evaluating an implementation.
  342. kid = kid.get_object()
  343. if "/Names" in kid:
  344. yield from cls._load_from_names(cast(ArrayObject, kid["/Names"]))
  345. if "/Names" in container:
  346. yield from cls._load_from_names(cast(ArrayObject, container["/Names"]))