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, StreamObject 

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 # 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}") 

198 

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) 

204 

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

206 super().install(parser) 

207 

208 parser.EntityDeclHandler = self.custom_entity_declaration_handler 

209 parser.StartElementHandler = self.start_element_handler 

210 

211 

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>`. 

216 

217 Raises: 

218 PdfReadError: if XML is invalid 

219 

220 """ 

221 

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] = {} 

238 

239 @classmethod 

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

241 """ 

242 Create a new XmpInformation object with minimal structure. 

243 

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) 

250 

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) 

264 

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) 

272 

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 

283 

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 

290 

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 

312 

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 

327 

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 

356 

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 

374 

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

379 

380 @dc_contributor.setter 

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

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

383 

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

388 

389 @dc_coverage.setter 

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

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

392 

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

397 

398 @dc_creator.setter 

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

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

401 

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) 

406 

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) 

419 

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

424 

425 @dc_description.setter 

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

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

428 

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

433 

434 @dc_format.setter 

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

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

437 

438 @property 

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

440 """Unique identifier of the resource.""" 

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

442 

443 @dc_identifier.setter 

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

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

446 

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

451 

452 @dc_language.setter 

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

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

455 

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

460 

461 @dc_publisher.setter 

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

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

464 

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

469 

470 @dc_relation.setter 

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

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

473 

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

478 

479 @dc_rights.setter 

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

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

482 

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

487 

488 @dc_source.setter 

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

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

491 

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

496 

497 @dc_subject.setter 

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

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

500 

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

505 

506 @dc_title.setter 

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

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

509 

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

514 

515 @dc_type.setter 

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

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

518 

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

523 

524 @pdf_keywords.setter 

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

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

527 

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

532 

533 @pdf_pdfversion.setter 

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

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

536 

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

541 

542 @pdf_producer.setter 

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

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

545 

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) 

550 

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) 

558 

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) 

563 

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) 

571 

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) 

576 

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) 

584 

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

589 

590 @xmp_creator_tool.setter 

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

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

593 

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

598 

599 @xmpmm_document_id.setter 

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

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

602 

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

607 

608 @xmpmm_instance_id.setter 

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

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

611 

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

616 

617 @pdfaid_part.setter 

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

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

620 

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

625 

626 @pdfaid_conformance.setter 

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

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

629 

630 @property 

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

632 """ 

633 Retrieve custom metadata properties defined in the undocumented pdfx 

634 metadata schema. 

635 

636 Returns: 

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

638 

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 

664 

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 

670 

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 

678 

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] 

684 

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() 

689 

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

691 for elem in existing_elements: 

692 desc.removeChild(elem) 

693 

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

695 desc.removeAttributeNode(existing_attr) 

696 

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) 

706 

707 self._update_stream() 

708 

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() 

713 

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

715 for elem in existing_elements: 

716 desc.removeChild(elem) 

717 

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

725 

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) 

731 

732 elem.appendChild(bag) 

733 desc.appendChild(elem) 

734 

735 self._update_stream() 

736 

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() 

741 

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

743 for elem in existing_elements: 

744 desc.removeChild(elem) 

745 

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

753 

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) 

759 

760 elem.appendChild(seq) 

761 desc.appendChild(elem) 

762 

763 self._update_stream() 

764 

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() 

769 

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

771 for elem in existing_elements: 

772 desc.removeChild(elem) 

773 

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

781 

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) 

788 

789 elem.appendChild(alt) 

790 desc.appendChild(elem) 

791 

792 self._update_stream() 

793 

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

797 

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

803 

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

805 self.stream.set_data(xml_data)