How-to
Formatting And Charting Excel Reports With PythonDeep dive

Make an Excel Report Accessible with Python

Generate workbooks that screen-reader users and people with low vision can use: meaningful sheet names and a title, data in real tables with header rows, alternative text on charts and images, no merged cells, meaning that does not rely on colour alone, colours that pass contrast checks, and an automated audit that flags the problems in any workbook.

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

Bash
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.

What an accessible report gets right Layers of an accessible workbook. Structure — meaningful sheet names, a title and data in tables with header rows. Content — alternative text on charts and images and no merged cells. Presentation — meaning carried by text as well as colour, and colours with enough contrast. Structure sheet names, title in A1, tables with header rows, document title Content alt text on charts and images, no merged cells, descriptive links Presentation status as text + colour, contrast of at least 4.5:1

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:

Python
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:

Python
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:

Python
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.

Colour alone versus colour plus text Two panels. A status column shown only as red and green fill is empty to a screen reader and ambiguous in print and for colour-blind readers. A status column with the words On target and Below target plus a matching colour works for everyone. Colour only empty cells to a screen reader lost in black-and-white print red/green hard to tell apart Text + colour 'Below target' is read aloud survives printing colour is a second signal

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:

Python
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:

Python
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.

Contrast ratios of the report's colour pairs Horizontal bars of contrast ratios. The pale grey header on white reaches 1.9 to 1 and white on mid blue 4.0 to 1, both below the 4.5 to 1 threshold for normal text; dark red on pale pink reaches 7.4 and dark teal on pale green 6.5. grey on white (typical) 1.9:1 — fail white on mid blue 4.0:1 — large text only dark red on pink 7.4:1 — pass dark teal on green 6.5:1 — pass WCAG AA asks for at least 4.5:1 for normal text and 3:1 for large or bold text.

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:

Python
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. Sheet1 says 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.

More on protection & sharing

More on protection & sharing →