# Spec: PDF figure-alt — script surfaces context, AI judges, human approves, script applies

**Status:** Rev 1 — architect Opus 2026-07-28. Implementer = Sonnet. Reviewer ≠ coder.
**Standard:** WCAG 1.1.1 (`a11y.wcag:1.1.1`) for PDF `/Figure` elements. PDF/UA 7.3 is satisfied by an
`/Alt` *existing* — which is exactly how a file scores veraPDF 106/106 with every figure reading
"image 1". This closes that gap the only way it can be closed: **meaning**, supplied by the AI.

## Doctrine (load-bearing — the whole point of this feature)
**Script + AI + human, never one substituting for another.** Each fails where the other holds:
- Script-alone can't judge meaning (veraPDF proves it — passes "image 1"). A size/position rule
  inherits that blindness: a 14×14 image can be a status icon; a full-bleed image can be decoration.
- AI-alone is non-deterministic and must not own disk (backup, fidelity, veraPDF are the engine's
  guarantees; a model can fabricate success).

Role split, enforced by the design — **every figure goes through all of it:**

| Sub-task | Owner | Never the other because |
|---|---|---|
| Extract figures, render each to PNG, gather **page context**, emit manifest | Script | deterministic, touches disk |
| Decide **decorative vs content** from that context + the pixels | **AI** | requires meaning; a heuristic misclassifies |
| Write the **description** grounded in the image's role in *this* document | **AI** | never automated — this is the AI's job by design |
| Approve the disposition | Human | wrong alt is worse than none |
| Apply (`set_alts` / demote-to-artifact) + verify (veraPDF, fidelity) | Script | deterministic, re-checkable, backed up |

**Hard rule:** the SCRIPT NEVER writes a disposition. It surfaces evidence (including size) and leaves
`disposition: null`. The AI concludes decorative/content by reading context — not the engine, not size.

## Why a standalone post-tagging stage (not folded into remediate_pdf's judgment)
`judgment.alt_text` is applied *during* the E2 re-tag, whose figure indices can shift vs the input.
Figure-alt must be **index-stable**, so it runs as a stage **after** `remediate_pdf` produces the tagged
file, operating on that exact file (no re-tag) — the same thing done by hand on 2026-07-28. Mirrors the
proven two-phase shape of `audit_image_alt.py` / `apply_image_alt.py` (which are Canvas-HTML only; this
is their PDF sibling), so the manifest-for-agent contract is identical and familiar.

## Deliverables

### 1. `scripts/pdf_tree_edit.py` — new primitive
`demote_figures_to_artifact(pdf, figure_indices)` — for each 0-based `walk_figures` index, resolve its
`/Pg` (1-based page) + `/K` MCIDs and call the existing `demote_mcids_to_artifact`. Returns count
demoted. (Generalizes the by-hand `{2:[30,124,125,126]}` call.) Out-of-range index = skip, never raise.

### 2. `scripts/pdf_tree_edit.py::list_figures` — extend context (SCRIPT half)
Add per-figure fields (all evidence, no conclusions):
- `width`, `height` (image XObject px — a datum, explicitly NOT a decider)
- `surrounding_text` — the text run immediately before and after the figure in **struct/reading order**
  (reuse `reading_order.py::struct_order_runs`), ~200 chars each side
- `reading_order_index` — position among all struct elements
- `repeat_count` — how many figures in the doc reference the **same image XObject** (repetition across
  pages is a strong decorative/branding signal — for the AI to weigh, not the script)
- `in_link`, `in_heading` — is the figure nested inside a `/Link` or heading struct element
- `mcids`, `page` — for artifact application
- keep existing `index`, `alt`, `is_placeholder`, `caption_candidate`

### 3. `scripts/figure_alt.py` — new two-phase CLI
- `--pdf FILE --build-manifest [--outdir DIR]`: renders each figure to `DIR/figures/fig{index}.png`
  and writes `DIR/figure-alt-manifest.json` = `[{...list_figures context..., "png": path,
  "disposition": null, "alt": null, "reason": null}, ...]`. **Every `disposition` is null** (AC6).
  Default `DIR` = `<pdf_stem>.figure-alt/` beside the PDF (courseless-friendly; file-remediation
  runs `--path` without course context).
