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

491 statements  

1""" 

2Anything related to Extensible Metadata Platform (XMP) metadata. 

3 

4https://en.wikipedia.org/wiki/Extensible_Metadata_Platform 

5""" 

6 

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 

25 

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 

30 

31XMP_MAX_INPUT_LENGTH = 5_000_000 

32XMP_MAX_ELEMENT_COUNT = 100_000 

33 

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/" 

39 

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/" 

59 

60# PDF/A 

61PDFAID_NAMESPACE = "http://www.aiim.org/pdfa/ns/id/" 

62 

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} 

72 

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) 

91 

92 

93K = TypeVar("K") 

94 

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"?>""" 

110 

111 

112def _identity(value: K) -> K: 

113 return value 

114 

115 

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 

128 

129 seconds = int(seconds_dec) 

130 milliseconds = int(milliseconds_dec) 

131 

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 

141 

142 

143def _format_datetime_utc(value: datetime.datetime) -> str: 

144 """Format a datetime as UTC with trailing 'Z'. 

145 

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) 

151 

152 value = value.replace(tzinfo=None) 

153 return value.strftime("%Y-%m-%dT%H:%M:%S.%fZ") 

154 

155 

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 

169 

170 

171class _XmpBuilder(ExpatBuilderNS): 

172 """ 

173 Custom XML parser denying all entity declarations. 

174 

175 This is a stripped down and typed version inspired by what *defusedxml* does. 

176 

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 """ 

180 

181 def __init__(self, options: Optional[Options] = None) -> None: 

182 super().__init__(options=options) 

183 self._element_count = 0 

184 

185 def custom_entity_declaration_handler( 

186 self, 

187 entity_name: str, 

188 is_parameter_entity: bool, 

189 value: Optional[str], 

190 base: Optional[str], 

191 system_id: str, 

192 public_id: Optional[str], 

193 notation_name: Optional[str], 

194 ) -> None: 

195 raise ExpatError(f"Forbidden entities: {entity_name!r}") 

196 

197 def start_element_handler(self, name: str, attributes: list[str]) -> None: 

198 self._element_count += 1 

199 if self._element_count > XMP_MAX_ELEMENT_COUNT: 

200 raise LimitReachedError(f"XMP metadata exceeds limit of {XMP_MAX_ELEMENT_COUNT} elements.") 

201 super().start_element_handler(name=name, attributes=attributes) 

202 

203 def install(self, parser: XMLParserType) -> None: 

204 super().install(parser) 

205 

206 parser.EntityDeclHandler = self.custom_entity_declaration_handler 

207 parser.StartElementHandler = self.start_element_handler 

208 

209 

210class XmpInformation(XmpInformationProtocol, PdfObject): 

211 """ 

212 An object that represents Extensible Metadata Platform (XMP) metadata. 

213 Usually accessed by :py:attr:`xmp_metadata()<pypdf.PdfReader.xmp_metadata>`. 

214 

215 Raises: 

216 PdfReadError: if XML is invalid 

217 

218 """ 

219 

220 def __init__(self, stream: ContentStream) -> None: 

221 self.stream = stream 

222 try: 

223 data = self.stream.get_data() 

224 if (length := len(data)) > XMP_MAX_INPUT_LENGTH: 

225 raise LimitReachedError(f"XMP stream size {length} exceeds limit of {XMP_MAX_INPUT_LENGTH}.") 

226 doc_root: Document = _XmpBuilder().parseString(data) 

227 except (AttributeError, ExpatError) as e: 

228 raise PdfReadError(f"XML in XmpInformation was invalid: {e}") 

229 rdf_roots = doc_root.getElementsByTagNameNS(RDF_NAMESPACE, "RDF") 

230 if not rdf_roots: 

231 raise PdfReadError( 

232 "XML in XmpInformation was invalid: Missing rdf:RDF root element" 

233 ) 

234 self.rdf_root: XmlElement = rdf_roots[0] 

235 self.cache: dict[Any, Any] = {} 

236 

237 @classmethod 

238 def create(cls) -> "XmpInformation": 

239 """ 

240 Create a new XmpInformation object with minimal structure. 

241 

242 Returns: 

243 A new XmpInformation instance with empty metadata fields. 

