Skip to content
Draft
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
26 changes: 26 additions & 0 deletions docs/user/shapes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,29 @@ issue tracker. The ``Document.add_picture()`` method adds a specified picture
to the end of the document in a paragraph of its own. However, by digging
a little deeper into the API you can place text on either side of the picture
in its paragraph, or both.


Image alternative text
----------------------

Use the returned |InlineShape| to describe an image for readers who cannot see
it::

picture = document.add_picture("revenue.png")
picture.description = "Quarterly revenue increased by 20 percent."
picture.title = "Quarterly revenue"

``description`` maps to ``wp:docPr/@descr`` and ``title`` maps to
``wp:docPr/@title``. Both properties accept Unicode strings. A missing attribute
returns |None|. Assign |None| to remove it, or ``""`` to store an explicitly empty
value. An empty description does not mark the image as decorative.

These properties belong to each placed shape. Two uses of the same image can
have different descriptions. They do not change the image bytes, filename,
internal object name, dimensions, or one another. They also leave any metadata
on the nested ``pic:cNvPr`` element unchanged and do not use it as a fallback.

The same properties are available on shapes returned by ``Run.add_picture()``,
including pictures in table cells, headers, and footers. The optional title is
separate from the description and is not a replacement for it. This API does
not add floating-shape or decorative-image support.
17 changes: 17 additions & 0 deletions features/shp-alt-text.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
Feature: Read and write inline shape alternative text
In order to describe a picture without changing its appearance
As a python-docx developer
I need public access to the placed shape's description and title

Scenario: Save a description and title
Given a picture with no alternative text
When I set its description and title
And I save and reopen the picture document
Then its description and title are preserved

Scenario: Preserve an empty description and remove a title
Given a picture with no alternative text
When I set its description and title
And I empty its description and remove its title
And I save and reopen the picture document
Then its description is empty and its title is absent
49 changes: 49 additions & 0 deletions features/steps/shape_alt_text.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Acceptance steps for inline shape alternative text."""

from io import BytesIO
from pathlib import Path

from behave import given, then, when

from docx import Document


@given("a picture with no alternative text")
def given_picture_without_alternative_text(context):
context.document = Document()
picture = Path(__file__).parents[2] / "tests" / "test_files" / "monty-truth.png"
context.picture = context.document.add_picture(str(picture))
assert context.picture.description is None
assert context.picture.title is None


@when("I set its description and title")
def when_set_picture_alternative_text(context):
context.picture.description = 'Café chart & "résumé"'
context.picture.title = "Revenue"


@when("I empty its description and remove its title")
def when_clear_picture_alternative_text(context):
context.picture.description = ""
context.picture.title = None


@when("I save and reopen the picture document")
def when_reopen_picture_document(context):
stream = BytesIO()
context.document.save(stream)
context.document = Document(stream)
context.picture = context.document.inline_shapes[0]


@then("its description and title are preserved")
def then_picture_alternative_text_is_preserved(context):
assert context.picture.description == 'Café chart & "résumé"'
assert context.picture.title == "Revenue"


@then("its description is empty and its title is absent")
def then_picture_alternative_text_is_cleared(context):
assert context.picture.description == ""
assert context.picture.title is None
2 changes: 2 additions & 0 deletions src/docx/oxml/shape.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ class CT_NonVisualDrawingProps(BaseOxmlElement):

id = RequiredAttribute("id", ST_DrawingElementId)
name = RequiredAttribute("name", XsdString)
descr: str | None = OptionalAttribute("descr", XsdString) # pyright: ignore[reportAssignmentType]
title: str | None = OptionalAttribute("title", XsdString) # pyright: ignore[reportAssignmentType]


class CT_NonVisualPictureProperties(BaseOxmlElement):
Expand Down
31 changes: 31 additions & 0 deletions src/docx/shape.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,23 @@ def __init__(self, inline: CT_Inline):
super(InlineShape, self).__init__()
self._inline = inline

@property
def description(self) -> str | None:
"""Read/write alternative-text description of this inline shape.

Stored in the ``descr`` attribute of ``wp:docPr``. |None| means the
attribute is absent. Assigning |None| removes it. An empty string is
preserved and does not mark the shape as decorative.

This describes the placed shape, independently of its title, internal
object name, and image filename.
"""
return self._inline.docPr.descr

@description.setter
def description(self, value: str | None) -> None:
self._inline.docPr.descr = value

@property
def height(self) -> Length:
"""Read/write.
Expand All @@ -69,6 +86,20 @@ def height(self, cy: Length):
self._inline.extent.cy = cy
self._inline.graphic.graphicData.pic.spPr.cy = cy

@property
def title(self) -> str | None:
"""Read/write alternative-text title of this inline shape.

Stored in the ``title`` attribute of ``wp:docPr``. |None| means the
attribute is absent. Assigning |None| removes it. An empty string is
preserved. This optional title does not replace the description.
"""
return self._inline.docPr.title

@title.setter
def title(self, value: str | None) -> None:
self._inline.docPr.title = value

