xmp.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748
  1. """
  2. Anything related to Extensible Metadata Platform (XMP) metadata.
  3. https://en.wikipedia.org/wiki/Extensible_Metadata_Platform
  4. """
  5. import datetime
  6. import decimal
  7. import re
  8. from collections.abc import Iterator
  9. from typing import (
  10. Any,
  11. Callable,
  12. Optional,
  13. TypeVar,
  14. Union,
  15. )
  16. from xml.dom.minidom import Document, parseString
  17. from xml.dom.minidom import Element as XmlElement
  18. from xml.parsers.expat import ExpatError
  19. from ._protocols import XmpInformationProtocol
  20. from ._utils import StreamType, deprecate_with_replacement, deprecation_no_replacement
  21. from .errors import PdfReadError, XmpDocumentError
  22. from .generic import ContentStream, PdfObject
  23. RDF_NAMESPACE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
  24. DC_NAMESPACE = "http://purl.org/dc/elements/1.1/"
  25. XMP_NAMESPACE = "http://ns.adobe.com/xap/1.0/"
  26. PDF_NAMESPACE = "http://ns.adobe.com/pdf/1.3/"
  27. XMPMM_NAMESPACE = "http://ns.adobe.com/xap/1.0/mm/"
  28. # What is the PDFX namespace, you might ask?
  29. # It's documented here: https://github.com/adobe/xmp-docs/raw/master/XMPSpecifications/XMPSpecificationPart3.pdf
  30. # This namespace is used to place "custom metadata"
  31. # properties, which are arbitrary metadata properties with no semantic or
  32. # documented meaning.
  33. #
  34. # Elements in the namespace are key/value-style storage,
  35. # where the element name is the key and the content is the value. The keys
  36. # are transformed into valid XML identifiers by substituting an invalid
  37. # identifier character with \u2182 followed by the unicode hex ID of the
  38. # original character. A key like "my car" is therefore "my\u21820020car".
  39. #
  40. # \u2182 is the unicode character \u{ROMAN NUMERAL TEN THOUSAND}
  41. #
  42. # The pdfx namespace should be avoided.
  43. # A custom data schema and sensical XML elements could be used instead, as is
  44. # suggested by Adobe's own documentation on XMP under "Extensibility of
  45. # Schemas".
  46. PDFX_NAMESPACE = "http://ns.adobe.com/pdfx/1.3/"
  47. # PDF/A
  48. PDFAID_NAMESPACE = "http://www.aiim.org/pdfa/ns/id/"
  49. # Internal mapping of namespace URI → prefix
  50. _NAMESPACE_PREFIX_MAP = {
  51. DC_NAMESPACE: "dc",
  52. XMP_NAMESPACE: "xmp",
  53. PDF_NAMESPACE: "pdf",
  54. XMPMM_NAMESPACE: "xmpMM",
  55. PDFAID_NAMESPACE: "pdfaid",
  56. PDFX_NAMESPACE: "pdfx",
  57. }
  58. iso8601 = re.compile(
  59. """
  60. (?P<year>[0-9]{4})
  61. (-
  62. (?P<month>[0-9]{2})
  63. (-
  64. (?P<day>[0-9]+)
  65. (T
  66. (?P<hour>[0-9]{2}):
  67. (?P<minute>[0-9]{2})
  68. (:(?P<second>[0-9]{2}(.[0-9]+)?))?
  69. (?P<tzd>Z|[-+][0-9]{2}:[0-9]{2})
  70. )?
  71. )?
  72. )?
  73. """,
  74. re.VERBOSE,
  75. )
  76. K = TypeVar("K")
  77. # Minimal XMP template
  78. _MINIMAL_XMP = f"""<?xpacket begin="\ufeff" id="W5M0MpCehiHzreSzNTczkc9d"?>
  79. <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="pypdf">
  80. <rdf:RDF xmlns:rdf="{RDF_NAMESPACE}">
  81. <rdf:Description rdf:about=""
  82. xmlns:dc="{DC_NAMESPACE}"
  83. xmlns:xmp="{XMP_NAMESPACE}"
  84. xmlns:pdf="{PDF_NAMESPACE}"
  85. xmlns:xmpMM="{XMPMM_NAMESPACE}"
  86. xmlns:pdfaid="{PDFAID_NAMESPACE}"
  87. xmlns:pdfx="{PDFX_NAMESPACE}">
  88. </rdf:Description>
  89. </rdf:RDF>
  90. </x:xmpmeta>
  91. <?xpacket end="r"?>"""
  92. def _identity(value: K) -> K:
  93. return value
  94. def _converter_date(value: str) -> datetime.datetime:
  95. matches = iso8601.match(value)
  96. if matches is None:
  97. raise ValueError(f"Invalid date format: {value}")
  98. year = int(matches.group("year"))
  99. month = int(matches.group("month") or "1")
  100. day = int(matches.group("day") or "1")
  101. hour = int(matches.group("hour") or "0")
  102. minute = int(matches.group("minute") or "0")
  103. second = decimal.Decimal(matches.group("second") or "0")
  104. seconds_dec = second.to_integral(decimal.ROUND_FLOOR)
  105. milliseconds_dec = (second - seconds_dec) * 1_000_000
  106. seconds = int(seconds_dec)
  107. milliseconds = int(milliseconds_dec)
  108. tzd = matches.group("tzd") or "Z"
  109. dt = datetime.datetime(year, month, day, hour, minute, seconds, milliseconds)
  110. if tzd != "Z":
  111. tzd_hours, tzd_minutes = (int(x) for x in tzd.split(":"))
  112. tzd_hours *= -1
  113. if tzd_hours < 0:
  114. tzd_minutes *= -1
  115. dt = dt + datetime.timedelta(hours=tzd_hours, minutes=tzd_minutes)
  116. return dt
  117. def _format_datetime_utc(value: datetime.datetime) -> str:
  118. """Format a datetime as UTC with trailing 'Z'.
  119. - If the input is timezone-aware, convert to UTC first.
  120. - If naive, assume UTC.
  121. """
  122. if value.tzinfo is not None and value.utcoffset() is not None:
  123. value = value.astimezone(datetime.timezone.utc)
  124. value = value.replace(tzinfo=None)
  125. return value.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
  126. def _generic_get(
  127. element: XmlElement, self: "XmpInformation", list_type: str, converter: Callable[[Any], Any] = _identity
  128. ) -> Optional[list[str]]:
  129. containers = element.getElementsByTagNameNS(RDF_NAMESPACE, list_type)
  130. retval: list[Any] = []
  131. if len(containers):
  132. for container in containers:
  133. for item in container.getElementsByTagNameNS(RDF_NAMESPACE, "li"):
  134. value = self._get_text(item)
  135. value = converter(value)
  136. retval.append(value)
  137. return retval
  138. return None
  139. class XmpInformation(XmpInformationProtocol, PdfObject):
  140. """
  141. An object that represents Extensible Metadata Platform (XMP) metadata.
  142. Usually accessed by :py:attr:`xmp_metadata()<pypdf.PdfReader.xmp_metadata>`.
  143. Raises:
  144. PdfReadError: if XML is invalid
  145. """
  146. def __init__(self, stream: ContentStream) -> None:
  147. self.stream = stream
  148. try:
  149. data = self.stream.get_data()
  150. doc_root: Document = parseString(data) # noqa: S318
  151. except (AttributeError, ExpatError) as e:
  152. raise PdfReadError(f"XML in XmpInformation was invalid: {e}")
  153. self.rdf_root: XmlElement = doc_root.getElementsByTagNameNS(
  154. RDF_NAMESPACE, "RDF"
  155. )[0]
  156. self.cache: dict[Any, Any] = {}
  157. @classmethod
  158. def create(cls) -> "XmpInformation":
  159. """
  160. Create a new XmpInformation object with minimal structure.
  161. Returns:
  162. A new XmpInformation instance with empty metadata fields.
  163. """
  164. stream = ContentStream(None, None)
  165. stream.set_data(_MINIMAL_XMP.encode("utf-8"))
  166. return cls(stream)
  167. def write_to_stream(
  168. self, stream: StreamType, encryption_key: Union[None, str, bytes] = None
  169. ) -> None:
  170. deprecate_with_replacement(
  171. "XmpInformation.write_to_stream",
  172. "PdfWriter.xmp_metadata",
  173. "6.0.0"
  174. )
  175. if encryption_key is not None: # deprecated
  176. deprecation_no_replacement(
  177. "the encryption_key parameter of write_to_stream", "5.0.0"
  178. )
  179. self.stream.write_to_stream(stream)
  180. def get_element(self, about_uri: str, namespace: str, name: str) -> Iterator[Any]:
  181. for desc in self.rdf_root.getElementsByTagNameNS(RDF_NAMESPACE, "Description"):
  182. if desc.getAttributeNS(RDF_NAMESPACE, "about") == about_uri:
  183. attr = desc.getAttributeNodeNS(namespace, name)
  184. if attr is not None:
  185. yield attr
  186. yield from desc.getElementsByTagNameNS(namespace, name)
  187. def get_nodes_in_namespace(self, about_uri: str, namespace: str) -> Iterator[Any]:
  188. for desc in self.rdf_root.getElementsByTagNameNS(RDF_NAMESPACE, "Description"):
  189. if desc.getAttributeNS(RDF_NAMESPACE, "about") == about_uri:
  190. for i in range(desc.attributes.length):
  191. attr = desc.attributes.item(i)
  192. if attr and attr.namespaceURI == namespace:
  193. yield attr
  194. for child in desc.childNodes:
  195. if child.namespaceURI == namespace:
  196. yield child
  197. def _get_text(self, element: XmlElement) -> str:
  198. text = ""
  199. for child in element.childNodes:
  200. if child.nodeType == child.TEXT_NODE:
  201. text += child.data
  202. return text
  203. def _get_single_value(
  204. self,
  205. namespace: str,
  206. name: str,
  207. converter: Callable[[str], Any] = _identity,
  208. ) -> Optional[Any]:
  209. cached = self.cache.get(namespace, {}).get(name)
  210. if cached:
  211. return cached
  212. value = None
  213. for element in self.get_element("", namespace, name):
  214. if element.nodeType == element.ATTRIBUTE_NODE:
  215. value = element.nodeValue
  216. else:
  217. value = self._get_text(element)
  218. break
  219. if value is not None:
  220. value = converter(value)
  221. ns_cache = self.cache.setdefault(namespace, {})
  222. ns_cache[name] = value
  223. return value
  224. def _getter_bag(self, namespace: str, name: str) -> Optional[list[str]]:
  225. cached = self.cache.get(namespace, {}).get(name)
  226. if cached:
  227. return cached
  228. retval: list[str] = []
  229. for element in self.get_element("", namespace, name):
  230. if (bags := _generic_get(element, self, list_type="Bag")) is not None:
  231. retval.extend(bags)
  232. else:
  233. value = self._get_text(element)
  234. retval.append(value)
  235. ns_cache = self.cache.setdefault(namespace, {})
  236. ns_cache[name] = retval
  237. return retval
  238. def _get_seq_values(
  239. self,
  240. namespace: str,
  241. name: str,
  242. converter: Callable[[Any], Any] = _identity,
  243. ) -> Optional[list[Any]]:
  244. cached = self.cache.get(namespace, {}).get(name)
  245. if cached:
  246. return cached
  247. retval: list[Any] = []
  248. for element in self.get_element("", namespace, name):
  249. if (seqs := _generic_get(element, self, list_type="Seq", converter=converter)) is not None:
  250. retval.extend(seqs)
  251. elif (bags := _generic_get(element, self, list_type="Bag")) is not None:
  252. # See issue at https://github.com/py-pdf/pypdf/issues/3324
  253. # Some applications violate the XMP metadata standard regarding `dc:creator` which should
  254. # be an "ordered array" and thus a sequence, but use an unordered array (bag) instead.
  255. # This seems to stem from the fact that the original Dublin Core specification does indeed
  256. # use bags or direct values, while PDFs are expected to follow the XMP standard and ignore
  257. # the plain Dublin Core variant. For this reason, add a fallback here to deal with such
  258. # issues accordingly.
  259. retval.extend(bags)
  260. else:
  261. value = converter(self._get_text(element))
  262. retval.append(value)
  263. ns_cache = self.cache.setdefault(namespace, {})
  264. ns_cache[name] = retval
  265. return retval
  266. def _get_langalt_values(self, namespace: str, name: str) -> Optional[dict[Any, Any]]:
  267. cached = self.cache.get(namespace, {}).get(name)
  268. if cached:
  269. return cached
  270. retval: dict[Any, Any] = {}
  271. for element in self.get_element("", namespace, name):
  272. alts = element.getElementsByTagNameNS(RDF_NAMESPACE, "Alt")
  273. if len(alts):
  274. for alt in alts:
  275. for item in alt.getElementsByTagNameNS(RDF_NAMESPACE, "li"):
  276. value = self._get_text(item)
  277. retval[item.getAttribute("xml:lang")] = value
  278. else:
  279. retval["x-default"] = self._get_text(element)
  280. ns_cache = self.cache.setdefault(namespace, {})
  281. ns_cache[name] = retval
  282. return retval
  283. @property
  284. def dc_contributor(self) -> Optional[list[str]]:
  285. """Contributors to the resource (other than the authors)."""
  286. return self._getter_bag(DC_NAMESPACE, "contributor")
  287. @dc_contributor.setter
  288. def dc_contributor(self, values: Optional[list[str]]) -> None:
  289. self._set_bag_values(DC_NAMESPACE, "contributor", values)
  290. @property
  291. def dc_coverage(self) -> Optional[str]:
  292. """Text describing the extent or scope of the resource."""
  293. return self._get_single_value(DC_NAMESPACE, "coverage")
  294. @dc_coverage.setter
  295. def dc_coverage(self, value: Optional[str]) -> None:
  296. self._set_single_value(DC_NAMESPACE, "coverage", value)
  297. @property
  298. def dc_creator(self) -> Optional[list[str]]:
  299. """A sorted array of names of the authors of the resource, listed in order of precedence."""
  300. return self._get_seq_values(DC_NAMESPACE, "creator")
  301. @dc_creator.setter
  302. def dc_creator(self, values: Optional[list[str]]) -> None:
  303. self._set_seq_values(DC_NAMESPACE, "creator", values)
  304. @property
  305. def dc_date(self) -> Optional[list[datetime.datetime]]:
  306. """A sorted array of dates of significance to the resource. The dates and times are in UTC."""
  307. return self._get_seq_values(DC_NAMESPACE, "date", _converter_date)
  308. @dc_date.setter
  309. def dc_date(self, values: Optional[list[Union[str, datetime.datetime]]]) -> None:
  310. if values is None:
  311. self._set_seq_values(DC_NAMESPACE, "date", None)
  312. else:
  313. date_strings = []
  314. for value in values:
  315. if isinstance(value, datetime.datetime):
  316. date_strings.append(_format_datetime_utc(value))
  317. else:
  318. date_strings.append(str(value))
  319. self._set_seq_values(DC_NAMESPACE, "date", date_strings)
  320. @property
  321. def dc_description(self) -> Optional[dict[str, str]]:
  322. """A language-keyed dictionary of textual descriptions of the content of the resource."""
  323. return self._get_langalt_values(DC_NAMESPACE, "description")
  324. @dc_description.setter
  325. def dc_description(self, values: Optional[dict[str, str]]) -> None:
  326. self._set_langalt_values(DC_NAMESPACE, "description", values)
  327. @property
  328. def dc_format(self) -> Optional[str]:
  329. """The mime-type of the resource."""
  330. return self._get_single_value(DC_NAMESPACE, "format")
  331. @dc_format.setter
  332. def dc_format(self, value: Optional[str]) -> None:
  333. self._set_single_value(DC_NAMESPACE, "format", value)
  334. @property
  335. def dc_identifier(self) -> Optional[str]:
  336. """Unique identifier of the resource."""
  337. return self._get_single_value(DC_NAMESPACE, "identifier")
  338. @dc_identifier.setter
  339. def dc_identifier(self, value: Optional[str]) -> None:
  340. self._set_single_value(DC_NAMESPACE, "identifier", value)
  341. @property
  342. def dc_language(self) -> Optional[list[str]]:
  343. """An unordered array specifying the languages used in the resource."""
  344. return self._getter_bag(DC_NAMESPACE, "language")
  345. @dc_language.setter
  346. def dc_language(self, values: Optional[list[str]]) -> None:
  347. self._set_bag_values(DC_NAMESPACE, "language", values)
  348. @property
  349. def dc_publisher(self) -> Optional[list[str]]:
  350. """An unordered array of publisher names."""
  351. return self._getter_bag(DC_NAMESPACE, "publisher")
  352. @dc_publisher.setter
  353. def dc_publisher(self, values: Optional[list[str]]) -> None:
  354. self._set_bag_values(DC_NAMESPACE, "publisher", values)
  355. @property
  356. def dc_relation(self) -> Optional[list[str]]:
  357. """An unordered array of text descriptions of relationships to other documents."""
  358. return self._getter_bag(DC_NAMESPACE, "relation")
  359. @dc_relation.setter
  360. def dc_relation(self, values: Optional[list[str]]) -> None:
  361. self._set_bag_values(DC_NAMESPACE, "relation", values)
  362. @property
  363. def dc_rights(self) -> Optional[dict[str, str]]:
  364. """A language-keyed dictionary of textual descriptions of the rights the user has to this resource."""
  365. return self._get_langalt_values(DC_NAMESPACE, "rights")
  366. @dc_rights.setter
  367. def dc_rights(self, values: Optional[dict[str, str]]) -> None:
  368. self._set_langalt_values(DC_NAMESPACE, "rights", values)
  369. @property
  370. def dc_source(self) -> Optional[str]:
  371. """Unique identifier of the work from which this resource was derived."""
  372. return self._get_single_value(DC_NAMESPACE, "source")
  373. @dc_source.setter
  374. def dc_source(self, value: Optional[str]) -> None:
  375. self._set_single_value(DC_NAMESPACE, "source", value)
  376. @property
  377. def dc_subject(self) -> Optional[list[str]]:
  378. """An unordered array of descriptive phrases or keywords that specify the topic of the content."""
  379. return self._getter_bag(DC_NAMESPACE, "subject")
  380. @dc_subject.setter
  381. def dc_subject(self, values: Optional[list[str]]) -> None:
  382. self._set_bag_values(DC_NAMESPACE, "subject", values)
  383. @property
  384. def dc_title(self) -> Optional[dict[str, str]]:
  385. """A language-keyed dictionary of the title of the resource."""
  386. return self._get_langalt_values(DC_NAMESPACE, "title")
  387. @dc_title.setter
  388. def dc_title(self, values: Optional[dict[str, str]]) -> None:
  389. self._set_langalt_values(DC_NAMESPACE, "title", values)
  390. @property
  391. def dc_type(self) -> Optional[list[str]]:
  392. """An unordered array of textual descriptions of the document type."""
  393. return self._getter_bag(DC_NAMESPACE, "type")
  394. @dc_type.setter
  395. def dc_type(self, values: Optional[list[str]]) -> None:
  396. self._set_bag_values(DC_NAMESPACE, "type", values)
  397. @property
  398. def pdf_keywords(self) -> Optional[str]:
  399. """An unformatted text string representing document keywords."""
  400. return self._get_single_value(PDF_NAMESPACE, "Keywords")
  401. @pdf_keywords.setter
  402. def pdf_keywords(self, value: Optional[str]) -> None:
  403. self._set_single_value(PDF_NAMESPACE, "Keywords", value)
  404. @property
  405. def pdf_pdfversion(self) -> Optional[str]:
  406. """The PDF file version, for example 1.0 or 1.3."""
  407. return self._get_single_value(PDF_NAMESPACE, "PDFVersion")
  408. @pdf_pdfversion.setter
  409. def pdf_pdfversion(self, value: Optional[str]) -> None:
  410. self._set_single_value(PDF_NAMESPACE, "PDFVersion", value)
  411. @property
  412. def pdf_producer(self) -> Optional[str]:
  413. """The name of the tool that saved the document as a PDF."""
  414. return self._get_single_value(PDF_NAMESPACE, "Producer")
  415. @pdf_producer.setter
  416. def pdf_producer(self, value: Optional[str]) -> None:
  417. self._set_single_value(PDF_NAMESPACE, "Producer", value)
  418. @property
  419. def xmp_create_date(self) -> Optional[datetime.datetime]:
  420. """The date and time the resource was originally created. Returned as a UTC datetime object."""
  421. return self._get_single_value(XMP_NAMESPACE, "CreateDate", _converter_date)
  422. @xmp_create_date.setter
  423. def xmp_create_date(self, value: Optional[datetime.datetime]) -> None:
  424. if value:
  425. date_str = _format_datetime_utc(value)
  426. self._set_single_value(XMP_NAMESPACE, "CreateDate", date_str)
  427. else:
  428. self._set_single_value(XMP_NAMESPACE, "CreateDate", None)
  429. @property
  430. def xmp_modify_date(self) -> Optional[datetime.datetime]:
  431. """The date and time the resource was last modified. Returned as a UTC datetime object."""
  432. return self._get_single_value(XMP_NAMESPACE, "ModifyDate", _converter_date)
  433. @xmp_modify_date.setter
  434. def xmp_modify_date(self, value: Optional[datetime.datetime]) -> None:
  435. if value:
  436. date_str = _format_datetime_utc(value)
  437. self._set_single_value(XMP_NAMESPACE, "ModifyDate", date_str)
  438. else:
  439. self._set_single_value(XMP_NAMESPACE, "ModifyDate", None)
  440. @property
  441. def xmp_metadata_date(self) -> Optional[datetime.datetime]:
  442. """The date and time that any metadata for this resource was last changed. Returned as a UTC datetime object."""
  443. return self._get_single_value(XMP_NAMESPACE, "MetadataDate", _converter_date)
  444. @xmp_metadata_date.setter
  445. def xmp_metadata_date(self, value: Optional[datetime.datetime]) -> None:
  446. if value:
  447. date_str = _format_datetime_utc(value)
  448. self._set_single_value(XMP_NAMESPACE, "MetadataDate", date_str)
  449. else:
  450. self._set_single_value(XMP_NAMESPACE, "MetadataDate", None)
  451. @property
  452. def xmp_creator_tool(self) -> Optional[str]:
  453. """The name of the first known tool used to create the resource."""
  454. return self._get_single_value(XMP_NAMESPACE, "CreatorTool")
  455. @xmp_creator_tool.setter
  456. def xmp_creator_tool(self, value: Optional[str]) -> None:
  457. self._set_single_value(XMP_NAMESPACE, "CreatorTool", value)
  458. @property
  459. def xmpmm_document_id(self) -> Optional[str]:
  460. """The common identifier for all versions and renditions of this resource."""
  461. return self._get_single_value(XMPMM_NAMESPACE, "DocumentID")
  462. @xmpmm_document_id.setter
  463. def xmpmm_document_id(self, value: Optional[str]) -> None:
  464. self._set_single_value(XMPMM_NAMESPACE, "DocumentID", value)
  465. @property
  466. def xmpmm_instance_id(self) -> Optional[str]:
  467. """An identifier for a specific incarnation of a document, updated each time a file is saved."""
  468. return self._get_single_value(XMPMM_NAMESPACE, "InstanceID")
  469. @xmpmm_instance_id.setter
  470. def xmpmm_instance_id(self, value: Optional[str]) -> None:
  471. self._set_single_value(XMPMM_NAMESPACE, "InstanceID", value)
  472. @property
  473. def pdfaid_part(self) -> Optional[str]:
  474. """The part of the PDF/A standard that the document conforms to (e.g., 1, 2, 3)."""
  475. return self._get_single_value(PDFAID_NAMESPACE, "part")
  476. @pdfaid_part.setter
  477. def pdfaid_part(self, value: Optional[str]) -> None:
  478. self._set_single_value(PDFAID_NAMESPACE, "part", value)
  479. @property
  480. def pdfaid_conformance(self) -> Optional[str]:
  481. """The conformance level within the PDF/A standard (e.g., 'A', 'B', 'U')."""
  482. return self._get_single_value(PDFAID_NAMESPACE, "conformance")
  483. @pdfaid_conformance.setter
  484. def pdfaid_conformance(self, value: Optional[str]) -> None:
  485. self._set_single_value(PDFAID_NAMESPACE, "conformance", value)
  486. @property
  487. def custom_properties(self) -> dict[Any, Any]:
  488. """
  489. Retrieve custom metadata properties defined in the undocumented pdfx
  490. metadata schema.
  491. Returns:
  492. A dictionary of key/value items for custom metadata properties.
  493. """
  494. if not hasattr(self, "_custom_properties"):
  495. self._custom_properties = {}
  496. for node in self.get_nodes_in_namespace("", PDFX_NAMESPACE):
  497. key = node.localName
  498. while True:
  499. # see documentation about PDFX_NAMESPACE earlier in file
  500. idx = key.find("\u2182")
  501. if idx == -1:
  502. break
  503. key = (
  504. key[:idx]
  505. + chr(int(key[idx + 1 : idx + 5], base=16))
  506. + key[idx + 5 :]
  507. )
  508. if node.nodeType == node.ATTRIBUTE_NODE:
  509. value = node.nodeValue
  510. else:
  511. value = self._get_text(node)
  512. self._custom_properties[key] = value
  513. return self._custom_properties
  514. def _get_or_create_description(self, about_uri: str = "") -> XmlElement:
  515. """Get or create an rdf:Description element with the given about URI."""
  516. for desc in self.rdf_root.getElementsByTagNameNS(RDF_NAMESPACE, "Description"):
  517. if desc.getAttributeNS(RDF_NAMESPACE, "about") == about_uri:
  518. return desc
  519. doc = self.rdf_root.ownerDocument
  520. if doc is None:
  521. raise XmpDocumentError("XMP Document is None")
  522. desc = doc.createElementNS(RDF_NAMESPACE, "rdf:Description")
  523. desc.setAttributeNS(RDF_NAMESPACE, "rdf:about", about_uri)
  524. self.rdf_root.appendChild(desc)
  525. return desc
  526. def _clear_cache_entry(self, namespace: str, name: str) -> None:
  527. """Remove a cached value for a given namespace/name if present."""
  528. ns_cache = self.cache.get(namespace)
  529. if ns_cache and name in ns_cache:
  530. del ns_cache[name]
  531. def _set_single_value(self, namespace: str, name: str, value: Optional[str]) -> None:
  532. """Set or remove a single metadata value."""
  533. self._clear_cache_entry(namespace, name)
  534. desc = self._get_or_create_description()
  535. existing_elements = list(desc.getElementsByTagNameNS(namespace, name))
  536. for elem in existing_elements:
  537. desc.removeChild(elem)
  538. if existing_attr := desc.getAttributeNodeNS(namespace, name):
  539. desc.removeAttributeNode(existing_attr)
  540. if value is not None:
  541. doc = self.rdf_root.ownerDocument
  542. if doc is None:
  543. raise XmpDocumentError("XMP Document is None")
  544. prefix = self._get_namespace_prefix(namespace)
  545. elem = doc.createElementNS(namespace, f"{prefix}:{name}")
  546. text_node = doc.createTextNode(str(value))
  547. elem.appendChild(text_node)
  548. desc.appendChild(elem)
  549. self._update_stream()
  550. def _set_bag_values(self, namespace: str, name: str, values: Optional[list[str]]) -> None:
  551. """Set or remove bag values (unordered array)."""
  552. self._clear_cache_entry(namespace, name)
  553. desc = self._get_or_create_description()
  554. existing_elements = list(desc.getElementsByTagNameNS(namespace, name))
  555. for elem in existing_elements:
  556. desc.removeChild(elem)
  557. if values:
  558. doc = self.rdf_root.ownerDocument
  559. if doc is None:
  560. raise XmpDocumentError("XMP Document is None")
  561. prefix = self._get_namespace_prefix(namespace)
  562. elem = doc.createElementNS(namespace, f"{prefix}:{name}")
  563. bag = doc.createElementNS(RDF_NAMESPACE, "rdf:Bag")
  564. for value in values:
  565. li = doc.createElementNS(RDF_NAMESPACE, "rdf:li")
  566. text_node = doc.createTextNode(str(value))
  567. li.appendChild(text_node)
  568. bag.appendChild(li)
  569. elem.appendChild(bag)
  570. desc.appendChild(elem)
  571. self._update_stream()
  572. def _set_seq_values(self, namespace: str, name: str, values: Optional[list[str]]) -> None:
  573. """Set or remove sequence values (ordered array)."""
  574. self._clear_cache_entry(namespace, name)
  575. desc = self._get_or_create_description()
  576. existing_elements = list(desc.getElementsByTagNameNS(namespace, name))
  577. for elem in existing_elements:
  578. desc.removeChild(elem)
  579. if values:
  580. doc = self.rdf_root.ownerDocument
  581. if doc is None:
  582. raise XmpDocumentError("XMP Document is None")
  583. prefix = self._get_namespace_prefix(namespace)
  584. elem = doc.createElementNS(namespace, f"{prefix}:{name}")
  585. seq = doc.createElementNS(RDF_NAMESPACE, "rdf:Seq")
  586. for value in values:
  587. li = doc.createElementNS(RDF_NAMESPACE, "rdf:li")
  588. text_node = doc.createTextNode(str(value))
  589. li.appendChild(text_node)
  590. seq.appendChild(li)
  591. elem.appendChild(seq)
  592. desc.appendChild(elem)
  593. self._update_stream()
  594. def _set_langalt_values(self, namespace: str, name: str, values: Optional[dict[str, str]]) -> None:
  595. """Set or remove language alternative values."""
  596. self._clear_cache_entry(namespace, name)
  597. desc = self._get_or_create_description()
  598. existing_elements = list(desc.getElementsByTagNameNS(namespace, name))
  599. for elem in existing_elements:
  600. desc.removeChild(elem)
  601. if values:
  602. doc = self.rdf_root.ownerDocument
  603. if doc is None:
  604. raise XmpDocumentError("XMP Document is None")
  605. prefix = self._get_namespace_prefix(namespace)
  606. elem = doc.createElementNS(namespace, f"{prefix}:{name}")
  607. alt = doc.createElementNS(RDF_NAMESPACE, "rdf:Alt")
  608. for lang, value in values.items():
  609. li = doc.createElementNS(RDF_NAMESPACE, "rdf:li")
  610. li.setAttribute("xml:lang", lang)
  611. text_node = doc.createTextNode(str(value))
  612. li.appendChild(text_node)
  613. alt.appendChild(li)
  614. elem.appendChild(alt)
  615. desc.appendChild(elem)
  616. self._update_stream()
  617. def _get_namespace_prefix(self, namespace: str) -> str:
  618. """Get the appropriate namespace prefix for a given namespace URI."""
  619. return _NAMESPACE_PREFIX_MAP.get(namespace, "unknown")
  620. def _update_stream(self) -> None:
  621. """Update the stream with the current XML content."""
  622. doc = self.rdf_root.ownerDocument
  623. if doc is None:
  624. raise XmpDocumentError("XMP Document is None")
  625. xml_data = doc.toxml(encoding="utf-8")
  626. self.stream.set_data(xml_data)