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
28 changes: 28 additions & 0 deletions features/a11y-phase-b.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
Feature: Accessibility Phase B — reading order, lint helper, decorative alias
In order to ship Section 508 / WCAG / ADA compliant decks
As a developer using python-pptx
I need to query reading order, identify shapes lacking alt text,
and toggle the "hidden from accessibility" flag


Scenario: is_hidden_from_accessibility mirrors is_decorative
Given a slide with one textbox
When I set shape.is_hidden_from_accessibility to True
Then shape.is_decorative is True


Scenario: reading_order returns shapes in z-order
Given a slide with three textboxes labelled A, B, C
Then reading_order produces shapes in the order A, B, C


Scenario: reading_order setter reorders the slide's spTree
Given a slide with three textboxes labelled A, B, C
When I set reading_order to (C, A, B)
Then iteration produces shapes in the order C, A, B


Scenario: accessibility_issues flags shapes without alt text or decorative flag
Given a slide with three textboxes labelled A, B, C
When I tag A with alt text and B as decorative
Then accessibility_issues returns just C
83 changes: 83 additions & 0 deletions features/steps/a11y.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""Gherkin step implementations for Accessibility Phase B (issue #22)."""

from __future__ import annotations

from behave import given, then, when

from pptx import Presentation
from pptx.util import Inches


# given ===================================================


@given("a slide with one textbox")
def given_one_textbox(context):
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[6])
context.shape = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(2), Inches(1))
context.prs = prs
context.slide = slide


@given("a slide with three textboxes labelled A, B, C")
def given_three_textboxes(context):
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[6])
a = slide.shapes.add_textbox(Inches(0.5), Inches(0.5), Inches(2), Inches(0.5))
b = slide.shapes.add_textbox(Inches(3), Inches(0.5), Inches(2), Inches(0.5))
c = slide.shapes.add_textbox(Inches(5.5), Inches(0.5), Inches(2), Inches(0.5))
a.name = "A"
b.name = "B"
c.name = "C"
context.prs = prs
context.slide = slide
context.shape_a = a
context.shape_b = b
context.shape_c = c


# when ====================================================


@when("I set shape.is_hidden_from_accessibility to True")
def when_set_hidden_from_accessibility(context):
context.shape.is_hidden_from_accessibility = True


@when("I set reading_order to (C, A, B)")
def when_set_reading_order(context):
context.slide.shapes.reading_order = (context.shape_c, context.shape_a, context.shape_b)


@when("I tag A with alt text and B as decorative")
def when_tag_a_alt_b_deco(context):
context.shape_a.alt_text = "Alpha"
context.shape_b.is_decorative = True


# then ====================================================


@then("shape.is_decorative is True")
def then_shape_is_decorative_true(context):
assert context.shape.is_decorative is True


@then("reading_order produces shapes in the order A, B, C")
def then_reading_order_a_b_c(context):
names = tuple(s.name for s in context.slide.shapes.reading_order)
assert names == ("A", "B", "C"), f"expected (A, B, C), got {names}"


@then("iteration produces shapes in the order C, A, B")
def then_iteration_c_a_b(context):
names = tuple(s.name for s in context.slide.shapes)
assert names == ("C", "A", "B"), f"expected (C, A, B), got {names}"


@then("accessibility_issues returns just C")
def then_accessibility_issues_just_c(context):
issues = context.slide.shapes.accessibility_issues()
assert len(issues) == 1, f"expected 1 issue, got {len(issues)}"
assert issues[0].name == "C"
6 changes: 3 additions & 3 deletions src/pptx/oxml/shapes/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ class CT_NonVisualDrawingProps(BaseOxmlElement):
del _tag_seq

# -- URI for the Office 2019+ "Mark as decorative" extension --
_DECORATIVE_EXT_URI = "{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}"
_DECORATIVE_EXT_URI = "{C183D7F6-B498-43B3-948B-1728B52AA6E4}"