@property
def type(self):
"""The type of this inline shape as a member of
Expand Down
128 changes: 128 additions & 0 deletions tests/test_shape_alt_text.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# pyright: reportPrivateUsage=false

"""Inline shape alternative text and saved-package preservation tests."""

from __future__ import annotations

from io import BytesIO
from pathlib import Path
from typing import cast
from zipfile import ZipFile

import pytest

from docx import Document
from docx.document import Document as DocumentObject
from docx.oxml.parser import parse_xml
from docx.oxml.shape import CT_Inline, CT_NonVisualDrawingProps
from docx.shape import InlineShape
from docx.shared import Inches
from docx.text.paragraph import Paragraph

from .unitutil.cxml import element

PICTURE = str(Path(__file__).parent / "test_files" / "monty-truth.png")


def saved_parts(document: DocumentObject) -> dict[str, bytes]:
stream = BytesIO()
document.save(stream)
with ZipFile(stream) as package:
return {name: package.read(name) for name in package.namelist()}


def paragraph_in(document: DocumentObject, story: str) -> Paragraph:
if story == "body":
return document.paragraphs[0]
if story == "cell":
return document.tables[0].cell(0, 0).paragraphs[0]
return getattr(document.sections[0], story).paragraphs[0]


class DescribeInlineShapeAlternativeText:
@pytest.mark.parametrize(
("property_name", "attribute"), [("description", "descr"), ("title", "title")]
)
@pytest.mark.parametrize("value", [None, "", 'Café <chart> & "résumé"\n東京\t ', " "])
def it_reads_sets_and_clears_each_attribute(
self, property_name: str, attribute: str, value: str | None
):
inline = cast(CT_Inline, element("wp:inline/wp:docPr{id=1,name=Picture 1}"))
shape = InlineShape(inline)
assert getattr(shape, property_name) is None
setattr(shape, property_name, value)
assert inline.docPr.get(attribute) == value
reopened = InlineShape(cast(CT_Inline, parse_xml(inline.xml)))
assert getattr(reopened, property_name) == value
setattr(shape, property_name, None)
assert attribute not in inline.docPr.attrib

@pytest.mark.parametrize("property_name", ["description", "title"])
@pytest.mark.parametrize("value", [42, False, b"bytes", "invalid\x00text"])
def it_rejects_invalid_values_without_changing_existing_metadata(
self, property_name: str, value: object
):
inline = cast(
CT_Inline, element("wp:inline/wp:docPr{id=1,name=Picture 1,descr=Original,title=Title}")
)
shape = InlineShape(inline)
before = inline.xml
with pytest.raises((TypeError, ValueError)):
setattr(shape, property_name, value)
assert inline.xml == before

@pytest.mark.parametrize("story", ["body", "cell", "header", "footer"])
def it_round_trips_independent_descriptions_for_shared_image_bytes(self, story: str):
document = Document()
document.add_paragraph("Before ")
document.add_table(1, 1)
paragraph = paragraph_in(document, story)
first = paragraph.add_run().add_picture(PICTURE, width=Inches(1))
second = paragraph.add_run().add_picture(PICTURE, width=Inches(2))
paragraph.add_run(" after")
first.description, first.title = "First description", "First title"
second.description, second.title = "Second description", "Second title"
stream = BytesIO()
document.save(stream)
reopened = Document(stream)
drawings = paragraph_in(reopened, story)._p.xpath(".//wp:inline")
shapes = [InlineShape(inline) for inline in drawings]
assert [(shape.description, shape.title) for shape in shapes] == [
("First description", "First title"),
("Second description", "Second title"),
]
assert [shape.width for shape in shapes] == [Inches(1), Inches(2)]
parts = saved_parts(reopened)
assert len([name for name in parts if name.startswith("word/media/")]) == 1

def it_changes_only_the_requested_drawing_attributes(self):
document = Document()
shape = document.add_picture(PICTURE, width=Inches(1))
shape.description, shape.title = "Original", "Original title"
before = saved_parts(document)
shape.description = "Updated description"
assert shape.title == "Original title"
shape.title = "Updated title"
assert shape.description == "Updated description"
after = saved_parts(document)
assert before.keys() == after.keys()
assert all(before[name] == after[name] for name in before if name != "word/document.xml")
expected = parse_xml(before["word/document.xml"])
props = expected.xpath(".//wp:docPr")[0]
props.set("descr", "Updated description")
props.set("title", "Updated title")
assert parse_xml(after["word/document.xml"]).xml == expected.xml

def it_preserves_picture_level_metadata_when_updating_the_placed_shape(self):
document = Document()
shape = document.add_picture(PICTURE)
picture_props = cast(
CT_NonVisualDrawingProps, shape._inline.graphic.graphicData.pic.nvPicPr.cNvPr
)
picture_props.set("descr", "Picture metadata")
picture_props.set("title", "Picture title")
assert shape.description is None
assert shape.title is None
shape.description, shape.title = "Placed shape description", "Placed shape title"
assert picture_props.get("descr") == "Picture metadata"
assert picture_props.get("title") == "Picture title"