244 """ 

245 stream = ContentStream(None, None) 

246 stream.set_data(_MINIMAL_XMP.encode("utf-8")) 

247 return cls(stream) 

248 

249 def write_to_stream( 

250 self, stream: StreamType, encryption_key: Union[str, bytes, None] = None 

251 ) -> None: 

252 deprecate_with_replacement( 

253 "XmpInformation.write_to_stream", 

254 "PdfWriter.xmp_metadata", 

255 "6.0.0" 

256 ) 

257 if encryption_key is not None: # deprecated 

258 deprecation_no_replacement( 

259 "the encryption_key parameter of write_to_stream", "5.0.0" 

260 ) 

261 self.stream.write_to_stream(stream) 

262 

263 def get_element(self, about_uri: str, namespace: str, name: str) -> Iterator[Any]: 

264 for desc in self.rdf_root.getElementsByTagNameNS(RDF_NAMESPACE, "Description"): 

265 if desc.getAttributeNS(RDF_NAMESPACE, "about") == about_uri: 

266 attr = desc.getAttributeNodeNS(namespace, name) 

267 if attr is not None: 

268 yield attr 

269 yield from desc.getElementsByTagNameNS(namespace, name) 

270 

271 def get_nodes_in_namespace(self, about_uri: str, namespace: str) -> Iterator[Any]: 

272 for desc in self.rdf_root.getElementsByTagNameNS(RDF_NAMESPACE, "Description"): 

273 if desc.getAttributeNS(RDF_NAMESPACE, "about") == about_uri: 

274 for i in range(desc.attributes.length): 

275 attr = desc.attributes.item(i) 

276 if attr and attr.namespaceURI == namespace: 

277 yield attr 

278 for child in desc.childNodes: 

279 if child.namespaceURI == namespace: 

280 yield child 

281 

282 def _get_text(self, element: XmlElement) -> str: 

283 text = "" 

284 for child in element.childNodes: 

285 if child.nodeType == child.TEXT_NODE: 

286 text += child.data 

287 return text 

288 

289 def _get_single_value( 

290 self, 

291 namespace: str, 

292 name: str, 

293 converter: Callable[[str], Any] = _identity, 

294 ) -> Optional[Any]: 

295 cached = self.cache.get(namespace, {}).get(name) 

296 if cached: 

297 return cached 

298 value = None 

299 for element in self.get_element("", namespace, name): 

300 if element.nodeType == element.ATTRIBUTE_NODE: 

301 value = element.nodeValue 

302 else: 

303 value = self._get_text(element) 

304 break 

305 if value is not None: 

306 value = converter(value) 

307 ns_cache = self.cache.setdefault(namespace, {}) 

308 ns_cache[name] = value 

309 return value 

310 

311 def _getter_bag(self, namespace: str, name: str) -> Optional[list[str]]: 

312 cached = self.cache.get(namespace, {}).get(name) 

313 if cached: 

314 return cast(list[str], cached) 

315 retval: list[str] = [] 

316 for element in self.get_element("", namespace, name): 

317 if (bags := _generic_get(element, self, list_type="Bag")) is not None: 

318 retval.extend(bags) 

319 else: 

320 value = self._get_text(element) 

321 retval.append(value) 

322 ns_cache = self.cache.setdefault(namespace, {}) 

323 ns_cache[name] = retval 

324 return retval 

325 

326 def _get_seq_values( 

327 self, 

328 namespace: str, 

329 name: str, 

330 converter: Callable[[Any], Any] = _identity, 

331 ) -> Optional[list[Any]]: 

332 cached = self.cache.get(namespace, {}).get(name) 

333 if cached: 

334 return cast(list[Any], cached) 

335 retval: list[Any] = [] 

336 for element in self.get_element("", namespace, name): 

337 if (seqs := _generic_get(element, self, list_type="Seq", converter=converter)) is not None: 

338 retval.extend(seqs) 

339 elif (bags := _generic_get(element, self, list_type="Bag")) is not None: 

340 # See issue at https://github.com/py-pdf/pypdf/issues/3324 

341 # Some applications violate the XMP metadata standard regarding `dc:creator` which should 

342 # be an "ordered array" and thus a sequence, but use an unordered array (bag) instead. 

343 # This seems to stem from the fact that the original Dublin Core specification does indeed 

344 # use bags or direct values, while PDFs are expected to follow the XMP standard and ignore 

345 # the plain Dublin Core variant. For this reason, add a fallback here to deal with such 

346 # issues accordingly. 

347 retval.extend(bags) 

348 else: 

349 value = converter(self._get_text(element)) 

350 retval.append(value) 

351 ns_cache = self.cache.setdefault(namespace, {}) 

352 ns_cache[name] = retval 

353 return retval 

354 

355 def _get_langalt_values(self, namespace: str, name: str) -> Optional[dict[Any, Any]]: 

356 cached = self.cache.get(namespace, {}).get(name) 

357 if cached: 

358 return cast(dict[Any, Any], cached) 

359 retval: dict[Any, Any] = {} 

360 for element in self.get_element("", namespace, name): 

361 alts = element.getElementsByTagNameNS(RDF_NAMESPACE, "Alt") 

362 if len(alts): 

363 for alt in alts: 

364 for item in alt.getElementsByTagNameNS(RDF_NAMESPACE, "li"): 

365 value = self._get_text(item) 

366 retval[item.getAttribute("xml:lang")] = value 

367 else: 

368 retval["x-default"] = self._get_text(element) 

369 ns_cache = self.cache.setdefault(namespace, {}) 

370 ns_cache[name] = retval 

371 return retval 

372 

373 @property 

374 def dc_contributor(self) -> Optional[list[str]]: 

375 """Contributors to the resource (other than the authors).""" 

376 return self._getter_bag(DC_NAMESPACE, "contributor") 

377 

378 @dc_contributor.setter 

379 def dc_contributor(self, values: Optional[list[str]]) -> None: 

380 self._set_bag_values(DC_NAMESPACE, "contributor", values) 

381 

382 @property 

383 def dc_coverage(self) -> Optional[str]: 

384 """Text describing the extent or scope of the resource.""" 

385 return self._get_single_value(DC_NAMESPACE, "coverage") 

386 

387 @dc_coverage.setter 

388 def dc_coverage(self, value: Optional[str]) -> None: 

389 self._set_single_value(DC_NAMESPACE, "coverage", value) 

390 

391 @property 

392 def dc_creator(self) -> Optional[list[str]]: 

393 """A sorted array of names of the authors of the resource, listed in order of precedence.""" 

394 return self._get_seq_values(DC_NAMESPACE, "creator") 

395 

396 @dc_creator.setter 

397 def dc_creator(self, values: Optional[list[str]]) -> None: 

398 self._set_seq_values(DC_NAMESPACE, "creator", values) 

399 

400 @property 

401 def dc_date(self) -> Optional[list[datetime.datetime]]: 

402 """A sorted array of dates of significance to the resource. The dates and times are in UTC.""" 

403 return self._get_seq_values(DC_NAMESPACE, "date", _converter_date) 

404 

405 @dc_date.setter 

406 def dc_date(self, values: Optional[list[Union[str, datetime.datetime]]]) -> None: 

407 if values is None: 

408 self._set_seq_values(DC_NAMESPACE, "date", None) 

409 else: 

410 date_strings = [] 

411 for value in values: 

412 if isinstance(value, datetime.datetime): 

413 date_strings.append(_format_datetime_utc(value)) 

414 else: 

415 date_strings.append(str(value)) 

416 self._set_seq_values(DC_NAMESPACE, "date", date_strings) 

417 

418 @property 

419 def dc_description(self) -> Optional[dict[str, str]]: 

420 """A language-keyed dictionary of textual descriptions of the content of the resource.""" 

421 return self._get_langalt_values(DC_NAMESPACE, "description") 

422 

423 @dc_description.setter 

424 def dc_description(self, values: Optional[dict[str, str]]) -> None: 

425 self._set_langalt_values(DC_NAMESPACE, "description", values) 

426 

427 @property 

428 def dc_format(self) -> Optional[str]: 

429 """The mime-type of the resource.""" 

430 return self._get_single_value(DC_NAMESPACE, "format") 

431 

432 @dc_format.setter 

433 def dc_format(self, value: Optional[str]) -> None: 

434 self._set_single_value(DC_NAMESPACE, "format", value) 

435 

436 @property 

437 def dc_identifier(self) -> Optional[str]: 

438 """Unique identifier of the resource.""" 

439 return self._get_single_value(DC_NAMESPACE, "identifier") 

440 

441 @dc_identifier.setter 

442 def dc_identifier(self, value: Optional[str]) -> None: 

443 self._set_single_value(DC_NAMESPACE, "identifier", value) 

444 

445 @property 

446 def dc_language(self) -> Optional[list[str]]: 

447 """An unordered array specifying the languages used in the resource.""" 

448 return self._getter_bag(DC_NAMESPACE, "language") 

449 

450 @dc_language.setter 

451 def dc_language(self, values: Optional[list[str]]) -> None: 

452 self._set_bag_values(DC_NAMESPACE, "language", values) 

453 

454 @property 

455 def dc_publisher(self) -> Optional[list[str]]: 

456 """An unordered array of publisher names.""" 

457 return self._getter_bag(DC_NAMESPACE, "publisher") 

458 

459 @dc_publisher.setter 

460 def dc_publisher(self, values: Optional[list[str]]) -> None: 

461 self._set_bag_values(DC_NAMESPACE, "publisher", values) 

462 

463 @property 

464 def dc_relation(self) -> Optional[list[str]]: 

465 """An unordered array of text descriptions of relationships to other documents.""" 

466 return self._getter_bag(DC_NAMESPACE, "relation") 

467 

468 @dc_relation.setter 

469 def dc_relation(self, values: Optional[list[str]]) -> None: 

470 self._set_bag_values(DC_NAMESPACE, "relation", values) 

471 

472 @property 

473 def dc_rights(self) -> Optional[dict[str, str]]: 

474 """A language-keyed dictionary of textual descriptions of the rights the user has to this resource.""" 

475 return self._get_langalt_values(DC_NAMESPACE, "rights") 

476 

477 @dc_rights.setter 

478 def dc_rights(self, values: Optional[dict[str, str]]) -> None: 

479 self._set_langalt_values(DC_NAMESPACE, "rights", values) 

480 

481 @property 

482 def dc_source(self) -> Optional[str]: 

483 """Unique identifier of the work from which this resource was derived.""" 

484 return self._get_single_value(DC_NAMESPACE, "source") 

485 

486 @dc_source.setter 

487 def dc_source(self, value: Optional[str]) -> None: 

488 self._set_single_value(DC_NAMESPACE, "source", value) 

489 

490 @property 

491 def dc_subject(self) -> Optional[list[str]]: 

492 """An unordered array of descriptive phrases or keywords that specify the topic of the content.""" 

493 return self._getter_bag(DC_NAMESPACE, "subject") 

494 

495 @dc_subject.setter 

496 def dc_subject(self, values: Optional[list[str]]) -> None: 

497 self._set_bag_values(DC_NAMESPACE, "subject", values) 

498 

499 @property 

500 def dc_title(self) -> Optional[dict[str, str]]: 

501 """A language-keyed dictionary of the title of the resource.""" 

502 return self._get_langalt_values(DC_NAMESPACE, "title") 

503 

504 @dc_title.setter 

505 def dc_title(self, values: Optional[dict[str, str]]) -> None: 

506 self._set_langalt_values(DC_NAMESPACE, "title", values) 

507 

508 @property 

509 def dc_type(self) -> Optional[list[str]]: 

510 """An unordered array of textual descriptions of the document type.""" 

511 return self._getter_bag(DC_NAMESPACE, "type") 

512 

513 @dc_type.setter 

514 def dc_type(self, values: Optional[list[str]]) -> None: 

515 self._set_bag_values(DC_NAMESPACE, "type", values) 

516 

517 @property 

518 def pdf_keywords(self) -> Optional[str]: 

519 """An unformatted text string representing document keywords.""" 

520 return self._get_single_value(PDF_NAMESPACE, "Keywords") 

521 

522 @pdf_keywords.setter 

523 def pdf_keywords(self, value: Optional[str]) -> None: 

524 self._set_single_value(PDF_NAMESPACE, "Keywords", value) 

525 

526 @property 

527 def pdf_pdfversion(self) -> Optional[str]: 

528 """The PDF file version, for example 1.0 or 1.3.""" 

529 return self._get_single_value(PDF_NAMESPACE, "PDFVersion") 

530 

531 @pdf_pdfversion.setter 

532 def pdf_pdfversion(self, value: Optional[str]) -> None: 

533 self._set_single_value(PDF_NAMESPACE, "PDFVersion", value) 

534 

535 @property 

536 def pdf_producer(self) -> Optional[str]: 

537 """The name of the tool that saved the document as a PDF.""" 

538 return self._get_single_value(PDF_NAMESPACE, "Producer") 

539 

540 @pdf_producer.setter 

541 def pdf_producer(self, value: Optional[str]) -> None: 

542 self._set_single_value(PDF_NAMESPACE, "Producer", value) 

543 

544 @property 

545 def xmp_create_date(self) -> Optional[datetime.datetime]: 

546 """The date and time the resource was originally created. Returned as a UTC datetime object.""" 

547 return self._get_single_value(XMP_NAMESPACE, "CreateDate", _converter_date) 

548 

549 @xmp_create_date.setter 

550 def xmp_create_date(self, value: Optional[datetime.datetime]) -> None: 

551 if value: 

552 date_str = _format_datetime_utc(value) 

553 self._set_single_value(XMP_NAMESPACE, "CreateDate", date_str) 

554 else: 

555 self._set_single_value(XMP_NAMESPACE, "CreateDate", None) 

556 

557 @property 

558 def xmp_modify_date(self) -> Optional[datetime.datetime]: 

559 """The date and time the resource was last modified. Returned as a UTC datetime object.""" 

560 return self._get_single_value(XMP_NAMESPACE, "ModifyDate", _converter_date) 

561 

562 @xmp_modify_date.setter 

563 def xmp_modify_date(self, value: Optional[datetime.datetime]) -> None: 

564 if value: 

565 date_str = _format_datetime_utc(value) 

566 self._set_single_value(XMP_NAMESPACE, "ModifyDate", date_str) 

567 else: 

568 self._set_single_value(XMP_NAMESPACE, "ModifyDate", None) 

569 

570 @property 

571 def xmp_metadata_date(self) -> Optional[datetime.datetime]: 

572 """The date and time that any metadata for this resource was last changed. Returned as a UTC datetime object.""" 

573 return self._get_single_value(XMP_NAMESPACE, "MetadataDate", _converter_date) 

574 

575 @xmp_metadata_date.setter 

576 def xmp_metadata_date(self, value: Optional[datetime.datetime]) -> None: 

577 if value: 

578 date_str = _format_datetime_utc(value) 

579 self._set_single_value(XMP_NAMESPACE, "MetadataDate", date_str) 

580 else: 

581 self._set_single_value(XMP_NAMESPACE, "MetadataDate", None) 

582 

583 @property 

584 def xmp_creator_tool(self) -> Optional[str]: 

585 """The name of the first known tool used to create the resource.""" 

586 return self._get_single_value(XMP_NAMESPACE, "CreatorTool") 

587 

588 @xmp_creator_tool.setter 

589 def xmp_creator_tool(self, value: Optional[str]) -> None: 

590 self._set_single_value(XMP_NAMESPACE, "CreatorTool", value) 

591 

592 @property 

593 def xmpmm_document_id(self) -> Optional[str]: 

594 """The common identifier for all versions and renditions of this resource.""" 

595 return self._get_single_value(XMPMM_NAMESPACE, "DocumentID") 

596 

597 @xmpmm_document_id.setter 

598 def xmpmm_document_id(self, value: Optional[str]) -> None: 

599 self._set_single_value(XMPMM_NAMESPACE, "DocumentID", value) 

600 

601 @property 

602 def xmpmm_instance_id(self) -> Optional[str]: 

603 """An identifier for a specific incarnation of a document, updated each time a file is saved.""" 

604 return self._get_single_value(XMPMM_NAMESPACE, "InstanceID") 

605 

606 @xmpmm_instance_id.setter 

607 def xmpmm_instance_id(self, value: Optional[str]) -> None: 

608 self._set_single_value(XMPMM_NAMESPACE, "InstanceID", value) 

609 

610 @property 

611 def pdfaid_part(self) -> Optional[str]: 

612 """The part of the PDF/A standard that the document conforms to (e.g., 1, 2, 3).""" 

613 return self._get_single_value(PDFAID_NAMESPACE, "part") 

614 

615 @pdfaid_part.setter 

616 def pdfaid_part(self, value: Optional[str]) -> None: 

617 self._set_single_value(PDFAID_NAMESPACE, "part", value) 

618 

619 @property 

620 def pdfaid_conformance(self) -> Optional[str]: 

621 """The conformance level within the PDF/A standard (e.g., 'A', 'B', 'U').""" 

622 return self._get_single_value(PDFAID_NAMESPACE, "conformance") 

623 

624 @pdfaid_conformance.setter 

625 def pdfaid_conformance(self, value: Optional[str]) -> None: 

626 self._set_single_value(PDFAID_NAMESPACE, "conformance", value) 

627 

628 @property 

629 def custom_properties(self) -> dict[Any, Any]: 

630 """ 