@property
def decorative(self) -> bool:
Expand Down Expand Up @@ -394,11 +394,11 @@ class CT_OfficeArtExtensionList(BaseOxmlElement):
we manipulate the `a:ext` children directly through lxml.
"""

_DECORATIVE_EXT_URI = "{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}"
_DECORATIVE_EXT_URI = "{C183D7F6-B498-43B3-948B-1728B52AA6E4}"

@property
def is_decorative(self) -> bool:
"""True if a child `<a:ext uri="{FF2B5EF4...}">` carries `<adec:decorative val="1"/>`."""
"""True if a child `<a:ext uri="{C183D7F6...}">` carries `<adec:decorative val="1"/>`."""
ext = self._decorative_ext
if ext is None:
return False
Expand Down
17 changes: 17 additions & 0 deletions src/pptx/shapes/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,23 @@ def is_decorative(self) -> bool:
def is_decorative(self, value: bool):
self._element._nvXxPr.cNvPr.decorative = bool(value) # pyright: ignore[reportPrivateUsage]

@property
def is_hidden_from_accessibility(self) -> bool:
"""Convenience alias for :attr:`is_decorative`.

Read/write. Decorative shapes (the official OOXML term — `<adec:decorative
val="1"/>`) are exactly those that are hidden from accessibility tools
such as screen readers. Some accessibility documentation (and a number of
third-party authoring tools) use the wording "hidden from accessibility"
for the same flag; this property exists so the API reads naturally for
either audience.
"""
return self.is_decorative

@is_hidden_from_accessibility.setter
def is_hidden_from_accessibility(self, value: bool):
self.is_decorative = bool(value)

@property
def part(self) -> BaseSlidePart:
"""The package part containing this shape.
Expand Down
68 changes: 68 additions & 0 deletions src/pptx/shapes/shapetree.py
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,74 @@ def title(self) -> Shape | None:
return cast(Shape, self._shape_factory(elm))
return None

@property
def reading_order(self) -> tuple[BaseShape, ...]:
"""Sequence of shapes in the order screen readers will narrate them.

Reading order on a slide that does not declare an explicit
``<p:tabLst>`` is the document order of children under
``<p:spTree>`` — i.e. the same order as iteration over
:class:`SlideShapes`. Returned as a tuple so callers can compare
against, slice, or index without affecting the underlying XML.

Assigning a reordered sequence reorders the underlying
``<p:spTree>`` children to match. The assigned sequence MUST be
a permutation of this slide's existing shapes — same set, same
length. Raises |ValueError| otherwise.
"""
return tuple(self)

@reading_order.setter
def reading_order(self, new_order):
"""Reorder the slide's shape tree to match `new_order` (a permutation)."""
new_list = list(new_order)
existing = list(self)
if len(new_list) != len(existing):
raise ValueError(
"reading_order must be a permutation of slide.shapes "
"(got %d items, expected %d)" % (len(new_list), len(existing))
)
existing_elements = {s._element for s in existing}
new_elements = [s._element for s in new_list]
if set(new_elements) != existing_elements:
raise ValueError("reading_order must contain exactly the slide's existing shapes")
# ---reorder by removing-then-appending in new order. Children before the
# first shape (e.g. nvGrpSpPr, grpSpPr) remain in place because we only
# move the shape elements themselves.
for elm in new_elements:
self._spTree.remove(elm)
for elm in new_elements:
self._spTree.append(elm)

def accessibility_issues(self) -> list[BaseShape]:
"""Return shapes on this slide that fail basic accessibility lint.

A shape is flagged when it carries no alt text (neither
``alt_text`` nor ``alt_title`` is set) AND is not marked
decorative (``is_decorative`` is False). Returned shapes are
ordered by reading order so callers can iterate top-down.

This is a basic Section 508 / WCAG style check — adding alt text
to every flagged shape, or marking it decorative, brings a slide
to a baseline level of screen-reader friendliness. It does not
cover every accessibility concern (color contrast, font size,
complex tab order, etc.) — treat it as a fast first-pass.
"""
issues: list[BaseShape] = []
for shape in self:
try:
if shape.is_decorative:
continue
if shape.alt_text or shape.alt_title:
continue
except (AttributeError, TypeError):
# ---accessibility properties live on _BaseShape; if a non-shape
# sneaks into the iter (shouldn't, but guard) it cannot be
# flagged.
continue
issues.append(shape)
return issues

