Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/pptx/oxml/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]):
CT_LineEndProperties,
CT_LineProperties,
CT_NonVisualDrawingProps,
CT_OfficeArtExtensionList,
CT_Placeholder,
CT_Point2D,
CT_PositiveSize2D,
Expand All @@ -428,6 +429,7 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]):
register_element_cls("a:chExt", CT_PositiveSize2D)
register_element_cls("a:chOff", CT_Point2D)
register_element_cls("a:ext", CT_PositiveSize2D)
register_element_cls("a:extLst", CT_OfficeArtExtensionList)
register_element_cls("a:headEnd", CT_LineEndProperties)
register_element_cls("a:ln", CT_LineProperties)
register_element_cls("a:lnB", CT_LineProperties)
Expand Down
1 change: 1 addition & 0 deletions src/pptx/oxml/ns.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
# -- Maps namespace prefix to namespace name for all known PowerPoint XML namespaces --
_nsmap = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"adec": "http://schemas.microsoft.com/office/drawing/2017/decorative",
"c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
"cp": "http://schemas.openxmlformats.org/package/2006/metadata/core-properties",
"ct": "http://schemas.openxmlformats.org/package/2006/content-types",
Expand Down
102 changes: 102 additions & 0 deletions src/pptx/oxml/shapes/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,14 +334,116 @@ class CT_NonVisualDrawingProps(BaseOxmlElement):

get_or_add_hlinkClick: Callable[[], CT_Hyperlink]
get_or_add_hlinkHover: Callable[[], CT_Hyperlink]
get_or_add_extLst: Callable[[], CT_OfficeArtExtensionList]
_remove_extLst: Callable[[], None]

_tag_seq = ("a:hlinkClick", "a:hlinkHover", "a:extLst")
hlinkClick: CT_Hyperlink | None = ZeroOrOne("a:hlinkClick", successors=_tag_seq[1:])
hlinkHover: CT_Hyperlink | None = ZeroOrOne("a:hlinkHover", successors=_tag_seq[2:])
extLst: CT_OfficeArtExtensionList | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
"a:extLst", successors=()
)
id = RequiredAttribute("id", ST_DrawingElementId)
name = RequiredAttribute("name", XsdString)
descr: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType]
"descr", XsdString, default=None
)
title: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType]
"title", XsdString, default=None
)
del _tag_seq

# -- URI for the Office 2019+ "Mark as decorative" extension --
_DECORATIVE_EXT_URI = "{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}"

@property
def decorative(self) -> bool:
"""True if this `p:cNvPr` carries an `<adec:decorative val="1"/>` extension.

Returns False when the extension is missing, when its `val` attribute is "0",
or when the extLst element is absent altogether.
"""
extLst = self.extLst
if extLst is None:
return False
return extLst.is_decorative

@decorative.setter
def decorative(self, value: bool) -> None:
if value:
extLst = self.get_or_add_extLst()
extLst.set_decorative()
else:
extLst = self.extLst
if extLst is not None:
extLst.clear_decorative()
# ---if extLst is now empty, remove it entirely---
if not list(extLst):
self._remove_extLst()


class CT_OfficeArtExtensionList(BaseOxmlElement):
"""`a:extLst` element under `p:cNvPr` (and elsewhere).

Holds zero or more `a:ext` children, each identified by a `uri` attribute. We use
this here to carry the Office 2019+ `<adec:decorative>` extension; other URIs are
preserved verbatim by virtue of being plain `a:ext` children.

Note: we cannot register a custom class for `a:ext` itself because that local-name
is shared with the transform-extents element (`<a:ext cx="..." cy="..."/>`). Instead
we manipulate the `a:ext` children directly through lxml.
"""

_DECORATIVE_EXT_URI = "{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}"

@property
def is_decorative(self) -> bool:
"""True if a child `<a:ext uri="{FF2B5EF4...}">` carries `<adec:decorative val="1"/>`."""
ext = self._decorative_ext
if ext is None:
return False
decorative = ext.find(qn("adec:decorative"))
if decorative is None:
return False
# ---val attribute defaults to "1" per the schema; be permissive on read---
val = decorative.get("val")
if val is None:
return True
return val not in ("0", "false")

