# Spec: background-aware contrast remediation (pdf_contrast v2)

**Status:** Rev 1 — architect Opus 2026-07-29. Implementer = Sonnet. Reviewer ≠ coder.
**Standard:** WCAG 2.1 AA §1.4.3. Fixes low-contrast **text** in PDFs, measured against the text's
**actual** background.

## Why (three real defects, all confirmed on `chapter10-remediated.pdf` via a third-party checker Ally)
1. **White-background assumption.** `pdf_contrast` computes every ratio against hardcoded white. Text
   in colored callout bars / table-cell fills has its real contrast against the *fill*. Confirmed:
   "Explore 10.1" was **white (255,255,255) on pink (206,100,166)** — real ratio 3.5 (failing), which
   the tool never saw (it measured white-on-white = 1.0).
2. **Active degradation.** Because of (1), the tool darkened that white label to grey (118,118,118) to
   pass *against white* — and grey on the pink fill is **1.30** (catastrophic). The tool made real,
   already-imperfect text far worse. A reviewer flagged exactly this ("the tool made the font a
   different color that has less contrast").
3. **Targeting exactly 4.5.** Fixed spans landed at 4.51–4.55 (pink title 4.51, teal subhead 4.51,
   blue heading 4.55, red links 4.52). Ally's luminance/rounding lands just under and flags them. A
   fix that sits on the boundary is not robust against third-party checkers.

## Design decisions (ratified by the user 2026-07-29)
- **Colored callout bars → darken the FILL, keep the existing (white) text.** Preserves the
  "light label on colored bar" design intent. Each bar type uses one consistent design-system color
  (all Explore bars = the same pink), so darkening that color darkens every matching bar uniformly —
  desirable, not a side effect.
- **Safety margin above 4.5.** Target an effective ratio with headroom (default **5.0**) so a
  checker computing luminance slightly differently still reads ≥4.5. Configurable.

## Ladder (ponytail)
Extends the existing `pdf_contrast.py` mechanism (content-stream color-operator rewrite). No new
dependency — `page.get_drawings()` (PyMuPDF, already vendored) supplies fill rects+colors for
background detection. The fill-recolor path mirrors the text-recolor path, just gated on the opposite
classifier branch (color op precedes a **path-paint**, not a text-show).

## Deliverable: rewrite `scripts/pdf_contrast.py` (`remediate_contrast` signature unchanged, add
`target_ratio` default note + optional `safety_margin`).

### 1. Background detection (new)
For each non-whitespace text span, determine its actual background:
- Collect fill rects from `page.get_drawings()` (fills only, with rect + RGB), in paint order.
- The span's background = the **topmost** (last in paint order) fill rect whose rect contains the
  span's bbox center. If none → white (255,255,255). If the covering element is an **image**
  (`page.get_image_rects`) → mark the span `unfixable` (reason: "text over image background — cannot
  recolor"), never guess.

### 2. Contrast + skip-already-passing (fixes defect 1 & 2)
- Compute contrast against the **real** background.
- **Skip any span that already passes** (ratio ≥ effective target) against its real background — do
  NOT touch it. This alone prevents the white→grey degradation (white-on-fill that passes is left
  alone; and nothing already-good is re-darkened).

### 3. Fix strategy (fixes defect 1)
For a span that fails against its real background:
- **Background is white/near-white** → darken the **text** (hue-preserving) to the effective target.
  (Existing behavior.)
- **Background is a colored/dark fill AND the text is the lighter element** (text luminance >
  bg luminance) → darken the **FILL** (hue-preserving) until the *existing* text color passes the
  effective target. Text color unchanged. (The callout-bar case.)
- **Background is a colored fill AND the text is the darker element** → darken the **text** to pass on
  that fill (text is already the dark element; push it darker).
- If the required move can't reach the target within valid color range (e.g. fill already near-black,
  or text near-black on a mid fill that can't go lighter) → `unfixable` with reason. Never fake it.

### 4. Fill recolor mechanism (new — mirrors text recolor)
- For each distinct **fill color** that sits behind failing light-on-fill text, compute the single
  darkened fill color that makes the *lightest* overlaying text pass the effective target
  (hue-preserving; darken fill luminance until `ratio(text, fill) ≥ target`).
- Rewrite every nonstroking fill color operator (`rg`/`g`/`k`) of that color that **precedes a
  path-paint operator** (`f`/`F`/`B`/`b`/etc.) — the opposite classifier branch from text recolor.
  Reuse `_classify_occurrence` + `_mask_paren_strings`.
- **Shared-color note:** recoloring by value darkens every path using that exact color. For
  design-system bar colors this is desired (uniform). `log`/record it in the result so it's visible;
  if a color is used by both a bar and unrelated decoration, that's surfaced, not silent.

### 5. Text recolor (existing, keep) — now uses real bg + margin
The `rg`/`g`/`k`-preceding-text path stays, but darkens to the **effective target** against the
**real** background, and only for spans selected in §3.

### 6. Verify (close the loop, against real bg)
Re-open output, re-detect backgrounds, re-probe: **zero** spans failing against their real background
except those in `unfixable`. veraPDF still compliant (recolor touches only color operands). Text
content (`get_text("text")`) byte-identical. Result dict gains `fills_darkened: [{from_rgb, to_rgb,
pages}]` alongside `fixed`/`unfixable`.

## Honesty rules (enforce)
- Skip-already-passing is mandatory — the regression that degraded white-on-fill must be impossible.
- Text-over-image, or any span whose fix can't reach target → `unfixable` with reason; `ok` reflects it.
- Verify against real backgrounds, not white. Never claim clean from a white-background probe.

## Tests (`tests/test_pdf_contrast.py` — extend; synthetic PyMuPDF fixtures)
- AC-bg: span over a fill rect resolves that fill as bg; over nothing → white; over an image → unfixable.
- **AC-regression (the exact bug):** white text on a colored fill where white already passes → NOT
  touched; white text on a fill where it fails → **fill darkened, text stays white**, never text→grey.
- AC-textwhite: dark-ish text on white failing → text darkened (existing).
- AC-margin: every fixed span reaches computed ratio ≥ effective target (default 5.0), not 4.5.
- AC-fill-share: two bars sharing one fill color both darken from one rewrite.
- AC-honesty: text-over-image → unfixable; unreachable target → unfixable.
- AC-verify: post-fix re-probe against real bg = 0 residual; `get_text` identical; veraPDF compliant.

## Acceptance (real files — reviewer verifies, and user re-checks in Ally)
- Re-remediate `chapter10.pdf` from source: callout bars keep white text, bar fills darkened, all pass
  ≥ target against the real fill; blue/pink/teal chapter headings and any links pass with margin;
  re-probe against real backgrounds = 0 residual; veraPDF 106/106; fidelity of tagging preserved.
- Re-remediate `Sample_annotated-rhetorical-analysis`: the red "here"/"this link" links reach margin.
- **Note honestly:** final proof that Ally reports green is the user re-uploading — the tool can only
  guarantee headroom against WCAG's formula, not replicate Ally's exact engine. State this.

## Ponytail floor
Reuse `darken_to_ratio`, `_classify_occurrence`, `_mask_paren_strings`, `_op_to_rgb`,
`_cmyk_to_rgb_fitz`. Add background detection + a fill-darkening pass + the margin. No new dependency,
no scoring, no config beyond `safety_margin`.
