---
name: file-remediation
description: "Assess or remediate accessibility on course files — PDF, DOCX, PPTX. Alt text, tagging, headings, tables. Trigger phrases: make this file accessible, remediate this PDF/deck, tag this PDF, alt text for this document, which course files need remediation."
---

# File Remediation Skill

> **Plugin**: ASU Canvas Course Builder
> **Scripts**: `scripts/remediate_assess.py` (Stage 1 Assess), `scripts/setup_remediation.py`
> (vendor + pip deps bootstrap, including LibreOffice), `scripts/remediate_pdf.py` (Stage 2/3
> E2/E3 orchestrator), `scripts/pdf_tree_edit.py` (tag-tree editor library), `scripts/pdf_table_weave.py`
> (table-semantics fallback), `scripts/ocr_text_layer.py` (scanned-page OCR text layer),
> `scripts/verapdf_gate.py` + `scripts/fidelity_gate.py` (hard gates), `scripts/remediate_docx.py`
> + `scripts/remediate_pptx.py` (Stage 2 Engine E1 Office in-place), `scripts/lo_export.py`
> (LibreOffice headless export + Impress artifact postfix), `scripts/push_remediated_file.py`
> (Stage 4 Canvas delivery: backup → upload overwrite → verify → clear staging),
> `scripts/retire_file.py` (delete-with-backup for stray/orphaned files), `scripts/deliver_batch.py`
> (batched-verb delivery fold: one verb authorizes `push_remediated_file.py` for every
> gate-passing file in an in-session remediation batch) — all shipped.
> **Plan**: `docs/plan-file-remediation-skill.md` — full architecture, engines, phases, acceptance criteria
> **Related**: `bulk-edit` (accessibility fixes on Canvas HTML content, not files), `course-audit` (accessibility assessment of a whole course)

## Purpose

Remediate course **files** — PDF, DOCX, PPTX — to the PDF/UA-1 machine-verifiable
subset plus a judgment layer for alt text, headings, and tables. This skill owns
**file-level** accessibility. It does not touch Canvas page/quiz/assignment/discussion
HTML — that's `bulk-edit`. It does not produce a whole-course accessibility report —
that's `course-audit`.

**Hard scope rule**: accessibility fixes on files (PDF/DOCX/PPTX) → `file-remediation`.
Accessibility fixes on Canvas HTML content → `bulk-edit`. Accessibility assessment of
a course → `course-audit`.

## Courseless Operation

This skill operates on local files without a Canvas course context. Skip the Setup
Gate's course-context lock (§Check 2) for local `--path` runs, same as
`plugin-feedback`. Course context is only needed when discovering or delivering files
through a Canvas course (`--course-id` mode).

## Phase Status

**Phase 1 (shipped)**: Stage 1 Assess — read-only classification of PDF/DOCX/PPTX
files into a remediation class + route. No file is modified.

**Phase 2 (shipped)**: Stage 2 Route + Remediate for **PDF only** (Engines E2 in-situ
tagging and E3 metadata-patch — see Stage 2 below) plus Stage 3 Verify (the veraPDF +
fidelity hard gates).

**Phase 3 (shipped)**: Stage 2 Route + Remediate for **DOCX/PPTX** (Engine E1 Office
in-place — see Stage 2 Office route below), including optional LibreOffice PDF/UA
export + veraPDF gating and a LibreOffice-rendered before/after appearance check.

**Phase 4 (shipped)**: Stage 4 Canvas delivery — replace a live course file with its
remediated version via `scripts/push_remediated_file.py` (backup → announce → explicit
push-verb → upload overwrite → verify → clear staging → backlog auto-close). See Stage 4
below. Live-verified: `tests/integration/test_live_file_replace.py` ran green against
sandbox 260866 (2026-07-21, v7.63.0 pre-tag live suite), and the flow delivered 28
real files to course 256162 the same day.

---

## Stage 0 — Source-First Guardrail

Before assessing or offering to remediate any **PDF**, always ask: "Do you have the
original DOCX or PPTX this PDF was exported from?" A source Office file is easier and
more reliable to remediate than a PDF — Engine E1 edits it in place with a visual
no-op guarantee, while E2 edits the harder PDF tag tree directly.

- **If yes**: remediate the source with Engine E1 (`remediate_docx.py` /
  `remediate_pptx.py`), then optionally export a tagged PDF via `lo_export.py`.
- **If no / blocked**: proceed with PDF remediation and note in the report that no
  source was available. If the user can't get an answer right now, this is a backlog
  item — surface it, don't block the conversation on it.

This guardrail applies to PDFs only. DOCX/PPTX are already the source.

---

## Stage 1 — Assess

Read-only. Never modifies a file. Batch-eligible (read-only, per CLAUDE.md §Batch
Execution).

### Local files

```bash
python scripts/remediate_assess.py --path file1.pdf file2.docx some-dir/
python scripts/remediate_assess.py --path file.pdf --json out.json
```

`--path` accepts one or more files and/or directories (directories are scanned
recursively for `.pdf`/`.docx`/`.pptx`). Output is a JSON array of per-file records
`{file, type, size_mb, class, route, flags[], detail{}}`, printed to stdout by
default or written to `--json OUT`. A one-line class/route summary prints to stderr.

### Course files

```bash
python scripts/remediate_assess.py --course-id 260866 --emit-backlog
```

Lists course Files of type PDF/DOCX/PPTX via `canvas_api.list_files`, downloads
matches to `courses/{id}/content_cache/files/`, classifies each, and (with
`--emit-backlog`) writes a `file_a11y` finding per file via `backlog_manager.add()`
so it surfaces in `/backlog` and routes back to this skill.

### Reach & retire candidates (optional, course mode)

```bash
python scripts/remediate_assess.py --course-id 260866 --with-linkage
```

