Chart gridlines are detected as a table, producing a /Table with only empty cells

Author: JuliaTol-properaccessCreated Aug 22, 2026Updated Aug 22, 2026

What happens

A line chart drawn with vector gridlines is tagged as a table. The resulting /Table has one /TD per grid rectangle and none of those cells contain any marked content. The axis labels that fall inside the chart area end up in a single /Caption under that /Table.

Version: opendataloader-pdf 2.5.1, format="tagged-pdf", Java path, hybrid off.

Minimal reproduction

One page, no images: eleven vertical and seven horizontal thin grey lines forming a grid, a polyline across it, eleven x-axis labels below the grid, seven y-axis labels to the left of it, and a title above it. The script that draws it is at the bottom of this issue (Python, pikepdf only).

Structure of the tagged output:

/Document
  /P        "Figuur 4 - Ontwikkeling werkgelegenheid, index"
  /P        "150" ... "90"           (7 paragraphs, the y-axis labels)
  /Table
    /TR x 6, each with /TD x 10      (60 cells, none of them has a /K)
    /Caption  "2015 2016 ... 2025"   (the 11 x-axis labels, in one element)

Removing the gridlines removes the table: the same chart drawn with only an axis and six bars produces 14 /P elements and no /Table. So the rules of the grid are being read as cell borders.

Why it matters

A screen reader announces a table of 6 rows by 10 columns and then reads nothing inside it. The reader goes looking for data that is not there. This fails WCAG 1.3.1 and PDF/UA, and it is worse than leaving the chart untagged, because the reader is told there is tabular data to explore.

We also see it on real documents. In an eleven-page newsletter that we tagged with 2.5.1, all four /Table structures were charts:

Page What was produced What is on the page
6 6 x 20 grid, 0 of 120 cells with content three charts
7 5 x 1 grid, 0 of 5 cells with content one chart
9 6 x 10 grid, 35 of 60 cells with text, words broken mid-word three charts
10 2 x 1 grid the header row of the one real table, plus the note below it

The only real table in that document, on page 10, was not tagged as a table.

Suggestion

A cheap guard, whatever the detector decides: if no cell of a detected table contains any content, do not emit a table. That alone would have removed two of the four false tables in our document and the one in this reproduction.

Deciding that a grid of rules over a polyline is a chart rather than a table is the harder problem, and we understand if that takes longer.

Happy to test a fix against our set of documents.

Script that draws the reproduction file
python
"""Maakt het bestand waarmee we naspelen dat een grafiek een tabel wordt.

Waarom een eigen bestand en niet dat van de klant: een melding bij een ander project gaat naar
buiten, en het document van een klant hoort daar niet in. Dit bestand tekent alleen wat er nodig is
om de fout op te roepen, en die is bij het schrijven hiervan één op één dezelfde als in het magazine
van een klant: een tabel met alleen lege cellen over de rasterlijnen van een grafiek.

Gebruik:

    python tools/maak_grafiek_repro.py uitvoer/

Daarna door de tagger halen en de structuur bekijken:

    python -c "from pathlib import Path; from pdfrepair.repareren import voeg_tags_toe; \
               print(voeg_tags_toe(Path('uitvoer/lijngrafiek.pdf'), Path('uitvoer')))"
"""

from __future__ import annotations

import sys
import zlib
from pathlib import Path

import pikepdf

# De maten van het raster. Elf verticale en zeven horizontale lijnen: precies wat Excel onder een
# lijngrafiek zet, en wat de tagger aanziet voor de randen van cellen.
KOLOMMEN = 11
RIJEN = 7
LINKS, ONDER, KOLOMBREEDTE, RIJHOOGTE = 90, 160, 42, 70

JAREN = ["2015", "2016", "2017", "2018", "2019", "2020", "2021", "2022", "2023", "2024", "2025"]
IJKPUNTEN = ["90", "100", "110", "120", "130", "140", "150"]
HOOGTES = [40, 80, 140, 120, 200, 260, 240, 310, 340, 360, 400]


def teken() -> str:
    """De tekenopdrachten van één pagina met een lijngrafiek erop."""
    o = ["0.75 0.75 0.75 RG 0.5 w"]
    for j in range(RIJEN):
        y = ONDER + j * RIJHOOGTE
        o.append(f"{LINKS} {y} m {LINKS + (KOLOMMEN - 1) * KOLOMBREEDTE} {y} l S")
    for i in range(KOLOMMEN):
        x = LINKS + i * KOLOMBREEDTE
        o.append(f"{x} {ONDER} m {x} {ONDER + (RIJEN - 1) * RIJHOOGTE} l S")

    punten = " ".join(
        f"{LINKS + i * KOLOMBREEDTE} {ONDER + hoogte} {'m' if i == 0 else 'l'}"
        for i, hoogte in enumerate(HOOGTES)
    )
    o.append(f"0.64 0.05 0.29 RG 1.5 w {punten} S")

    o.append("BT /F1 8 Tf 0.1 0.1 0.1 rg")
    for i, jaar in enumerate(JAREN):
        o.append(f"1 0 0 1 {LINKS - 10 + i * KOLOMBREEDTE} {ONDER - 12} Tm ({jaar}) Tj")
    for j, waarde in enumerate(IJKPUNTEN):
        o.append(f"1 0 0 1 {LINKS - 25} {ONDER - 3 + j * RIJHOOGTE} Tm ({waarde}) Tj")
    o.append(
        f"/F1 11 Tf 1 0 0 1 {LINKS} {ONDER + RIJEN * RIJHOOGTE} Tm "
        f"(Figuur 4 - Ontwikkeling werkgelegenheid, index) Tj"
    )
    o.append("ET")
    return "\n".join(o)


def maak(doel: Path) -> Path:
    pdf = pikepdf.new()
    lettertype = pdf.make_indirect(
        pikepdf.Dictionary(
            Type=pikepdf.Name.Font, Subtype=pikepdf.Name.Type1, BaseFont=pikepdf.Name.Helvetica
        )
    )
    pagina = pdf.make_indirect(
        pikepdf.Dictionary(
            Type=pikepdf.Name.Page,
            MediaBox=[0, 0, 595, 842],
            Resources=pikepdf.Dictionary(Font=pikepdf.Dictionary(F1=lettertype)),
            Contents=pdf.make_stream(
                zlib.compress(teken().encode("latin-1")), Filter=pikepdf.Name.FlateDecode
            ),
        )
    )
    pdf.pages.append(pikepdf.Page(pagina))
    pdf.save(str(doel))
    pdf.close()
    return doel


if __name__ == "__main__":
    map_ = Path(sys.argv[1] if len(sys.argv) > 1 else ".")
    map_.mkdir(parents=True, exist_ok=True)
    print(maak(map_ / "lijngrafiek.pdf"))

Source: opendataloader-project/opendataloader-pdf