Make an Excel Report Accessible with Python
Reports go to everyone on a distribution list, including colleagues who use a screen reader, magnification or high-contrast settings. Most accessibility problems in spreadsheets are introduced by the tool that generated them, not by the data: sheets called Sheet1, charts with no description, titles merged across eight columns, status shown only as red or green fill, pale grey text on white. Because a Python script builds the same report every week, fixing it once in the script fixes every future copy — and a short audit function can check any workbook, generated or not. This page is part of Protecting and Sharing Excel Workbooks. It follows the checks in Excel's own Accessibility Checker; it is not a legal compliance review.
Prerequisites
pip install xlsxwriter openpyxl pandas
The example is a small regional sales report: a summary sheet with a status per region, a chart and a logo. It is built twice — once the typical way and once accessibly — and the audit in Step 6 compares them.
Step 1 — The typical version
This is how many generated reports look: default sheet name, a merged title, status as coloured fill only, pale header text, a chart and a logo without descriptions:
import xlsxwriter
DATA = [("North", 482100, 450000), ("South", 391250, 400000), ("East", 297800, 280000), ("West", 259000, 315000)]
wb = xlsxwriter.Workbook("sales_typical.xlsx")
ws = wb.add_worksheet()
ws.merge_range("A1:D1", "Regional sales — September", wb.add_format({"bold": True, "align": "center"}))
head = wb.add_format({"bold": True, "font_color": "#BBBBBB", "bg_color": "#FFFFFF"})
green, red = wb.add_format({"bg_color": "#C6EFCE"}), wb.add_format({"bg_color": "#FFC7CE"})
ws.write_row(2, 0, ["Region", "Sales", "Target", ""], head)
for r, (region, sales, target) in enumerate(DATA, start=3):
ws.write_row(r, 0, [region, sales, target])
ws.write(r, 3, "", green if sales >= target else red)
chart = wb.add_chart({"type": "column"})
chart.add_series({"categories": "=Sheet1!$A$4:$A$7", "values": "=Sheet1!$B$4:$B$7"})
ws.insert_chart("F2", chart)
ws.insert_image("F18", "logo.png")
wb.close()
print("written")
To a sighted reader it looks fine. To a screen reader, the status column is four empty cells, the chart has no description at all, and the logo is announced as logo.png — xlsxwriter uses an image's file name as its alternative text unless told otherwise, which is no better than nothing; the merged title spans cells that navigation lands in unpredictably; and the header text is so pale it fails contrast checks for everyone else.
Step 2 — Structure: names, title, tables
Give each sheet a name that says what it contains, put the title in A1 without merging, and put the data in an Excel table so its first row is a real header row that screen readers announce as column names:
wb = xlsxwriter.Workbook("sales_accessible.xlsx")
wb.set_properties({"title": "Regional sales, September 2026", "subject": "Sales against target by region",
"author": "Sales reporting"})
ws = wb.add_worksheet("Sales by region")
ws.write("A1", "Regional sales, September 2026", wb.add_format({"bold": True, "font_size": 14}))
rows = [[region, sales, target, "On target" if sales >= target else "Below target"] for region, sales, target in DATA]
ws.add_table("A3:D7", {"name": "RegionalSales", "style": "Table Style Medium 2",
"columns": [{"header": h} for h in ("Region", "Sales", "Target", "Status")], "data": rows})
ws.set_column("A:A", 14)
ws.set_column("B:C", 12, wb.add_format({"num_format": "#,##0"}))
ws.set_column("D:D", 14)
ws.freeze_panes(3, 0)
The document title is what assistive technology announces when the file opens, and what file dialogs and search show. The table name, RegionalSales, gives the range a meaningful handle too. Create an Excel Table with Python covers tables in general.
Step 3 — Status as text, colour as a second signal
The status column now contains words, so its meaning survives a screen reader, a black-and-white printer and colour blindness. Colour can still help sighted readers scan — as a second signal on top of the text, with a dark text colour on a light fill:
ok = wb.add_format({"bg_color": "#E6F4EA", "font_color": "#0B6157", "bold": True})
bad = wb.add_format({"bg_color": "#FCE9E9", "font_color": "#9C0006", "bold": True})
ws.conditional_format("D4:D7", {"type": "text", "criteria": "begins with", "value": "On", "format": ok})
ws.conditional_format("D4:D7", {"type": "text", "criteria": "begins with", "value": "Below", "format": bad})
The conditional formats key off the text, so the colour always agrees with the words. Show Negative Numbers in Red with Parentheses applies the same principle to numbers: brackets carry the meaning, red reinforces it.
Step 4 — Alternative text on charts and images
Charts and pictures need a text alternative. xlsxwriter's description option writes it into the file; for a chart, say what it shows and what the reader should take from it, not just its type. Purely decorative images can be marked as such so screen readers skip them:
chart = wb.add_chart({"type": "column"})
chart.add_series({"name": "Sales", "categories": "='Sales by region'!$A$4:$A$7",
"values": "='Sales by region'!$B$4:$B$7"})
chart.add_series({"name": "Target", "categories": "='Sales by region'!$A$4:$A$7",
"values": "='Sales by region'!$C$4:$C$7"})
chart.set_title({"name": "Sales against target by region"})
ws.insert_chart("F3", chart, {"description": "Column chart of sales against target for four regions. "
"North and East are above target; South is slightly below; West is 18% below."})
ws.insert_image("F19", "logo.png", {"decorative": True})
wb.close()
print("written")
The chart's alt text summarises the finding, which is what a sighted reader gets at a glance. Numbers in the alt text should match the data — build the sentence from the same variables as the chart when it changes weekly. The chart also gets a title and named series, so its legend reads "Sales" and "Target" rather than "Series1".
Step 5 — Colours with enough contrast
Text needs a contrast ratio of at least 4.5:1 against its background under WCAG AA — the standard most accessibility policies refer to. The ratio is a formula on the two colours' relative luminance, so a report script can check every font-on-fill combination it uses:
def luminance(hex_colour):
rgb = [int(hex_colour.lstrip("#")[i:i + 2], 16) / 255 for i in (0, 2, 4)]
lin = [c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 for c in rgb]
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
def contrast(fg, bg):
hi, lo = sorted((luminance(fg), luminance(bg)), reverse=True)
return (hi + 0.05) / (lo + 0.05)
for fg, bg, where in [("#BBBBBB", "#FFFFFF", "typical header"), ("#0B6157", "#E6F4EA", "On target"),
("#9C0006", "#FCE9E9", "Below target"), ("#FFFFFF", "#4F81BD", "white on mid blue")]:
ratio = contrast(fg, bg)
print(f"{where:18} {ratio:5.2f}:1 {'pass' if ratio >= 4.5 else 'FAIL'}")
The pale grey header of the typical version fails badly and the dark-on-light status colours pass comfortably. The instructive one is white text on a mid blue, a very common header style: it looks crisp but reaches only about 4:1, short of 4.5:1 for normal text. WCAG allows 3:1 for large or bold text, so it is acceptable for a bold 14-point header and not for ordinary body text — darken the blue if it is used behind normal-sized text. Running a check like this over the palette a report uses — once, when the palette is chosen — prevents the most common readability complaint.
Step 6 — Audit any workbook
The same rules make an audit that runs on any .xlsx, generated or hand-made: default sheet names, merged cells, drawings without alt text, a missing document title:
import re
import zipfile
from openpyxl import load_workbook
def audit(path):
issues = []
wb = load_workbook(path)
if not wb.properties.title:
issues.append("no document title")
for ws in wb.worksheets:
if re.fullmatch(r"Sheet\d*", ws.title):
issues.append(f"default sheet name '{ws.title}'")
if ws.merged_cells.ranges:
issues.append(f"{ws.title}: merged cells {[str(r) for r in ws.merged_cells.ranges]}")
with zipfile.ZipFile(path) as z:
for part in (n for n in z.namelist() if n.startswith("xl/drawings/drawing")):
xml = z.read(part).decode()
for tag in re.findall(r"<xdr:cNvPr [^>]*>", xml):
decorative = "decorative" in xml[xml.find(tag):xml.find(tag) + 600]
name = re.search(r'name="([^"]*)"', tag).group(1)
descr = re.search(r'descr="([^"]*)"', tag)
if decorative:
continue
if not descr:
issues.append(f"{part}: '{name}' has no alt text")
elif re.fullmatch(r"[\w .-]+\.(png|jpe?g|gif|bmp|svg)", descr.group(1), re.I):
issues.append(f"{part}: '{name}' alt text is just a file name")
return issues
for path in ("sales_typical.xlsx", "sales_accessible.xlsx"):
found = audit(path)
print(path, "->", found or "no issues found")
The typical workbook fails on every count — including the logo, whose only alternative text is its file name; the accessible one passes. Alt text is read from the drawing XML because openpyxl does not expose it. Add the audit to the report's tests — Test Excel Output with pytest — so a later change cannot quietly reintroduce a merged title or an undescribed chart.
Common pitfalls
- Merged cells for titles and headers. They confuse navigation; write the title in one cell and use tables for headers.
- Colour as the only signal. Always carry the meaning in text too.
- Alt text that describes the chart type. "Column chart" helps nobody; state what it shows.
- Default sheet names.
Sheet1says nothing; name sheets for their content. - Blank rows and columns inside data. Screen readers treat them as the end of the table; keep data contiguous.
Conclusion
Name sheets for their content, set a document title, write the title in A1 without merging, and put data in Excel tables so header rows are real headers. Carry status in words with colour as a second signal, give charts alternative text that states the finding and mark decorative images as decorative, check every text colour pair for a contrast ratio of at least 4.5:1, and run an audit over the output so the report stays accessible as it evolves.
Frequently asked questions
How do I add alt text to a chart with Python? With xlsxwriter, pass {"description": "…"} as the options of worksheet.insert_chart or insert_image. The text is stored with the drawing and read by screen readers.
Why are merged cells bad for accessibility? Screen readers and keyboard navigation move cell by cell, and merged ranges make positions and headers ambiguous. Use a single cell for titles and centre text across columns with alignment instead if needed.
How do I check colour contrast in an Excel report? Compute the WCAG contrast ratio from the relative luminance of the text and background colours; normal text should reach at least 4.5:1. The function on this page does it for any pair of hex colours.
Does Excel have an accessibility checker? Yes, Review → Check Accessibility in desktop Excel. Building the same checks into the report script means every generated copy passes without anyone running the checker by hand.
Related
- Up one level: Protecting and Sharing Excel Workbooks — preparing workbooks for other people.
- Set Workbook Properties with Python — title, subject and other document properties.
- Merge Cells and Centre a Report Title with openpyxl — and the alternatives to merging.
- Style Chart Colours and Data Labels with openpyxl — choosing chart colours with contrast in mind.
More on protection & sharing
- How-toAnonymise Personal Data in an Excel File with Python
- How-toPassword-Protect a PDF Exported from Excel
- How-toSign a PDF Report Digitally with Python
- How-toDefine Names and Protect Sheets with XlsxWriter
- How-toAdd Comments and Notes to Excel Cells with Python
- How-toHide Sheets, Rows and Columns with openpyxl