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 ._configuration import get_configuration 

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 # DEPRECATED: Use pypdf.Configuration. 

32XMP_MAX_ELEMENT_COUNT = 100_000 # DEPRECATED: Use pypdf.Configuration. 

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[1:].split(":")) 

136 offset = datetime.timedelta(hours=tzd_hours, minutes=tzd_minutes) 

137 dt = dt - offset if tzd.startswith("+") else dt + offset 

138 return dt 

139 

140 

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

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

143 

144 - If the input is timezone-aware, convert to UTC first. 

145 - If naive, assume UTC. 

146 """ 

147 if value.tzinfo is not None and value.utcoffset() is not None: 

148 value = value.astimezone(datetime.timezone.utc) 

149 

150 value = value.replace(tzinfo=None) 

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

152 

153 

154def _generic_get( 

155 element: XmlElement, self: "XmpInformation", list_type: str, converter: Callable[[Any], Any] = _identity 

156) -> Optional[list[str]]: 

157 containers = element.getElementsByTagNameNS(RDF_NAMESPACE, list_type) 

158 retval: list[Any] = [] 

159 if len(containers): 

160 for container in containers: 

161 for item in container.getElementsByTagNameNS(RDF_NAMESPACE, "li"): 

162 value = self._get_text(item) 

163 value = converter(value) 

164 retval.append(value) 

165 return retval 

166 return None 

167 

168 

169class _XmpBuilder(ExpatBuilderNS): 

170 """ 

171 Custom XML parser denying all entity declarations. 

172 

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

174 

175 Why do we need this? The default limits of *libexpat* used by Python only block exponential entity expansion, 

176 but not cases like quadratic entity expansion which can still cause quite some memory usage. 

177 """ 

178 

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

180 super().__init__(options=options) 

181 self._element_count = 0 

182 self._configuration = get_configuration() 

183 

184 def custom_entity_declaration_handler( 

185 self, 

186 entity_name: str, 

187 # expat passes an int here rather than a bool, but typeshed declares 

188 # the handler with a bool, so the annotation follows typeshed. 

189 is_parameter_entity: bool, 

190 value: Optional[str], 

191 base: Optional[str], 

192 system_id: str, 

193 public_id: Optional[str], 

194 notation_name: Optional[str], 

195 ) -> None: 

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

197 

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

199 self._element_count += 1 

200 if self._element_count > self._configuration.xmp_maximum_element_count: 

201 raise LimitReachedError( 

202 f"XMP metadata exceeds limit of {self._configuration.xmp_maximum_element_count} elements." 

203 ) 

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

205 

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

207 super().install(parser) 

208 

209 parser.EntityDeclHandler = self.custom_entity_declaration_handler 

210 parser.StartElementHandler = self.start_element_handler 

211 

212 

213class XmpInformation(PdfObject): 

214 """ 

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

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

217 

218 Raises: 

219 PdfReadError: if XML is invalid 

220 

221 """ 

222 

223 def __init__(self, stream: StreamObject) -> None: 

224 self.stream = stream 

225 try: 

226 data = self.stream.get_data() 

227 configuration = get_configuration() 

228 if (length := len(data)) > configuration.xmp_maximum_input_length: 

229 raise LimitReachedError( 

230 f"XMP stream size {length} exceeds limit of {configuration.xmp_maximum_input_length}." 

231 ) 

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

233 except (AttributeError, ExpatError) as exception: 

234 raise PdfReadError(f"XML in XmpInformation was invalid: {exception}") from exception 

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

236 if not rdf_roots: 

237 raise PdfReadError( 

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

239 ) 

240 self.rdf_root: XmlElement = rdf_roots[0] 

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

242 

243 @classmethod 

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

245 """ 

246 Create a new XmpInformation object with minimal structure. 

247 

248 Returns: 

249 A new XmpInformation instance with empty metadata fields. 