- Agent step (in-session, no script): Claude reads the manifest, views each `png` **with its
  `surrounding_text`/context**, and writes `disposition ∈ {"describe","decorative"}`, `alt` (required
  and non-empty when `describe`), `reason` (one clause) back into the manifest. Mirrors
  `alt-classifications.json`.
- `--pdf FILE --apply --manifest FILE [--confirm CONFIRM]`: backs up the PDF bytes first (abort if
  backup fails), then applies — `describe` → `set_alts({index: alt})`; `decorative` →
  `demote_figures_to_artifact([indices])` — saves, re-verifies (veraPDF compliant AND 0 placeholders
  remaining), restores from backup on verify failure. Prints a result JSON.

## Honesty rules (encode, don't just document)
- **No disposition = no action.** A figure with `disposition: null` at `--apply` → refused, listed under
  `needs_manual`, never guessed. (AC5)
- **describe requires real alt.** Empty, whitespace, or `is_placeholder_alt(alt)` true → rejected, that
  figure stays unremediated and is reported. (AC5)
- **Verify, never claim.** Post-apply asserts veraPDF still compliant and `list_figures` shows 0
  placeholders; on failure, restore backup and report — never leave a half-written file claimed done.

## remediate_pdf / SKILL integration (make it always-on)
- `remediate_pdf`'s existing placeholder gate already sets `needs_manual` when figures lack real alt.
  On such a result, the `file-remediation` SKILL workflow ALWAYS runs the figure-alt stage next:
  `figure_alt.py --build-manifest` → AI annotates every figure from context → `--apply`. Document this
  as a mandatory stage in `skills/file-remediation/SKILL.md` (the script half is deterministic and
  always runs; the AI annotation is always-on whenever a session AI is present; human approves via the
  confirm verb). Fatigue is handled by the AI doing *all* figures properly, not by a script pre-filter.
- No new remediate_pdf flag; figure-alt is its own stage on the tagged output (index-stable).

## Tests (`tests/test_figure_alt.py` + extend `tests/test_pdf_tree_edit*`)
Build synthetic tagged PDFs with PyMuPDF (a content figure + a small repeated decorative image).
- AC1: `--build-manifest` emits one record per figure, each with a rendered PNG and every context field.
- AC3: `demote_figures_to_artifact` drops the named figures from `walk_figures`, veraPDF stays compliant.
- AC4: `--apply` on an annotated manifest → described figure has real `/Alt`, decorative figure
  artifacted (gone from `list_figures`), post-apply veraPDF compliant + 0 placeholders.
- AC5-neg: unannotated figure → apply refuses (needs_manual); describe with placeholder alt → rejected.
- AC6: build-manifest sets `disposition: null` for every figure INCLUDING a 14×14 one — proves the
  script never auto-classifies decorative by size.
- AC7: apply changes only `/Alt` + marked-content operators — `get_text("text")` byte-identical and a
  rendered-page pixel spot check identical (artifact/alt are non-visual).

`pytest tests/test_figure_alt.py -q` green + full `pytest -q` green pre-merge. Then Opus verifies by
running the real two-file flow (Memo 5 figures, Letter 1) end-to-end and re-probing.

## Ponytail floor
No vision API in the script (the session AI is the vision). No scoring, no size threshold, no
auto-decorative rule. Two script entry points (build/apply) + one primitive + context on an existing
lister. Reuses `demote_mcids_to_artifact`, `set_alts`, `struct_order_runs`, `list_figures`,
`verapdf_gate`, `backup_manager`. New surface is the manifest schema + the AI annotation contract.

## Out of scope
- Complex-image escalation ("this schedule should be a real table, not a 700-word alt") — surface it as
  a `reason` note for the human; automated table-conversion of an image is a separate feature.
- Canvas delivery — that's `push_remediated_file.py`, unchanged.
