| 1 | """Canonical and compatible attribute access for chart/table replacements.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | from collections.abc import Callable |
| 6 | from xml.etree import ElementTree as ET |
| 7 | |
| 8 | |
| 9 | REPLACE_WITH_ATTR = "data-pptx-replace-with" |
| 10 | LEGACY_REPLACE_WITH_ATTR = "data-pptx-native" |
| 11 | REPLACEMENT_STATUS_ATTR = "data-pptx-replacement-status" |
| 12 | LEGACY_REPLACEMENT_STATUS_ATTR = "data-pptx-native-status" |
| 13 | IMPORT_SOURCE_ATTR = "data-pptx-import-source" |
| 14 | LEGACY_IMPORT_SOURCE_ATTR = "data-pptx-native-source" |
| 15 | FALLBACK_KIND_ATTR = "data-pptx-fallback-kind" |
| 16 | LEGACY_FALLBACK_KIND_ATTR = "data-pptx-visual-status" |
| 17 | LEGACY_ROUTE_STATUS_ATTR = "data-pptx-route-status" |
| 18 | |
| 19 | |
| 20 | class NativeMarkerAttributeError(ValueError): |
| 21 | """Raised when canonical and compatible marker attributes conflict.""" |
| 22 | |
| 23 | |
| 24 | def _normalized_token(value: str) -> str: |
| 25 | return value.strip().lower() |
| 26 | |
| 27 | |
| 28 | def _resolved_alias( |
| 29 | elem: ET.Element, |
| 30 | canonical: str, |
| 31 | legacy: str, |
| 32 | *, |
| 33 | normalize: Callable[[str], str] = _normalized_token, |
| 34 | ) -> str | None: |
| 35 | canonical_raw = elem.get(canonical) |
| 36 | legacy_raw = elem.get(legacy) |
| 37 | canonical_value = normalize(canonical_raw) if canonical_raw is not None else None |
| 38 | legacy_value = normalize(legacy_raw) if legacy_raw is not None else None |
| 39 | if ( |
| 40 | canonical_value is not None |
| 41 | and legacy_value is not None |
| 42 | and canonical_value != legacy_value |
| 43 | ): |
| 44 | raise NativeMarkerAttributeError( |
| 45 | f"{canonical}={canonical_raw!r} conflicts with compatible " |
| 46 | f"{legacy}={legacy_raw!r}" |
| 47 | ) |
| 48 | return canonical_value if canonical_value is not None else legacy_value |
| 49 | |
| 50 | |
| 51 | def native_replacement_kind(elem: ET.Element) -> str: |
| 52 | """Return the requested chart/table replacement kind, or an empty string.""" |
| 53 | return _resolved_alias(elem, REPLACE_WITH_ATTR, LEGACY_REPLACE_WITH_ATTR) or "" |
| 54 | |
| 55 | |
| 56 | def native_replacement_status(elem: ET.Element) -> str: |
| 57 | """Return the fallback-only replacement status, or an empty string.""" |
| 58 | return ( |
| 59 | _resolved_alias( |
| 60 | elem, |
| 61 | REPLACEMENT_STATUS_ATTR, |
| 62 | LEGACY_REPLACEMENT_STATUS_ATTR, |
| 63 | normalize=str.strip, |
| 64 | ) |
| 65 | or "" |
| 66 | ) |
| 67 | |
| 68 | |
| 69 | def native_import_source(elem: ET.Element) -> str: |
| 70 | """Return replacement payload provenance, or an empty string.""" |
| 71 | return ( |
| 72 | _resolved_alias( |
| 73 | elem, |
| 74 | IMPORT_SOURCE_ATTR, |
| 75 | LEGACY_IMPORT_SOURCE_ATTR, |
| 76 | normalize=str.strip, |
| 77 | ) |
| 78 | or "" |
| 79 | ) |
| 80 | |
| 81 | |
| 82 | def native_fallback_kind(elem: ET.Element) -> str | None: |
| 83 | """Return the visible fallback classification when declared.""" |
| 84 | return _resolved_alias( |
| 85 | elem, |
| 86 | FALLBACK_KIND_ATTR, |
| 87 | LEGACY_FALLBACK_KIND_ATTR, |
| 88 | normalize=str.strip, |
| 89 | ) |
| 90 | |
| 91 | |
| 92 | def native_metadata_payload_matches( |
| 93 | elem: ET.Element, |
| 94 | parent_kind: str, |
| 95 | ) -> bool: |
| 96 | """Return whether one metadata node carries this replacement payload. |
| 97 | |
| 98 | Compatible metadata kind attributes remain readable, but any declared |
| 99 | kind must agree with both its alias and the parent replacement marker. |
| 100 | """ |
| 101 | native_kind = elem.get(LEGACY_REPLACE_WITH_ATTR) |
| 102 | compatible_kind = elem.get("data-pptx-kind") |
| 103 | for attr, raw in ( |
| 104 | (LEGACY_REPLACE_WITH_ATTR, native_kind), |
| 105 | ("data-pptx-kind", compatible_kind), |
| 106 | ): |
| 107 | if raw is not None and raw != raw.strip(): |
| 108 | raise NativeMarkerAttributeError( |
| 109 | f"metadata {attr} must not contain surrounding whitespace" |
| 110 | ) |
| 111 | if native_kind is not None and compatible_kind is not None: |
| 112 | if _normalized_token(native_kind) != _normalized_token(compatible_kind): |
| 113 | raise NativeMarkerAttributeError( |
| 114 | "metadata data-pptx-native conflicts with data-pptx-kind" |
| 115 | ) |
| 116 | declared_raw = native_kind if native_kind is not None else compatible_kind |
| 117 | declared_kind = _normalized_token(declared_raw) if declared_raw is not None else None |
| 118 | if declared_kind is not None and declared_kind != parent_kind: |
| 119 | raise NativeMarkerAttributeError( |
| 120 | f"metadata kind {declared_kind!r} conflicts with parent " |
| 121 | f"replacement kind {parent_kind!r}" |
| 122 | ) |
| 123 | metadata_type_raw = elem.get("type") |
| 124 | if metadata_type_raw is not None and metadata_type_raw != metadata_type_raw.strip(): |
| 125 | raise NativeMarkerAttributeError( |
| 126 | "metadata type must not contain surrounding whitespace" |
| 127 | ) |
| 128 | metadata_type = (metadata_type_raw or "").lower() |
| 129 | return metadata_type == "application/json" or declared_kind == parent_kind |
| 130 | |
| 131 | |
| 132 | def native_marker_legacy_warnings(elem: ET.Element) -> list[str]: |
| 133 | """Return migration advice for compatible legacy marker spellings.""" |
| 134 | tag = elem.tag.rsplit("}", 1)[-1] |
| 135 | if tag == "metadata": |
| 136 | native_kind = elem.get(LEGACY_REPLACE_WITH_ATTR) |
| 137 | compatible_kind = elem.get("data-pptx-kind") |
| 138 | if ( |
| 139 | native_kind is not None |
| 140 | and compatible_kind is not None |
| 141 | and _normalized_token(native_kind) != _normalized_token(compatible_kind) |
| 142 | ): |
| 143 | return [] |
| 144 | warnings = [] |
| 145 | for legacy in (LEGACY_REPLACE_WITH_ATTR, "data-pptx-kind"): |
| 146 | if elem.get(legacy) is not None: |
| 147 | warnings.append( |
| 148 | f"legacy metadata attribute {legacy} is compatible; use " |
| 149 | 'type="application/json" because the parent replacement ' |
| 150 | "marker determines the payload kind. No change is required " |
| 151 | "for export." |
| 152 | ) |
| 153 | return warnings |
| 154 | |
| 155 | try: |
| 156 | native_replacement_kind(elem) |
| 157 | native_replacement_status(elem) |
| 158 | native_import_source(elem) |
| 159 | native_fallback_kind(elem) |
| 160 | except NativeMarkerAttributeError: |
| 161 | return [] |
| 162 | |
| 163 | warnings = [] |
| 164 | replacements = ( |
| 165 | (LEGACY_REPLACE_WITH_ATTR, REPLACE_WITH_ATTR), |
| 166 | (LEGACY_REPLACEMENT_STATUS_ATTR, REPLACEMENT_STATUS_ATTR), |
| 167 | (LEGACY_IMPORT_SOURCE_ATTR, IMPORT_SOURCE_ATTR), |
| 168 | (LEGACY_FALLBACK_KIND_ATTR, FALLBACK_KIND_ATTR), |
| 169 | ) |
| 170 | for legacy, canonical in replacements: |
| 171 | if elem.get(legacy) is not None: |
| 172 | warnings.append( |
| 173 | f"legacy attribute {legacy} is compatible; use {canonical} " |
| 174 | "for project-canonical SVG. No change is required for export." |
| 175 | ) |
| 176 | if elem.get(LEGACY_ROUTE_STATUS_ATTR) is not None: |
| 177 | warnings.append( |
| 178 | f"legacy attribute {LEGACY_ROUTE_STATUS_ATTR} is compatible; omit it " |
| 179 | f"because {FALLBACK_KIND_ATTR}='placeholder' already implies the " |
| 180 | "reconstruction-only route. No change is required for export." |
| 181 | ) |
| 182 | return warnings |
| 183 |