diff --git a/docs/dev/analysis/shp-shapes.rst b/docs/dev/analysis/shp-shapes.rst index 69d8993c9..0be67d564 100644 --- a/docs/dev/analysis/shp-shapes.rst +++ b/docs/dev/analysis/shp-shapes.rst @@ -56,6 +56,21 @@ Proposed protocol:: >>> shape.name u'T501 - Foo; B. Baz; 2014' +``Shape.alternative_text`` +------------------------------------- + +``Shape.alternative_text`` is a read-write property. It's the alternative +text for the shape and is readable by screen readers. + +Proposed protocol:: + + >>> shape.alt_text + 'Company Logo' + >>> shape.alt_text = 'Trademark' + >>> shape.alt_text + 'Trademark' + + :attr:`Shape.rotation` ---------------------- diff --git a/features/shp-alt-text.feature b/features/shp-alt-text.feature new file mode 100644 index 000000000..c685acd86 --- /dev/null +++ b/features/shp-alt-text.feature @@ -0,0 +1,32 @@ +Feature: Shape alternative text + In order to provide accessibility features + As a developer using python-pptx + I need to get and set alternative text for shapes + + Scenario: Get alt_text from shape with no alternative text + Given a shape with no alternative text + Then the alt_text property should be None + + Scenario: Get alt_text from shape with existing alternative text + Given a shape with alternative text "A red rectangle" + Then the alt_text property should be "A red rectangle" + + Scenario: Set alt_text on a shape + Given a shape with no alternative text + When I set the alt_text property to "Accessibility description" + Then the alt_text property should be "Accessibility description" + + Scenario: Update alt_text on a shape + Given a shape with alternative text "Old description" + When I set the alt_text property to "New description" + Then the alt_text property should be "New description" + + Scenario: Clear alt_text from a shape + Given a shape with alternative text "Some description" + When I set the alt_text property to an empty string + Then the alt_text property should be an empty string + + Scenario: Delete alt_text from a shape + Given a shape with alternative text "Some description" + When I delete the alt_text property + Then the alt_text property should be None \ No newline at end of file diff --git a/features/steps/shapes.py b/features/steps/shapes.py index 57d5f2bb0..6eb927898 100644 --- a/features/steps/shapes.py +++ b/features/steps/shapes.py @@ -340,3 +340,48 @@ def then_the_table_appears_in_the_slide(context): prs = Presentation(saved_pptx_path) expected_table_graphic_frame = prs.slides[0].shapes[0] assert expected_table_graphic_frame.has_table + + +# alt_text step definitions =============================================== + +@given("a shape with no alternative text") +def given_a_shape_with_no_alternative_text(context): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + context.shape = slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, Inches(1), Inches(1), Inches(2), Inches(1) + ) + +@given('a shape with alternative text "{text}"') +def given_a_shape_with_alternative_text(context, text): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + shape = slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, Inches(1), Inches(1), Inches(2), Inches(1) + ) + shape.alt_text = text + context.shape = shape + +@when('I set the alt_text property to "{text}"') +def when_I_set_the_alt_text_property_to(context, text): + context.shape.alt_text = text + +@then('the alt_text property should be "{expected_text}"') +def then_the_alt_text_property_should_be(context, expected_text): + assert context.shape.alt_text == expected_text + +@when('I set the alt_text property to an empty string') +def when_I_set_the_alt_text_property_to_empty_string(context): + context.shape.alt_text = "" + +@then("the alt_text property should be an empty string") +def then_the_alt_text_property_should_be_an_empty_string(context): + assert context.shape.alt_text == "" + +@when('I delete the alt_text property') +def when_I_delete_the_alt_text_property(context): + del context.shape.alt_text + +@then("the alt_text property should be None") +def then_the_alt_text_property_should_be_None(context): + assert context.shape.alt_text is None diff --git a/src/pptx/shapes/base.py b/src/pptx/shapes/base.py index 751235023..f83aa65d4 100644 --- a/src/pptx/shapes/base.py +++ b/src/pptx/shapes/base.py @@ -214,6 +214,21 @@ def width(self) -> Length: def width(self, value: Length): self._element.cx = value + @property + def alt_text(self): + """ + Read/write. Alternate text string for this shape. + """ + return self._element._nvXxPr.cNvPr.attrib.get("descr") + + @alt_text.setter + def alt_text(self, value): + self._element._nvXxPr.cNvPr.attrib["descr"] = value + + @alt_text.deleter + def alt_text(self): + del self._element._nvXxPr.cNvPr.attrib["descr"] + class _PlaceholderFormat(ElementProxy): """Provides properties specific to placeholders, such as the placeholder type. diff --git a/tests/shapes/test_base.py b/tests/shapes/test_base.py index 89632ca80..e243d0c78 100644 --- a/tests/shapes/test_base.py +++ b/tests/shapes/test_base.py @@ -66,6 +66,7 @@ def it_can_change_its_name(self, name_set_fixture): shape.name = new_value assert shape._element.xml == expected_xml + @pytest.mark.parametrize( ("shape_cxml", "expected_x", "expected_y"), [ @@ -108,6 +109,20 @@ def part(self) -> XmlPart: return FakeProvidesPart() + def it_knows_its_alt_text(self, alt_text_get_fixture): + shape, alt_text = alt_text_get_fixture + assert shape.alt_text == alt_text + + def it_can_change_its_alt_text(self, alt_text_set_fixture): + shape, new_value, expected_xml = alt_text_set_fixture + shape.alt_text = new_value + assert shape._element.xml == expected_xml + + def it_can_delete_its_alt_text(self, alt_text_del_fixture): + shape, expected_xml = alt_text_del_fixture + del shape.alt_text + assert shape._element.xml == expected_xml + def it_can_change_its_position(self, position_set_fixture): shape, left, top, expected_xml = position_set_fixture shape.left = left @@ -301,6 +316,91 @@ def name_set_fixture(self, request): expected_xml = xml(expected_xSp_cxml) return shape, new_value, expected_xml + @pytest.fixture + def alt_text_get_fixture(self, shape_alt_text): + shape_elm = ( + an_sp() + .with_nsdecls() + .with_child(an_nvSpPr().with_child(a_cNvPr().with_descr(shape_alt_text))) + ).element + shape = BaseShape(shape_elm, None) + return shape, shape_alt_text + + @pytest.fixture( + params=[ + ( + "p:sp/p:nvSpPr/p:cNvPr{id=1,descr=foo}", + Shape, + "AltText1", + "p:sp/p:nvSpPr/p:cNvPr{id=1,descr=AltText1}", + ), + ( + "p:grpSp/p:nvGrpSpPr/p:cNvPr{id=2,descr=bar}", + BaseShape, + "AltText2", + "p:grpSp/p:nvGrpSpPr/p:cNvPr{id=2,descr=AltText2}", + ), + ( + "p:graphicFrame/p:nvGraphicFramePr/p:cNvPr{id=3,descr=baz}", + GraphicFrame, + "AltText3", + "p:graphicFrame/p:nvGraphicFramePr/p:cNvPr{id=3,descr=AltText3}", + ), + ( + "p:cxnSp/p:nvCxnSpPr/p:cNvPr{id=4,descr=boo}", + BaseShape, + "AltText4", + "p:cxnSp/p:nvCxnSpPr/p:cNvPr{id=4,descr=AltText4}", + ), + ( + "p:pic/p:nvPicPr/p:cNvPr{id=5,descr=far}", + Picture, + "AltText5", + "p:pic/p:nvPicPr/p:cNvPr{id=5,descr=AltText5}", + ), + ] + ) + def alt_text_set_fixture(self, request): + xSp_cxml, ShapeCls, new_value, expected_xSp_cxml = request.param + shape = ShapeCls(element(xSp_cxml), None) + expected_xml = xml(expected_xSp_cxml) + return shape, new_value, expected_xml + + @pytest.fixture( + params=[ + ( + "p:sp/p:nvSpPr/p:cNvPr{id=1,descr=foo}", + Shape, + "p:sp/p:nvSpPr/p:cNvPr{id=1}", + ), + ( + "p:grpSp/p:nvGrpSpPr/p:cNvPr{id=2,descr=bar}", + BaseShape, + "p:grpSp/p:nvGrpSpPr/p:cNvPr{id=2}", + ), + ( + "p:graphicFrame/p:nvGraphicFramePr/p:cNvPr{id=3,descr=baz}", + GraphicFrame, + "p:graphicFrame/p:nvGraphicFramePr/p:cNvPr{id=3}", + ), + ( + "p:cxnSp/p:nvCxnSpPr/p:cNvPr{id=4,descr=boo}", + BaseShape, + "p:cxnSp/p:nvCxnSpPr/p:cNvPr{id=4}", + ), + ( + "p:pic/p:nvPicPr/p:cNvPr{id=5,descr=far}", + Picture, + "p:pic/p:nvPicPr/p:cNvPr{id=5}", + ), + ] + ) + def alt_text_del_fixture(self, request): + xSp_cxml, ShapeCls, expected_xSp_cxml = request.param + shape = ShapeCls(element(xSp_cxml), None) + expected_xml = xml(expected_xSp_cxml) + return shape, expected_xml + @pytest.fixture def part_fixture(self, shapes_): parent_ = shapes_ @@ -534,6 +634,10 @@ def shape_id(self): def shape_name(self): return "Foobar 41" + @pytest.fixture + def shape_alt_text(self): + return "Alternative text description" + @pytest.fixture def shapes_(self, request): return instance_mock(request, SlideShapes)