Adds one extra read-only sweep of the course (pages, assignments, quiz descriptions
+ questions/answers, discussions, announcements, syllabus, module items, and any
uploaded HTML file's body — catching the plugin's iframe-interactive host pattern)
counting inbound `/files/(\d+)` references and direct module-item File links per
Canvas file. Each record gains:

- `reach` — count of distinct linking surfaces (a page linking a file twice still
  counts once).
- `linked_from` — up to 20 short labels (e.g. `page:module-2-overview`,
  `assignment:456`, `module-item:file`, `file-html:789`).
- `folder` — the file's Canvas folder full_name.
- `retire_candidate` — `true` iff `reach == 0`.

Top-level output becomes `{"records": [...], "unlinked": [...], "linkage_caveat":
"..."}` (unlinked = the `reach == 0` files with folder + class). `linkage_caveat`
names what this sweep does NOT see — LTI/New Quizzes item banks, Classic question
banks, Canvas Studio embeds, front page/course card/calendar events,
blueprint-associated or other courses, and external/hardcoded links. Confirm before
acting on any `retire_candidate`.

**Retire is a human decision, and retire means delete (with a backup) — not unpublish.**
Field lesson (2026-07-22, STP530/HSC 210 sandbox): Anthology Ally scans ALL course
files regardless of publish state, so an unpublished stray still drags the course
accessibility score down — an unpublished orphan kept STP530 at 90% while flagging
0% on that one file. Unpublishing does not remove the file from Ally's scan surface;
only removing it from Canvas does.

This flag never deletes automatically — it surfaces reach data for an ID to review.
With `--emit-backlog`, a `retire_candidate` finding reads "unlinked in searched
content — review for retirement — retire_file.py backs it up locally, then
retires it, after explicit approval" instead of the usual class/route text.

To act on a confirmed retire candidate, use `scripts/retire_file.py`, which mirrors
the same Write Safety Guard discipline as `push_remediated_file.py`: explicit verb
gate, backup bytes locally (with a `.meta.json` sidecar recording folder_id,
display_name, size, and deleted_at — the restore recipe), verify the backup before
ever deleting, then delete via `canvas_api.delete_file(confirm=True)`, then verify
the delete. Deletion is irreversible on Canvas — the local backup + `--restore` is
the safety net, and the human's explicit approval verb is what protects against a
false-orphan (see the `linkage_caveat` blind spots above: LTI, Studio, New Quizzes,
other courses, and hardcoded links are not visible to the reach sweep).

```bash
# Retire (delete-with-backup)
python scripts/retire_file.py --course-id 260866 --file-id 12345 --confirm-verb retire

# Restore from the backup produced above (Canvas assigns a new file id)
python scripts/retire_file.py --course-id 260866 \
  --restore courses/260866/backups/retired-files/20260722T120000Z_12345-old-syllabus.pdf \
  --folder-id 6789 --confirm-verb approved
```

### Vendor health (optional)

`scripts/setup_remediation.py --check` reports whether the veraPDF baseline check is
available (`--install` fetches it). Assess runs and classifies with or without it —
veraPDF is never a hard requirement in Phase 1; when absent, PDF records show
`detail.verapdf: "not installed"` instead of a compliance baseline.

`--check` also reports LibreOffice presence (`report.libreoffice.present`) — always
report-only, never part of `report.ok`. LibreOffice is deferred, opt-in install:
`scripts/setup_remediation.py --install-libreoffice`. Without it, Engine E1
(`remediate_docx.py`/`remediate_pptx.py`) still applies every judgment edit — only
the appearance check and `--export-pdf` are unavailable, and both degrade to a
reported skip rather than an error.

### Classes and routes

| Class | Route | Meaning |
|---|---|---|
| `digital-tagged` | `E3-metadata-patch` | Structure tree present; needs metadata polish only |
| `digital-untagged` | `E2-insitu` | Real text, no tags |
| `scanned` | `OCR+E2` | ≥80% of pages are image-only |
| `hybrid` | `E2+OCR-pages` | Mixed text and image-only pages |
| `docx` / `pptx` | `E1-office-inplace` | Office source file |
| `encrypted` | `flag` | Never opened with a password |
| `corrupt` | `flag` | Unreadable |
| `unsupported` / `missing` | `flag` | Not a PDF/DOCX/PPTX, or path doesn't exist |

`oversized` (>150MB or >300 pages) is a flag alongside whatever class/route the file
otherwise gets — it does not block classification.

---

## Stage 2 — Route + Remediate (Engines E2/E3, PDF)

`scripts/remediate_pdf.py` routes by Assess class and runs the matching engine, then
always runs both hard gates (Stage 3) before returning. Gate failure is a hard abort —
the output is never handed back as if it succeeded.

- `digital-tagged` → **E3 metadata-patch**: pikepdf-only (`MarkInfo`, `ViewerPreferences.
  DisplayDocTitle`, `/Lang`, docinfo + XMP title, `pdfuaid:part`, `/Tabs`). No retagging.
  If E3 doesn't pass the gate: a file with embedded font-program defects (veraPDF `7.21.x`
  — missing character maps) is flagged `needs_manual` (unfixable by re-tagging — needs
  Acrobat/source font remediation); otherwise it falls through to a full E2 re-tag.
  Link annotations missing `/Contents` are auto-fixed on every PDF output path.
- `digital-untagged` → **E2 in-situ**: `opendataloader-pdf` tags the PDF (sandboxed
  subprocess: vendored JRE, bounded heap, isolated tmp dir, 150MB input cap, 300s
  timeout), then judgment repairs (alt text, heading overrides, table flags) are
  applied to the tag tree, then the same E3 metadata pass.
- `scanned` / `hybrid` → an invisible OCR text layer (macOS Vision) is added first
  (`--skip-ocr` to bypass), then E2 runs on the OCR'd file.
- `encrypted` / `corrupt` / `unsupported` / `missing` → refused outright (flag-only
  Assess routes are never remediated).

