Skip to content

Commit 02049b3

Browse files
MHoroszowskiMatthew Horoszowski
andauthored
Add alt_text, alt_title, and is_decorative to BaseShape (a11y phase 1) (#31)
Implements the first slice of issue #22 (Accessibility epic). All shapes deriving from BaseShape (autoshapes, group shapes, graphic frames, connectors, pictures) now expose three properties screen readers and accessibility checkers depend on: • shape.alt_text — alternative text description (cNvPr/@Descr) • shape.alt_title — alt-text title (cNvPr/@title) • shape.is_decorative — Office 2019+ decorative flag suppressing the shape from screen-reader output Surface lives on BaseShape so every concrete shape type inherits it without duplication. alt_text and alt_title are independent attributes on <p:cNvPr> and round-trip cleanly. is_decorative is the Office 2019+ extension element <adec:decorative val="1"/> nested inside <a:extLst>/<a:ext uri="{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}">; the extension is added on True, removed entirely on False so unaffected shapes serialize identically. Setters accept None and empty string to clear, matching python-pptx convention. Getters return empty string when the attribute is absent rather than None, mirroring shape.name. Out of scope (deferred to follow-up PRs): • slide.accessibility_issues audit method • Reading-order surface • PictureFormat.alt_text shortcut convenience Tests: 37 new unit tests in tests/shapes/test_base.py covering get/set, None and empty-string handling, round-trip XML stability, independence of alt_text vs alt_title, and the decorative extension structure. Verification: pytest tests/ → 3054 passed (was 3017; +37 new) behave features/ → 981 scenarios passed, 0 failed ruff check → All checks passed ruff format → 201 files already formatted Refs #22 Co-authored-by: Matthew Horoszowski <mhoroszowski@Winton.lan>
1 parent eacbea6 commit 02049b3

5 files changed

Lines changed: 464 additions & 0 deletions

File tree

src/pptx/oxml/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,7 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]):
418418
CT_LineEndProperties,
419419
CT_LineProperties,
420420
CT_NonVisualDrawingProps,
421+
CT_OfficeArtExtensionList,
421422
CT_Placeholder,
422423
CT_Point2D,
423424
CT_PositiveSize2D,
@@ -428,6 +429,7 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]):
428429
register_element_cls("a:chExt", CT_PositiveSize2D)
429430
register_element_cls("a:chOff", CT_Point2D)
430431
register_element_cls("a:ext", CT_PositiveSize2D)
432+
register_element_cls("a:extLst", CT_OfficeArtExtensionList)
431433
register_element_cls("a:headEnd", CT_LineEndProperties)
432434
register_element_cls("a:ln", CT_LineProperties)
433435
register_element_cls("a:lnB", CT_LineProperties)

src/pptx/oxml/ns.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
# -- Maps namespace prefix to namespace name for all known PowerPoint XML namespaces --
66
_nsmap = {
77
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
8+
"adec": "http://schemas.microsoft.com/office/drawing/2017/decorative",
89
"c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
910
"cp": "http://schemas.openxmlformats.org/package/2006/metadata/core-properties",
1011
"ct": "http://schemas.openxmlformats.org/package/2006/content-types",

src/pptx/oxml/shapes/shared.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,14 +334,116 @@ class CT_NonVisualDrawingProps(BaseOxmlElement):
334334

335335
get_or_add_hlinkClick: Callable[[], CT_Hyperlink]
336336
get_or_add_hlinkHover: Callable[[], CT_Hyperlink]
337+
get_or_add_extLst: Callable[[], CT_OfficeArtExtensionList]
338+
_remove_extLst: Callable[[], None]
337339

338340
_tag_seq = ("a:hlinkClick", "a:hlinkHover", "a:extLst")
339341
hlinkClick: CT_Hyperlink | None = ZeroOrOne("a:hlinkClick", successors=_tag_seq[1:])
340342
hlinkHover: CT_Hyperlink | None = ZeroOrOne("a:hlinkHover", successors=_tag_seq[2:])
343+
extLst: CT_OfficeArtExtensionList | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
344+
"a:extLst", successors=()
345+
)
341346
id = RequiredAttribute("id", ST_DrawingElementId)
342347
name = RequiredAttribute("name", XsdString)
348+
descr: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType]
349+
"descr", XsdString, default=None
350+
)
351+
title: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType]
352+
"title", XsdString, default=None
353+
)
343354
del _tag_seq
344355

