# PDF Table Judgment Standard

Authoritative decision rules for classifying a tagged PDF's `/Table` elements and
spec'ing their remediation. Consumed by `file-remediation` Stage 2 (`judgment.json`
keys `table_headers`, `table_to_list`, `table_to_figure`).

**Provenance.** Every rule below was adjudicated against real course PDFs by an ASU
instructional designer across three randomized batches (20 items, 22-document corpus,
2026-07-27). Rules are anchored to W3C PDF techniques and PDF 32000-1 clauses so a
recommendation cites a principle, not a preference. Measured outcome across those
batches: classification was correct 17/17; **header structure was the error class**,
and roughly half those errors were engine capability gaps rather than judgment gaps.

Taggers (opendataloader-pdf included) emit `/Table` for **any visual grid**. The tag
asserts nothing about meaning. Deciding what the thing actually *is* is judgment work
and is never automated — see `pdf_table_weave.py`'s opt-in contract.

---

## 1. The classes

| Class | What it is | Remediation | Anchor |
|---|---|---|---|
| `data_table` | Row/column positions carry meaning | keep as table, promote headers | PDF6 |
| `layout_table` | Grid used only for visual arrangement | convert to `/L` list | PDF21 |
| `layout_wrapper` | Single-cell or title-over-content card, tabled for styling | unwrap; **title becomes a heading**, body a paragraph | PDF9 |
| `figure` | Chart, plot or diagram mistagged as a grid | `/Figure` + `/Alt` | PDF1 |
| `other` | Fragment or unclassifiable | no action | — |

`other` is the only class that triggers no structural rewrite. Every other class
rewrites the PDF, so a wrong call on any of them is destructive — this is why
`judgment_eval.py` scores a wrong non-`other` call as `harmful`.

## 2. The tabular test (PDF6)

> "Information is considered tabular when logical relationships among text, numbers,
> images, or other data exist in two dimensions (vertical and horizontal)."

**Two-dimensional → `data_table`. One-dimensional or none → not a table.**

A multi-column word list is one-dimensional: reading across a row means nothing. It is
a `layout_table` → PDF21. A single-row grid cannot be two-dimensional by construction.

## 3. Decision procedure

Work the evidence in this order. Stop at the first decisive signal.

1. **Caption** (`caption` field) — decisive when present.
   - `Table N …` → a table. `Table N. (Continued)` → a table **and** a continuation (§5).
   - `Figure N …` → a figure.
   - Numeric-only garbage (e.g. `6810121416`) → axis tick labels → **figure**.
2. **Empty cell text throughout** (`first_row_text` and `first_col_text` all empty) plus
   a numeric or absent caption → **figure**. A real table has text in its cells.
3. **Shape** — 1 row × N, or N × 1 with a styled title row → `layout_wrapper`.
4. **PDF6's two-dimensional test** on the actual cell text.
5. **Render the page** and look, whenever 1–4 leave any doubt, and always before
   emitting `table_to_figure` (that conversion collapses all descendant structure into
   one `/Alt` — see §7).
6. **A table's title may not be attached to it.** The heading that names the table is
   often ordinary page content outside the `/Table` (e.g. a "Hosting a Meeting" heading
   above a step/screenshot grid). Look for it on the render; a converted list belongs
   under that heading.

### Context escalation — widen only on need

Judge from **the current page first**: the grid, the caption, and the render usually
settle it. Escalate only when the current page genuinely does not resolve the question,
and stop as soon as it does:

| Step | Read | Escalate when |
|---|---|---|
| 1 | current page (grid, caption, render) | ambiguous after §3 steps 1–6 |
| 2 | `page_context.prev_page` / `next_page` | still ambiguous |
| 3 | pages ±2 and beyond, on demand | still ambiguous |

Deciding whether the current page suffices is itself a judgment call — make it, don't
default either way. Reflexively pulling more pages wastes context; refusing to pull any
produced a real misclassification (a tools-reference table read as a `layout_wrapper`
when the following page showed it was one row of a category → tools → use-cases → specs
table). **An empty `prev_page` because the table sits on page 1 is not a reason to stop
— check the other direction.**

`list_tables()` ships `page_context` at ±1 only. For anything wider, read the page
directly:

```python
import fitz; print(fitz.open(path)[n - 1].get_text())   # n is 1-based
```

**Abstain rather than guess.** Omitting a table from `judgment.json` means no action is
taken, which is always safe. A wrong structural rewrite is not recoverable from the
tagged file alone.

## 4. Header rules

**4.1 Never assume the header axis.** Headers may be in row 0, column 0, both, neither,
or several of each. Determine it; do not default to row 0.

