# Accessible charts & infographics – worked example (findings)

Prepared by Ali Karabüyük, 2026-10-06. Everything below was produced by `scripts/build.sh` (all numbers come from its outputs in `work/` and `findings.json`).

## Source & licence

| Item | Value |
|---|---|
| Dataset | NASA GISS Surface Temperature Analysis (GISTEMP v4), Global Land-Ocean Temperature Index, annual mean anomaly, °C vs 1951–1980 |
| File | `https://raw.githubusercontent.com/datasets/global-temp/main/data/annual.csv` (branch main), retrieved 2026-10-06 |
| SHA-256 | `c9f98d4dfb6a985d9dbbb7a99e9208378d8d693ce951cd69f7983a628c296e20` |
| Rows used | `Source = GISTEMP` only: 146 years, 1880–2025 (GCAG rows not used) |
| Licence | GISTEMP: US Government work, public domain. Data package repackaging: CC BY 4.0 (datasets/global-temp) – attributed on the page and in `accessible-charts-data-provenance.txt` |
| Derived | 10-year trailing mean (1889–2025), 15 decade means (2020s = 2020–2025, 6 years) |

No fictional data is used. The "Climate report 2025" page wrapper is an example layout, not a real publication.

```
curl -sS -fL -o annual.csv https://raw.githubusercontent.com/datasets/global-temp/main/data/annual.csv
python3 scripts/data.py src/annual.csv final/accessible-charts-data-gistemp.csv work/data.json
```

## Before (deliberately typical bad state)

matplotlib PNGs embedded in an HTML report page and printed to PDF with headless Chromium (tagged PDF on, so the failures are about the content and not about tagging being off):
- line chart: light green `#7fd17f` vs light red `#ff8080`, 0.8 pt lines, identified only by a legend ("GISTEMP", "10y"); grey `#999` 6 pt tick labels, 5.5 pt legend;
- decade bar chart: `jet` rainbow colour per bar, decades only in the legend, no x labels, no values;
- "infographic" card: three key numbers baked into a gradient PNG;
- `<img>` without alt, fixed 960 px layout, no `<title>`, no `lang`, grey body text.

## After

- Hand-built SVG (`scripts/after.py`), three width variants per chart (260 / 600 / 880 px) chosen with CSS container queries, so chart text is never scaled below its design size.
- Line chart: `role="img"` + `<title>` (short alt) + `<desc>`, `aria-labelledby`/`aria-describedby`; annual = thin orange `#e8590c` line with dots, 10-year mean = thick navy `#14284b` line with white halo; direct end labels with values; annotations for warmest (2024) and coldest (1909) years.
- Bar chart (horizontal): every bar labelled with decade and value; below-average bars hatched navy, above-average solid orange; on the web page it is `role="graphics-document"` with 15 `graphics-symbol` bars, one Tab stop, arrow/Home/End navigation and a visible readout (`aria-live="polite"`).
- Visible long description per chart (numbers generated from the data), `<details>` data tables with `<caption>`, `th scope="col"`/`scope="row"` (146 + 15 rows), CSV download.
- Infographic rebuilt as live HTML text (`<ul>` of stat cards).
- PDF: Chromium `page.pdf(tagged=True)`; before printing, details are opened, the explorable bar chart is switched to static `role="img"`, local links are turned into plain text; then pikepdf adds XMP `dc:title`, `pdfuaid:part=1`, `/Lang en`, `DisplayDocTitle`, link `/Contents`; untagged decoration (card backgrounds/borders) is wrapped as `/Artifact` (script reused from the accessible-office sample). Rendered pixels before/after the artifact pass: 0 bytes changed (PyMuPDF, 72 dpi).

## Measured results

