power-pptx

Accessibility

A generated deck can pass every layout check and still be unusable with a screen reader: pictures with no description, slides with no title landmark, text that all but disappears into its own card. power_pptx.accessibility is a read-only audit for exactly that class of problem — it never mutates the deck, and it answers "is this deck usable by someone who cannot see it?" in one call.

Alt text on any shape

shape.alt_text is a read/write str mapping to the descrattribute of the shape's <p:cNvPr> element — the OOXML-sanctioned alt-text slot that screen readers announce and that PowerPoint surfaces in its Alt Text pane. Reading returns "" when nothing has been set; assigning "" orNone removes the attribute; anything that is not a string raisesTypeError. shape.title_text is the sibling property for the shortertitle attribute.

pic = slide.shapes.add_picture("charts/revenue.png",
                               Inches(1), Inches(2), Inches(4), Inches(3))

pic.alt_text                 # 'revenue.png' - auto-filled from the filename
pic.alt_text = "Bar chart: EMEA leads Q3 revenue at 42%, AMER at 33%."
pic.title_text = "Q3 revenue by region"   # short label, optional

pic.alt_text = ""            # or None - clears the descr attribute entirely
pic.alt_text                 # ''

Note the first line of output. add_picture() pre-fills descr with the image's filename — inherited upstream behaviour. That is enough to satisfy any "does this picture have alt text?" check, including the one below, while telling a listener nothing at all. Treat every inserted picture as needing an explicit description, not as already having one.

# Works on every shape, not just pictures
chart_frame.alt_text = "Column chart of quarterly revenue; Q4 is the peak."
table_frame.alt_text = "Pricing table: three tiers by monthly cost."
card.alt_text = "KPI card: net retention 118%, up 6 points."

# Written to <p:cNvPr descr="..."> / <p:cNvPr title="...">, the OOXML slots
# PowerPoint's Alt Text pane reads and writes.
card._element._nvXxPr.cNvPr.get("descr")

card.alt_text = 123          # TypeError - str or None only

What to actually write

Alt text is read aloud in place of the thing it describes, so it should carry the same information the sighted reader gets from a glance — which for a data graphic is thetakeaway, not the file format:

  • Charts: the claim the chart is making. "Column chart; Q4 revenue is the highest of the year at £4.2m" beats "revenue chart".
  • Photographs and logos: what is in them and why they are on the slide.
  • Diagrams: the structure in words — "Five-stage pipeline: intake, triage, build, review, release."
  • Tables: what the table holds; the cell text itself is already exposed, so the description is orientation, not a transcript.
  • Purely decorative shapes: leave them alone. There is no "mark as decorative" flag in this library, and the audit does not ask for alt text on plain autoshapes or text boxes — they already expose their text.

audit_accessibility()

One call walks the whole deck and returns an AccessibilityReport. It is strictly read-only.

from power_pptx import accessibility

report = accessibility.audit_accessibility(prs)

report.total_slides          # int
report.has_errors            # True when any ERROR-severity issue is present
report.issues                # list[AccessibilityIssue]

print(report.markdown())
# Accessibility report — 1 slide(s)

## LowContrast (1)
- slide 0 — `Rectangle 3`: text-on-fill contrast 1.16:1 is below WCAG AA (4.5:1).

## MissingAltText (2)
- slide 0 — `Picture 1`: picture has no alt text; set shape.alt_text for screen readers.
- slide 0 — `Chart 2`: chart has no alt text; set shape.alt_text for screen readers.

## NoSlideTitle (1)
- slide 0: slide has no title; screen-reader users rely on the title as a navigation landmark.

The module exposes exactly four public names — three issue codes are possible:

CodeMeaningSeverity
MissingAltTextA meaningful shape carries no alt_texterror for pictures, linked pictures, media and web video (a screen reader has literally nothing to announce); warning for charts, tables, diagrams, freeforms and OLE objects
LowContrastText-on-fill contrast below WCAG AA (4.5:1), using the same maths as the linterwarning
NoSlideTitleThe slide has no title placeholder with text, so it offers no navigation landmarkwarning
from power_pptx.accessibility import AccessibilitySeverity, audit_accessibility

report = audit_accessibility(prs)

for issue in report.issues:
    issue.slide       # zero-based slide index
    issue.code        # 'MissingAltText' | 'LowContrast' | 'NoSlideTitle'
    issue.message     # human-readable explanation
    issue.severity    # AccessibilitySeverity.ERROR / WARNING / INFO
    issue.shape       # shape name, or None for slide-level issues
    issue.to_dict()   # JSON-serializable

blocking = [i for i in report.issues
            if i.severity is AccessibilitySeverity.ERROR]

report.to_dict()                 # {'total_slides': ..., 'has_errors': ..., 'issues': [...]}
report.to_json(indent=2)         # the same, serialized
str(report)                      # same as report.markdown()