def _add_graphicFrame_containing_table(
self, rows: int, cols: int, x: Length, y: Length, cx: Length, cy: Length
) -> CT_GraphicalObjectFrame:
Expand Down
14 changes: 7 additions & 7 deletions tests/shapes/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ def it_knows_is_decorative_returns_True_when_decorative_ext_is_present(self):
' xmlns:adec="http://schemas.microsoft.com/office/drawing/2017/decorative">'
'<p:nvSpPr><p:cNvPr id="1" name="foo">'
"<a:extLst>"
'<a:ext uri="{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}">'
'<a:ext uri="{C183D7F6-B498-43B3-948B-1728B52AA6E4}">'
'<adec:decorative val="1"/>'
"</a:ext>"
"</a:extLst>"
Expand All @@ -280,7 +280,7 @@ def it_knows_is_decorative_returns_False_when_decorative_val_is_0(self):
' xmlns:adec="http://schemas.microsoft.com/office/drawing/2017/decorative">'
'<p:nvSpPr><p:cNvPr id="1" name="foo">'
"<a:extLst>"
'<a:ext uri="{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}">'
'<a:ext uri="{C183D7F6-B498-43B3-948B-1728B52AA6E4}">'
'<adec:decorative val="0"/>'
"</a:ext>"
"</a:extLst>"
Expand All @@ -301,7 +301,7 @@ def it_can_set_is_decorative_to_True_on_a_clean_cNvPr(self):
assert shape.is_decorative is True
# ---round-trip read of underlying XML confirms structure---
cNvPr = shape._element.xpath("./p:nvSpPr/p:cNvPr")[0]
ext = cNvPr.xpath("./a:extLst/a:ext[@uri='{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}']")
ext = cNvPr.xpath("./a:extLst/a:ext[@uri='{C183D7F6-B498-43B3-948B-1728B52AA6E4}']")
assert len(ext) == 1
decoratives = ext[0].xpath("./adec:decorative")
assert len(decoratives) == 1
Expand All @@ -316,7 +316,7 @@ def it_can_set_is_decorative_to_False_clears_the_decorative_ext(self):
' xmlns:adec="http://schemas.microsoft.com/office/drawing/2017/decorative">'
'<p:nvSpPr><p:cNvPr id="1" name="foo">'
"<a:extLst>"
'<a:ext uri="{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}">'
'<a:ext uri="{C183D7F6-B498-43B3-948B-1728B52AA6E4}">'
'<adec:decorative val="1"/>'
"</a:ext>"
"</a:extLst>"
Expand All @@ -326,7 +326,7 @@ def it_can_set_is_decorative_to_False_clears_the_decorative_ext(self):
shape.is_decorative = False
assert shape.is_decorative is False
cNvPr = shape._element.xpath("./p:nvSpPr/p:cNvPr")[0]
ext = cNvPr.xpath("./a:extLst/a:ext[@uri='{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}']")
ext = cNvPr.xpath("./a:extLst/a:ext[@uri='{C183D7F6-B498-43B3-948B-1728B52AA6E4}']")
assert ext == []

def it_setting_is_decorative_True_when_already_True_is_idempotent(self):
Expand All @@ -338,7 +338,7 @@ def it_setting_is_decorative_True_when_already_True_is_idempotent(self):
' xmlns:adec="http://schemas.microsoft.com/office/drawing/2017/decorative">'
'<p:nvSpPr><p:cNvPr id="1" name="foo">'
"<a:extLst>"
'<a:ext uri="{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}">'
'<a:ext uri="{C183D7F6-B498-43B3-948B-1728B52AA6E4}">'
'<adec:decorative val="1"/>'
"</a:ext>"
"</a:extLst>"
Expand All @@ -349,7 +349,7 @@ def it_setting_is_decorative_True_when_already_True_is_idempotent(self):
# ---still True, only one ext element present---
assert shape.is_decorative is True
cNvPr = shape._element.xpath("./p:nvSpPr/p:cNvPr")[0]
ext = cNvPr.xpath("./a:extLst/a:ext[@uri='{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}']")
ext = cNvPr.xpath("./a:extLst/a:ext[@uri='{C183D7F6-B498-43B3-948B-1728B52AA6E4}']")
assert len(ext) == 1

def it_setting_is_decorative_False_when_already_False_is_idempotent(self):
Expand Down
Loading
Loading