Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/pypdf/xmp.py: 33%
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
1"""
2Anything related to Extensible Metadata Platform (XMP) metadata.
4https://en.wikipedia.org/wiki/Extensible_Metadata_Platform
5"""
7import datetime
8import decimal
9import re
10from collections.abc import Iterator
11from string import hexdigits
12from typing import (
13 Any,
14 Callable,
15 Optional,
16 TypeVar,
17 Union,
18 cast,
19)
20from xml.dom.expatbuilder import ExpatBuilderNS
21from xml.dom.minidom import Document
22from xml.dom.minidom import Element as XmlElement
23from xml.dom.xmlbuilder import Options
24from xml.parsers.expat import ExpatError, XMLParserType
26from ._protocols import XmpInformationProtocol
27from ._utils import StreamType, deprecate_with_replacement, deprecation_no_replacement
28from .errors import LimitReachedError, PdfReadError, XmpDocumentError
29from .generic import ContentStream, PdfObject, StreamObject
31XMP_MAX_INPUT_LENGTH = 5_000_000
32XMP_MAX_ELEMENT_COUNT = 100_000
34RDF_NAMESPACE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
35DC_NAMESPACE = "http://purl.org/dc/elements/1.1/"
36XMP_NAMESPACE = "http://ns.adobe.com/xap/1.0/"
37PDF_NAMESPACE = "http://ns.adobe.com/pdf/1.3/"
38XMPMM_NAMESPACE = "http://ns.adobe.com/xap/1.0/mm/"
40# What is the PDFX namespace, you might ask?
41# It's documented here: https://github.com/adobe/xmp-docs/raw/master/XMPSpecifications/XMPSpecificationPart3.pdf
42# This namespace is used to place "custom metadata"
43# properties, which are arbitrary metadata properties with no semantic or
44# documented meaning.
45#
46# Elements in the namespace are key/value-style storage,
47# where the element name is the key and the content is the value. The keys
48# are transformed into valid XML identifiers by substituting an invalid
49# identifier character with \u2182 followed by the unicode hex ID of the
50# original character. A key like "my car" is therefore "my\u21820020car".
51#
52# \u2182 is the unicode character \u{ROMAN NUMERAL TEN THOUSAND}
53#
54# The pdfx namespace should be avoided.
55# A custom data schema and sensical XML elements could be used instead, as is
56# suggested by Adobe's own documentation on XMP under "Extensibility of
57# Schemas".
58PDFX_NAMESPACE = "http://ns.adobe.com/pdfx/1.3/"
60# PDF/A
61PDFAID_NAMESPACE = "http://www.aiim.org/pdfa/ns/id/"
63# Internal mapping of namespace URI → prefix
64_NAMESPACE_PREFIX_MAP = {
65 DC_NAMESPACE: "dc",
66 XMP_NAMESPACE: "xmp",
67 PDF_NAMESPACE: "pdf",
68 XMPMM_NAMESPACE: "xmpMM",
69 PDFAID_NAMESPACE: "pdfaid",
70 PDFX_NAMESPACE: "pdfx",
71}
73iso8601 = re.compile(
74 """
75 (?P<year>[0-9]{4})
76 (-
77 (?P<month>[0-9]{2})
78 (-
79 (?P<day>[0-9]+)
80 (T
81 (?P<hour>[0-9]{2}):
82 (?P<minute>[0-9]{2})
83 (:(?P<second>[0-9]{2}(.[0-9]+)?))?
84 (?P<tzd>Z|[-+][0-9]{2}:[0-9]{2})
85 )?
86 )?
87 )?
88 """,
89 re.VERBOSE,
90)
93K = TypeVar("K")
95# Minimal XMP template
96_MINIMAL_XMP = f"""<?xpacket begin="\ufeff" id="W5M0MpCehiHzreSzNTczkc9d"?>
97<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="pypdf">
98 <rdf:RDF xmlns:rdf="{RDF_NAMESPACE}">
99 <rdf:Description rdf:about=""
100 xmlns:dc="{DC_NAMESPACE}"
101 xmlns:xmp="{XMP_NAMESPACE}"
102 xmlns:pdf="{PDF_NAMESPACE}"
103 xmlns:xmpMM="{XMPMM_NAMESPACE}"
104 xmlns:pdfaid="{PDFAID_NAMESPACE}"
105 xmlns:pdfx="{PDFX_NAMESPACE}">
106 </rdf:Description>
107 </rdf:RDF>
108</x:xmpmeta>
109<?xpacket end="r"?>"""
112def _identity(value: K) -> K:
113 return value
116def _converter_date(value: str) -> datetime.datetime:
117 matches = iso8601.match(value)
118 if matches is None:
119 raise ValueError(f"Invalid date format: {value}")
120 year = int(matches.group("year"))
121 month = int(matches.group("month") or "1")
122 day = int(matches.group("day") or "1")
123 hour = int(matches.group("hour") or "0")
124 minute = int(matches.group("minute") or "0")
125 second = decimal.Decimal(matches.group("second") or "0")
126 seconds_dec = second.to_integral(decimal.ROUND_FLOOR)
127 milliseconds_dec = (second - seconds_dec) * 1_000_000
129 seconds = int(seconds_dec)
130 milliseconds = int(milliseconds_dec)
132 tzd = matches.group("tzd") or "Z"
133 dt = datetime.datetime(year, month, day, hour, minute, seconds, milliseconds)
134 if tzd != "Z":
135 tzd_hours, tzd_minutes = (int(x) for x in tzd.split(":"))
136 tzd_hours *= -1
137 if tzd_hours < 0:
138 tzd_minutes *= -1
139 dt = dt + datetime.timedelta(hours=tzd_hours, minutes=tzd_minutes)
140 return dt
143def _format_datetime_utc(value: datetime.datetime) -> str:
144 """Format a datetime as UTC with trailing 'Z'.
146 - If the input is timezone-aware, convert to UTC first.
147 - If naive, assume UTC.
148 """
149 if value.tzinfo is not None and value.utcoffset() is not None:
150 value = value.astimezone(datetime.timezone.utc)
152 value = value.replace(tzinfo=None)
153 return value.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
156def _generic_get(
157 element: XmlElement, self: "XmpInformation", list_type: str, converter: Callable[[Any], Any] = _identity
158) -> Optional[list[str]]:
159 containers = element.getElementsByTagNameNS(RDF_NAMESPACE, list_type)
160 retval: list[Any] = []
161 if len(containers):
162 for container in containers:
163 for item in container.getElementsByTagNameNS(RDF_NAMESPACE, "li"):
164 value = self._get_text(item)
165 value = converter(value)
166 retval.append(value)
167 return retval
168 return None
171class _XmpBuilder(ExpatBuilderNS):
172 """
173 Custom XML parser denying all entity declarations.
175 This is a stripped down and typed version inspired by what *defusedxml* does.
177 Why do we need this? The default limits of *libexpat* used by Python only block exponential entity expansion,
178 but not cases like quadratic entity expansion which can still cause quite some memory usage.
179 """
181 def __init__(self, options: Optional[Options] = None) -> None:
182 super().__init__(options=options)
183 self._element_count = 0
185 def custom_entity_declaration_handler(
186 self,
187 entity_name: str,
188 # expat passes an int here rather than a bool, but typeshed declares
189 # the handler with a bool, so the annotation follows typeshed.
190 is_parameter_entity: bool,
191 value: Optional[str],
192 base: Optional[str],
193 system_id: str,
194 public_id: Optional[str],
195 notation_name: Optional[str],
196 ) -> None:
197 raise ExpatError(f"Forbidden entities: {entity_name!r}")
199 def start_element_handler(self, name: str, attributes: list[str]) -> None:
200 self._element_count += 1
201 if self._element_count > XMP_MAX_ELEMENT_COUNT:
202 raise LimitReachedError(f"XMP metadata exceeds limit of {XMP_MAX_ELEMENT_COUNT} elements.")
203 super().start_element_handler(name=name, attributes=attributes)
205 def install(self, parser: XMLParserType) -> None:
206 super().install(parser)
208 parser.EntityDeclHandler = self.custom_entity_declaration_handler
209 parser.StartElementHandler = self.start_element_handler
212class XmpInformation(XmpInformationProtocol, PdfObject):
213 """
214 An object that represents Extensible Metadata Platform (XMP) metadata.
215 Usually accessed by :py:attr:`xmp_metadata()<pypdf.PdfReader.xmp_metadata>`.
217 Raises:
218 PdfReadError: if XML is invalid
220 """
222 def __init__(self, stream: StreamObject) -> None:
223 self.stream = stream
224 try:
225 data = self.stream.get_data()
226 if (length := len(data)) > XMP_MAX_INPUT_LENGTH:
227 raise LimitReachedError(f"XMP stream size {length} exceeds limit of {XMP_MAX_INPUT_LENGTH}.")
228 doc_root: Document = _XmpBuilder().parseString(data)
229 except (AttributeError, ExpatError) as e:
230 raise PdfReadError(f"XML in XmpInformation was invalid: {e}")
231 rdf_roots = doc_root.getElementsByTagNameNS(RDF_NAMESPACE, "RDF")
232 if not rdf_roots:
233 raise PdfReadError(
234 "XML in XmpInformation was invalid: Missing rdf:RDF root element"
235 )
236 self.rdf_root: XmlElement = rdf_roots[0]
237 self.cache: dict[Any, Any] = {}
239 @classmethod
240 def create(cls) -> "XmpInformation":
241 """
242 Create a new XmpInformation object with minimal structure.
244 Returns:
245 A new XmpInformation instance with empty metadata fields.
246 """
247 stream = ContentStream(None, None)
248 stream.set_data(_MINIMAL_XMP.encode("utf-8"))
249 return cls(stream)
251 def write_to_stream(
252 self, stream: StreamType, encryption_key: Union[str, bytes, None] = None
253 ) -> None:
254 deprecate_with_replacement(
255 "XmpInformation.write_to_stream",
256 "PdfWriter.xmp_metadata",
257 "6.0.0"
258 )
259 if encryption_key is not None: # deprecated
260 deprecation_no_replacement(
261 "the encryption_key parameter of write_to_stream", "5.0.0"
262 )
263 self.stream.write_to_stream(stream)
265 def get_element(self, about_uri: str, namespace: str, name: str) -> Iterator[Any]:
266 for desc in self.rdf_root.getElementsByTagNameNS(RDF_NAMESPACE, "Description"):
267 if desc.getAttributeNS(RDF_NAMESPACE, "about") == about_uri:
268 attr = desc.getAttributeNodeNS(namespace, name)
269 if attr is not None:
270 yield attr
271 yield from desc.getElementsByTagNameNS(namespace, name)
273 def get_nodes_in_namespace(self, about_uri: str, namespace: str) -> Iterator[Any]:
274 for desc in self.rdf_root.getElementsByTagNameNS(RDF_NAMESPACE, "Description"):
275 if desc.getAttributeNS(RDF_NAMESPACE, "about") == about_uri:
276 for i in range(desc.attributes.length):
277 attr = desc.attributes.item(i)
278 if attr and attr.namespaceURI == namespace:
279 yield attr
280 for child in desc.childNodes:
281 if child.namespaceURI == namespace:
282 yield child
284 def _get_text(self, element: XmlElement) -> str:
285 text = ""
286 for child in element.childNodes:
287 if child.nodeType == child.TEXT_NODE:
288 text += child.data
289 return text
291 def _get_single_value(
292 self,
293 namespace: str,
294 name: str,
295 converter: Callable[[str], Any] = _identity,
296 ) -> Optional[Any]:
297 cached = self.cache.get(namespace, {}).get(name)
298 if cached:
299 return cached
300 value = None
301 for element in self.get_element("", namespace, name):
302 if element.nodeType == element.ATTRIBUTE_NODE:
303 value = element.nodeValue
304 else:
305 value = self._get_text(element)
306 break
307 if value is not None:
308 value = converter(value)
309 ns_cache = self.cache.setdefault(namespace, {})
310 ns_cache[name] = value
311 return value
313 def _getter_bag(self, namespace: str, name: str) -> Optional[list[str]]:
314 cached = self.cache.get(namespace, {}).get(name)
315 if cached:
316 return cast(list[str], cached)
317 retval: list[str] = []
318 for element in self.get_element("", namespace, name):
319 if (bags := _generic_get(element, self, list_type="Bag")) is not None:
320 retval.extend(bags)
321 else:
322 value = self._get_text(element)
323 retval.append(value)
324 ns_cache = self.cache.setdefault(namespace, {})
325 ns_cache[name] = retval
326 return retval
328 def _get_seq_values(
329 self,
330 namespace: str,
331 name: str,
332 converter: Callable[[Any], Any] = _identity,
333 ) -> Optional[list[Any]]:
334 cached = self.cache.get(namespace, {}).get(name)
335 if cached:
336 return cast(list[Any], cached)
337 retval: list[Any] = []
338 for element in self.get_element("", namespace, name):
339 if (seqs := _generic_get(element, self, list_type="Seq", converter=converter)) is not None:
340 retval.extend(seqs)
341 elif (bags := _generic_get(element, self, list_type="Bag")) is not None:
342 # See issue at https://github.com/py-pdf/pypdf/issues/3324
343 # Some applications violate the XMP metadata standard regarding `dc:creator` which should
344 # be an "ordered array" and thus a sequence, but use an unordered array (bag) instead.
345 # This seems to stem from the fact that the original Dublin Core specification does indeed
346 # use bags or direct values, while PDFs are expected to follow the XMP standard and ignore
347 # the plain Dublin Core variant. For this reason, add a fallback here to deal with such
348 # issues accordingly.
349 retval.extend(bags)
350 else:
351 value = converter(self._get_text(element))
352 retval.append(value)
353 ns_cache = self.cache.setdefault(namespace, {})
354 ns_cache[name] = retval
355 return retval
357 def _get_langalt_values(self, namespace: str, name: str) -> Optional[dict[Any, Any]]:
358 cached = self.cache.get(namespace, {}).get(name)
359 if cached:
360 return cast(dict[Any, Any], cached)
361 retval: dict[Any, Any] = {}
362 for element in self.get_element("", namespace, name):
363 alts = element.getElementsByTagNameNS(RDF_NAMESPACE, "Alt")
364 if len(alts):
365 for alt in alts:
366 for item in alt.getElementsByTagNameNS(RDF_NAMESPACE, "li"):
367 value = self._get_text(item)
368 retval[item.getAttribute("xml:lang")] = value
369 else:
370 retval["x-default"] = self._get_text(element)
371 ns_cache = self.cache.setdefault(namespace, {})
372 ns_cache[name] = retval
373 return retval
375 @property
376 def dc_contributor(self) -> Optional[list[str]]:
377 """Contributors to the resource (other than the authors)."""
378 return self._getter_bag(DC_NAMESPACE, "contributor")
380 @dc_contributor.setter
381 def dc_contributor(self, values: Optional[list[str]]) -> None:
382 self._set_bag_values(DC_NAMESPACE, "contributor", values)
384 @property
385 def dc_coverage(self) -> Optional[str]:
386 """Text describing the extent or scope of the resource."""
387 return self._get_single_value(DC_NAMESPACE, "coverage")
389 @dc_coverage.setter
390 def dc_coverage(self, value: Optional[str]) -> None:
391 self._set_single_value(DC_NAMESPACE, "coverage", value)
393 @property
394 def dc_creator(self) -> Optional[list[str]]:
395 """A sorted array of names of the authors of the resource, listed in order of precedence."""
396 return self._get_seq_values(DC_NAMESPACE, "creator")
398 @dc_creator.setter
399 def dc_creator(self, values: Optional[list[str]]) -> None:
400 self._set_seq_values(DC_NAMESPACE, "creator", values)
402 @property
403 def dc_date(self) -> Optional[list[datetime.datetime]]:
404 """A sorted array of dates of significance to the resource. The dates and times are in UTC."""
405 return self._get_seq_values(DC_NAMESPACE, "date", _converter_date)
407 @dc_date.setter
408 def dc_date(self, values: Optional[list[Union[str, datetime.datetime]]]) -> None:
409 if values is None:
410 self._set_seq_values(DC_NAMESPACE, "date", None)
411 else:
412 date_strings = []
413 for value in values:
414 if isinstance(value, datetime.datetime):
415 date_strings.append(_format_datetime_utc(value))
416 else:
417 date_strings.append(str(value))
418 self._set_seq_values(DC_NAMESPACE, "date", date_strings)
420 @property
421 def dc_description(self) -> Optional[dict[str, str]]:
422 """A language-keyed dictionary of textual descriptions of the content of the resource."""
423 return self._get_langalt_values(DC_NAMESPACE, "description")
425 @dc_description.setter
426 def dc_description(self, values: Optional[dict[str, str]]) -> None:
427 self._set_langalt_values(DC_NAMESPACE, "description", values)
429 @property
430 def dc_format(self) -> Optional[str]:
431 """The mime-type of the resource."""
432 return self._get_single_value(DC_NAMESPACE, "format")
434 @dc_format.setter
435 def dc_format(self, value: Optional[str]) -> None:
436 self._set_single_value(DC_NAMESPACE, "format", value)
438 @property
439 def dc_identifier(self) -> Optional[str]:
440 """Unique identifier of the resource."""
441 return self._get_single_value(DC_NAMESPACE, "identifier")
443 @dc_identifier.setter
444 def dc_identifier(self, value: Optional[str]) -> None:
445 self._set_single_value(DC_NAMESPACE, "identifier", value)
447 @property
448 def dc_language(self) -> Optional[list[str]]:
449 """An unordered array specifying the languages used in the resource."""
450 return self._getter_bag(DC_NAMESPACE, "language")
452 @dc_language.setter
453 def dc_language(self, values: Optional[list[str]]) -> None:
454 self._set_bag_values(DC_NAMESPACE, "language", values)
456 @property
457 def dc_publisher(self) -> Optional[list[str]]:
458 """An unordered array of publisher names."""
459 return self._getter_bag(DC_NAMESPACE, "publisher")
461 @dc_publisher.setter
462 def dc_publisher(self, values: Optional[list[str]]) -> None:
463 self._set_bag_values(DC_NAMESPACE, "publisher", values)
465 @property
466 def dc_relation(self) -> Optional[list[str]]:
467 """An unordered array of text descriptions of relationships to other documents."""
468 return self._getter_bag(DC_NAMESPACE, "relation")
470 @dc_relation.setter
471 def dc_relation(self, values: Optional[list[str]]) -> None:
472 self._set_bag_values(DC_NAMESPACE, "relation", values)
474 @property
475 def dc_rights(self) -> Optional[dict[str, str]]:
476 """A language-keyed dictionary of textual descriptions of the rights the user has to this resource."""
477 return self._get_langalt_values(DC_NAMESPACE, "rights")
479 @dc_rights.setter
480 def dc_rights(self, values: Optional[dict[str, str]]) -> None:
481 self._set_langalt_values(DC_NAMESPACE, "rights", values)
483 @property
484 def dc_source(self) -> Optional[str]:
485 """Unique identifier of the work from which this resource was derived."""
486 return self._get_single_value(DC_NAMESPACE, "source")
488 @dc_source.setter
489 def dc_source(self, value: Optional[str]) -> None:
490 self._set_single_value(DC_NAMESPACE, "source", value)
492 @property
493 def dc_subject(self) -> Optional[list[str]]:
494 """An unordered array of descriptive phrases or keywords that specify the topic of the content."""
495 return self._getter_bag(DC_NAMESPACE, "subject")
497 @dc_subject.setter
498 def dc_subject(self, values: Optional[list[str]]) -> None:
499 self._set_bag_values(DC_NAMESPACE, "subject", values)
501 @property
502 def dc_title(self) -> Optional[dict[str, str]]:
503 """A language-keyed dictionary of the title of the resource."""
504 return self._get_langalt_values(DC_NAMESPACE, "title")
506 @dc_title.setter
507 def dc_title(self, values: Optional[dict[str, str]]) -> None:
508 self._set_langalt_values(DC_NAMESPACE, "title", values)
510 @property
511 def dc_type(self) -> Optional[list[str]]:
512 """An unordered array of textual descriptions of the document type."""
513 return self._getter_bag(DC_NAMESPACE, "type")
515 @dc_type.setter
516 def dc_type(self, values: Optional[list[str]]) -> None:
517 self._set_bag_values(DC_NAMESPACE, "type", values)
519 @property
520 def pdf_keywords(self) -> Optional[str]:
521 """An unformatted text string representing document keywords."""
522 return self._get_single_value(PDF_NAMESPACE, "Keywords")
524 @pdf_keywords.setter
525 def pdf_keywords(self, value: Optional[str]) -> None:
526 self._set_single_value(PDF_NAMESPACE, "Keywords", value)
528 @property
529 def pdf_pdfversion(self) -> Optional[str]:
530 """The PDF file version, for example 1.0 or 1.3."""
531 return self._get_single_value(PDF_NAMESPACE, "PDFVersion")
533 @pdf_pdfversion.setter
534 def pdf_pdfversion(self, value: Optional[str]) -> None:
535 self._set_single_value(PDF_NAMESPACE, "PDFVersion", value)
537 @property
538 def pdf_producer(self) -> Optional[str]:
539 """The name of the tool that saved the document as a PDF."""
540 return self._get_single_value(PDF_NAMESPACE, "Producer")
542 @pdf_producer.setter
543 def pdf_producer(self, value: Optional[str]) -> None:
544 self._set_single_value(PDF_NAMESPACE, "Producer", value)
546 @property
547 def xmp_create_date(self) -> Optional[datetime.datetime]:
548 """The date and time the resource was originally created. Returned as a UTC datetime object."""
549 return self._get_single_value(XMP_NAMESPACE, "CreateDate", _converter_date)
551 @xmp_create_date.setter
552 def xmp_create_date(self, value: Optional[datetime.datetime]) -> None:
553 if value:
554 date_str = _format_datetime_utc(value)
555 self._set_single_value(XMP_NAMESPACE, "CreateDate", date_str)
556 else:
557 self._set_single_value(XMP_NAMESPACE, "CreateDate", None)
559 @property
560 def xmp_modify_date(self) -> Optional[datetime.datetime]:
561 """The date and time the resource was last modified. Returned as a UTC datetime object."""
562 return self._get_single_value(XMP_NAMESPACE, "ModifyDate", _converter_date)
564 @xmp_modify_date.setter
565 def xmp_modify_date(self, value: Optional[datetime.datetime]) -> None:
566 if value:
567 date_str = _format_datetime_utc(value)
568 self._set_single_value(XMP_NAMESPACE, "ModifyDate", date_str)
569 else:
570 self._set_single_value(XMP_NAMESPACE, "ModifyDate", None)
572 @property
573 def xmp_metadata_date(self) -> Optional[datetime.datetime]:
574 """The date and time that any metadata for this resource was last changed. Returned as a UTC datetime object."""
575 return self._get_single_value(XMP_NAMESPACE, "MetadataDate", _converter_date)
577 @xmp_metadata_date.setter
578 def xmp_metadata_date(self, value: Optional[datetime.datetime]) -> None:
579 if value:
580 date_str = _format_datetime_utc(value)
581 self._set_single_value(XMP_NAMESPACE, "MetadataDate", date_str)
582 else:
583 self._set_single_value(XMP_NAMESPACE, "MetadataDate", None)
585 @property
586 def xmp_creator_tool(self) -> Optional[str]:
587 """The name of the first known tool used to create the resource."""
588 return self._get_single_value(XMP_NAMESPACE, "CreatorTool")
590 @xmp_creator_tool.setter
591 def xmp_creator_tool(self, value: Optional[str]) -> None:
592 self._set_single_value(XMP_NAMESPACE, "CreatorTool", value)
594 @property
595 def xmpmm_document_id(self) -> Optional[str]:
596 """The common identifier for all versions and renditions of this resource."""
597 return self._get_single_value(XMPMM_NAMESPACE, "DocumentID")
599 @xmpmm_document_id.setter
600 def xmpmm_document_id(self, value: Optional[str]) -> None:
601 self._set_single_value(XMPMM_NAMESPACE, "DocumentID", value)
603 @property
604 def xmpmm_instance_id(self) -> Optional[str]:
605 """An identifier for a specific incarnation of a document, updated each time a file is saved."""
606 return self._get_single_value(XMPMM_NAMESPACE, "InstanceID")
608 @xmpmm_instance_id.setter
609 def xmpmm_instance_id(self, value: Optional[str]) -> None:
610 self._set_single_value(XMPMM_NAMESPACE, "InstanceID", value)
612 @property
613 def pdfaid_part(self) -> Optional[str]:
614 """The part of the PDF/A standard that the document conforms to (e.g., 1, 2, 3)."""
615 return self._get_single_value(PDFAID_NAMESPACE, "part")
617 @pdfaid_part.setter
618 def pdfaid_part(self, value: Optional[str]) -> None:
619 self._set_single_value(PDFAID_NAMESPACE, "part", value)
621 @property
622 def pdfaid_conformance(self) -> Optional[str]:
623 """The conformance level within the PDF/A standard (e.g., 'A', 'B', 'U')."""
624 return self._get_single_value(PDFAID_NAMESPACE, "conformance")
626 @pdfaid_conformance.setter
627 def pdfaid_conformance(self, value: Optional[str]) -> None:
628 self._set_single_value(PDFAID_NAMESPACE, "conformance", value)
630 @property
631 def custom_properties(self) -> dict[Any, Any]:
632 """
633 Retrieve custom metadata properties defined in the undocumented pdfx
634 metadata schema.
636 Returns:
637 A dictionary of key/value items for custom metadata properties.
639 """
640 if not hasattr(self, "_custom_properties"):
641 self._custom_properties = {}
642 for node in self.get_nodes_in_namespace("", PDFX_NAMESPACE):
643 key = node.localName
644 start = 0
645 while True:
646 # see documentation about PDFX_NAMESPACE earlier in file
647 idx = key.find("\u2182", start)
648 if idx == -1:
649 break
650 hex_id = key[idx + 1 : idx + 5]
651 if len(hex_id) != 4 or not all(c in hexdigits for c in hex_id):
652 # Not a well-formed escape; leave the marker untouched
653 # and continue past it instead of crashing.
654 start = idx + 1
655 continue
656 key = key[:idx] + chr(int(hex_id, base=16)) + key[idx + 5 :]
657 start = idx + 1
658 if node.nodeType == node.ATTRIBUTE_NODE:
659 value = node.nodeValue
660 else:
661 value = self._get_text(node)
662 self._custom_properties[key] = value
663 return self._custom_properties
665 def _get_or_create_description(self, about_uri: str = "") -> XmlElement:
666 """Get or create an rdf:Description element with the given about URI."""
667 for desc in self.rdf_root.getElementsByTagNameNS(RDF_NAMESPACE, "Description"):
668 if desc.getAttributeNS(RDF_NAMESPACE, "about") == about_uri:
669 return desc
671 doc = self.rdf_root.ownerDocument
672 if doc is None:
673 raise XmpDocumentError("XMP Document is None")
674 desc = doc.createElementNS(RDF_NAMESPACE, "rdf:Description")
675 desc.setAttributeNS(RDF_NAMESPACE, "rdf:about", about_uri)
676 self.rdf_root.appendChild(desc)
677 return desc
679 def _clear_cache_entry(self, namespace: str, name: str) -> None:
680 """Remove a cached value for a given namespace/name if present."""
681 ns_cache = self.cache.get(namespace)
682 if ns_cache and name in ns_cache:
683 del ns_cache[name]
685 def _set_single_value(self, namespace: str, name: str, value: Optional[str]) -> None:
686 """Set or remove a single metadata value."""
687 self._clear_cache_entry(namespace, name)
688 desc = self._get_or_create_description()
690 existing_elements = list(desc.getElementsByTagNameNS(namespace, name))
691 for elem in existing_elements:
692 desc.removeChild(elem)
694 if existing_attr := desc.getAttributeNodeNS(namespace, name):
695 desc.removeAttributeNode(existing_attr)
697 if value is not None:
698 doc = self.rdf_root.ownerDocument
699 if doc is None:
700 raise XmpDocumentError("XMP Document is None")
701 prefix = self._get_namespace_prefix(namespace)
702 elem = doc.createElementNS(namespace, f"{prefix}:{name}")
703 text_node = doc.createTextNode(str(value))
704 elem.appendChild(text_node)
705 desc.appendChild(elem)
707 self._update_stream()
709 def _set_bag_values(self, namespace: str, name: str, values: Optional[list[str]]) -> None:
710 """Set or remove bag values (unordered array)."""
711 self._clear_cache_entry(namespace, name)
712 desc = self._get_or_create_description()
714 existing_elements = list(desc.getElementsByTagNameNS(namespace, name))
715 for elem in existing_elements:
716 desc.removeChild(elem)
718 if values:
719 doc = self.rdf_root.ownerDocument
720 if doc is None:
721 raise XmpDocumentError("XMP Document is None")
722 prefix = self._get_namespace_prefix(namespace)
723 elem = doc.createElementNS(namespace, f"{prefix}:{name}")
724 bag = doc.createElementNS(RDF_NAMESPACE, "rdf:Bag")
726 for value in values:
727 li = doc.createElementNS(RDF_NAMESPACE, "rdf:li")
728 text_node = doc.createTextNode(str(value))
729 li.appendChild(text_node)
730 bag.appendChild(li)
732 elem.appendChild(bag)
733 desc.appendChild(elem)
735 self._update_stream()
737 def _set_seq_values(self, namespace: str, name: str, values: Optional[list[str]]) -> None:
738 """Set or remove sequence values (ordered array)."""
739 self._clear_cache_entry(namespace, name)
740 desc = self._get_or_create_description()
742 existing_elements = list(desc.getElementsByTagNameNS(namespace, name))
743 for elem in existing_elements:
744 desc.removeChild(elem)
746 if values:
747 doc = self.rdf_root.ownerDocument
748 if doc is None:
749 raise XmpDocumentError("XMP Document is None")
750 prefix = self._get_namespace_prefix(namespace)
751 elem = doc.createElementNS(namespace, f"{prefix}:{name}")
752 seq = doc.createElementNS(RDF_NAMESPACE, "rdf:Seq")
754 for value in values:
755 li = doc.createElementNS(RDF_NAMESPACE, "rdf:li")
756 text_node = doc.createTextNode(str(value))
757 li.appendChild(text_node)
758 seq.appendChild(li)
760 elem.appendChild(seq)
761 desc.appendChild(elem)
763 self._update_stream()
765 def _set_langalt_values(self, namespace: str, name: str, values: Optional[dict[str, str]]) -> None:
766 """Set or remove language alternative values."""
767 self._clear_cache_entry(namespace, name)
768 desc = self._get_or_create_description()
770 existing_elements = list(desc.getElementsByTagNameNS(namespace, name))
771 for elem in existing_elements:
772 desc.removeChild(elem)
774 if values:
775 doc = self.rdf_root.ownerDocument
776 if doc is None:
777 raise XmpDocumentError("XMP Document is None")
778 prefix = self._get_namespace_prefix(namespace)
779 elem = doc.createElementNS(namespace, f"{prefix}:{name}")
780 alt = doc.createElementNS(RDF_NAMESPACE, "rdf:Alt")
782 for lang, value in values.items():
783 li = doc.createElementNS(RDF_NAMESPACE, "rdf:li")
784 li.setAttribute("xml:lang", lang)
785 text_node = doc.createTextNode(str(value))
786 li.appendChild(text_node)
787 alt.appendChild(li)
789 elem.appendChild(alt)
790 desc.appendChild(elem)
792 self._update_stream()
794 def _get_namespace_prefix(self, namespace: str) -> str:
795 """Get the appropriate namespace prefix for a given namespace URI."""
796 return _NAMESPACE_PREFIX_MAP.get(namespace, "unknown")
798 def _update_stream(self) -> None:
799 """Update the stream with the current XML content."""
800 doc = self.rdf_root.ownerDocument
801 if doc is None:
802 raise XmpDocumentError("XMP Document is None")
804 xml_data = doc.toxml(encoding="utf-8")
805 self.stream.set_data(xml_data)