250 """ 

251 stream = ContentStream(None, None) 

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

253 return cls(stream) 

254 

255 def write_to_stream( 

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

257 ) -> None: 

258 deprecate_with_replacement( 

259 "XmpInformation.write_to_stream", 

260 "PdfWriter.xmp_metadata", 

261 "6.0.0" 

262 ) 

263 if encryption_key is not None: # deprecated 

264 deprecation_no_replacement( 

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

266 ) 

267 self.stream.write_to_stream(stream) 

268 

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

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

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

272 attr = desc.getAttributeNodeNS(namespace, name) 

273 if attr is not None: 

274 yield attr 

275 yield from desc.getElementsByTagNameNS(namespace, name) 

276 

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

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

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

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

281 attr = desc.attributes.item(i) 

282 if attr and attr.namespaceURI == namespace: 

283 yield attr 

284 for child in desc.childNodes: 

285 if child.namespaceURI == namespace: 

286 yield child 

287 

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

289 text = "" 

290 for child in element.childNodes: 

291 if child.nodeType == child.TEXT_NODE: 

292 text += child.data 

293 return text 

294 

295 def _get_single_value( 

296 self, 

297 namespace: str, 

298 name: str, 

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

300 ) -> Optional[Any]: 

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

302 if cached: 

303 return cached 

304 value = None 

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

306 if element.nodeType == element.ATTRIBUTE_NODE: 

307 value = element.nodeValue 

308 else: 

309 value = self._get_text(element) 

310 break 

311 if value is not None: 

312 value = converter(value) 

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

314 ns_cache[name] = value 

315 return value 

316 

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

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

319 if cached: 

320 return cast(list[str], cached) 

321 retval: list[str] = [] 

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

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

324 retval.extend(bags) 

325 else: 

326 value = self._get_text(element) 

327 retval.append(value) 

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

329 ns_cache[name] = retval 

330 return retval 

331 

332 def _get_seq_values( 

333 self, 

334 namespace: str, 

335 name: str, 

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

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

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

339 if cached: 

340 return cast(list[Any], cached) 

341 retval: list[Any] = [] 

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

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

344 retval.extend(seqs) 

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

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

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

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

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

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

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

352 # issues accordingly. 

353 retval.extend(bags) 

354 else: 

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

356 retval.append(value) 

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

358 ns_cache[name] = retval 

359 return retval 

360 

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

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

363 if cached: 

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

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

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

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

368 if len(alts): 

369 for alt in alts: 

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

371 value = self._get_text(item) 

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

373 else: 

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

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

376 ns_cache[name] = retval 

377 return retval 

378 

379 @property 

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

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

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

383 

384 @dc_contributor.setter 

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

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

387 

388 @property 

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

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

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

392 

393 @dc_coverage.setter 

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

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

396 

397 @property 

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

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

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

401 

402 @dc_creator.setter 

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

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

405 

406 @property 

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

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

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

410 

411 @dc_date.setter 

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

413 if values is None: 

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

415 else: 

416 date_strings = [] 

417 for value in values: 

418 if isinstance(value, datetime.datetime): 

419 date_strings.append(_format_datetime_utc(value)) 

420 else: 

421 date_strings.append(str(value)) 

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

423 

424 @property 

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

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

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

428 

429 @dc_description.setter 

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

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

432 

433 @property 

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

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

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

437 

438 @dc_format.setter 

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

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

441 

442 @property 

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

444 """Unique identifier of the resource.""" 

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

446 

447 @dc_identifier.setter 

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

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

450 

451 @property 

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

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

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

455 

456 @dc_language.setter 

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

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

459 

460 @property 

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

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

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

464 

465 @dc_publisher.setter 

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

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

468 

469 @property 

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

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

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

473 

474 @dc_relation.setter 

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

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

477 

478 @property 

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

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

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

482 

483 @dc_rights.setter 

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

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

486 

487 @property 

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

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

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

491 

492 @dc_source.setter 

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

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

495 

496 @property 

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

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

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

500 

501 @dc_subject.setter 

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

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

504 

505 @property 

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

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

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

509 

510 @dc_title.setter 

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

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

513 

514 @property 

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

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

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

518 

519 @dc_type.setter 

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

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

522 

523 @property 

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

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

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

527 

528 @pdf_keywords.setter 

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

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

531 

532 @property 

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

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

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

536 

537 @pdf_pdfversion.setter 

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

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

540 

541 @property 

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

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

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

545 

546 @pdf_producer.setter 

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

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

549 

550 @property 

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

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

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

554 

555 @xmp_create_date.setter 

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

557 if value: 

558 date_str = _format_datetime_utc(value) 

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

560 else: 

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

562 

563 @property 

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

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

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

567 

568 @xmp_modify_date.setter 

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

570 if value: 

571 date_str = _format_datetime_utc(value) 

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

573 else: 

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

575 

576 @property 

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

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

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

580 

581 @xmp_metadata_date.setter 

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

583 if value: 

584 date_str = _format_datetime_utc(value) 

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

586 else: 

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

588 

589 @property 

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

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

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

593 

594 @xmp_creator_tool.setter 

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

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

597 

598 @property 

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

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

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

602 

603 @xmpmm_document_id.setter 

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

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

606 

607 @property 

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

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

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

611 

612 @xmpmm_instance_id.setter 

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

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

615 

616 @property 

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

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

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

620 

621 @pdfaid_part.setter 

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

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

624 

625 @property 

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

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

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

629 

630 @pdfaid_conformance.setter 

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

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

633 

634 @property 

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

636 """ 