**Automatic font-program embedding** (`scripts/pdf_font_embed.py`, P1+P2, runs
immediately before font repair on both E2 and E3): fixes veraPDF `7.21.4.1` ("font
program not embedded") for the Windows-core case — a substitutable TrueType **and
Type1 base-14** font (Times/Arial/Courier-family, by `/BaseFont`) with no `FontFile*`
gets a bundled Liberation substitute (`standards/fonts/`, SIL OFL, committed in-repo)
subset to its used Unicode range and injected as `/FontFile2` (a Type1 font is
flipped to `/TrueType` on embed — `/FontFile2` is only valid on that subtype; a
missing `/FontDescriptor` or `/Widths` is synthesized from the subset font's own
metrics). Layout is unaffected (advances come from `/Widths`, not the outline), so
the outline swap is sub-perceptual on Windows-core PDFs — but raw pixel diff can't
tell a correct substitution from a wrong one on text-dense pages (anti-aliasing alone
reads as ~10-15%), so any run that embeds a font gets a *structural* fidelity gate
(`fidelity_gate.structural_diff`, `FONT_EMBED_DELTA = 64` / `FONT_EMBED_STRUCT_PCT =
3.5` in `remediate_pdf.py` — fraction of pixels whose intensity changes by more than
the delta, ignoring anti-aliasing) instead of the usual exact-zero pixel_diff, and a
real layout break still fails it. Counts land in `edits.font_embed`. Type3,
already-embedded, or no-free-clone
fonts (Symbol/Wingdings/Webdings/ZapfDingbats/unmapped families, CFF pending P3)
degrade gracefully to `skipped_*` — never a crash, never a guess. `7.21.4.1` is now
**partially** auto-fixed; a surviving `7.21.4.1` after embed still trips
`needs_manual` via `_has_font_defects` and needs Acrobat/source font remediation.

**Automatic font repair** (`scripts/pdf_font_repair.py`, runs right after font-program
embedding on both E2 and E3): deletes incomplete `/CharSet` from subset-prefixed
Type1 FontDescriptors (veraPDF `7.21.4.2`), builds a full ToUnicode CMap for simple
fonts that are missing one but declare a named `/MacRomanEncoding` or
`/WinAnsiEncoding` (`7.21.7-1`), and rewrites existing ToUnicode CMaps to full 0-255
coverage while scrubbing forbidden codepoints U+0000/U+FFFE/U+FEFF to U+FFFD
(`7.21.7-2`). Nonzero counts land in `edits.font_repair`. Reviewer-validated live:
took 5 real course PDFs from veraPDF failure to COMPLIANT 106/106, pixel-identical.
Ceiling: math fonts (`CM*`) that falsely declare a MacRoman encoding get
plausible-but-imperfect character mappings — matches pre-existing extraction
behavior, not a regression.

```bash
python scripts/remediate_pdf.py --input file.pdf --output file.remediated.pdf \
    --judgment judgment.json --json
```

`--class CLASS` overrides Assess's auto-classification. `--skip-ocr` bypasses the OCR
step on scanned/hybrid input.

**`--fix-contrast` (opt-in, off by default)**: WCAG 1.4.3 text-color recolor
(`scripts/pdf_contrast.py`), wired in as a final step that runs ONLY after the veraPDF +
fidelity hard gates above already passed. Recoloring text is a deliberate appearance
change — it must never be checked by the exact-zero pixel_diff gate, so it runs strictly
after gates_ok rather than being folded into the E2/E3 edit passes. It re-runs veraPDF on
the recolored file as its own safety net; if that regresses compliance the recolor is
discarded and the tagged-only output is kept (`result.contrast.skipped`), without failing
the overall remediation. Contrast is **not** a PDF/UA or veraPDF checkpoint — clause 7.3
and the Matterhorn checklist don't check color contrast at all — so this has no effect on
`gates_ok` either way; it only ever adds `result.contrast` (recolored spans, any
unfixable colors) on top of an already-compliant file.

### judgment.json

The judgment layer is where Claude's work goes — reading page renders, writing alt
text, confirming ambiguous headings, spec'ing tables. Same shape as the Phase 1
prototype:

```json
{
  "doc_title": "optional — derived from the first H1 or existing metadata if absent",
  "language": "en-US",
  "alt_text": {"0": "alt text for the 0th /Figure, depth-first order"},
  "heading_overrides": [
    {"index": 3, "level": 2},
    {"bbox": [x0, y0, x1, y1], "page": 1, "level": 3}
  ],
  "tables": [{"page": 1, "bbox": [x0, y0, x1, y1]}],
  "table_headers": [
    {"index": 0, "header_row": true},
    {"index": 4, "header_rows": [0], "header_cols": [0, 1], "band_rows": [3, 9]}
  ],
  "table_to_list": [{"index": 1, "order": "column"}],
  "table_to_figure": [{"index": 2, "alt": "2x2 panel of regression residual plots"}],
  "vector_figures": {"5": "alt text for the vector-drawn chart on page 5"},
  "vector_artifacts": [1, 2, 3]
}
```

**Table classification is judgment work with its own standard.** Before writing
`table_headers`, `table_to_list` or `table_to_figure`, read
`standards/pdf-table-judgment.md` — the authoritative, ID-adjudicated rules for deciding
what a tagged `/Table` actually IS (`data_table` / `layout_table` / `layout_wrapper` /
`figure`), which axis carries the headers, and when a chunk is a headerless fragment that
must not be promoted. Measured across three adjudicated batches: classification is the
easy half; **header structure is where the errors are.** `list_tables()` supplies the
evidence the standard's decision procedure consumes — `first_row_text`, `first_col_text`,
`caption`, `page_context`, plus a page render.

**Decorative content** (slide backgrounds, repeated chrome, nav symbols — common in
lecture-deck PDFs): list the pages in `vector_artifacts` and their unmarked content is
wrapped as `/Artifact` — hidden from AT, no alt text. Judgment rule: describe what
carries meaning (`vector_figures`), artifact what doesn't (`vector_artifacts`). Tagged
real content on those pages is never touched. Signal for the decorative call: the same
image xref repeated across many pages, or full-slide background dimensions.

**Vector-drawn charts** (R/matplotlib plots exported as paths, not images): ODL misses
these — the run reports them in `result.edits.unmarked_vector_pages` and the gate fails.
The loop: run once, render each reported page (`fitz` pixmap), look at the chart, write
alt text, rerun with `vector_figures` — each page's paint cluster is wrapped as ONE
tagged `/Figure` with your alt (MCID + ParentTree wired; verified pixel-identical).

`alt_text` keys by **figure index** (0-based, depth-first among `/Figure` elements) —
not a filename, since ODL's output has no per-image filenames to key against.
`heading_overrides` promotes a struct element to `/H<level>`; `normalize_heading_levels`
then clamps any remaining level skip to `prev+1` — no heading ever jumps a level, and
every clamp is reported in `edits.promotions`. A heading candidate Claude is unsure
about should simply be left out of `heading_overrides` — it stays untagged/unpromoted
and is visible in the report as unchanged, never guessed.

`tables` records each region as **flagged-incomplete, not rebuilt** — real Table/TR/
TH/TD MCID re-parenting is deferred (plan §3, AC6); the fallback is the sanctioned
default pass condition, and the report must say "table semantics incomplete," never
imply real table markup was produced.

`table_headers` promotes existing `/TD` cells to `/TH` (with `/Scope`) — the tagger
already builds `/Table`/`/TR`/`/TD` grouping, it just never emits `/TH`, which is what
Anthology Ally's "tables missing headers" finding checks for. Run
`python scripts/pdf_table_weave.py --input file.pdf --list` (or call `list_tables`) to
see each table's `first_row_text`, `header_row_index`, and `cols`, then set
`index`/`header_row`/`header_col` in `table_headers`. **Opt-in only — a table absent
from the list is never touched.** Do not add an entry for a table with no real header
row/column (e.g. a plain word list): inventing headers makes a screen reader announce
ordinary data as column/row headers, which actively misleads.

The header row defaults to the first cell-bearing `/TR`, never raw `/TR` index 0 —
real tagged PDFs frequently sweep a cell-less junk row (a `/Link` struct elem) into
TR0 ahead of the genuine header row; `list_tables`'s `header_row_index` shows which
row will actually be promoted. Pass `header_row_index` explicitly on a spec to
override when the real header is a different row.

**Ragged tables are refused by default.** `list_tables`'s `ragged: true` means the
table's cell-bearing rows don't all have the same cell count (merged cells with no
`/ColSpan` recorded). Attributing such a table (`/A` with `/Scope`) makes veraPDF
actually validate clause 7.2 ("table rows shall have the same number of columns,
taking spans into account") — a check it never runs on an un-attributed table — so
promoting a ragged table can turn a COMPLIANT document NON-compliant. `promote_headers`
skips ragged tables into `result.skipped` unless the spec sets `"force": true`. The
real fix is writing `/ColSpan`/`/RowSpan` from the tagger's span data
(opendataloader-pdf's JSON exposes `row span`/`column span` per cell) — not
implemented.

`table_to_list` converts a **layout** table — a visual grid with no real row/
column relationship — to `/L` -> `/LI` -> `/LBody`. ODL tags any visual grid as
`/Table`, so a multi-column word list gets the same tag as a real data table; as a
`/Table` it also forces row-major reading order, which scrambles a list authored
to read down each column (e.g. an alphabetical word grid: column-major is the
authored order, row-major is not). `"order": "column"` emits items in the
authored column-major order; `"order": "row"` (default) preserves existing
row-major order. **Opt-in only, same rule as `table_headers`** — never list a
genuine data table; that destroys real header/row/column semantics. Inspect
`list_tables`'s `first_row_text` or a page render before adding an entry.

`table_to_figure` converts a **mis-detected** table — a visual grid that is actually a
chart/plot/diagram (e.g. ODL tagging a 2x2 panel of regression residual plots as
`/Table`) — to `/Figure`. Tagging a chart as `/Table` gives a screen reader meaningless
row/column semantics for what is really a figure. `"alt"` is **required and non-empty**
— a `/Figure` with no `/Alt` fails veraPDF 7.3; a spec with a missing or blank `alt` is
skipped, nothing is created. **Opt-in only, same rule as `table_headers`/`table_to_list`**
— never convert a genuine data table. Inspect `list_tables`'s `first_row_text` or a page
render before adding an entry. The table's own marked content (MCIDs) is reused for the
new `/Figure`, not re-rendered; a `/Caption` child is preserved as a child of the figure
instead of being flattened into it.

**Run `table_to_list`/`table_to_figure` before `table_headers` in the same session, and
never mix two of these three keys against one `list_tables()` snapshot.** Converting a
table (to a list or a figure) removes it from `list_tables()`'s document order, shifting
the indices of every table after it. `table_headers`, `table_to_list`, and
`table_to_figure` indices must all be computed against the ORIGINAL `list_tables()`
order — do it in two passes: convert first, re-run `list_tables` (or `remediate_assess`)
to get fresh indices, then act on the remaining tables in a second judgment run.
implemented yet; `force` is an escape hatch for a caller who has verified the merge
structure separately, not a routine setting.

---

## Stage 2 — Route + Remediate (Engine E1, DOCX/PPTX)

`scripts/remediate_docx.py` and `scripts/remediate_pptx.py` are Engine E1: in-place,
minimal-diff Office remediation. Both follow the same shape:

1. **Copy first, edit only the copy** (`shutil.copy2`) — the input Office file is never
   opened for writing, matching Edit Discipline / Office-artifact preservation. Every
   edit below runs ONLY when `judgment.json` supplies it; there are no auto-fixes in
   v1 — even `doc_title` needs human intent placed explicitly, never guessed.
2. **Apply judgment edits** (see schemas below).
3. **Appearance check** (always attempted, never required): renders input vs. output
   via `lo_export.render_pdf_plain` (plain LibreOffice convert, no PDF/UA filter
   options) and compares with `fidelity_gate.pixel_diff(threshold_pct=0.5)`. If
   LibreOffice isn't vendored, this is reported as `appearance.checked: false` with a
   reason — never a failure. If it runs and the diff exceeds 0.5%, the run returns
   `ok: false` with the **output kept** (never deleted or renamed) so the failure is
   reviewable, not hidden. Alt-text-only and other purely-metadata edits are visually
   invisible and pass at 0%; style-swap edits (heading promotion) rely on the caller's
   run having explicit font formatting to snapshot — a run that leaves font name/color
   fully unset (inheriting from the old paragraph style) can show a small residual
   diff after the new heading style's own font/color partially shows through on the
   unset properties. This is a documented v1 boundary, not a bug: the snapshot only
   captures explicit run-level formatting (name/size/bold/italic/underline/color),
   never a resolved style-cascade value.
4. **`--export-pdf OUT.pdf`** (optional): `lo_export.export_pdf` (tagged PDF/UA export,
   Impress path always gets the artifact postfix) + `verapdf_gate.run_verapdf`. **This
   export is a LibreOffice re-render — it never claims pixel-identity to a Word/
   PowerPoint rendering.** That claim belongs only to the fidelity gate's PDF-vs-PDF
   compare (Stage 3, PDF route); the Office→PDF export is a different document, tagged
   for compliance, not a fidelity-gated artifact.

```bash
python scripts/remediate_docx.py --input file.docx --output file.remediated.docx \
    --judgment judgment.json --export-pdf file.remediated.pdf --json
python scripts/remediate_pptx.py --input deck.pptx --output deck.remediated.pptx \
    --judgment judgment.json --json
```

### judgment.json (DOCX)

```json
{
  "doc_title": "optional",
  "language": "en-US",
  "alt_text": {"0": "alt text for inline shape 0, document order"},
  "headings": [{"paragraph_index": 3, "level": 2}],
  "header_row_tables": [0]
}
```

`alt_text` keys by 0-based index into `document.inline_shapes` (document order).
`headings` promotes a body paragraph to `Heading N` — the paragraph's first run's
font (name/size/bold/italic/underline/color) is snapshotted before the style swap and
re-applied as direct formatting on every run in the paragraph afterward, so the
promoted text keeps looking the way it did; a missing `Heading N` style definition is
created (outline level set) rather than raising. `header_row_tables` sets
`w:tblHeader` on row 0 only — no bold, no visual change.

### judgment.json (PPTX)

```json
{
  "doc_title": "optional",
  "language": "en-US",
  "alt_text": {"0": "alt text for picture shape 0, slide order, document-wide index"},
  "slide_titles": {"1": "A real title"},
  "header_tables": [0]
}
```

`alt_text` keys by 0-based index across every picture shape in slide order
(document-wide, not per-slide) — python-pptx defaults a picture's alt text to its
source filename on `add_picture`, so a filename-shaped `descr` should be treated as
missing. `slide_titles` sets text on the slide's title/`ctrTitle` placeholder **only
where one already exists**; a slide with no title placeholder (a blank-layout slide
with a text box faking a title) is reported in `edits.flags`, never guessed at —
injecting a real placeholder into a blank layout is deferred past v1. `header_tables`
sets `first_row = True` (document-wide table index, slide order) — note this is NOT a
visual no-op the way DOCX's `tblHeader` is: PowerPoint table styles typically render a
distinct "first row" look (bold/shaded), so this edit can legitimately trip the
appearance check. That is expected, reportable behavior, not a bug — say so if it
happens rather than silently re-trying or suppressing the failure.

## Stage 3 — Verify (hard gates)

Both gates run automatically inside `remediate_pdf.py`; they can also be invoked
directly (`scripts/verapdf_gate.py FILE [--json]`, `scripts/fidelity_gate.py A B
[--dpi 110] [--json]`).

- **veraPDF gate** (`verapdf_gate.run_verapdf`): `--flavour ua1` against the vendored
  veraPDF. Reports `compliant`, `passed_rules`/`failed_rules` with failure clauses,
  and the constant `matterhorn_human_checkpoints: 47` — always surface this number
  alongside any pass/fail claim (see Report Rules below).
- **Fidelity gate** (`fidelity_gate.pixel_diff`): renders input vs. output page-by-page
  via PyMuPDF and requires **pixel-exact** identity (plan §3 — exact-zero policy;
  bounded per-class thresholds are a documented future task, not implemented). Any
  nonzero diff, or a page-count mismatch, is a hard fail.

On gate failure, `remediate_pdf.py` preserves the output at `{output}.rejected.pdf`
and returns `ok: false` — never silently ships a non-compliant or visually-altered
file. Report the failure and the specific failed clause(s)/page(s); do not retry
silently.

### Two failures the hard gates cannot see

Both gates are blind to output that is structurally valid and semantically wrong.
Each of the following shipped a file that veraPDF called **106/106 compliant**:

- **Reading order** (`scripts/reading_order.py`) — `struct_order_text()` /
  `struct_order_runs()` extract text in struct-tree order (= reading order);
  `--diff BEFORE AFTER` reports a similarity ratio. veraPDF checks structural
  validity and the fidelity gate checks rendered pixels, so a re-tag that moves
  running headers out of `/Artifact` and into the reading flow passes both — a
  screen reader then announces the header on every page. **Run the diff on any
  file whose tag tree was rebuilt.** Note a large diff is not automatically a
  regression: a source with ~0 struct-order runs was never really tagged, so
  0 → many is recovery, not loss. Check the direction before calling it.
- **Placeholder figure alt** (`pdf_tree_edit.list_figures`, gated in
  `remediate_pdf.py` as `edits.figure_alt_review`) — clause 7.3 is satisfied by an
  `/Alt` entry *existing* and never inspects whether it is useful. A re-tag that
  fills every figure with `"image 1"` … `"image 12"` passes, and overwrites any
  author-written caption already in the document. A placeholder now sets
  `needs_manual` and surfaces `caption_candidate` per figure; supply real text via
  `judgment.alt_text`, preferring the author's printed caption over your own
  wording. Captions are **surfaced, never auto-applied** — association is
  heuristic and a wrong caption is worse than none.

### Mandatory post-tagging stage — figure-alt (`scripts/figure_alt.py`)

When the placeholder-alt gate above sets `needs_manual` (any figure's `/Alt` is
`is_placeholder_alt`), **always** run the figure-alt stage next, on the tagged
output `remediate_pdf.py` already produced — never re-tag first, since a re-tag
can shift figure indices (see `docs/plan-figure-alt.md`'s "why a standalone
post-tagging stage"). Role split, enforced by the tooling, not a suggestion:

1. **Script (deterministic, always runs)** — `figure_alt.py --pdf FILE
   --build-manifest [--outdir DIR]` renders every `/Figure` to a PNG and writes
   `figure-alt-manifest.json` carrying every `pdf_tree_edit.list_figures()`
   context field (current alt, `is_placeholder`, `caption_candidate`, width,
   height, `surrounding_text`, `reading_order_index`, `repeat_count`, `in_link`,
   `in_heading`, `mcids`/`page`) plus the PNG path. **Every `disposition` is
   `null`** — the script never concludes decorative vs. content, not even for a
   tiny icon-sized image; width/height are evidence, not a decider.
2. **AI annotation (always-on whenever a session AI is present)** — read each
   PNG alongside its context fields and decide `disposition` (`"describe"` or
   `"decorative"`), write `alt` (required, real, non-placeholder text for
   `"describe"`) and a one-clause `reason`, for **every** figure in the
   manifest — this is what actually closes the gap the placeholder gate found;
   there is no size/position shortcut that substitutes for reading the image
   and its surrounding text.
3. **Human approval** — the confirm verb before `--apply` (same discipline as
   any other file-remediation write).
4. **Apply** — `figure_alt.py --pdf FILE --apply --manifest FILE` backs up the
   PDF bytes, applies `set_alts` (describe) / `demote_figures_to_artifact`
   (decorative), saves, and **re-verifies**: veraPDF must still be compliant
   AND `list_figures()` must show zero remaining placeholders. Any figure left
   at `disposition: null`, or a `"describe"` with empty/whitespace/placeholder
   alt text, is refused (`needs_manual`) rather than guessed — and since the
   verify step is whole-file, a single unresolved figure fails the entire
   apply and **restores the pre-apply backup**, discarding every other fix in
   that run too. Report the `needs_manual` list, re-annotate, and re-run.

Fatigue is handled by the AI annotating every figure properly, never by a
script pre-filter that quietly skips some.

### Mandatory post-tagging stage — heading promotion (`scripts/heading_promote.py`)

veraPDF checks structural *validity*, never whether headings exist. A file
can be **106/106 compliant** while every element is tagged `/P` and zero
`/Hn` exist (confirmed live: `chapter10-remediated.pdf` — 854 `/P`, 0 `/H`) —
Ally correctly flags "This PDF does not have headings" on exactly this kind
of file. Run this stage whenever a remediated PDF's heading count is zero
(or an external checker flags missing headings), on the already-tagged
output — never re-tag first (same index-stability reasoning as figure-alt).
Same role split, enforced by the tooling:

1. **Script (deterministic, always runs)** — `heading_promote.py --pdf FILE
   --build-manifest [--outdir DIR]` calls `pdf_tree_edit.heading_candidates()`,
   which clusters every non-heading, non-Figure/Link leaf struct element's
   text by font signature (rounded size, bold, fill rgb, base font name),
   excludes the document's body-text signature (the most common signature
   among small/non-bold/dark spans — ordinary paragraph text), and writes
   `heading-manifest.json` — one record per surviving occurrence
   (`cluster_id`, `size`, `bold`, `rgb`, `font`, `text`, `page`, `mcids`,
   `current_tag`, `cluster_count`) plus a `font_size_rank` summary (distinct
   clusters, largest first). **Every `level` is `null`** — the script never
   concludes a cluster IS a heading or picks its level; font size is
   evidence, not a verdict (a large word mid-sentence isn't a heading, a
   colored label might not be one either).
2. **AI annotation (always-on whenever a session AI is present)** — read the
   manifest and `font_size_rank` summary and assign each occurrence a
   `level` (1–6) or leave it `null` ("not a heading") from document
   structure and context — batching by `cluster_id` is expected (a whole
   cluster usually shares one level), but each occurrence is annotated
   individually so an outlier inside a cluster can be overridden.
3. **Human approval** — the confirm verb before `--apply` (same discipline
   as any other file-remediation write).
4. **Apply** — `heading_promote.py --pdf FILE --apply --manifest FILE` backs
   up the PDF bytes, promotes every occurrence with a non-null level via
   `pdf_tree_edit.promote_headings()` (resolves the owning leaf struct
   element by page + mcids, retags it `/Hn`, then runs
   `normalize_heading_levels()` to clamp any level jump), saves, and
   **re-verifies**: veraPDF must still be compliant AND at least one heading
   must be present. A manifest left all-`null`, or a post-apply veraPDF
   regression, **restores the pre-apply backup** and reports it rather than
   claiming a false success. Retagging changes only a struct element's `/S`
   — never the content stream — so page text extraction stays byte-identical
   and rendered pixels are unchanged.

Note honestly: the final "has headings" proof is the user re-uploading the
remediated file to Ally (or their checker of record) — this stage adds the
tags and verifies structurally, but does not itself confirm the external
checker's green dial.

### Known upstream defect — Indexed image palettes

The opendataloader re-tag can truncate an image's Indexed colour-space palette
(observed: 768 → 766 bytes). An Indexed space with `hival 255` needs exactly
256 × 3 = 768; a short palette makes the renderer misread the whole lookup table
and the image renders as a solid wrong colour. The image stream itself is
unchanged — only the palette string. **The fidelity gate catches this** (it
surfaced as a 1.04% page diff), so nothing corrupt ships; it is documented here
because the symptom looks nothing like the cause. Repair by copying the source
palette: `pdf.pages[n]['/Resources']['/XObject'][key]['/ColorSpace'][3]`. Not
auto-repaired — the gate already contains it, and n=1.

---

## Stage 4 — Deliver (Canvas file replacement)

Only reachable when the file being remediated came from a Canvas course (Stage 1 ran in
`--course-id` mode, or the user names a course file directly). Local-only `--path` runs
have nothing to deliver — hand the remediated output back and stop.

**Replacing a live faculty file is destructive-ish.** It overwrites the content students
and faculty currently see at that Canvas file URL. It is not silently reversible from
Canvas's side — the pre-push backup this stage writes IS the rollback path. Treat it with
the same care as any other Canvas write; this stage never bypasses the Write Safety Guard
gate just because the payload is a file instead of HTML.

1. **Remediate + pass the gates first.** Never offer to deliver a file that Stage 3
   rejected (veraPDF non-compliant, fidelity gate failed, or — for DOCX/PPTX — the
   appearance check failed). Delivery is for gate-passing output only.
2. **Stage the output.** Copy the remediated file into
   `courses/{course_id}/staging/files/` (any filename — `push_remediated_file.py` uploads
   it under the LIVE file's own `display_name`, not the local filename, so
   `on_duplicate=overwrite` replaces the right Canvas object regardless of what the local
   copy is named).
3. **Announce + require an explicit push verb**, per CLAUDE.md §Write Safety Guard step 4.
   State plainly that this replaces the live file and that a backup will be written first.
   Accept only `push`, `approved`, `deploy`, `ship it`, `looks good push`, or an equivalent
   affirmative-with-intent (the full spoken set) — never act on a bare `ok`/`yes`/`sure`/
   `confirm`; re-ask "Confirming you want me to replace the live Canvas file — yes/no?" on
   an ambiguous reply. Normalize whatever verb the user gives to `{push, approved, deploy}`
   before invoking the script (its `--confirm-verb` flag accepts only those three, same
   contract as `push_to_canvas.py`/`deploy_interactives.py`).
4. **Push**:
   ```bash
   python scripts/push_remediated_file.py --course-id 260866 --file-id 98765 \
       --local courses/260866/staging/files/syllabus.remediated.pdf \
       --confirm-verb push --json
   ```
   This is the ONLY authorized write path for a remediated-file replacement — never call
   `canvas_api.upload_file` directly for this. It: fetches the live file's metadata,
   downloads and backs up the current bytes to `courses/{id}/backups/files/`, uploads the
   staged file under the live `display_name` into the live `folder_id`, re-fetches the file
   to verify (size matches the local file, `updated_at` advanced) — hard-failing with the
   backup path printed on any mismatch and leaving staging untouched — and, only on
   verified success, auto-closes matching `file_a11y` backlog findings
   (`backlog_manager.resolve_file_a11y`, matched by Canvas file id) and clears the staged
   file.
5. **Report** the backup path, the verified Canvas file URL, and which backlog findings
   (if any) were auto-closed. If the Canvas file id changed across the overwrite (Canvas is
   expected to keep it stable, but the script hedges — `result.file_id_changed`), say so.
6. **Rollback**: `--restore` is **not implemented in v1**. `# ponytail:` restoring means
   re-running `push_remediated_file.py` with `--local` pointed at the backup file (from
   `courses/{course_id}/backups/files/`, copied into the staging directory first) — the
   same script, run in reverse, rather than a bespoke restore path.

### Delivery fold (batched push)

Staged-work rot is a field lesson: after remediating several files in one session, the
human forgets to push each one individually, and verified fixes never reach Canvas.
`scripts/deliver_batch.py` fixes this with ONE up-front batched verb instead of a
per-file push prompt — authorized by CLAUDE.md §Write Safety Guard step 6 (batched
approval covers N objects). It adds no new write path: it is a loop that routes each
file's gate result and calls the same `scripts/push_remediated_file.py` per delivery
(backup → upload overwrite → verify → clear staging happens inside that call, exactly
as in the single-file flow above).

**Batch = the current session's remediation set.** The batch is the files Claude just
remediated in this chat, not a ledger — the §3 pre-remediation approval ledger is
deferred; when it ships, its `Approve`/`Change-to-remediate` rows become the batch
input instead, with the same delivery logic downstream.

1. **Ask once, up front**, after every file in the batch has been remediated and
   gate-checked: *"Push all N gate-passing remediated files to Canvas as they complete?
   (push/approved/deploy)"* — normalize the reply to `{push, approved, deploy}` per the
   standing verb contract; re-ask on a bare `ok`/`yes`/`sure`/`confirm`.
2. **Build the manifest**: one record per file — `{kind, local_path, file_id, display_name,
   gates: {verapdf_compliant, fidelity_identical}, appearance: {ok} | null, needs_manual}`.
   `kind` is **required** and explicit — `"pdf"` (carries `gates`, `appearance: null`) or
   `"office"` (DOCX/PPTX, carries `appearance: {ok: <bool>}` from the Stage 2 appearance
   check, empty `gates`). File type is NEVER inferred from which gate fields are present: a
   row with a missing/unknown `kind`, or an `office` row missing `appearance.ok`, or a `pdf`
   row missing a gate boolean, is **malformed** — reported in `skipped_malformed`, never
   delivered, and the run exits nonzero so you notice the bad input.
3. **Run**:
   ```bash
   python scripts/deliver_batch.py --course-id 260866 --manifest batch.json \
       --confirm-verb push --json
   ```
4. **Three routes, and the batched verb only ever covers the first**:
   - **Deliver** — `needs_manual` is false AND gates pass (PDF: both
     `verapdf_compliant` and `fidelity_identical`; office: `appearance.ok is true`).
     Calls `push_remediated_file.py` under the one batched verb; the result's
     `approved_via` reads `batched-verb:{verb}@{timestamp}`.
   - **Skip (gate-fail / needs_manual)** — never delivered, reported with a reason.
     A gate failure or an unresolved manual item is never silently pushed just because
     the rest of the batch passed.
   - **Needs individual approval (appearance-drift)** — an office file whose
     `appearance.ok` is false changed visibly; the batched verb never covers a
     visibly-changed file. Surface it for its own explicit yes, same as any other
     Canvas write.
5. **Retire is out of scope here, always.** A manifest row that smuggles
   `action: "retire"` is a hard error for the whole run — nothing is delivered.
   Retiring a file is `retire_file.py`'s own verb gate (`--confirm-verb retire`),
   never batch-covered; route retire candidates there instead.
6. **Report** the delivered/skipped/needs-individual-approval/malformed counts and any
   failed delivery attempts (an item that was gate-PASS but failed `push_remediated_file`'s
   post-write verification) separately from correctly-skipped items — a failure there
   still leaves the backup + staged file in place, same as a single-file push failure.
   **No rollback across a batch**: if item 3 fails after items 1–2 delivered, 1–2 stay
   delivered (each independently backed up + verified by `push_remediated_file`); only item 3
   is reported failed. The batch is not atomic — it is N independent gated pushes under one
   authorizing verb, each recoverable from its own backup.

### Apply image alt (Canvas HTML)

Counterpart to Stage 4 above, but for `<img>` alt/decorative attributes living in Canvas
HTML (pages, assignments, discussions) rather than in a file's bytes. Scope is intentionally
narrow — this writes only `alt`/`role` on `<img>` tags, nothing else in the body. Broader HTML
remediation belongs to `bulk-edit`, not here.

1. **Audit first.** `scripts/audit_image_alt.py --course-id ID --build-manifest` extracts
   every `<img>` across pages/quizzes/assignments/discussions, downloads the images, and
   writes `courses/{id}/reports/vision/alt-manifest.json`. Classify each image (agent vision
   pass — relevant vs. decorative, with `proposed_alt` for relevant images) into
   `alt-classifications.json` keyed by `image_id`.
2. **Apply**: `scripts/apply_image_alt.py --course-id ID [--dry-run] [--confirm-verb VERB]`
   reads the manifest + classifications, groups classified images by containing object,
   LIVE-fetches each object's current HTML (never a stale local copy — same MUST-2 discipline
   as `repush_transform.py`), and injects: `alt="" role="presentation"` for decorative images,
   `alt="{proposed_alt}"` for relevant ones. Idempotent — re-running is a no-op once applied.
   Only images WITH a classification entry are touched; unclassified images are left alone.
   Quiz images are logged and skipped (the manifest doesn't carry `question_id`, so the
   containing question can't be targeted safely — a known gap, not a guess).
3. **Gated push**, same contract as everywhere else: no `--confirm-verb` (or bare `--dry-run`)
   stages the change and reports what would happen without writing to Canvas. `--confirm-verb`
   accepts only `push`/`approved`/`deploy`. On a good verb: backs up the live body
   (`backup_manager.save_backup`), pushes via `canvas_api.update_page`/`update_assignment`/
   `update_discussion`, then **verifies field-targeted** — re-fetches and checks the specific
   changed `<img>` carries the expected `alt`/`role` as a substring, never whole-body equality
   (Canvas re-encodes entities, so whole-body diffs false-positive). A verify mismatch hard-fails
   with the backup path in the result.
4. **`--clear-cache`** deletes the downloaded image bytes under
   `courses/{id}/reports/vision/images/` after a successful run (reporting bytes freed) while
   keeping the manifest and classifications JSON as the audit record.

---

## Report Rules (apply to every report this skill produces, at any phase)

1. **Always state machine-checks vs. human checkpoints separately.** Assess output is
   a classification, not a conformance verdict. When a veraPDF baseline is available,
   report it as "machine checks: N passed / M failed" — never collapse it into a
   single pass/fail without naming what was and wasn't checked.
2. **Matterhorn human-checkpoint count.** Color contrast, alt-text *quality*, reading
   order *correctness*, and link purpose are human checkpoints, not machine-verifiable.
   Name them; don't silently skip them.
3. **"Fully compliant" / "fully conformant" is a forbidden phrase in any report this
   skill produces.** Even a veraPDF `COMPLIANT` result is machine-checks-only —
   always pair it with the `matterhorn_human_checkpoints` count. No file-remediation
   output may claim full WCAG or PDF/UA conformance. State what was checked and what
   remains a human judgment call.
4. **Never claim a capability that isn't shipped or verified.** PDF Assess (Stage 1),
   PDF remediation via Engines E2/E3 (Stages 2-3), DOCX/PPTX remediation via Engine E1,
   and Canvas file delivery (Stage 4) are all shipped. Stage 4 specifically has not yet
   been verified against real Canvas — its live integration test
   (`tests/integration/test_live_file_replace.py`) has not been run (sandbox token
   expired at ship time) — so report it as "unit-tested, not yet live-verified" until
   that suite has actually run green, never as fully proven end-to-end.
5. **Table semantics are always reported as incomplete, never as fixed.** Stage 2's
   table handling is the flagged-incomplete fallback (plan AC6) — never say a table
   was "remediated," "tagged," or "fixed"; say the region is flagged for a human
   pass.
6. **Never claim pixel-identity for a LibreOffice PDF export.** `--export-pdf` on
   DOCX/PPTX is a re-render for compliance (veraPDF), not a fidelity-gated artifact —
   say "exported and gated," never "identical to the original file's rendering."