# Skip the colour pass when the deck is themed and contrast is unresolvable
audit_accessibility(prs, check_contrast=False)

The walk recurses into groups, so a picture buried in a GroupShape is still held to the same standard. Shapes whose type cannot be determined are skipped rather than guessed at.AccessibilitySeverity is a str enum with ERROR,WARNING and INFO members — the fourth public name, alongsideAccessibilityIssue, AccessibilityReport andaudit_accessibility. Nothing else in the module is public API.

NoSlideTitle is worth calling out for anyone using the design recipes: they build on the Blank layout, so slide.shapes.title is None and every recipe-built slide reports a missing landmark. That is a real finding, not a false positive — if the deck is meant to be navigable, put those slides on a layout with a title placeholder, or accept the warning knowingly.

Contrast: the LowContrast rule

LowContrast fires from both slide.lint() and the accessibility audit — they share one implementation. The threshold is the WCAG AA ratio for body text,4.5:1, computed from WCAG relative luminance. Resolution is deliberately conservative:

  • the text colour is the first run in the frame that has an explicit RGB colour;
  • the background is the shape's own fill, falling back to the slide's explicit background;
  • only solid RGB counts. Theme colours, gradients, pictures and any fill inherited from the layout or master are skipped silently — resolving those correctly means walking the theme and the colour map, and guessing wrong would flood the report with false positives.

So a clean contrast result is not proof of adequate contrast; it means nothingresolvable failed. On a fully themed deck the check may have almost nothing to say. If contrast matters, set explicit fills and run colours on the text you care about — then the rule can see it.

panel.fill.solid()
panel.fill.fore_color.rgb = "#FFFFFF"
panel.text_frame.text = "barely visible"
panel.text_frame.paragraphs[0].runs[0].font.color.rgb = "#EEEEEE"

[i.message for i in slide.lint().issues if i.code == "LowContrast"]
# ["Shape 'Rectangle 3': text-on-fill contrast ratio 1.16:1 is below WCAG AA
#   threshold (4.5:1)."]

Legibility: the MinFontSize rule

MinFontSize is a lint rule rather than part of the accessibility audit, but it belongs to the same conversation: it reports the smallest explicitly-sized run in a shape when that run falls below the 9pt legibility threshold. Runs with no explicit size (inheriting from the placeholder or the master) are not measured.

# The panel above, with a 7pt run in it
panel.text_frame.paragraphs[0].runs[0].font.size = Pt(7)

[(i.code, i.severity.value) for i in slide.lint().issues]
# [('MinFontSize', 'warning'), ('LowContrast', 'warning')]

# Neither is auto-fixable - both need a design decision, so tidy() leaves them
slide.tidy()

# Silence a rule you have deliberately decided against
slide.lint(disable=["MinFontSize"])

Neither MinFontSize nor LowContrast is auto-fixable — shrinking type or repainting a card is a design decision, so report.auto_fix() andslide.tidy() report them and move on. SeeLint & Audit for the rest of the rule set.

Reading order and tab order

power-pptx does not expose a reading-order or tab-order API. There is no equivalent of PowerPoint's Reading Order pane, and nobring_to_front() / send_to_back() on shapes.

What you do control is the order shapes are added. Assistive technology announces a slide's shapes in shape-tree order, and add_* appends — so the sequence in which your generator creates shapes is the order they will be read. Build a slide in the order a person should hear it: title, then body, then the supporting graphic, then the footnote. The only restacking the library performs is the LayerOrderViolation repair inauto_fix(), which moves a shape that declared layer_above so the drawing order matches the declaration — a visual fix that also changes announcement order, so prefer to get the build order right in the first place.

Wiring it into a build

There is no save-time gate for accessibility the way there is for lint — noprs.lint_on_save equivalent — so run the audit explicitly before you save, and decide what an error should mean for your pipeline.

def check_accessible(prs) -> None:
    report = accessibility.audit_accessibility(prs)
    if report.has_errors:
        raise SystemExit(report.markdown())
    for slide in prs.slides:
        slide.tidy()

# The layout audit is a separate call and does not include any of the above
from power_pptx import audit
print(audit(prs).markdown())

Note the last two lines: power_pptx.audit() is the layout audit (lint issues, broken pictures, empty slides, uncommon fonts, oversized images). It does not include accessibility findings, and audit_accessibility() does not include layout ones. Run both.

A screen-reader-usable deck

  • Every slide has a title with text — the landmark screen-reader users navigate by.
  • Every picture, chart, table and diagram has alt_text written by you, not inherited from a filename.
  • Text colours and card fills are explicit enough for the contrast rule to check them, and clear 4.5:1.
  • No run below 9pt.
  • Shapes are created in the order they should be announced.
  • audit_accessibility(prs).has_errors is False before the file is written, and the warnings that remain are ones you read and accepted.