From b2072fdc80cb373483bb5e131e0e2f9f4e82d54c Mon Sep 17 00:00:00 2001 From: Huan Di Date: Thu, 13 Nov 2025 16:03:40 +0800 Subject: [PATCH 1/4] feat(shapes): add alt_text property to BaseShape Add read/write alt_text property to BaseShape for accessibility support. Includes implementation, documentation, and tests for the new property. Signed-off-by: Huan Di --- docs/dev/analysis/shp-shapes.rst | 15 ++++++++ pptx/shapes/base.py | 11 ++++++ tests/shapes/test_base.py | 63 ++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+) 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/pptx/shapes/base.py b/pptx/shapes/base.py index c9472434d..ce8edd25e 100644 --- a/pptx/shapes/base.py +++ b/pptx/shapes/base.py @@ -221,6 +221,17 @@ def width(self): def width(self, value): 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 + class _PlaceholderFormat(ElementProxy): """ diff --git a/tests/shapes/test_base.py b/tests/shapes/test_base.py index 8182323de..5967106e7 100644 --- a/tests/shapes/test_base.py +++ b/tests/shapes/test_base.py @@ -57,6 +57,15 @@ def it_can_change_its_name(self, name_set_fixture): shape.name = new_value assert shape._element.xml == expected_xml + 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_has_a_position(self, position_get_fixture): shape, expected_left, expected_top = position_get_fixture assert shape.left == expected_left @@ -255,6 +264,56 @@ 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 def part_fixture(self, shapes_): parent_ = shapes_ @@ -522,6 +581,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) From ccbb98f9d7a6f581c8b2d9054aeac0279953e382 Mon Sep 17 00:00:00 2001 From: Huan Di Date: Thu, 13 Nov 2025 16:49:09 +0800 Subject: [PATCH 2/4] feat(shapes): add alt_text deleter and update behavior Add deleter for alt_text property to allow complete removal of alternative text from shapes. Update getter to return None instead of empty string when no alt text is present. Add BDD tests for alt_text get/set/delete operations. Signed-off-by: Huan Di --- features/shp-alt-text.feature | 32 +++++++++++++++++++++++++ features/steps/shapes.py | 45 +++++++++++++++++++++++++++++++++++ pptx/shapes/base.py | 6 ++++- 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 features/shp-alt-text.feature 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 53a081ce6..a65ec05e7 100644 --- a/features/steps/shapes.py +++ b/features/steps/shapes.py @@ -356,3 +356,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/pptx/shapes/base.py b/pptx/shapes/base.py index ce8edd25e..3227bd3cf 100644 --- a/pptx/shapes/base.py +++ b/pptx/shapes/base.py @@ -226,12 +226,16 @@ def alt_text(self): """ Read/write. Alternate text string for this shape. """ - return self._element._nvXxPr.cNvPr.attrib.get("descr", "") + 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): """ From 9620dcff25f8540c0b7d15545d2725ccd829671c Mon Sep 17 00:00:00 2001 From: Huan Di Date: Thu, 13 Nov 2025 17:01:02 +0800 Subject: [PATCH 3/4] test(shapes): add test for deleting alt text from shapes Add test cases to verify proper deletion of alt text attribute from various shape types including Shape, BaseShape, GraphicFrame, and Picture. Includes fixture with multiple test scenarios. Signed-off-by: Huan Di --- tests/shapes/test_base.py | 40 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/shapes/test_base.py b/tests/shapes/test_base.py index 5967106e7..b95fcf43b 100644 --- a/tests/shapes/test_base.py +++ b/tests/shapes/test_base.py @@ -66,6 +66,11 @@ def it_can_change_its_alt_text(self, 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_has_a_position(self, position_get_fixture): shape, expected_left, expected_top = position_get_fixture assert shape.left == expected_left @@ -314,6 +319,41 @@ def alt_text_set_fixture(self, request): 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_ From f291ae2c0f0663bec41395802c3d26b7fce4dfda Mon Sep 17 00:00:00 2001 From: huandzh Date: Thu, 13 Nov 2025 20:36:16 +0800 Subject: [PATCH 4/4] refactor(shapes): remove duplicate width setter method wrongly introduced when merging master. Signed-off-by: huandzh --- src/pptx/shapes/base.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/pptx/shapes/base.py b/src/pptx/shapes/base.py index b75aec7b3..f83aa65d4 100644 --- a/src/pptx/shapes/base.py +++ b/src/pptx/shapes/base.py @@ -214,10 +214,6 @@ def width(self) -> Length: def width(self, value: Length): self._element.cx = value - @width.setter - def width(self, value): - self._element.cx = value - @property def alt_text(self): """