Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
#!/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
|
||||
)
|
||||
Reference in New Issue
Block a user