| 1 | """Validate chart/table replacement fallback and release-route attributes.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | from xml.etree import ElementTree as ET |
| 6 | |
| 7 | from .marker_attributes import ( |
| 8 | FALLBACK_KIND_ATTR, |
| 9 | IMPORT_SOURCE_ATTR, |
| 10 | LEGACY_FALLBACK_KIND_ATTR, |
| 11 | LEGACY_IMPORT_SOURCE_ATTR, |
| 12 | LEGACY_REPLACEMENT_STATUS_ATTR, |
| 13 | LEGACY_REPLACE_WITH_ATTR, |
| 14 | LEGACY_ROUTE_STATUS_ATTR, |
| 15 | REPLACEMENT_STATUS_ATTR, |
| 16 | REPLACE_WITH_ATTR, |
| 17 | NativeMarkerAttributeError, |
| 18 | native_fallback_kind, |
| 19 | native_import_source, |
| 20 | native_replacement_kind, |
| 21 | native_replacement_status, |
| 22 | ) |
| 23 | |
| 24 | |
| 25 | VISUAL_STATUSES = frozenset({"source-preview", "normalized", "placeholder"}) |
| 26 | ROUTE_STATUSES = frozenset({"reconstruction-only"}) |
| 27 | REPLACEMENT_KINDS = frozenset({"chart", "table"}) |
| 28 | # Closed importer outputs from chart_to_svg, chartex_to_svg, and tbl_to_svg. |
| 29 | # This includes codes forwarded through their dynamic ``status`` parameters. |
| 30 | REPLACEMENT_STATUS_CODES = frozenset({ |
| 31 | "unsupported-3d-chart", |
| 32 | "unsupported-chart-analysis-features", |
| 33 | "unsupported-chart-axis-number-format", |
| 34 | "unsupported-chart-axis-options", |
| 35 | "unsupported-chart-axis-titles", |
| 36 | "unsupported-chart-bar-options", |
| 37 | "unsupported-chart-bubble-options", |
| 38 | "unsupported-chart-cache", |
| 39 | "unsupported-chart-category-format", |
| 40 | "unsupported-chart-data-labels", |
| 41 | "unsupported-chart-data-table", |
| 42 | "unsupported-chart-doughnut-options", |
| 43 | "unsupported-chart-legend-position", |
| 44 | "unsupported-chart-line-style", |
| 45 | "unsupported-chart-of-pie-options", |
| 46 | "unsupported-chart-parse", |
| 47 | "unsupported-chart-part", |
| 48 | "unsupported-chart-pie-options", |
| 49 | "unsupported-chart-plot", |
| 50 | "unsupported-chart-point-labels", |
| 51 | "unsupported-chart-radar-style", |
| 52 | "unsupported-chart-reference", |
| 53 | "unsupported-chart-relationship", |
| 54 | "unsupported-chart-scatter-style", |
| 55 | "unsupported-chart-schema", |
| 56 | "unsupported-chart-series-data-labels", |
| 57 | "unsupported-chart-series-order", |
| 58 | "unsupported-chart-series-style", |
| 59 | "unsupported-chart-type", |
| 60 | "unsupported-chart-uri", |
| 61 | "unsupported-chartex-cache", |
| 62 | "unsupported-chartex-data-id", |
| 63 | "unsupported-chartex-dimension", |
| 64 | "unsupported-chartex-parse", |
| 65 | "unsupported-chartex-part", |
| 66 | "unsupported-chartex-schema", |
| 67 | "unsupported-chartex-series", |
| 68 | "unsupported-chartex-structure", |
| 69 | "unsupported-chartex-type", |
| 70 | "unsupported-combo-category-format", |
| 71 | "unsupported-combo-category-layout", |
| 72 | "unsupported-combo-chart", |
| 73 | "unsupported-combo-series-order", |
| 74 | "unsupported-date-axis", |
| 75 | "unsupported-date-system", |
| 76 | "unsupported-formatted-category-cache", |
| 77 | "unsupported-merge-topology", |
| 78 | "unsupported-native-transform", |
| 79 | "unsupported-stock-chart", |
| 80 | "unsupported-table-direct-formatting", |
| 81 | "unsupported-table-geometry", |
| 82 | "unsupported-table-size", |
| 83 | "unsupported-table-style", |
| 84 | }) |
| 85 | |
| 86 | |
| 87 | def native_marker_status_errors(elem: ET.Element) -> list[str]: |
| 88 | """Return invalid or contradictory replacement status declarations.""" |
| 89 | errors: list[str] = [] |
| 90 | native_raw_values = { |
| 91 | REPLACE_WITH_ATTR: elem.get(REPLACE_WITH_ATTR), |
| 92 | LEGACY_REPLACE_WITH_ATTR: elem.get(LEGACY_REPLACE_WITH_ATTR), |
| 93 | } |
| 94 | fallback_status_raw_values = { |
| 95 | REPLACEMENT_STATUS_ATTR: elem.get(REPLACEMENT_STATUS_ATTR), |
| 96 | LEGACY_REPLACEMENT_STATUS_ATTR: elem.get(LEGACY_REPLACEMENT_STATUS_ATTR), |
| 97 | } |
| 98 | import_source_raw_values = { |
| 99 | IMPORT_SOURCE_ATTR: elem.get(IMPORT_SOURCE_ATTR), |
| 100 | LEGACY_IMPORT_SOURCE_ATTR: elem.get(LEGACY_IMPORT_SOURCE_ATTR), |
| 101 | } |
| 102 | visual_raw = elem.get(FALLBACK_KIND_ATTR) |
| 103 | legacy_visual_raw = elem.get(LEGACY_FALLBACK_KIND_ATTR) |
| 104 | route_raw = elem.get(LEGACY_ROUTE_STATUS_ATTR) |
| 105 | try: |
| 106 | visual = native_fallback_kind(elem) |
| 107 | native = native_replacement_kind(elem) |
| 108 | fallback = native_replacement_status(elem) |
| 109 | native_import_source(elem) |
| 110 | except NativeMarkerAttributeError as exc: |
| 111 | errors.append(str(exc)) |
| 112 | return errors |
| 113 | |
| 114 | route = route_raw.strip() if route_raw is not None else None |
| 115 | |
| 116 | for attr, raw in ( |
| 117 | *native_raw_values.items(), |
| 118 | *fallback_status_raw_values.items(), |
| 119 | *import_source_raw_values.items(), |
| 120 | ): |
| 121 | if raw is not None and raw != raw.strip(): |
| 122 | errors.append(f"{attr} must not contain surrounding whitespace") |
| 123 | canonical_kind_raw = native_raw_values[REPLACE_WITH_ATTR] |
| 124 | if ( |
| 125 | canonical_kind_raw is not None |
| 126 | and canonical_kind_raw == canonical_kind_raw.strip() |
| 127 | and canonical_kind_raw != canonical_kind_raw.lower() |
| 128 | ): |
| 129 | errors.append(f"{REPLACE_WITH_ATTR} must use lowercase chart or table") |
| 130 | if visual_raw is not None and visual_raw != visual_raw.strip(): |
| 131 | errors.append(f"{FALLBACK_KIND_ATTR} must not contain surrounding whitespace") |
| 132 | if legacy_visual_raw is not None and legacy_visual_raw != legacy_visual_raw.strip(): |
| 133 | errors.append( |
| 134 | f"{LEGACY_FALLBACK_KIND_ATTR} must not contain surrounding whitespace" |
| 135 | ) |
| 136 | if route_raw is not None and route_raw != route: |
| 137 | errors.append(f"{LEGACY_ROUTE_STATUS_ATTR} must not contain surrounding whitespace") |
| 138 | if visual is not None and visual not in VISUAL_STATUSES: |
| 139 | errors.append(f"unsupported {FALLBACK_KIND_ATTR} value: {visual!r}") |
| 140 | if route is not None and route not in ROUTE_STATUSES: |
| 141 | errors.append(f"unsupported {LEGACY_ROUTE_STATUS_ATTR} value: {route!r}") |
| 142 | if any(raw is not None for raw in native_raw_values.values()): |
| 143 | if native not in REPLACEMENT_KINDS: |
| 144 | errors.append(f"unsupported {REPLACE_WITH_ATTR} value: {native!r}") |
| 145 | if any(raw is not None for raw in fallback_status_raw_values.values()): |
| 146 | if not fallback: |
| 147 | errors.append(f"{REPLACEMENT_STATUS_ATTR} must not be empty") |
| 148 | elif fallback not in REPLACEMENT_STATUS_CODES: |
| 149 | errors.append( |
| 150 | f"unsupported {REPLACEMENT_STATUS_ATTR} value: {fallback!r}" |
| 151 | ) |
| 152 | if any(raw is not None for raw in import_source_raw_values.values()): |
| 153 | source = native_import_source(elem) |
| 154 | if source != "pptx": |
| 155 | errors.append(f"unsupported {IMPORT_SOURCE_ATTR} value: {source!r}") |
| 156 | if ( |
| 157 | visual_raw is None |
| 158 | and legacy_visual_raw is not None |
| 159 | and visual == "placeholder" |
| 160 | and route != "reconstruction-only" |
| 161 | ): |
| 162 | errors.append( |
| 163 | f"{LEGACY_FALLBACK_KIND_ATTR}='placeholder' requires " |
| 164 | f"{LEGACY_ROUTE_STATUS_ATTR}='reconstruction-only'" |
| 165 | ) |
| 166 | if route == "reconstruction-only" and visual != "placeholder": |
| 167 | errors.append( |
| 168 | f"{LEGACY_ROUTE_STATUS_ATTR}='reconstruction-only' requires " |
| 169 | f"{FALLBACK_KIND_ATTR}='placeholder'" |
| 170 | ) |
| 171 | if native and fallback: |
| 172 | errors.append( |
| 173 | f"data-pptx-replace-with and {REPLACEMENT_STATUS_ATTR} are mutually exclusive" |
| 174 | ) |
| 175 | return errors |
| 176 | |
| 177 | |
| 178 | def native_marker_release_block_reason(elem: ET.Element) -> str | None: |
| 179 | """Return invalid status metadata that must block an export. |
| 180 | |
| 181 | A valid ``reconstruction-only`` declaration is diagnostic rather than a |
| 182 | release block: default export keeps its visible placeholder, while an |
| 183 | an active replacement marker may still reconstruct a native Chart/Table. |
| 184 | """ |
| 185 | errors = native_marker_status_errors(elem) |
| 186 | if errors: |
| 187 | return f"invalid-status: {errors[0]}" |
| 188 | return None |
| 189 |