631 Retrieve custom metadata properties defined in the undocumented pdfx 

632 metadata schema. 

633 

634 Returns: 

635 A dictionary of key/value items for custom metadata properties. 

636 

637 """ 

638 if not hasattr(self, "_custom_properties"): 

639 self._custom_properties = {} 

640 for node in self.get_nodes_in_namespace("", PDFX_NAMESPACE): 

641 key = node.localName 

642 start = 0 

643 while True: 

644 # see documentation about PDFX_NAMESPACE earlier in file 

645 idx = key.find("\u2182", start) 

646 if idx == -1: 

647 break 

648 hex_id = key[idx + 1 : idx + 5] 

649 if len(hex_id) != 4 or not all(c in hexdigits for c in hex_id): 

650 # Not a well-formed escape; leave the marker untouched 

651 # and continue past it instead of crashing. 

652 start = idx + 1 

653 continue 

654 key = key[:idx] + chr(int(hex_id, base=16)) + key[idx + 5 :] 

655 start = idx + 1 

656 if node.nodeType == node.ATTRIBUTE_NODE: 

657 value = node.nodeValue 

658 else: 

659 value = self._get_text(node) 

660 self._custom_properties[key] = value 

661 return self._custom_properties 

662 

663 def _get_or_create_description(self, about_uri: str = "") -> XmlElement: 

664 """Get or create an rdf:Description element with the given about URI.""" 

665 for desc in self.rdf_root.getElementsByTagNameNS(RDF_NAMESPACE, "Description"): 

666 if desc.getAttributeNS(RDF_NAMESPACE, "about") == about_uri: 

667 return desc 

668 

669 doc = self.rdf_root.ownerDocument 

670 if doc is None: 

671 raise XmpDocumentError("XMP Document is None") 

672 desc = doc.createElementNS(RDF_NAMESPACE, "rdf:Description") 

673 desc.setAttributeNS(RDF_NAMESPACE, "rdf:about", about_uri) 

674 self.rdf_root.appendChild(desc) 

675 return desc 

676 

677 def _clear_cache_entry(self, namespace: str, name: str) -> None: 

678 """Remove a cached value for a given namespace/name if present.""" 

679 ns_cache = self.cache.get(namespace) 

680 if ns_cache and name in ns_cache: 

681 del ns_cache[name] 

682 

683 def _set_single_value(self, namespace: str, name: str, value: Optional[str]) -> None: 

684 """Set or remove a single metadata value.""" 

685 self._clear_cache_entry(namespace, name) 

686 desc = self._get_or_create_description() 

687 

688 existing_elements = list(desc.getElementsByTagNameNS(namespace, name)) 

689 for elem in existing_elements: 

690 desc.removeChild(elem) 

691 

692 if existing_attr := desc.getAttributeNodeNS(namespace, name): 

693 desc.removeAttributeNode(existing_attr) 

694 

695 if value is not None: 

696 doc = self.rdf_root.ownerDocument 

697 if doc is None: 

698 raise XmpDocumentError("XMP Document is None") 

699 prefix = self._get_namespace_prefix(namespace) 

700 elem = doc.createElementNS(namespace, f"{prefix}:{name}") 

701 text_node = doc.createTextNode(str(value)) 

702 elem.appendChild(text_node) 

703 desc.appendChild(elem) 

704 

705 self._update_stream() 

706 

707 def _set_bag_values(self, namespace: str, name: str, values: Optional[list[str]]) -> None: 

708 """Set or remove bag values (unordered array).""" 

709 self._clear_cache_entry(namespace, name) 

710 desc = self._get_or_create_description() 

711 

712 existing_elements = list(desc.getElementsByTagNameNS(namespace, name)) 

713 for elem in existing_elements: 

714 desc.removeChild(elem) 

715 

716 if values: 

717 doc = self.rdf_root.ownerDocument 

718 if doc is None: 

719 raise XmpDocumentError("XMP Document is None") 

720 prefix = self._get_namespace_prefix(namespace) 

721 elem = doc.createElementNS(namespace, f"{prefix}:{name}") 

722 bag = doc.createElementNS(RDF_NAMESPACE, "rdf:Bag") 

723 

724 for value in values: 

725 li = doc.createElementNS(RDF_NAMESPACE, "rdf:li") 

726 text_node = doc.createTextNode(str(value)) 

727 li.appendChild(text_node) 

728 bag.appendChild(li) 

729 

730 elem.appendChild(bag) 

731 desc.appendChild(elem) 

732 

733 self._update_stream() 

734 

735 def _set_seq_values(self, namespace: str, name: str, values: Optional[list[str]]) -> None: 

736 """Set or remove sequence values (ordered array).""" 

737 self._clear_cache_entry(namespace, name) 

738 desc = self._get_or_create_description() 

739 

740 existing_elements = list(desc.getElementsByTagNameNS(namespace, name)) 

741 for elem in existing_elements: 

742 desc.removeChild(elem) 

743 

744 if values: 

745 doc = self.rdf_root.ownerDocument 

746 if doc is None: 

747 raise XmpDocumentError("XMP Document is None") 

748 prefix = self._get_namespace_prefix(namespace) 

749 elem = doc.createElementNS(namespace, f"{prefix}:{name}") 

750 seq = doc.createElementNS(RDF_NAMESPACE, "rdf:Seq") 

751 

752 for value in values: 

753 li = doc.createElementNS(RDF_NAMESPACE, "rdf:li") 

754 text_node = doc.createTextNode(str(value)) 

755 li.appendChild(text_node) 

756 seq.appendChild(li) 

757 

758 elem.appendChild(seq) 

759 desc.appendChild(elem) 

760 

761 self._update_stream() 

762 

763 def _set_langalt_values(self, namespace: str, name: str, values: Optional[dict[str, str]]) -> None: 

764 """Set or remove language alternative values.""" 

765 self._clear_cache_entry(namespace, name) 

766 desc = self._get_or_create_description() 

767 

768 existing_elements = list(desc.getElementsByTagNameNS(namespace, name)) 

769 for elem in existing_elements: 

770 desc.removeChild(elem) 

771 

772 if values: 

773 doc = self.rdf_root.ownerDocument 

774 if doc is None: 

775 raise XmpDocumentError("XMP Document is None") 

776 prefix = self._get_namespace_prefix(namespace) 

777 elem = doc.createElementNS(namespace, f"{prefix}:{name}") 

778 alt = doc.createElementNS(RDF_NAMESPACE, "rdf:Alt") 

779 

780 for lang, value in values.items(): 

781 li = doc.createElementNS(RDF_NAMESPACE, "rdf:li") 

782 li.setAttribute("xml:lang", lang) 

783 text_node = doc.createTextNode(str(value)) 

784 li.appendChild(text_node) 

785 alt.appendChild(li) 

786 

787 elem.appendChild(alt) 

788 desc.appendChild(elem) 

789 

790 self._update_stream() 

791 

792 def _get_namespace_prefix(self, namespace: str) -> str: 

793 """Get the appropriate namespace prefix for a given namespace URI.""" 

794 return _NAMESPACE_PREFIX_MAP.get(namespace, "unknown") 

795 

796 def _update_stream(self) -> None: 

797 """Update the stream with the current XML content.""" 

798 doc = self.rdf_root.ownerDocument 

799 if doc is None: 

800 raise XmpDocumentError("XMP Document is None") 

801 

802 xml_data = doc.toxml(encoding="utf-8") 

803 self.stream.set_data(xml_data)