def set_decorative(self) -> None:
"""Ensure an `<a:ext uri="...">/<adec:decorative val="1"/>` is present."""
ext = self._decorative_ext
if ext is None:
ext = OxmlElement(
"a:ext",
nsmap={"a": "http://schemas.openxmlformats.org/drawingml/2006/main"},
)
ext.set("uri", self._DECORATIVE_EXT_URI)
self.append(ext)
decorative = ext.find(qn("adec:decorative"))
if decorative is None:
decorative = OxmlElement(
"adec:decorative",
nsmap={"adec": "http://schemas.microsoft.com/office/drawing/2017/decorative"},
)
ext.append(decorative)
decorative.set("val", "1")

def clear_decorative(self) -> None:
"""Remove the decorative `a:ext` child if present."""
ext = self._decorative_ext
if ext is not None:
self.remove(ext)

@property
def _decorative_ext(self):
"""Return the `a:ext` child whose `uri` is the decorative-extension URI, or None."""
matches = self.xpath(f"./a:ext[@uri='{self._DECORATIVE_EXT_URI}']")
if not matches:
return None
return matches[0]


class CT_Placeholder(BaseOxmlElement):
"""`p:ph` custom element class."""
Expand Down
52 changes: 52 additions & 0 deletions src/pptx/shapes/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,58 @@ def name(self) -> str:
def name(self, value: str):
self._element._nvXxPr.cNvPr.name = value # pyright: ignore[reportPrivateUsage]

@property
def alt_text(self) -> str | None:
"""Alternative text describing this shape, used by screen readers and accessibility tools.

Read/write. Returns the value of the `descr` attribute on the shape's
`<p:cNvPr>` element. None if the attribute is not present (the shape has no
alt text). Assigning None removes the attribute. Assigning an empty string
is a meaningful, distinct value — it preserves the attribute as `descr=""`,
useful for callers who want to round-trip an explicit "no description"
marker.

See Microsoft Accessibility guidance: prefer `alt_text` for the description
and `alt_title` for a short heading, when both are needed.
"""
return self._element._nvXxPr.cNvPr.descr # pyright: ignore[reportPrivateUsage]

@alt_text.setter
def alt_text(self, value: str | None):
self._element._nvXxPr.cNvPr.descr = value # pyright: ignore[reportPrivateUsage]

@property
def alt_title(self) -> str | None:
"""Short title (heading) for this shape's alternative text, used for accessibility.

Read/write. Returns the value of the `title` attribute on the shape's
`<p:cNvPr>` element. None if the attribute is not present. Assigning None
removes the attribute. Microsoft accessibility guidance recommends a brief
title plus a longer `alt_text` description, mirroring the two-field UX in
PowerPoint's "Alt Text" pane.
"""
return self._element._nvXxPr.cNvPr.title # pyright: ignore[reportPrivateUsage]

@alt_title.setter
def alt_title(self, value: str | None):
self._element._nvXxPr.cNvPr.title = value # pyright: ignore[reportPrivateUsage]

@property
def is_decorative(self) -> bool:
"""True if this shape is marked as decorative (Office 2019+ accessibility flag).

Read/write boolean. Decorative shapes are skipped by screen readers; they
carry no semantic meaning beyond visual decoration (background grids,
ornaments, dividers). Backed by an `<adec:decorative val="1"/>` extension
inside `<p:cNvPr>/<a:extLst>`. Setting to False removes the extension; the
attribute defaults to False on shapes that have never been touched.
"""
return self._element._nvXxPr.cNvPr.decorative # pyright: ignore[reportPrivateUsage]

@is_decorative.setter
def is_decorative(self, value: bool):
self._element._nvXxPr.cNvPr.decorative = bool(value) # pyright: ignore[reportPrivateUsage]

@property
def part(self) -> BaseSlidePart:
"""The package part containing this shape.
Expand Down
Loading
Loading