144 lines
4.9 KiB
Python
144 lines
4.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Shared diagnostic contract for unsupported imported object effects."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from xml.etree import ElementTree as ET
|
|
|
|
|
|
EFFECT_STATUS_ATTR = "data-pptx-effect-status"
|
|
EFFECT_REASON_ATTR = "data-pptx-effect-reason"
|
|
UNSUPPORTED_EFFECT_STATUS = "unsupported"
|
|
_EFFECT_OBJECT_IDENTITY_ATTRS = (
|
|
"data-pptx-object",
|
|
"data-pptx-shape-id",
|
|
"data-pptx-shape-scope",
|
|
)
|
|
_DML_NAMESPACE = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
|
_TEXT_PROPERTY_TAGS = frozenset({
|
|
f"{{{_DML_NAMESPACE}}}defRPr",
|
|
f"{{{_DML_NAMESPACE}}}endParaRPr",
|
|
f"{{{_DML_NAMESPACE}}}rPr",
|
|
})
|
|
_RUN_EFFECT_CONTAINER_TAGS = frozenset({
|
|
f"{{{_DML_NAMESPACE}}}effectLst",
|
|
f"{{{_DML_NAMESPACE}}}effectDag",
|
|
})
|
|
|
|
|
|
def project_effect_status_errors(root: ET.Element) -> list[str]:
|
|
"""Return blocking diagnostics for invalid or unsupported effect metadata."""
|
|
errors: set[str] = set()
|
|
parents = {
|
|
child: parent
|
|
for parent in root.iter()
|
|
for child in parent
|
|
}
|
|
for elem in root.iter():
|
|
raw_status = elem.get(EFFECT_STATUS_ATTR)
|
|
raw_reason = elem.get(EFFECT_REASON_ATTR)
|
|
if raw_status is None and raw_reason is None:
|
|
continue
|
|
parent = parents.get(elem)
|
|
if (
|
|
parent is not None
|
|
and parent.get(EFFECT_STATUS_ATTR) == raw_status
|
|
and parent.get(EFFECT_REASON_ATTR) == raw_reason
|
|
and _same_source_object(parent, elem)
|
|
):
|
|
# Import duplicates the marker on the logical object and carrier
|
|
# so stripping either copy cannot erase the block. Report it once.
|
|
continue
|
|
label = _element_label(elem)
|
|
status = (raw_status or "").strip()
|
|
if status != UNSUPPORTED_EFFECT_STATUS:
|
|
errors.add(
|
|
f'{label} {EFFECT_STATUS_ATTR} must equal '
|
|
f'{UNSUPPORTED_EFFECT_STATUS!r}; got {raw_status!r}'
|
|
)
|
|
continue
|
|
reason = (raw_reason or "").strip()
|
|
if not reason:
|
|
errors.add(
|
|
f'{label} {EFFECT_REASON_ATTR} requires a non-empty reason'
|
|
)
|
|
continue
|
|
errors.add(f'{label} has unsupported source PPTX effect: {reason}')
|
|
return sorted(errors)
|
|
|
|
|
|
def unsupported_effect_metadata(*reasons: str) -> dict[str, str]:
|
|
"""Build one canonical import marker without dropping compound reasons."""
|
|
normalized: set[str] = set()
|
|
for reason in reasons:
|
|
reason = reason.strip()
|
|
if not reason:
|
|
raise ValueError("Unsupported PPTX effect reason must not be empty")
|
|
items: object = reason
|
|
if reason.startswith("["):
|
|
try:
|
|
items = json.loads(reason)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
if not isinstance(items, list):
|
|
items = [reason]
|
|
if not all(isinstance(item, str) and item.strip() for item in items):
|
|
raise ValueError("Unsupported PPTX effect reasons must be strings")
|
|
normalized.update(item.strip() for item in items)
|
|
if not normalized:
|
|
raise ValueError("Unsupported PPTX effect reason must not be empty")
|
|
ordered = sorted(normalized)
|
|
encoded = (
|
|
ordered[0]
|
|
if len(ordered) == 1
|
|
else json.dumps(ordered, separators=(",", ":"))
|
|
)
|
|
return {
|
|
EFFECT_STATUS_ATTR: UNSUPPORTED_EFFECT_STATUS,
|
|
EFFECT_REASON_ATTR: encoded,
|
|
}
|
|
|
|
|
|
def txbody_has_run_effects(*text_style_roots: ET.Element | None) -> bool:
|
|
"""Return whether rebuilding any supplied text style would lose an effect."""
|
|
for root in text_style_roots:
|
|
if root is None:
|
|
continue
|
|
for properties in root.iter():
|
|
if properties.tag not in _TEXT_PROPERTY_TAGS:
|
|
continue
|
|
for child in properties:
|
|
if child.tag in _RUN_EFFECT_CONTAINER_TAGS and any(
|
|
isinstance(effect.tag, str)
|
|
for effect in child
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _element_label(elem: ET.Element) -> str:
|
|
tag = elem.tag.rsplit("}", 1)[-1]
|
|
elem_id = elem.get("id") or elem.get("data-name")
|
|
if elem_id:
|
|
return f'<{tag} id="{elem_id}">'
|
|
shape_id = elem.get("data-pptx-shape-id")
|
|
if shape_id:
|
|
object_kind = elem.get("data-pptx-object") or "object"
|
|
scope = elem.get("data-pptx-shape-scope") or "unknown"
|
|
return (
|
|
f'<{tag} data-pptx-object="{object_kind}" '
|
|
f'data-pptx-shape-id="{shape_id}" '
|
|
f'data-pptx-shape-scope="{scope}">'
|
|
)
|
|
return f"<{tag}>"
|
|
|
|
|
|
def _same_source_object(parent: ET.Element, child: ET.Element) -> bool:
|
|
"""Return whether a parent/child marker describes one imported object."""
|
|
return all(
|
|
child.get(attr) is not None
|
|
and child.get(attr) == parent.get(attr)
|
|
for attr in _EFFECT_OBJECT_IDENTITY_ATTRS
|
|
)
|