From 71329285fb87181e873bbe244fa597d21851428a Mon Sep 17 00:00:00 2001 From: Matthew Horoszowski Date: Thu, 7 May 2026 23:31:20 -0400 Subject: [PATCH] feat(a11y): reading_order + accessibility_issues + is_hidden_from_accessibility (Phase B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B of issue #22 (Accessibility epic). Phase A — `Shape.alt_text`, `Shape.alt_title`, `Shape.is_decorative` — shipped in PR #31. Phase B adds the remaining items the issue called out, finishing the epic modulo Microsoft Accessibility Checker's deeper diagnostics (color contrast, font size, etc.) which are outside python-pptx's scope. Phase A regression fix (rolled into this PR) -------------------------------------------- PR #31's `` extension used the wrong `` GUID — it shipped with `{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}`, but PowerPoint's accessibility-rendering pipeline only recognizes the extension under `{C183D7F6-B498-43B3-948B-1728B52AA6E4}`. With the wrong GUID, the XML round-tripped within python-pptx (so unit tests passed) but PowerPoint silently ignored the extension and the "Mark as Decorative" checkbox in the Alt Text pane stayed unchecked on reopen. Confirmed empirically by diffing a maintainer-authored PowerPoint deck (where the checkbox was set in the UI, then saved) against a python-pptx-emitted deck — the only material difference was the GUID. Both `is_decorative` getter and setter, plus their unit tests, are updated to the correct GUID. New public API (Phase B) ------------------------ - `Shape.is_hidden_from_accessibility` (boolean, read/write) Convenience alias for `is_decorative`. Mirrors the wording used in some accessibility documentation and third-party tools so the API reads naturally for either audience. Backed by the same `` extension. - `Slide.shapes.reading_order` (read/write) Getter returns a tuple of shapes in z-order — the order screen readers will narrate them on a slide that does not declare an explicit ``. Setter accepts a permutation of the slide's existing shapes and reorders the underlying `` to match. Raises `ValueError` on wrong length or unknown shapes. - `Slide.shapes.accessibility_issues()` (returns `list[BaseShape]`) Lint helper. Returns shapes that lack alt text (neither `alt_text` nor `alt_title` is set) AND are not marked decorative. Returned in reading order. A fully-tagged slide reports `[]`. Treat it as a fast first-pass — color-contrast / font-size / explicit-tab-order checks are not covered. Phase B scope decisions ----------------------- - `` (the OOXML element for explicit reading order on a per-slide basis) is rare in practice — PowerPoint's UI doesn't expose it, and the OOXML spec calls out that the fallback for reading order is shape z-order. We deliberately model `reading_order` as z-order rather than emitting ``. This keeps the API simple and works correctly for the overwhelming majority of decks. If a real-world need for explicit `` emerges, that's a Phase C addition that would preserve the existing API as the no-tabLst fallback. Test coverage ------------- - 20 new unit tests in `tests/test_a11y_phase_b.py` - 4 new behave scenarios in `features/a11y-phase-b.feature` - Existing `tests/shapes/test_base.py` `is_decorative` cases updated to the corrected GUID; semantic test surface unchanged. - New `uat_a11y_phase_b.py` (untracked per repo §6) builds a 2-slide deck demonstrating the lint helper and reading_order setter. UAT signoff: ✓ on the corrected URI (verified end-to-end against a PowerPoint-authored reference deck). Verification ------------ ``` $ python3 -m pytest tests/ -q | tail -3 3242 passed in 4.42s $ ruff check src tests | tail -3 All checks passed! $ python3 -m behave features/ --no-color | tail -3 1003 scenarios passed, 0 failed, 0 skipped 3011 steps passed, 0 failed, 0 skipped ``` Closes #22 --- features/a11y-phase-b.feature | 28 ++++ features/steps/a11y.py | 83 ++++++++++++ src/pptx/oxml/shapes/shared.py | 6 +- src/pptx/shapes/base.py | 17 +++ src/pptx/shapes/shapetree.py | 68 ++++++++++ tests/shapes/test_base.py | 14 +- tests/test_a11y_phase_b.py | 230 +++++++++++++++++++++++++++++++++ 7 files changed, 436 insertions(+), 10 deletions(-) create mode 100644 features/a11y-phase-b.feature create mode 100644 features/steps/a11y.py create mode 100644 tests/test_a11y_phase_b.py diff --git a/features/a11y-phase-b.feature b/features/a11y-phase-b.feature new file mode 100644 index 000000000..4a2783f36 --- /dev/null +++ b/features/a11y-phase-b.feature @@ -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 diff --git a/features/steps/a11y.py b/features/steps/a11y.py new file mode 100644 index 000000000..806a61928 --- /dev/null +++ b/features/steps/a11y.py @@ -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" diff --git a/src/pptx/oxml/shapes/shared.py b/src/pptx/oxml/shapes/shared.py index 8bfd31039..6ef3a7721 100644 --- a/src/pptx/oxml/shapes/shared.py +++ b/src/pptx/oxml/shapes/shared.py @@ -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: @@ -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 `` carries ``.""" + """True if a child `` carries ``.""" ext = self._decorative_ext if ext is None: return False diff --git a/src/pptx/shapes/base.py b/src/pptx/shapes/base.py index 165678122..4af96dea7 100644 --- a/src/pptx/shapes/base.py +++ b/src/pptx/shapes/base.py @@ -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 — ``) 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. diff --git a/src/pptx/shapes/shapetree.py b/src/pptx/shapes/shapetree.py index 553382b62..ca088dd48 100644 --- a/src/pptx/shapes/shapetree.py +++ b/src/pptx/shapes/shapetree.py @@ -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 + ```` is the document order of children under + ```` — 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 + ```` 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: diff --git a/tests/shapes/test_base.py b/tests/shapes/test_base.py index aff786dc2..cc8b32bb7 100644 --- a/tests/shapes/test_base.py +++ b/tests/shapes/test_base.py @@ -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">' '' "" - '' + '' '' "" "" @@ -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">' '' "" - '' + '' '' "" "" @@ -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 @@ -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">' '' "" - '' + '' '' "" "" @@ -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): @@ -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">' '' "" - '' + '' '' "" "" @@ -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): diff --git a/tests/test_a11y_phase_b.py b/tests/test_a11y_phase_b.py new file mode 100644 index 000000000..0f708fa3e --- /dev/null +++ b/tests/test_a11y_phase_b.py @@ -0,0 +1,230 @@ +# pyright: reportPrivateUsage=false + +"""Unit-test suite for Accessibility Phase B (issue #22). + +Phase A — `Shape.alt_text`, `Shape.alt_title`, `Shape.is_decorative` — +shipped in PR #31. Phase B adds: + +- `Shape.is_hidden_from_accessibility` — boolean alias for `is_decorative`. +- `Slide.shapes.reading_order` — getter (tuple of shapes in z-order) + and setter (reorders the spTree to match the assigned permutation). +- `Slide.shapes.accessibility_issues()` — lint helper returning shapes + that lack alt text and are not marked decorative. +""" + +from __future__ import annotations + +import io + +import pytest + +from pptx import Presentation +from pptx.util import Inches + +# --------------------------------------------------------------------------- +# `Shape.is_hidden_from_accessibility` — alias for `is_decorative` +# --------------------------------------------------------------------------- + + +class DescribeIsHiddenFromAccessibility(object): + """Unit-test suite for the new `is_hidden_from_accessibility` alias.""" + + def it_returns_False_by_default(self): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + shape = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(2), Inches(1)) + assert shape.is_hidden_from_accessibility is False + + def it_returns_True_when_marked_decorative(self): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + shape = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(2), Inches(1)) + shape.is_decorative = True + assert shape.is_hidden_from_accessibility is True + + def it_can_be_set_True_to_mark_decorative(self): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + shape = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(2), Inches(1)) + + shape.is_hidden_from_accessibility = True + + assert shape.is_decorative is True + assert shape.is_hidden_from_accessibility is True + + def it_can_be_set_False_to_unmark(self): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + shape = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(2), Inches(1)) + shape.is_decorative = True + + shape.is_hidden_from_accessibility = False + + assert shape.is_decorative is False + + def it_round_trips_through_save_and_reopen(self): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + shape = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(2), Inches(1)) + shape.is_hidden_from_accessibility = True + buf = io.BytesIO() + prs.save(buf) + buf.seek(0) + + rt = Presentation(buf) + assert rt.slides[0].shapes[0].is_hidden_from_accessibility is True + + +# --------------------------------------------------------------------------- +# `Slide.shapes.reading_order` +# --------------------------------------------------------------------------- + + +def _seed_three_shapes(): + 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.alt_text = "alpha" + b.alt_text = "bravo" + c.alt_text = "charlie" + return prs, slide, a, b, c + + +class DescribeReadingOrderGetter(object): + """Unit-test suite for the `reading_order` getter.""" + + def it_returns_shapes_in_z_order(self): + _, slide, a, b, c = _seed_three_shapes() + order = slide.shapes.reading_order + assert tuple(s.alt_text for s in order) == ("alpha", "bravo", "charlie") + + def it_returns_a_tuple_not_a_list(self): + _, slide, *_ = _seed_three_shapes() + assert isinstance(slide.shapes.reading_order, tuple) + + def it_returns_an_empty_tuple_for_an_empty_slide(self): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + # ---layout 6 ("blank") — placeholder set may still drop in elements; + # use the actual length to set expectations rather than hard-coding 0 + assert slide.shapes.reading_order == tuple(slide.shapes) + + +class DescribeReadingOrderSetter(object): + """Unit-test suite for the `reading_order` setter.""" + + def it_can_reorder_shapes(self): + _, slide, a, b, c = _seed_three_shapes() + + slide.shapes.reading_order = (c, a, b) + + order = tuple(s.alt_text for s in slide.shapes) + assert order == ("charlie", "alpha", "bravo") + + def it_can_reverse_shape_order(self): + _, slide, a, b, c = _seed_three_shapes() + + slide.shapes.reading_order = list(reversed(slide.shapes.reading_order)) + + order = tuple(s.alt_text for s in slide.shapes) + assert order == ("charlie", "bravo", "alpha") + + def it_round_trips_through_save_and_reopen(self): + prs, slide, a, b, c = _seed_three_shapes() + slide.shapes.reading_order = (c, a, b) + + buf = io.BytesIO() + prs.save(buf) + buf.seek(0) + rt = Presentation(buf) + + order = tuple(s.alt_text for s in rt.slides[0].shapes) + assert order == ("charlie", "alpha", "bravo") + + def it_is_a_noop_when_assigning_current_order(self): + _, slide, *_ = _seed_three_shapes() + before = tuple(s._element for s in slide.shapes) + + slide.shapes.reading_order = slide.shapes.reading_order + + after = tuple(s._element for s in slide.shapes) + # ---same elements, same order (identity-preserved) + assert after == before + + def but_it_raises_on_wrong_length(self): + _, slide, a, b, _ = _seed_three_shapes() + with pytest.raises(ValueError): + slide.shapes.reading_order = (a, b) + + def but_it_raises_on_unknown_shape(self): + prs1, slide1, a1, b1, c1 = _seed_three_shapes() + _, _, a2, _, _ = _seed_three_shapes() # ---from a different presentation + + with pytest.raises(ValueError): + slide1.shapes.reading_order = (a1, b1, a2) + + +# --------------------------------------------------------------------------- +# `Slide.shapes.accessibility_issues()` +# --------------------------------------------------------------------------- + + +class DescribeAccessibilityIssues(object): + """Unit-test suite for the lint helper.""" + + def it_flags_shapes_without_alt_text_or_decorative(self): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + flagged = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(2), Inches(1)) + + issues = slide.shapes.accessibility_issues() + + assert flagged in issues + + def it_passes_shapes_with_alt_text(self): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + ok_shape = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(2), Inches(1)) + ok_shape.alt_text = "described" + + assert ok_shape not in slide.shapes.accessibility_issues() + + def it_passes_shapes_with_alt_title_only(self): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + title_only = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(2), Inches(1)) + title_only.alt_title = "title" + + assert title_only not in slide.shapes.accessibility_issues() + + def it_passes_decorative_shapes(self): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + deco = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(2), Inches(1)) + deco.is_decorative = True + + assert deco not in slide.shapes.accessibility_issues() + + def it_returns_an_empty_list_for_a_fully_tagged_slide(self): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + s1 = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(2), Inches(1)) + s2 = slide.shapes.add_textbox(Inches(3), Inches(1), Inches(2), Inches(1)) + s1.alt_text = "x" + s2.is_decorative = True + + assert slide.shapes.accessibility_issues() == [] + + def it_returns_flagged_shapes_in_reading_order(self): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + first_unflagged = slide.shapes.add_textbox(Inches(0.5), Inches(0.5), Inches(2), Inches(0.5)) + ok_middle = slide.shapes.add_textbox(Inches(3), Inches(0.5), Inches(2), Inches(0.5)) + ok_middle.alt_text = "ok" + last_unflagged = slide.shapes.add_textbox(Inches(5.5), Inches(0.5), Inches(2), Inches(0.5)) + + issues = slide.shapes.accessibility_issues() + + assert issues == [first_unflagged, last_unflagged]