| 1 | #!/usr/bin/env python3 |
| 2 | """Shared diagnostic contract for unsupported imported object effects.""" |
| 3 | |
| 4 | from __future__ import annotations |
| 5 | |
| 6 | import json |
| 7 | from xml.etree import ElementTree as ET |
| 8 | |
| 9 | |
| 10 | EFFECT_STATUS_ATTR = "data-pptx-effect-status" |
| 11 | EFFECT_REASON_ATTR = "data-pptx-effect-reason" |
| 12 | UNSUPPORTED_EFFECT_STATUS = "unsupported" |
| 13 | _EFFECT_OBJECT_IDENTITY_ATTRS = ( |
| 14 | "data-pptx-object", |
| 15 | "data-pptx-shape-id", |
| 16 | "data-pptx-shape-scope", |
| 17 | ) |
| 18 | _DML_NAMESPACE = "http://schemas.openxmlformats.org/drawingml/2006/main" |
| 19 | _TEXT_PROPERTY_TAGS = frozenset({ |
| 20 | f"{{{_DML_NAMESPACE}}}defRPr", |
| 21 | f"{{{_DML_NAMESPACE}}}endParaRPr", |
| 22 | f"{{{_DML_NAMESPACE}}}rPr", |
| 23 | }) |
| 24 | _RUN_EFFECT_CONTAINER_TAGS = frozenset({ |
| 25 | f"{{{_DML_NAMESPACE}}}effectLst", |
| 26 | f"{{{_DML_NAMESPACE}}}effectDag", |
| 27 | }) |
| 28 | |
| 29 | |
| 30 | def project_effect_status_errors(root: ET.Element) -> list[str]: |
| 31 | """Return blocking diagnostics for invalid or unsupported effect metadata.""" |
| 32 | errors: set[str] = set() |
| 33 | parents = { |
| 34 | child: parent |
| 35 | for parent in root.iter() |
| 36 | for child in parent |
| 37 | } |
| 38 | for elem in root.iter(): |
| 39 | raw_status = elem.get(EFFECT_STATUS_ATTR) |
| 40 | raw_reason = elem.get(EFFECT_REASON_ATTR) |
| 41 | if raw_status is None and raw_reason is None: |
| 42 | continue |
| 43 | parent = parents.get(elem) |
| 44 | if ( |
| 45 | parent is not None |
| 46 | and parent.get(EFFECT_STATUS_ATTR) == raw_status |
| 47 | and parent.get(EFFECT_REASON_ATTR) == raw_reason |
| 48 | and _same_source_object(parent, elem) |
| 49 | ): |
| 50 | # Import duplicates the marker on the logical object and carrier |
| 51 | # so stripping either copy cannot erase the block. Report it once. |
| 52 | continue |
| 53 | label = _element_label(elem) |
| 54 | status = (raw_status or "").strip() |
| 55 | if status != UNSUPPORTED_EFFECT_STATUS: |
| 56 | errors.add( |
| 57 | f'{label} {EFFECT_STATUS_ATTR} must equal ' |
| 58 | f'{UNSUPPORTED_EFFECT_STATUS!r}; got {raw_status!r}' |
| 59 | ) |
| 60 | continue |
| 61 | reason = (raw_reason or "").strip() |
| 62 | if not reason: |
| 63 | errors.add( |
| 64 | f'{label} {EFFECT_REASON_ATTR} requires a non-empty reason' |
| 65 | ) |
| 66 | continue |
| 67 | errors.add(f'{label} has unsupported source PPTX effect: {reason}') |
| 68 | return sorted(errors) |
| 69 | |
| 70 | |
| 71 | def unsupported_effect_metadata(*reasons: str) -> dict[str, str]: |
| 72 | """Build one canonical import marker without dropping compound reasons.""" |
| 73 | normalized: set[str] = set() |
| 74 | for reason in reasons: |
| 75 | reason = reason.strip() |
| 76 | if not reason: |
| 77 | raise ValueError("Unsupported PPTX effect reason must not be empty") |
| 78 | items: object = reason |
| 79 | if reason.startswith("["): |
| 80 | try: |
| 81 | items = json.loads(reason) |
| 82 | except json.JSONDecodeError: |
| 83 | pass |
| 84 | if not isinstance(items, list): |
| 85 | items = [reason] |
| 86 | if not all(isinstance(item, str) and item.strip() for item in items): |
| 87 | raise ValueError("Unsupported PPTX effect reasons must be strings") |
| 88 | normalized.update(item.strip() for item in items) |
| 89 | if not normalized: |
| 90 | raise ValueError("Unsupported PPTX effect reason must not be empty") |
| 91 | ordered = sorted(normalized) |
| 92 | encoded = ( |
| 93 | ordered[0] |
| 94 | if len(ordered) == 1 |
| 95 | else json.dumps(ordered, separators=(",", ":")) |
| 96 | ) |
| 97 | return { |
| 98 | EFFECT_STATUS_ATTR: UNSUPPORTED_EFFECT_STATUS, |
| 99 | EFFECT_REASON_ATTR: encoded, |
| 100 | } |
| 101 | |
| 102 | |
| 103 | def txbody_has_run_effects(*text_style_roots: ET.Element | None) -> bool: |
| 104 | """Return whether rebuilding any supplied text style would lose an effect.""" |
| 105 | for root in text_style_roots: |
| 106 | if root is None: |
| 107 | continue |
| 108 | for properties in root.iter(): |
| 109 | if properties.tag not in _TEXT_PROPERTY_TAGS: |
| 110 | continue |
| 111 | for child in properties: |
| 112 | if child.tag in _RUN_EFFECT_CONTAINER_TAGS and any( |
| 113 | isinstance(effect.tag, str) |
| 114 | for effect in child |
| 115 | ): |
| 116 | return True |
| 117 | return False |
| 118 | |
| 119 | |
| 120 | def _element_label(elem: ET.Element) -> str: |
| 121 | tag = elem.tag.rsplit("}", 1)[-1] |
| 122 | elem_id = elem.get("id") or elem.get("data-name") |
| 123 | if elem_id: |
| 124 | return f'<{tag} id="{elem_id}">' |
| 125 | shape_id = elem.get("data-pptx-shape-id") |
| 126 | if shape_id: |
| 127 | object_kind = elem.get("data-pptx-object") or "object" |
| 128 | scope = elem.get("data-pptx-shape-scope") or "unknown" |
| 129 | return ( |
| 130 | f'<{tag} data-pptx-object="{object_kind}" ' |
| 131 | f'data-pptx-shape-id="{shape_id}" ' |
| 132 | f'data-pptx-shape-scope="{scope}">' |
| 133 | ) |
| 134 | return f"<{tag}>" |
| 135 | |
| 136 | |
| 137 | def _same_source_object(parent: ET.Element, child: ET.Element) -> bool: |
| 138 | """Return whether a parent/child marker describes one imported object.""" |
| 139 | return all( |
| 140 | child.get(attr) is not None |
| 141 | and child.get(attr) == parent.get(attr) |
| 142 | for attr in _EFFECT_OBJECT_IDENTITY_ATTRS |
| 143 | ) |
| 144 |