# Spec: PDF text-contrast remediation (`pdf_contrast.py`)

**Status:** Rev 1 — architect Opus 2026-07-28. Implementer = Sonnet agent. Reviewer ≠ coder.
**Standard:** WCAG 2.1 AA §1.4.3 (`a11y.wcag:1.4.3`). Fixes low-contrast **text** in PDFs.
**Verified on disk (Opus spikes, 2026-07-28):**
- Content streams set text fill via `r g b rg` operators; every failing color in the test PDF sits
  directly before a `BT` text object (no path/graphics share the color) → in-place recolor is safe.
- Hue-preserving HLS darkening reaches ≥4.5:1 while keeping the hue recognizable.
- `wcag_checks.contrast_ratio(fg, bg)` already accepts RGB tuples — **reuse it, add no new math.**

## Ladder (ponytail — why this shape)
PyMuPDF is already vendored (rung 4). No new dependency. The whole surface is: probe failing text
spans → compute darkened replacements → rewrite the matching fill-color operators in the content
stream → re-probe to verify. No scoring, no config, no color-theory engine. Reject any redaction/
re-insertion approach (wrecks font/position fidelity) — content-stream color-operator rewrite
preserves glyphs, fonts, and positions exactly; only the fill color changes.

## Deliverable
`scripts/pdf_contrast.py` — standalone module + CLI.

```python
def remediate_contrast(input_path, output_path=None, target_ratio=4.5, background=(255,255,255)):
    """Recolor low-contrast text in a PDF, preserving hue. Returns result dict."""
```

### Detection
- Use `fitz` `page.get_text("dict")`; for each span compute `contrast_ratio(rgb, background)`
  (import from `wcag_checks`). Failing = `ratio < target_ratio`.
- **Skip whitespace-only spans** (`span["text"].strip() == ""`) — invisible spacers, not real
  failures (the test PDF has several blue/yellow/white spacer spans; do not touch them).
- Group failing spans by their exact source color.

### Recolor math (hue-preserving darken)
- `colorsys.rgb_to_hls` → binary-search lightness downward → `hls_to_rgb`, keep hue+saturation,
  find the **minimal** darkening that reaches `target_ratio` on the given background. (Spike proved
  40 iterations converges; green (61,179,112)→(46,134,84), orange (233,112,49)→(199,82,21).)
- If a color already passes, never darken it.

### Content-stream rewrite (safety-critical — per-occurrence, NOT global value substitution)
- Read each page's content stream (`page.read_contents()`), rewrite, write back
  (`doc.update_stream()` on the correct xref, or `page.clean_contents()` + set — use whichever
  PyMuPDF path round-trips; verify the stream actually persists on re-open).
- Handle nonstroking (fill) color operators: **`rg`** (RGB), **`g`** (gray), **`k`** (CMYK).
  Uppercase `RG`/`G`/`K` are stroking — skip (text uses nonstroking fill).
- **Classify each color-op occurrence by the next painting operator**: if the next paint op after
  the color op is a text-show (`Tj`/`TJ`/`'`/`"`), it is text → eligible to rewrite; if it is a
  path-paint (`f`/`F`/`S`/`B`/`b`/etc.), it is graphics → **leave untouched**. This prevents
  recoloring decorative vectors that happen to share a heading's color. (In the test PDF all
  failing colors are text-only, but the classifier must be general.)
- Only rewrite occurrences whose (rounded) RGB matches a failing color from detection. Replace the
  operator's numeric operands with the darkened color's operands in the same colorspace form the
  op used (rewrite `rg` with new rg operands; convert gray/cmyk failing text to `rg` output is
  acceptable since we recolor to an RGB target — emit `r g b rg`).

### Unfixable honesty (hard rule — never fake success)
If a failing span's color cannot be located/rewritten in the content stream (e.g. color set via
`scn`/pattern/ICC separation, or op precedes a path so it's ineligible), record it under
`unfixable` with the reason. **Do not claim it fixed.**

### Verify (close the loop)
Re-open the output, re-probe with the same detector, assert **zero remaining meaningful failing
spans** except those listed in `unfixable`. Body/black text must be unchanged.

### Result dict
```python
{
  "ok": bool,                      # True iff no meaningful failures remain outside `unfixable`
  "target_ratio": 4.5,
  "fixed": [{"page","text","from_rgb","to_rgb","ratio_before","ratio_after"}, ...],
  "unfixable": [{"page","text","rgb","ratio","reason"}, ...],
  "unchanged_black_text": int,     # sanity counter, spans left as-is
}
```

### CLI
`pdf_contrast.py INPUT [-o OUTPUT] [--target-ratio 4.5]` → prints result JSON, exit 0 iff `ok`.

## Pipeline placement (do NOT auto-run in the tagging gate)
Contrast fix **changes appearance by design**, so it must NOT go through `fidelity_gate.pixel_diff`
(which would fail on the intended color change). It is a separate appearance-changing route that
requires per-file human eyeball (before/after), like the E1 appearance-drift escape hatch. For THIS
task ship the standalone module + CLI only; wiring an opt-in `--fix-contrast` step into
`remediate_pdf.py` (running after tagging) is a follow-up, out of scope here.

## Tests (`tests/test_pdf_contrast.py` — assert-based, real fixtures)
1. **darken math**: darkened color hits `>= target_ratio`; already-passing color returned unchanged;
   hue preserved (HLS hue within tolerance).
2. **classifier**: a color op before `BT`/`Tj` → text (eligible); before `re`/`f` → path (skip).
   Use small synthetic content-stream strings.
3. **integration** on a copy of the real Memo PDF (place a small fixture; if the ~500KB PDF is too
   big to vendor, generate a tiny 1-page PDF with PyMuPDF containing green (61,179,112) heading text
   + black body, then assert green→below-4.5 becomes ≥4.5 and black is untouched, `ok=True`).
4. **whitespace skip**: a whitespace-only low-contrast span is not counted/altered.
5. **unfixable honesty**: a failing color the rewriter can't map lands in `unfixable`, `ok` reflects it.

Run `pytest tests/test_pdf_contrast.py -q` green before PR. Then Opus verifies by running the CLI on
the two real Downloads PDFs and re-probing.

## Acceptance criteria
- AC1: On the Memo PDF, green/orange/pink heading text all reach ≥4.5:1; black body text unchanged;
  `ok=True`; `unfixable` empty.
- AC2: Whitespace-only spacer colors are neither counted nor modified.
- AC3: A shared-color decorative vector (synthetic test) is NOT recolored (classifier holds).
- AC4: Re-probe of the output shows 0 meaningful failing spans.
- AC5: Output opens cleanly in PyMuPDF and glyph count / text content is byte-identical to input
  (only color changed — verify via `get_text("text")` equality).
