Skip to content

Commit 589002f

Browse files
authored
Merge pull request #38 from MHoroszowski:feature/a11y-phase-b
feat(a11y): reading_order + accessibility_issues + is_hidden_from_accessibility (Phase B, closes #22)
2 parents f4ec56a + 7132928 commit 589002f

7 files changed

Lines changed: 436 additions & 10 deletions

File tree

features/a11y-phase-b.feature

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
Feature: Accessibility Phase B — reading order, lint helper, decorative alias
2+
In order to ship Section 508 / WCAG / ADA compliant decks
3+
As a developer using python-pptx
4+
I need to query reading order, identify shapes lacking alt text,
5+
and toggle the "hidden from accessibility" flag
6+
7+
8+
Scenario: is_hidden_from_accessibility mirrors is_decorative
9+
Given a slide with one textbox
10+
When I set shape.is_hidden_from_accessibility to True
11+
Then shape.is_decorative is True
12+
13+
14+
Scenario: reading_order returns shapes in z-order
15+
Given a slide with three textboxes labelled A, B, C
16+
Then reading_order produces shapes in the order A, B, C
17+
18+
19+
Scenario: reading_order setter reorders the slide's spTree
20+
Given a slide with three textboxes labelled A, B, C
21+
When I set reading_order to (C, A, B)
22+
Then iteration produces shapes in the order C, A, B
23+
24+
25+
Scenario: accessibility_issues flags shapes without alt text or decorative flag
26+
Given a slide with three textboxes labelled A, B, C
27+
When I tag A with alt text and B as decorative
28+
Then accessibility_issues returns just C

features/steps/a11y.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Gherkin step implementations for Accessibility Phase B (issue #22)."""
2+
3+
from __future__ import annotations
4+
5+
from behave import given, then, when
6+
7+
from pptx import Presentation
8+
from pptx.util import Inches
9+
10+
11+
# given ===================================================
12+
13+
14+
@given("a slide with one textbox")
15+
def given_one_textbox(context):
16+
prs = Presentation()
17+
slide = prs.slides.add_slide(prs.slide_layouts[6])
18+
context.shape = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(2), Inches(1))
19+
context.prs = prs
20+
context.slide = slide
21+
22+
23+
@given("a slide with three textboxes labelled A, B, C")
24+
def given_three_textboxes(context):
25+
prs = Presentation()
26+
slide = prs.slides.add_slide(prs.slide_layouts[6])
27+
a = slide.shapes.add_textbox(Inches(0.5), Inches(0.5), Inches(2), Inches(0.5))
28+
b = slide.shapes.add_textbox(Inches(3), Inches(0.5), Inches(2), Inches(0.5))
29+
c = slide.shapes.add_textbox(Inches(5.5), Inches(0.5), Inches(2), Inches(0.5))
30+
a.name = "A"
31+
b.name = "B"
32+
c.name = "C"
33+
context.prs = prs
34+
context.slide = slide
35+
context.shape_a = a
36+
context.shape_b = b
37+
context.shape_c = c
38+
39+
40+
# when ====================================================
41+
42+
43+
@when("I set shape.is_hidden_from_accessibility to True")
44+
def when_set_hidden_from_accessibility(context):
45+
context.shape.is_hidden_from_accessibility = True
46+
47+
48+
@when("I set reading_order to (C, A, B)")
49+
def when_set_reading_order(context):
50+
context.slide.shapes.reading_order = (context.shape_c, context.shape_a, context.shape_b)
51+
52+
53+
@when("I tag A with alt text and B as decorative")
54+
def when_tag_a_alt_b_deco(context):
55+
context.shape_a.alt_text = "Alpha"
56+
context.shape_b.is_decorative = True
57+
58+
59+
# then ====================================================
60+
61+
62+
@then("shape.is_decorative is True")
63+
def then_shape_is_decorative_true(context):
64+
assert context.shape.is_decorative is True
65+
66+
67+
@then("reading_order produces shapes in the order A, B, C")
68+
def then_reading_order_a_b_c(context):
69+
names = tuple(s.name for s in context.slide.shapes.reading_order)
70+
assert names == ("A", "B", "C"), f"expected (A, B, C), got {names}"
71+
72+
73+
@then("iteration produces shapes in the order C, A, B")
74+
def then_iteration_c_a_b(context):
75+
names = tuple(s.name for s in context.slide.shapes)
76+
assert names == ("C", "A", "B"), f"expected (C, A, B), got {names}"
77+
78+
79+
@then("accessibility_issues returns just C")
80+
def then_accessibility_issues_just_c(context):
81+
issues = context.slide.shapes.accessibility_issues()
82+
assert len(issues) == 1, f"expected 1 issue, got {len(issues)}"
83+
assert issues[0].name == "C"

src/pptx/oxml/shapes/shared.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,7 @@ class CT_NonVisualDrawingProps(BaseOxmlElement):
354354
del _tag_seq
355355

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

359359
@property
360360
def decorative(self) -> bool:
@@ -394,11 +394,11 @@ class CT_OfficeArtExtensionList(BaseOxmlElement):
394394
we manipulate the `a:ext` children directly through lxml.
395395
"""
396396

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

399399
@property
400400
def is_decorative(self) -> bool:
401-
"""True if a child `<a:ext uri="{FF2B5EF4...}">` carries `<adec:decorative val="1"/>`."""
401+
"""True if a child `<a:ext uri="{C183D7F6...}">` carries `<adec:decorative val="1"/>`."""
402402
ext = self._decorative_ext
403403
if ext is None:
404404
return False

src/pptx/shapes/base.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,23 @@ def is_decorative(self) -> bool:
183183
def is_decorative(self, value: bool):
184184
self._element._nvXxPr.cNvPr.decorative = bool(value) # pyright: ignore[reportPrivateUsage]
185185

186+
@property
187+
def is_hidden_from_accessibility(self) -> bool:
188+
"""Convenience alias for :attr:`is_decorative`.
189+
190+
Read/write. Decorative shapes (the official OOXML term — `<adec:decorative
191+
val="1"/>`) are exactly those that are hidden from accessibility tools
192+
such as screen readers. Some accessibility documentation (and a number of
193+
third-party authoring tools) use the wording "hidden from accessibility"
194+
for the same flag; this property exists so the API reads naturally for
195+
either audience.
196+
"""
197+
return self.is_decorative
198+
199+
@is_hidden_from_accessibility.setter
200+
def is_hidden_from_accessibility(self, value: bool):
201+
self.is_decorative = bool(value)
202+
186203
@property
187204
def part(self) -> BaseSlidePart:
188205
"""The package part containing this shape.

src/pptx/shapes/shapetree.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,74 @@ def title(self) -> Shape | None:
625625
return cast(Shape, self._shape_factory(elm))
626626
return None
627627

628+
@property
629+
def reading_order(self) -> tuple[BaseShape, ...]:
630+
"""Sequence of shapes in the order screen readers will narrate them.
631+
632+
Reading order on a slide that does not declare an explicit
633+
``<p:tabLst>`` is the document order of children under
634+
``<p:spTree>`` — i.e. the same order as iteration over
635+
:class:`SlideShapes`. Returned as a tuple so callers can compare
636+
against, slice, or index without affecting the underlying XML.
637+
638+
Assigning a reordered sequence reorders the underlying
639+
``<p:spTree>`` children to match. The assigned sequence MUST be
640+
a permutation of this slide's existing shapes — same set, same
641+
length. Raises |ValueError| otherwise.
642+
"""
643+
return tuple(self)
644+
645+
@reading_order.setter
646+
def reading_order(self, new_order):
647+
"""Reorder the slide's shape tree to match `new_order` (a permutation)."""
648+
new_list = list(new_order)
649+
existing = list(self)
650+
if len(new_list) != len(existing):
651+
raise ValueError(
652+
"reading_order must be a permutation of slide.shapes "
653+
"(got %d items, expected %d)" % (len(new_list), len(existing))
654+
)
655+
existing_elements = {s._element for s in existing}
656+
new_elements = [s._element for s in new_list]
657+
if set(new_elements) != existing_elements:
658+
raise ValueError("reading_order must contain exactly the slide's existing shapes")
659+
# ---reorder by removing-then-appending in new order. Children before the
660+
# first shape (e.g. nvGrpSpPr, grpSpPr) remain in place because we only
661+
# move the shape elements themselves.
662+
for elm in new_elements:
663+
self._spTree.remove(elm)
664+
for elm in new_elements:
665+
self._spTree.append(elm)
666+
667+
def accessibility_issues(self) -> list[BaseShape]:
668+
"""Return shapes on this slide that fail basic accessibility lint.
669+
670+
A shape is flagged when it carries no alt text (neither
671+
``alt_text`` nor ``alt_title`` is set) AND is not marked
672+
decorative (``is_decorative`` is False). Returned shapes are
673+
ordered by reading order so callers can iterate top-down.
674+
675+
This is a basic Section 508 / WCAG style check — adding alt text
676+
to every flagged shape, or marking it decorative, brings a slide
677+
to a baseline level of screen-reader friendliness. It does not
678+
cover every accessibility concern (color contrast, font size,
679+
complex tab order, etc.) — treat it as a fast first-pass.
680+
"""
681+
issues: list[BaseShape] = []
682+
for shape in self:
683+
try:
684+
if shape.is_decorative:
685+
continue
686+
if shape.alt_text or shape.alt_title:
687+
continue
688+
except (AttributeError, TypeError):
689+
# ---accessibility properties live on _BaseShape; if a non-shape
690+
# sneaks into the iter (shouldn't, but guard) it cannot be
691+
# flagged.
692+
continue
693+
issues.append(shape)
694+
return issues
695+
628696
def _add_graphicFrame_containing_table(
629697
self, rows: int, cols: int, x: Length, y: Length, cx: Length, cy: Length
630698
) -> CT_GraphicalObjectFrame:

tests/shapes/test_base.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ def it_knows_is_decorative_returns_True_when_decorative_ext_is_present(self):
262262
' xmlns:adec="http://schemas.microsoft.com/office/drawing/2017/decorative">'
263263
'<p:nvSpPr><p:cNvPr id="1" name="foo">'
264264
"<a:extLst>"
265-
'<a:ext uri="{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}">'
265+
'<a:ext uri="{C183D7F6-B498-43B3-948B-1728B52AA6E4}">'
266266
'<adec:decorative val="1"/>'
267267
"</a:ext>"
268268
"</a:extLst>"
@@ -280,7 +280,7 @@ def it_knows_is_decorative_returns_False_when_decorative_val_is_0(self):
280280
' xmlns:adec="http://schemas.microsoft.com/office/drawing/2017/decorative">'
281281
'<p:nvSpPr><p:cNvPr id="1" name="foo">'
282282
"<a:extLst>"
283-
'<a:ext uri="{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}">'
283+
'<a:ext uri="{C183D7F6-B498-43B3-948B-1728B52AA6E4}">'
284284
'<adec:decorative val="0"/>'
285285
"</a:ext>"
286286
"</a:extLst>"
@@ -301,7 +301,7 @@ def it_can_set_is_decorative_to_True_on_a_clean_cNvPr(self):
301301
assert shape.is_decorative is True
302302
# ---round-trip read of underlying XML confirms structure---
303303
cNvPr = shape._element.xpath("./p:nvSpPr/p:cNvPr")[0]
304-
ext = cNvPr.xpath("./a:extLst/a:ext[@uri='{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}']")
304+
ext = cNvPr.xpath("./a:extLst/a:ext[@uri='{C183D7F6-B498-43B3-948B-1728B52AA6E4}']")
305305
assert len(ext) == 1
306306
decoratives = ext[0].xpath("./adec:decorative")
307307
assert len(decoratives) == 1
@@ -316,7 +316,7 @@ def it_can_set_is_decorative_to_False_clears_the_decorative_ext(self):
316316
' xmlns:adec="http://schemas.microsoft.com/office/drawing/2017/decorative">'
317317
'<p:nvSpPr><p:cNvPr id="1" name="foo">'
318318
"<a:extLst>"
319-
'<a:ext uri="{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}">'
319+
'<a:ext uri="{C183D7F6-B498-43B3-948B-1728B52AA6E4}">'
320320
'<adec:decorative val="1"/>'
321321
"</a:ext>"
322322
"</a:extLst>"
@@ -326,7 +326,7 @@ def it_can_set_is_decorative_to_False_clears_the_decorative_ext(self):
326326
shape.is_decorative = False
327327
assert shape.is_decorative is False
328328
cNvPr = shape._element.xpath("./p:nvSpPr/p:cNvPr")[0]
329-
ext = cNvPr.xpath("./a:extLst/a:ext[@uri='{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}']")
329+
ext = cNvPr.xpath("./a:extLst/a:ext[@uri='{C183D7F6-B498-43B3-948B-1728B52AA6E4}']")
330330
assert ext == []
331331

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

355355
def it_setting_is_decorative_False_when_already_False_is_idempotent(self):

0 commit comments

Comments
 (0)