**4.2 The test is "does this cell identify its row (or column)?" — not "is this value
data?"** Both `Do exercise` and `February 5` are data values; both also key their row,
so both are row headers. A schedule's date column IS a header column: without it a
screen-reader user reads "Post-Class Assignment: …" with no anchor to which class it
belongs to. *(Adjudicated 2026-07-27; supersedes an earlier narrower reading.)*

**4.3 Headers are hierarchical.** Real course tables routinely carry a main header plus
sub-headers plus spanning band rows. Use `header_rows`, `header_cols` and `band_rows`
(all lists — more than one row or column can be a header level):

- *Rubric*: `header_rows:[0]` (column labels) + `header_cols:[0]` (criteria) + `band_rows`
  for section markers (`I.`, `A. (voluntariness)`).
- *Banner over row headers*: `DOMAIN I PEOPLE—33%` spanning row + `Task 1..7` in column 0.
- *Three-level statistics table*: main header row (`Intercept β±CI`, `Gender: female`,
  `Standardized effect size`) + nested header **columns** `[0,1]` (humor type, then effect
  description) — only the numeric cells are data.

`/Scope` alone cannot express this: it is single-axis and single-level. Multi-level
association requires `/ID` on header cells and a `/Headers` array on each governed cell
(PDF 32000-1 §14.8.4.3.5), which `promote_headers` emits when any of the three keys is
present.

**4.3.1 A header row can repeat *inside* one table.** When a table spans a page break
the tagger often duplicates the header so readers keep context, and both copies land in
the same `/Table`. List **every** one in `header_rows` — e.g. a grading table whose grid
rows 0 and 3 are both `Letter Grade | Percent | Description` takes `header_rows: [0, 3]`.
Distinct from §5's cross-*fragment* repeat, where the copies live in sibling `/Table`
elements. Note the page render will show only one of them, so read the `grid` rather
than trusting the picture.

**4.4 Never invent headers.** A table with no real header row or column must not be
promoted — a screen reader would announce ordinary data as headers, which actively
misleads. This is why promotion is opt-in per table.

## 5. Fragments and continuations

Taggers split one visual table into several `/Table` elements, and tables span pages.
Two cases with opposite handling:

- **Header repeats on each chunk** (multi-page schedule: the same `Class Date | Topic …`
  row appears on the previous and next page) → each chunk is self-describing. **Safe to
  promote per chunk.**
- **Header does NOT repeat** (a rubric whose head is on page 2 and whose later chunks
  carry only data rows) → later chunks are **headerless fragments**. Promoting one tags
  a data row as a header. **Do not promote.** The correct fix is merging the fragments,
  which no engine currently performs.

`page_context` is how you tell them apart. Read it. A `Table N. (Continued)` caption is
an explicit continuation marker.

## 6. Known engine limitations

State these rather than working around them silently.

- **`/ColSpan` is not synthesized.** A spanning main-header row has fewer cells than its
  data rows, so it reads `ragged` and the veraPDF-7.2 guard refuses it; `force: true` is
  the interim workaround. Measured: 8 of 80 corpus tables.
- **`/RowSpan` is not synthesized.** A table whose column 0 has row-spanning category
  cells reads `ragged` for the same reason — including the archetypal case where that
  spanning column *is* the header column.
- **Fragment merging does not exist.** See §5.
- **Cross-table merging does not exist.** A tagger that splits one agenda list into six
  one-row tables cannot be repaired into a single list.
- **Mixing conversion keys is not index-safe.** `table_to_list` and `table_to_figure`
  remove a `/Table`, shifting every later index. `_validate_judgment` rejects
  co-occurrence; convert with one key, re-run `list_tables`, then act again.

## 7. Why a wrong call is destructive

`convert_tables_to_figures` builds a `/Figure` from the collected marked content and
captions — **descendant structure elements do not survive**. Anything converted to a
figure collapses into one `/Alt` string. `convert_tables_to_lists` rewrites to
`/L`→`/LI`→`/LBody`. `promote_headers` fabricates header semantics. Each is correct
only for its class and destructive for the others, which is why §3 ends in "abstain
rather than guess."

## 8. Gates do not catch semantic errors

Three defects in this line of work validated clean and were functionally wrong:
`/Headers` written outside its `/Table` attribute dictionary (veraPDF does not check
attribute placement); a caption resolving to a `/Link`'s placeholder `/Alt="Annotation"`;
and WinAnsi text mapped to the wrong Unicode. veraPDF's machine-checkable subset and the
pixel-fidelity gate are both blind to "structurally valid, semantically wrong." Judge
against the rendered page and the extracted text, never against a green gate alone.
