Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes14kdownloads
xmp.py779 linesDownload Raw Back to pypdf
1"""2Anything related to Extensible Metadata Platform (XMP) metadata.3 4https://en.wikipedia.org/wiki/Extensible_Metadata_Platform5"""6 7import datetime8import decimal9import re10from collections.abc import Iterator11from typing import (12    Any,13    Callable,14    Optional,15    TypeVar,16    Union,17    cast,18)19from xml.dom.expatbuilder import ExpatBuilderNS20from xml.dom.minidom import Document21from xml.dom.minidom import Element as XmlElement22from xml.parsers.expat import ExpatError, XMLParserType23 24from ._protocols import XmpInformationProtocol25from ._utils import StreamType, deprecate_with_replacement, deprecation_no_replacement26from .errors import PdfReadError, XmpDocumentError27from .generic import ContentStream, PdfObject28 29RDF_NAMESPACE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"30DC_NAMESPACE = "http://purl.org/dc/elements/1.1/"31XMP_NAMESPACE = "http://ns.adobe.com/xap/1.0/"32PDF_NAMESPACE = "http://ns.adobe.com/pdf/1.3/"33XMPMM_NAMESPACE = "http://ns.adobe.com/xap/1.0/mm/"34 35# What is the PDFX namespace, you might ask?36# It's documented here: https://github.com/adobe/xmp-docs/raw/master/XMPSpecifications/XMPSpecificationPart3.pdf37# This namespace is used to place "custom metadata"38# properties, which are arbitrary metadata properties with no semantic or39# documented meaning.40#41# Elements in the namespace are key/value-style storage,42# where the element name is the key and the content is the value. The keys43# are transformed into valid XML identifiers by substituting an invalid44# identifier character with \u2182 followed by the unicode hex ID of the45# original character. A key like "my car" is therefore "my\u21820020car".46#47# \u2182 is the unicode character \u{ROMAN NUMERAL TEN THOUSAND}48#49# The pdfx namespace should be avoided.50# A custom data schema and sensical XML elements could be used instead, as is51# suggested by Adobe's own documentation on XMP under "Extensibility of52# Schemas".53PDFX_NAMESPACE = "http://ns.adobe.com/pdfx/1.3/"54 55# PDF/A56PDFAID_NAMESPACE = "http://www.aiim.org/pdfa/ns/id/"57 58# Internal mapping of namespace URI → prefix59_NAMESPACE_PREFIX_MAP = {60    DC_NAMESPACE: "dc",61    XMP_NAMESPACE: "xmp",62    PDF_NAMESPACE: "pdf",63    XMPMM_NAMESPACE: "xmpMM",64    PDFAID_NAMESPACE: "pdfaid",65    PDFX_NAMESPACE: "pdfx",66}67 68iso8601 = re.compile(69    """70        (?P<year>[0-9]{4})71        (-72            (?P<month>[0-9]{2})73            (-74                (?P<day>[0-9]+)75                (T76                    (?P<hour>[0-9]{2}):77                    (?P<minute>[0-9]{2})78                    (:(?P<second>[0-9]{2}(.[0-9]+)?))?79                    (?P<tzd>Z|[-+][0-9]{2}:[0-9]{2})80                )?81            )?82        )?83        """,84    re.VERBOSE,85)86 87 88K = TypeVar("K")89 90# Minimal XMP template91_MINIMAL_XMP = f"""<?xpacket begin="\ufeff" id="W5M0MpCehiHzreSzNTczkc9d"?>92<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="pypdf">93    <rdf:RDF xmlns:rdf="{RDF_NAMESPACE}">94        <rdf:Description rdf:about=""95            xmlns:dc="{DC_NAMESPACE}"96            xmlns:xmp="{XMP_NAMESPACE}"97            xmlns:pdf="{PDF_NAMESPACE}"98            xmlns:xmpMM="{XMPMM_NAMESPACE}"99            xmlns:pdfaid="{PDFAID_NAMESPACE}"100            xmlns:pdfx="{PDFX_NAMESPACE}">101        </rdf:Description>102    </rdf:RDF>103</x:xmpmeta>104<?xpacket end="r"?>"""105 106 107def _identity(value: K) -> K:108    return value109 110 111def _converter_date(value: str) -> datetime.datetime:112    matches = iso8601.match(value)113    if matches is None:114        raise ValueError(f"Invalid date format: {value}")115    year = int(matches.group("year"))116    month = int(matches.group("month") or "1")117    day = int(matches.group("day") or "1")118    hour = int(matches.group("hour") or "0")119    minute = int(matches.group("minute") or "0")120    second = decimal.Decimal(matches.group("second") or "0")121    seconds_dec = second.to_integral(decimal.ROUND_FLOOR)122    milliseconds_dec = (second - seconds_dec) * 1_000_000123 124    seconds = int(seconds_dec)125    milliseconds = int(milliseconds_dec)126 127    tzd = matches.group("tzd") or "Z"128    dt = datetime.datetime(year, month, day, hour, minute, seconds, milliseconds)129    if tzd != "Z":130        tzd_hours, tzd_minutes = (int(x) for x in tzd.split(":"))131        tzd_hours *= -1132        if tzd_hours < 0:133            tzd_minutes *= -1134        dt = dt + datetime.timedelta(hours=tzd_hours, minutes=tzd_minutes)135    return dt136 137 138def _format_datetime_utc(value: datetime.datetime) -> str:139    """Format a datetime as UTC with trailing 'Z'.140 141    - If the input is timezone-aware, convert to UTC first.142    - If naive, assume UTC.143    """144    if value.tzinfo is not None and value.utcoffset() is not None:145        value = value.astimezone(datetime.timezone.utc)146 147    value = value.replace(tzinfo=None)148    return value.strftime("%Y-%m-%dT%H:%M:%S.%fZ")149 150 151def _generic_get(152        element: XmlElement, self: "XmpInformation", list_type: str, converter: Callable[[Any], Any] = _identity153) -> Optional[list[str]]:154    containers = element.getElementsByTagNameNS(RDF_NAMESPACE, list_type)155    retval: list[Any] = []156    if len(containers):157        for container in containers:158            for item in container.getElementsByTagNameNS(RDF_NAMESPACE, "li"):159                value = self._get_text(item)160                value = converter(value)161                retval.append(value)162        return retval163    return None164 165 166class _XmpBuilder(ExpatBuilderNS):167    """168    Custom XML parser denying all entity declarations.169 170    This is a stripped down and typed version inspired by what *defusedxml* does.171 172    Why do we need this? The default limits of *libexpat* used by Python only block exponential entity expansion,173    but not cases like quadratic entity expansion which can still cause quite some memory usage.174    """175 176    def custom_entity_declaration_handler(177            self,178            entity_name: str,179            is_parameter_entity: bool,180            value: Optional[str],181            base: Optional[str],182            system_id: str,183            public_id: Optional[str],184            notation_name: Optional[str],185    ) -> None:186        raise ExpatError(f"Forbidden entities: {entity_name!r}")187 188    def install(self, parser: XMLParserType) -> None:189        super().install(parser)190 191        parser.EntityDeclHandler = self.custom_entity_declaration_handler192 193 194class XmpInformation(XmpInformationProtocol, PdfObject):195    """196    An object that represents Extensible Metadata Platform (XMP) metadata.197    Usually accessed by :py:attr:`xmp_metadata()<pypdf.PdfReader.xmp_metadata>`.198 199    Raises:200      PdfReadError: if XML is invalid201 202    """203 204    def __init__(self, stream: ContentStream) -> None:205        self.stream = stream206        try:207            data = self.stream.get_data()208            doc_root: Document = _XmpBuilder().parseString(data)209        except (AttributeError, ExpatError) as e:210            raise PdfReadError(f"XML in XmpInformation was invalid: {e}")211        self.rdf_root: XmlElement = doc_root.getElementsByTagNameNS(212            RDF_NAMESPACE, "RDF"213        )[0]214        self.cache: dict[Any, Any] = {}215 216    @classmethod217    def create(cls) -> "XmpInformation":218        """219        Create a new XmpInformation object with minimal structure.220 221        Returns:222            A new XmpInformation instance with empty metadata fields.223        """224        stream = ContentStream(None, None)225        stream.set_data(_MINIMAL_XMP.encode("utf-8"))226        return cls(stream)227 228    def write_to_stream(229        self, stream: StreamType, encryption_key: Union[None, str, bytes] = None230    ) -> None:231        deprecate_with_replacement(232            "XmpInformation.write_to_stream",233            "PdfWriter.xmp_metadata",234            "6.0.0"235        )236        if encryption_key is not None:  # deprecated237            deprecation_no_replacement(238                "the encryption_key parameter of write_to_stream", "5.0.0"239            )240        self.stream.write_to_stream(stream)241 242    def get_element(self, about_uri: str, namespace: str, name: str) -> Iterator[Any]:243        for desc in self.rdf_root.getElementsByTagNameNS(RDF_NAMESPACE, "Description"):244            if desc.getAttributeNS(RDF_NAMESPACE, "about") == about_uri:245                attr = desc.getAttributeNodeNS(namespace, name)246                if attr is not None:247                    yield attr248                yield from desc.getElementsByTagNameNS(namespace, name)249 250    def get_nodes_in_namespace(self, about_uri: str, namespace: str) -> Iterator[Any]:251        for desc in self.rdf_root.getElementsByTagNameNS(RDF_NAMESPACE, "Description"):252            if desc.getAttributeNS(RDF_NAMESPACE, "about") == about_uri:253                for i in range(desc.attributes.length):254                    attr = desc.attributes.item(i)255                    if attr and attr.namespaceURI == namespace:256                        yield attr257                for child in desc.childNodes:258                    if child.namespaceURI == namespace:259                        yield child260 261    def _get_text(self, element: XmlElement) -> str:262        text = ""263        for child in element.childNodes:264            if child.nodeType == child.TEXT_NODE:265                text += child.data266        return text267 268    def _get_single_value(269        self,270        namespace: str,271        name: str,272        converter: Callable[[str], Any] = _identity,273    ) -> Optional[Any]:274        cached = self.cache.get(namespace, {}).get(name)275        if cached:276            return cached277        value = None278        for element in self.get_element("", namespace, name):279            if element.nodeType == element.ATTRIBUTE_NODE:280                value = element.nodeValue281            else:282                value = self._get_text(element)283            break284        if value is not None:285            value = converter(value)286        ns_cache = self.cache.setdefault(namespace, {})287        ns_cache[name] = value288        return value289 290    def _getter_bag(self, namespace: str, name: str) -> Optional[list[str]]:291        cached = self.cache.get(namespace, {}).get(name)292        if cached:293            return cast(list[str], cached)294        retval: list[str] = []295        for element in self.get_element("", namespace, name):296            if (bags := _generic_get(element, self, list_type="Bag")) is not None:297                retval.extend(bags)298            else:299                value = self._get_text(element)300                retval.append(value)301        ns_cache = self.cache.setdefault(namespace, {})302        ns_cache[name] = retval303        return retval304 305    def _get_seq_values(306        self,307        namespace: str,308        name: str,309        converter: Callable[[Any], Any] = _identity,310    ) -> Optional[list[Any]]:311        cached = self.cache.get(namespace, {}).get(name)312        if cached:313            return cast(list[Any], cached)314        retval: list[Any] = []315        for element in self.get_element("", namespace, name):316            if (seqs := _generic_get(element, self, list_type="Seq", converter=converter)) is not None:317                retval.extend(seqs)318            elif (bags := _generic_get(element, self, list_type="Bag")) is not None:319                # See issue at https://github.com/py-pdf/pypdf/issues/3324320                # Some applications violate the XMP metadata standard regarding `dc:creator` which should321                # be an "ordered array" and thus a sequence, but use an unordered array (bag) instead.322                # This seems to stem from the fact that the original Dublin Core specification does indeed323                # use bags or direct values, while PDFs are expected to follow the XMP standard and ignore324                # the plain Dublin Core variant. For this reason, add a fallback here to deal with such325                # issues accordingly.326                retval.extend(bags)327            else:328                value = converter(self._get_text(element))329                retval.append(value)330        ns_cache = self.cache.setdefault(namespace, {})331        ns_cache[name] = retval332        return retval333 334    def _get_langalt_values(self, namespace: str, name: str) -> Optional[dict[Any, Any]]:335        cached = self.cache.get(namespace, {}).get(name)336        if cached:337            return cast(dict[Any, Any], cached)338        retval: dict[Any, Any] = {}339        for element in self.get_element("", namespace, name):340            alts = element.getElementsByTagNameNS(RDF_NAMESPACE, "Alt")341            if len(alts):342                for alt in alts:343                    for item in alt.getElementsByTagNameNS(RDF_NAMESPACE, "li"):344                        value = self._get_text(item)345                        retval[item.getAttribute("xml:lang")] = value346            else:347                retval["x-default"] = self._get_text(element)348        ns_cache = self.cache.setdefault(namespace, {})349        ns_cache[name] = retval350        return retval351 352    @property353    def dc_contributor(self) -> Optional[list[str]]:354        """Contributors to the resource (other than the authors)."""355        return self._getter_bag(DC_NAMESPACE, "contributor")356 357    @dc_contributor.setter358    def dc_contributor(self, values: Optional[list[str]]) -> None:359        self._set_bag_values(DC_NAMESPACE, "contributor", values)360 361    @property362    def dc_coverage(self) -> Optional[str]:363        """Text describing the extent or scope of the resource."""364        return self._get_single_value(DC_NAMESPACE, "coverage")365 366    @dc_coverage.setter367    def dc_coverage(self, value: Optional[str]) -> None:368        self._set_single_value(DC_NAMESPACE, "coverage", value)369 370    @property371    def dc_creator(self) -> Optional[list[str]]:372        """A sorted array of names of the authors of the resource, listed in order of precedence."""373        return self._get_seq_values(DC_NAMESPACE, "creator")374 375    @dc_creator.setter376    def dc_creator(self, values: Optional[list[str]]) -> None:377        self._set_seq_values(DC_NAMESPACE, "creator", values)378 379    @property380    def dc_date(self) -> Optional[list[datetime.datetime]]:381        """A sorted array of dates of significance to the resource. The dates and times are in UTC."""382        return self._get_seq_values(DC_NAMESPACE, "date", _converter_date)383 384    @dc_date.setter385    def dc_date(self, values: Optional[list[Union[str, datetime.datetime]]]) -> None:386        if values is None:387            self._set_seq_values(DC_NAMESPACE, "date", None)388        else:389            date_strings = []390            for value in values:391                if isinstance(value, datetime.datetime):392                    date_strings.append(_format_datetime_utc(value))393                else:394                    date_strings.append(str(value))395            self._set_seq_values(DC_NAMESPACE, "date", date_strings)396 397    @property398    def dc_description(self) -> Optional[dict[str, str]]:399        """A language-keyed dictionary of textual descriptions of the content of the resource."""400        return self._get_langalt_values(DC_NAMESPACE, "description")401 402    @dc_description.setter403    def dc_description(self, values: Optional[dict[str, str]]) -> None:404        self._set_langalt_values(DC_NAMESPACE, "description", values)405 406    @property407    def dc_format(self) -> Optional[str]:408        """The mime-type of the resource."""409        return self._get_single_value(DC_NAMESPACE, "format")410 411    @dc_format.setter412    def dc_format(self, value: Optional[str]) -> None:413        self._set_single_value(DC_NAMESPACE, "format", value)414 415    @property416    def dc_identifier(self) -> Optional[str]:417        """Unique identifier of the resource."""418        return self._get_single_value(DC_NAMESPACE, "identifier")419 420    @dc_identifier.setter421    def dc_identifier(self, value: Optional[str]) -> None:422        self._set_single_value(DC_NAMESPACE, "identifier", value)423 424    @property425    def dc_language(self) -> Optional[list[str]]:426        """An unordered array specifying the languages used in the resource."""427        return self._getter_bag(DC_NAMESPACE, "language")428 429    @dc_language.setter430    def dc_language(self, values: Optional[list[str]]) -> None:431        self._set_bag_values(DC_NAMESPACE, "language", values)432 433    @property434    def dc_publisher(self) -> Optional[list[str]]:435        """An unordered array of publisher names."""436        return self._getter_bag(DC_NAMESPACE, "publisher")437 438    @dc_publisher.setter439    def dc_publisher(self, values: Optional[list[str]]) -> None:440        self._set_bag_values(DC_NAMESPACE, "publisher", values)441 442    @property443    def dc_relation(self) -> Optional[list[str]]:444        """An unordered array of text descriptions of relationships to other documents."""445        return self._getter_bag(DC_NAMESPACE, "relation")446 447    @dc_relation.setter448    def dc_relation(self, values: Optional[list[str]]) -> None:449        self._set_bag_values(DC_NAMESPACE, "relation", values)450 451    @property452    def dc_rights(self) -> Optional[dict[str, str]]:453        """A language-keyed dictionary of textual descriptions of the rights the user has to this resource."""454        return self._get_langalt_values(DC_NAMESPACE, "rights")455 456    @dc_rights.setter457    def dc_rights(self, values: Optional[dict[str, str]]) -> None:458        self._set_langalt_values(DC_NAMESPACE, "rights", values)459 460    @property461    def dc_source(self) -> Optional[str]:462        """Unique identifier of the work from which this resource was derived."""463        return self._get_single_value(DC_NAMESPACE, "source")464 465    @dc_source.setter466    def dc_source(self, value: Optional[str]) -> None:467        self._set_single_value(DC_NAMESPACE, "source", value)468 469    @property470    def dc_subject(self) -> Optional[list[str]]:471        """An unordered array of descriptive phrases or keywords that specify the topic of the content."""472        return self._getter_bag(DC_NAMESPACE, "subject")473 474    @dc_subject.setter475    def dc_subject(self, values: Optional[list[str]]) -> None:476        self._set_bag_values(DC_NAMESPACE, "subject", values)477 478    @property479    def dc_title(self) -> Optional[dict[str, str]]:480        """A language-keyed dictionary of the title of the resource."""481        return self._get_langalt_values(DC_NAMESPACE, "title")482 483    @dc_title.setter484    def dc_title(self, values: Optional[dict[str, str]]) -> None:485        self._set_langalt_values(DC_NAMESPACE, "title", values)486 487    @property488    def dc_type(self) -> Optional[list[str]]:489        """An unordered array of textual descriptions of the document type."""490        return self._getter_bag(DC_NAMESPACE, "type")491 492    @dc_type.setter493    def dc_type(self, values: Optional[list[str]]) -> None:494        self._set_bag_values(DC_NAMESPACE, "type", values)495 496    @property497    def pdf_keywords(self) -> Optional[str]:498        """An unformatted text string representing document keywords."""499        return self._get_single_value(PDF_NAMESPACE, "Keywords")500 501    @pdf_keywords.setter502    def pdf_keywords(self, value: Optional[str]) -> None:503        self._set_single_value(PDF_NAMESPACE, "Keywords", value)504 505    @property506    def pdf_pdfversion(self) -> Optional[str]:507        """The PDF file version, for example 1.0 or 1.3."""508        return self._get_single_value(PDF_NAMESPACE, "PDFVersion")509 510    @pdf_pdfversion.setter511    def pdf_pdfversion(self, value: Optional[str]) -> None:512        self._set_single_value(PDF_NAMESPACE, "PDFVersion", value)513 514    @property515    def pdf_producer(self) -> Optional[str]:516        """The name of the tool that saved the document as a PDF."""517        return self._get_single_value(PDF_NAMESPACE, "Producer")518 519    @pdf_producer.setter520    def pdf_producer(self, value: Optional[str]) -> None:521        self._set_single_value(PDF_NAMESPACE, "Producer", value)522 523    @property524    def xmp_create_date(self) -> Optional[datetime.datetime]:525        """The date and time the resource was originally created. Returned as a UTC datetime object."""526        return self._get_single_value(XMP_NAMESPACE, "CreateDate", _converter_date)527 528    @xmp_create_date.setter529    def xmp_create_date(self, value: Optional[datetime.datetime]) -> None:530        if value:531            date_str = _format_datetime_utc(value)532            self._set_single_value(XMP_NAMESPACE, "CreateDate", date_str)533        else:534            self._set_single_value(XMP_NAMESPACE, "CreateDate", None)535 536    @property537    def xmp_modify_date(self) -> Optional[datetime.datetime]:538        """The date and time the resource was last modified. Returned as a UTC datetime object."""539        return self._get_single_value(XMP_NAMESPACE, "ModifyDate", _converter_date)540 541    @xmp_modify_date.setter542    def xmp_modify_date(self, value: Optional[datetime.datetime]) -> None:543        if value:544            date_str = _format_datetime_utc(value)545            self._set_single_value(XMP_NAMESPACE, "ModifyDate", date_str)546        else:547            self._set_single_value(XMP_NAMESPACE, "ModifyDate", None)548 549    @property550    def xmp_metadata_date(self) -> Optional[datetime.datetime]:551        """The date and time that any metadata for this resource was last changed. Returned as a UTC datetime object."""552        return self._get_single_value(XMP_NAMESPACE, "MetadataDate", _converter_date)553 554    @xmp_metadata_date.setter555    def xmp_metadata_date(self, value: Optional[datetime.datetime]) -> None:556        if value:557            date_str = _format_datetime_utc(value)558            self._set_single_value(XMP_NAMESPACE, "MetadataDate", date_str)559        else:560            self._set_single_value(XMP_NAMESPACE, "MetadataDate", None)561 562    @property563    def xmp_creator_tool(self) -> Optional[str]:564        """The name of the first known tool used to create the resource."""565        return self._get_single_value(XMP_NAMESPACE, "CreatorTool")566 567    @xmp_creator_tool.setter568    def xmp_creator_tool(self, value: Optional[str]) -> None:569        self._set_single_value(XMP_NAMESPACE, "CreatorTool", value)570 571    @property572    def xmpmm_document_id(self) -> Optional[str]:573        """The common identifier for all versions and renditions of this resource."""574        return self._get_single_value(XMPMM_NAMESPACE, "DocumentID")575 576    @xmpmm_document_id.setter577    def xmpmm_document_id(self, value: Optional[str]) -> None:578        self._set_single_value(XMPMM_NAMESPACE, "DocumentID", value)579 580    @property581    def xmpmm_instance_id(self) -> Optional[str]:582        """An identifier for a specific incarnation of a document, updated each time a file is saved."""583        return self._get_single_value(XMPMM_NAMESPACE, "InstanceID")584 585    @xmpmm_instance_id.setter586    def xmpmm_instance_id(self, value: Optional[str]) -> None:587        self._set_single_value(XMPMM_NAMESPACE, "InstanceID", value)588 589    @property590    def pdfaid_part(self) -> Optional[str]:591        """The part of the PDF/A standard that the document conforms to (e.g., 1, 2, 3)."""592        return self._get_single_value(PDFAID_NAMESPACE, "part")593 594    @pdfaid_part.setter595    def pdfaid_part(self, value: Optional[str]) -> None:596        self._set_single_value(PDFAID_NAMESPACE, "part", value)597 598    @property599    def pdfaid_conformance(self) -> Optional[str]:600        """The conformance level within the PDF/A standard (e.g., 'A', 'B', 'U')."""601        return self._get_single_value(PDFAID_NAMESPACE, "conformance")602 603    @pdfaid_conformance.setter604    def pdfaid_conformance(self, value: Optional[str]) -> None:605        self._set_single_value(PDFAID_NAMESPACE, "conformance", value)606 607    @property608    def custom_properties(self) -> dict[Any, Any]:609        """610        Retrieve custom metadata properties defined in the undocumented pdfx611        metadata schema.612 613        Returns:614            A dictionary of key/value items for custom metadata properties.615 616        """617        if not hasattr(self, "_custom_properties"):618            self._custom_properties = {}619            for node in self.get_nodes_in_namespace("", PDFX_NAMESPACE):620                key = node.localName621                while True:622                    # see documentation about PDFX_NAMESPACE earlier in file623                    idx = key.find("\u2182")624                    if idx == -1:625                        break626                    key = (627                        key[:idx]628                        + chr(int(key[idx + 1 : idx + 5], base=16))629                        + key[idx + 5 :]630                    )631                if node.nodeType == node.ATTRIBUTE_NODE:632                    value = node.nodeValue633                else:634                    value = self._get_text(node)635                self._custom_properties[key] = value636        return self._custom_properties637 638    def _get_or_create_description(self, about_uri: str = "") -> XmlElement:639        """Get or create an rdf:Description element with the given about URI."""640        for desc in self.rdf_root.getElementsByTagNameNS(RDF_NAMESPACE, "Description"):641            if desc.getAttributeNS(RDF_NAMESPACE, "about") == about_uri:642                return desc643 644        doc = self.rdf_root.ownerDocument645        if doc is None:646            raise XmpDocumentError("XMP Document is None")647        desc = doc.createElementNS(RDF_NAMESPACE, "rdf:Description")648        desc.setAttributeNS(RDF_NAMESPACE, "rdf:about", about_uri)649        self.rdf_root.appendChild(desc)650        return desc651 652    def _clear_cache_entry(self, namespace: str, name: str) -> None:653        """Remove a cached value for a given namespace/name if present."""654        ns_cache = self.cache.get(namespace)655        if ns_cache and name in ns_cache:656            del ns_cache[name]657 658    def _set_single_value(self, namespace: str, name: str, value: Optional[str]) -> None:659        """Set or remove a single metadata value."""660        self._clear_cache_entry(namespace, name)661        desc = self._get_or_create_description()662 663        existing_elements = list(desc.getElementsByTagNameNS(namespace, name))664        for elem in existing_elements:665            desc.removeChild(elem)666 667        if existing_attr := desc.getAttributeNodeNS(namespace, name):668            desc.removeAttributeNode(existing_attr)669 670        if value is not None:671            doc = self.rdf_root.ownerDocument672            if doc is None:673                raise XmpDocumentError("XMP Document is None")674            prefix = self._get_namespace_prefix(namespace)675            elem = doc.createElementNS(namespace, f"{prefix}:{name}")676            text_node = doc.createTextNode(str(value))677            elem.appendChild(text_node)678            desc.appendChild(elem)679 680        self._update_stream()681 682    def _set_bag_values(self, namespace: str, name: str, values: Optional[list[str]]) -> None:683        """Set or remove bag values (unordered array)."""684        self._clear_cache_entry(namespace, name)685        desc = self._get_or_create_description()686 687        existing_elements = list(desc.getElementsByTagNameNS(namespace, name))688        for elem in existing_elements:689            desc.removeChild(elem)690 691        if values:692            doc = self.rdf_root.ownerDocument693            if doc is None:694                raise XmpDocumentError("XMP Document is None")695            prefix = self._get_namespace_prefix(namespace)696            elem = doc.createElementNS(namespace, f"{prefix}:{name}")697            bag = doc.createElementNS(RDF_NAMESPACE, "rdf:Bag")698 699            for value in values:700                li = doc.createElementNS(RDF_NAMESPACE, "rdf:li")701                text_node = doc.createTextNode(str(value))702                li.appendChild(text_node)703                bag.appendChild(li)704 705            elem.appendChild(bag)706            desc.appendChild(elem)707 708        self._update_stream()709 710    def _set_seq_values(self, namespace: str, name: str, values: Optional[list[str]]) -> None:711        """Set or remove sequence values (ordered array)."""712        self._clear_cache_entry(namespace, name)713        desc = self._get_or_create_description()714 715        existing_elements = list(desc.getElementsByTagNameNS(namespace, name))716        for elem in existing_elements:717            desc.removeChild(elem)718 719        if values:720            doc = self.rdf_root.ownerDocument721            if doc is None:722                raise XmpDocumentError("XMP Document is None")723            prefix = self._get_namespace_prefix(namespace)724            elem = doc.createElementNS(namespace, f"{prefix}:{name}")725            seq = doc.createElementNS(RDF_NAMESPACE, "rdf:Seq")726 727            for value in values:728                li = doc.createElementNS(RDF_NAMESPACE, "rdf:li")729                text_node = doc.createTextNode(str(value))730                li.appendChild(text_node)731                seq.appendChild(li)732 733            elem.appendChild(seq)734            desc.appendChild(elem)735 736        self._update_stream()737 738    def _set_langalt_values(self, namespace: str, name: str, values: Optional[dict[str, str]]) -> None:739        """Set or remove language alternative values."""740        self._clear_cache_entry(namespace, name)741        desc = self._get_or_create_description()742 743        existing_elements = list(desc.getElementsByTagNameNS(namespace, name))744        for elem in existing_elements:745            desc.removeChild(elem)746 747        if values:748            doc = self.rdf_root.ownerDocument749            if doc is None:750                raise XmpDocumentError("XMP Document is None")751            prefix = self._get_namespace_prefix(namespace)752            elem = doc.createElementNS(namespace, f"{prefix}:{name}")753            alt = doc.createElementNS(RDF_NAMESPACE, "rdf:Alt")754 755            for lang, value in values.items():756                li = doc.createElementNS(RDF_NAMESPACE, "rdf:li")757                li.setAttribute("xml:lang", lang)758                text_node = doc.createTextNode(str(value))759                li.appendChild(text_node)760                alt.appendChild(li)761 762            elem.appendChild(alt)763            desc.appendChild(elem)764 765        self._update_stream()766 767    def _get_namespace_prefix(self, namespace: str) -> str:768        """Get the appropriate namespace prefix for a given namespace URI."""769        return _NAMESPACE_PREFIX_MAP.get(namespace, "unknown")770 771    def _update_stream(self) -> None:772        """Update the stream with the current XML content."""773        doc = self.rdf_root.ownerDocument774        if doc is None:775            raise XmpDocumentError("XMP Document is None")776 777        xml_data = doc.toxml(encoding="utf-8")778        self.stream.set_data(xml_data)779 
codekingpro/portable-devtools · Team Ai