| Measure | Before | After | Command |
|---|---|---|---|
| axe-core 4.13.0 violations @1280 px (rules / nodes) | 7 / 13 | 0 / 0 | `measure_web.py` |
| axe-core violations @320 px (rules) | 7 | 0 | `measure_web.py` |
| Page width at 320 px viewport (`scrollWidth`) | 1008 px | 320 px | `measure_web.py` |
| Smallest chart text, rendered | 7.64 px (legend), 8.33 px (ticks) in PNG | 15 px @1280, 14 px @320 (SVG) | `measure_web.py` |
| Smallest HTML text | 10 px | 14 px | `measure_web.py` |
| Line series vs white (1.4.11 needs 3:1) | 1.85:1, 2.43:1 | 3.58:1, 14.64:1 | `measure_colors.py` |
| Line series vs each other | 1.31:1 | 4.09:1 | `measure_colors.py` |
| Line series ΔE2000 normal / deutan / protan / tritan | 61.9 / **3.1** / 17.6 / 58.6 | 53.1 / 61.3 / 50.0 / 58.6 | `measure_colors.py` |
| Bar colours below 3:1 vs white | 8 of 15 (min 1.12:1) | 0 of 2 (min 3.58:1) | `measure_colors.py` |
| Bars: smallest ΔE2000 between neighbours (normal / deutan / protan) | 6.4 / 4.4 / **0.8** | 53.1 / 61.3 / 50.0 (2 classes + hatch) | `measure_colors.py` |
| Chart text colours (axis text, title, caption) | 2.85:1, 2.32:1, 1.92:1 | label text ≥5.18:1, muted text 7.56:1 | `measure_colors.py` |
| Chart images with accessible name | 0 / 3 | 2 / 2 (+15 named bars) | CDP `Accessibility.getFullAXTree` |
| Annual values available as text | 0 / 146 | 146 / 146 | `measure_web.py` (tables expanded) |
| Decade values as text | 0 / 15 | 15 / 15 | `measure_web.py` |
| Infographic key numbers as text | 0 / 2 | 2 / 2 | `measure_web.py` |
| Two-decimal numbers in exposed text (body text + alt/aria-label/title/desc) | 0 | 397 | `measure_web.py` |
| Keyboard: bar chart | not operable | reached in 2 Tabs; ArrowDown/Up/Home/End move focus and readout; next Tab leaves chart | `keyboard_test.py` |
| PDF/UA-1 (veraPDF 1.26.5 in Mustang) | **not compliant, 9 failed checks** | **compliant, 0 failed checks** | `java -cp mustang.jar:scripts/uacheck UACheck <pdf>` |
| PDF Figure tags with /Alt | 0 / 3 | 2 / 2 | `pdf_struct.py` |
| PDF tables | none (data only as pixels) | 2 Table, 163 TR, 168 TH (7 Column + 161 Row scope), 337 TD | `pdf_struct.py` |
| PDF pages | 1 | 9 (includes the full 146-row table) | `pdf_struct.py` |

veraPDF failures before: 7.3-1 ×3 (Figure without alt), 7.1-3 ×2 (untagged content), 5-1 (no PDF/UA identifier), 7.1-9 (no dc:title), 7.18.1-2 and 7.18.5-2 (link without Contents). Full output: `final/accessible-charts-before-verapdf-pdfua1.txt`.

Nu Html Checker 26.10.2 on the after page: no HTML/ARIA errors; 4 CSS errors only because the checker does not know `container-type`/`@container`. Both standalone SVGs: no errors.

## Findings worth knowing (observed in this run)

- Chromium 141 maps an HTML `<figure>` to a PDF `Figure` whose /Alt is the figcaption, which hides the chart's own alt and nests the data table inside a Figure. The after page therefore uses `<section>` + heading instead of `<figure>`.
- Chromium 141 emitted **no Figure tag at all** for an `<svg role="img">` that contained `aria-hidden` descendants; the print step removes those attributes (inside the SVG they are already hidden by `role="img"`).
- Empty table cells produced no TD tag and failed PDF/UA 7.2-43 (unequal row lengths); fixed by writing "n/a" (explained in the caption).
- Hyphen-minus is used in tables and aria-labels (screen readers handle it more reliably than U+2212); U+2212 only in visual labels.

## What the tools do not check / limits

- No screen-reader testing (NVDA, JAWS, VoiceOver, TalkBack) was done.
- axe-core and veraPDF cannot judge whether the alt text and long descriptions are accurate, sufficient or well written – that is human judgement. The long-description numbers were generated from the data, but their wording is ours.
- veraPDF tests only the machine-checkable PDF/UA-1 rules; reading order, the logic of the tag tree and alt quality need manual (Matterhorn) review. The /Artifact pass marks untagged decoration; a human should confirm nothing meaningful was artifacted (here the visual output is pixel-identical and all text is tagged).
- Contrast between series is measured on flat colours; the hatch pattern's average appearance and anti-aliasing are not measured. Gridlines (1.25:1) are deliberately decorative – values are carried by labels and tables.
- CVD simulation (Machado 2009, severity 1.0) approximates dichromacy and is not testing with people; tritan matrices are an approximation (colour-science warns).
- The "numbers as text" metric counts two-decimal numbers and table rows matching the data; it does not measure comprehension.
- Interactive chart libraries (Highcharts, Chart.js, D3, Power BI, Tableau) and their own accessibility modules are not covered. Office-embedded charts (Word/PowerPoint) are covered in the accessible-office sample, not here.
- 320 px reflow was checked in Chromium only; text-spacing (1.4.12) and 200 % zoom were not separately measured.

## Files (final/)

See the summary in `findings.json`; all files are in `final/` and in `accessible-charts-worked-example.zip` together with `scripts/` and `src/`.