356+
# -- URI for the Office 2019+ "Mark as decorative" extension --
357+
_DECORATIVE_EXT_URI = "{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}"
358+
359+
@property
360+
def decorative(self) -> bool:
361+
"""True if this `p:cNvPr` carries an `<adec:decorative val="1"/>` extension.
362+
363+
Returns False when the extension is missing, when its `val` attribute is "0",
364+
or when the extLst element is absent altogether.
365+
"""
366+
extLst = self.extLst
367+
if extLst is None:
368+
return False
369+
return extLst.is_decorative
370+
371+
@decorative.setter
372+
def decorative(self, value: bool) -> None:
373+
if value:
374+
extLst = self.get_or_add_extLst()
375+
extLst.set_decorative()
376+
else:
377+
extLst = self.extLst
378+
if extLst is not None:
379+
extLst.clear_decorative()
380+
# ---if extLst is now empty, remove it entirely---
381+
if not list(extLst):
382+
self._remove_extLst()
383+
384+
385+
class CT_OfficeArtExtensionList(BaseOxmlElement):
386+
"""`a:extLst` element under `p:cNvPr` (and elsewhere).
387+
388+
Holds zero or more `a:ext` children, each identified by a `uri` attribute. We use
389+
this here to carry the Office 2019+ `<adec:decorative>` extension; other URIs are
390+
preserved verbatim by virtue of being plain `a:ext` children.
391+
392+
Note: we cannot register a custom class for `a:ext` itself because that local-name
393+
is shared with the transform-extents element (`<a:ext cx="..." cy="..."/>`). Instead
394+
we manipulate the `a:ext` children directly through lxml.
395+
"""
396+
397+
_DECORATIVE_EXT_URI = "{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}"
398+
399+
@property
400+
def is_decorative(self) -> bool:
401+
"""True if a child `<a:ext uri="{FF2B5EF4...}">` carries `<adec:decorative val="1"/>`."""
402+
ext = self._decorative_ext
403+
if ext is None:
404+
return False
405+
decorative = ext.find(qn("adec:decorative"))
406+
if decorative is None:
407+
return False
408+
# ---val attribute defaults to "1" per the schema; be permissive on read---
409+
val = decorative.get("val")
410+
if val is None:
411+
return True
412+
return val not in ("0", "false")
413+
414+
def set_decorative(self) -> None:
415+
"""Ensure an `<a:ext uri="...">/<adec:decorative val="1"/>` is present."""
416+
ext = self._decorative_ext
417+
if ext is None:
418+
ext = OxmlElement(
419+
"a:ext",
420+
nsmap={"a": "http://schemas.openxmlformats.org/drawingml/2006/main"},
421+
)
422+
ext.set("uri", self._DECORATIVE_EXT_URI)
423+
self.append(ext)
424+
decorative = ext.find(qn("adec:decorative"))
425+
if decorative is None:
426+
decorative = OxmlElement(
427+
"adec:decorative",
428+
nsmap={"adec": "http://schemas.microsoft.com/office/drawing/2017/decorative"},
429+
)
430+
ext.append(decorative)
431+
decorative.set("val", "1")
432+
433+
def clear_decorative(self) -> None:
434+
"""Remove the decorative `a:ext` child if present."""
435+
ext = self._decorative_ext
436+
if ext is not None:
437+
self.remove(ext)
438+
439+
@property
440+
def _decorative_ext(self):
441+
"""Return the `a:ext` child whose `uri` is the decorative-extension URI, or None."""
442+
matches = self.xpath(f"./a:ext[@uri='{self._DECORATIVE_EXT_URI}']")
443+
if not matches:
444+
return None
445+
return matches[0]
446+
345447

346448
class CT_Placeholder(BaseOxmlElement):
347449
"""`p:ph` custom element class."""

src/pptx/shapes/base.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,58 @@ def name(self) -> str:
131131
def name(self, value: str):
132132
self._element._nvXxPr.cNvPr.name = value # pyright: ignore[reportPrivateUsage]
133133

134+
@property
135+
def alt_text(self) -> str | None:
136+
"""Alternative text describing this shape, used by screen readers and accessibility tools.
137+
138+
Read/write. Returns the value of the `descr` attribute on the shape's
139+
`<p:cNvPr>` element. None if the attribute is not present (the shape has no
140+
alt text). Assigning None removes the attribute. Assigning an empty string
141+
is a meaningful, distinct value — it preserves the attribute as `descr=""`,
142+
useful for callers who want to round-trip an explicit "no description"
143+
marker.
144+
145+
See Microsoft Accessibility guidance: prefer `alt_text` for the description
146+
and `alt_title` for a short heading, when both are needed.
147+
"""
148+
return self._element._nvXxPr.cNvPr.descr # pyright: ignore[reportPrivateUsage]
149+
150+
@alt_text.setter
151+
def alt_text(self, value: str | None):
152+
self._element._nvXxPr.cNvPr.descr = value # pyright: ignore[reportPrivateUsage]
153+
154+
@property
155+
def alt_title(self) -> str | None:
156+
"""Short title (heading) for this shape's alternative text, used for accessibility.
157+
158+
Read/write. Returns the value of the `title` attribute on the shape's
159+
`<p:cNvPr>` element. None if the attribute is not present. Assigning None
160+
removes the attribute. Microsoft accessibility guidance recommends a brief
161+
title plus a longer `alt_text` description, mirroring the two-field UX in
162+
PowerPoint's "Alt Text" pane.
163+
"""
164+
return self._element._nvXxPr.cNvPr.title # pyright: ignore[reportPrivateUsage]
165+
166+
@alt_title.setter
167+
def alt_title(self, value: str | None):
168+
self._element._nvXxPr.cNvPr.title = value # pyright: ignore[reportPrivateUsage]
169+
170+
@property
171+
def is_decorative(self) -> bool:
172+
"""True if this shape is marked as decorative (Office 2019+ accessibility flag).
173+
174+
Read/write boolean. Decorative shapes are skipped by screen readers; they
175+
carry no semantic meaning beyond visual decoration (background grids,
176+
ornaments, dividers). Backed by an `<adec:decorative val="1"/>` extension
177+
inside `<p:cNvPr>/<a:extLst>`. Setting to False removes the extension; the
178+
attribute defaults to False on shapes that have never been touched.
179+
"""
180+
return self._element._nvXxPr.cNvPr.decorative # pyright: ignore[reportPrivateUsage]
181+
182+
@is_decorative.setter
183+
def is_decorative(self, value: bool):
184+
self._element._nvXxPr.cNvPr.decorative = bool(value) # pyright: ignore[reportPrivateUsage]
185+
134186
@property
135187
def part(self) -> BaseSlidePart:
136188
"""The package part containing this shape.

0 commit comments

Comments
 (0)