637 Retrieve custom metadata properties defined in the undocumented pdfx 

638 metadata schema. 

639 

640 Returns: 

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

642 

643 """ 

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

645 self._custom_properties = {} 

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

647 key = node.localName 

648 start = 0 

649 while True: 

650 # see documentation about PDFX_NAMESPACE earlier in file 

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

652 if idx == -1: 

653 break 

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

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

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

657 # and continue past it instead of crashing. 

658 start = idx + 1 

659 continue 

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

661 start = idx + 1 

662 if node.nodeType == node.ATTRIBUTE_NODE: 

663 value = node.nodeValue 

664 else: 

665 value = self._get_text(node) 

666 self._custom_properties[key] = value 

667 return self._custom_properties 

668 

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

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

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

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

673 return desc 

674 

675 doc = self.rdf_root.ownerDocument 

676 if doc is None: 

677 raise XmpDocumentError("XMP Document is None") 

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

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

680 self.rdf_root.appendChild(desc) 

681 return desc 

682 

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

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

685 ns_cache = self.cache.get(namespace) 

686 if ns_cache and name in ns_cache: 

687 del ns_cache[name] 

688 

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

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

691 self._clear_cache_entry(namespace, name) 

692 desc = self._get_or_create_description() 

693 

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

695 for elem in existing_elements: 

696 desc.removeChild(elem) 

697 

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

699 desc.removeAttributeNode(existing_attr) 

700 

701 if value is not None: 

702 doc = self.rdf_root.ownerDocument 

703 if doc is None: 

704 raise XmpDocumentError("XMP Document is None") 

705 prefix = self._get_namespace_prefix(namespace) 

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

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

708 elem.appendChild(text_node) 

709 desc.appendChild(elem) 

710 

711 self._update_stream() 

712 

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

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

715 self._clear_cache_entry(namespace, name) 

716 desc = self._get_or_create_description() 

717 

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

719 for elem in existing_elements: 

720 desc.removeChild(elem) 

721 

722 if values: 

723 doc = self.rdf_root.ownerDocument 

724 if doc is None: 

725 raise XmpDocumentError("XMP Document is None") 

726 prefix = self._get_namespace_prefix(namespace) 

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

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

729 

730 for value in values: 

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

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

733 li.appendChild(text_node) 

734 bag.appendChild(li) 

735 

736 elem.appendChild(bag) 

737 desc.appendChild(elem) 

738 

739 self._update_stream() 

740 

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

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

743 self._clear_cache_entry(namespace, name) 

744 desc = self._get_or_create_description() 

745 

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

747 for elem in existing_elements: 

748 desc.removeChild(elem) 

749 

750 if values: 

751 doc = self.rdf_root.ownerDocument 

752 if doc is None: 

753 raise XmpDocumentError("XMP Document is None") 

754 prefix = self._get_namespace_prefix(namespace) 

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

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

757 

758 for value in values: 

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

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

761 li.appendChild(text_node) 

762 seq.appendChild(li) 

763 

764 elem.appendChild(seq) 

765 desc.appendChild(elem) 

766 

767 self._update_stream() 

768 

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

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

771 self._clear_cache_entry(namespace, name) 

772 desc = self._get_or_create_description() 

773 

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

775 for elem in existing_elements: 

776 desc.removeChild(elem) 

777 

778 if values: 

779 doc = self.rdf_root.ownerDocument 

780 if doc is None: 

781 raise XmpDocumentError("XMP Document is None") 

782 prefix = self._get_namespace_prefix(namespace) 

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

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

785 

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

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

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

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

790 li.appendChild(text_node) 

791 alt.appendChild(li) 

792 

793 elem.appendChild(alt) 

794 desc.appendChild(elem) 

795 

796 self._update_stream() 

797 

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

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

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

801 

802 def _update_stream(self) -> None: 

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

804 doc = self.rdf_root.ownerDocument 

805 if doc is None: 

806 raise XmpDocumentError("XMP Document is None") 

807 

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

809 self.stream.set_data(xml_data)