|
1 | | -"""Manual dark-mode text auditor for a single rendered ThesisAgents deck. |
| 1 | +"""Manual dark-mode text auditor CLI (thin wrapper). |
2 | 2 |
|
3 | | -Why this script exists |
4 | | ----------------------- |
5 | | -The dark-mode / no-red / contrast contracts (see `.claude/agents/rules/deck-design.md`) |
6 | | -are pinned by `tests/test_exporters.py` regression tests — but those only run on |
7 | | -decks the exporter *generates*. CLAUDE.md's "Read Subagents BEFORE Editing Any |
8 | | -.pptx" rule extends the same contracts to **hand-made decks** (anything under |
9 | | -`exports/`, `assets/template/`, etc.), which no test covers. This is the companion |
10 | | -debug script the deck-design doc refers to: point it at any `.pptx` and it reports |
11 | | -every run that would render invisible / off-contract. |
| 3 | +The implementation now lives in ``thesisagents.exporters.audit`` so the MCP |
| 4 | +``pptx_review`` tool, the CLI ``review`` subcommand, the ``review_deck`` audit, |
| 5 | +and this script all share one auditor. This file stays as the documented |
| 6 | +command-line entry point: |
12 | 7 |
|
13 | | -Checks (each maps to a deck-design contract): |
14 | | -
|
15 | | -1. **Invisible run** — `rgb is None` or `rgb == (0,0,0)`: inherits the theme |
16 | | - colour and renders near-black on the dark slide background. |
17 | | -2. **Red text** — `#C0392B` (`_BRAND_ACCENT`): banned as a TEXT colour in both |
18 | | - modes (reads as error, pattern-matches AI-generated emphasis). |
19 | | -3. **Light-on-light** — a near-white run inside a near-white-fill shape (both |
20 | | - luminances > 0.7 × 255): the contrast-contract invisibility bug. |
21 | | -4. **Off-palette run (warning)** — a run whose colour is none of the sanctioned |
22 | | - dark-mode run colours (`_ACCEPTED_DARK_RUN_COLORS`). Informational: a custom |
23 | | - colour may be intentional, but it's worth a human glance. |
24 | | -
|
25 | | -Usage: |
26 | 8 | .venv/Scripts/python.exe scripts/_audit_dark_text.py exports/<deck>.pptx [more.pptx ...] |
27 | 9 |
|
28 | 10 | Exit code is the number of decks with at least one hard issue (checks 1-3); the |
|
31 | 13 | from __future__ import annotations |
32 | 14 |
|
33 | 15 | import sys |
34 | | -from dataclasses import dataclass |
35 | | -from pathlib import Path |
36 | | - |
37 | | -from pptx import Presentation |
38 | | - |
39 | | -# Sanctioned dark-mode run colours — the *values* of _LIGHT_TO_DARK_TEXT plus the |
40 | | -# near-white promotion target and the white table-header foreground (which sits on |
41 | | -# the dark navy header fill). Keep in sync with pptx.py if the palette changes. |
42 | | -_ACCEPTED_DARK_RUN_COLORS = frozenset({ |
43 | | - (0xE5, 0xE7, 0xEB), # near-white body text |
44 | | - (0x9C, 0xA3, 0xAF), # mid grey |
45 | | - (0x6B, 0x72, 0x80), # muted grey |
46 | | - (0x60, 0xA5, 0xFA), # blue-400 highlight |
47 | | - (0xFF, 0xFF, 0xFF), # white table-header foreground (on navy fill) |
48 | | -}) |
49 | | -_RED_ACCENT = (0xC0, 0x39, 0x2B) |
50 | | -_LIGHT_LUMA = 0.7 * 255 # luminance above which a colour counts as "light" |
51 | | - |
52 | | - |
53 | | -@dataclass(frozen=True) |
54 | | -class Issue: |
55 | | - slide: int |
56 | | - shape: str |
57 | | - kind: str # "invisible" | "red text" | "light-on-light" | "off-palette" |
58 | | - detail: str |
59 | | - hard: bool # hard issues fail the deck; warnings do not |
60 | | - |
61 | | - |
62 | | -def _rgb_tuple(rgb): |
63 | | - if rgb is None: |
64 | | - return None |
65 | | - return (int(rgb[0]), int(rgb[1]), int(rgb[2])) |
66 | | - |
67 | | - |
68 | | -def _luma(rgb: tuple[int, int, int]) -> float: |
69 | | - r, g, b = rgb |
70 | | - return 0.299 * r + 0.587 * g + 0.114 * b |
71 | | - |
72 | | - |
73 | | -def _shape_fill_rgb(shape): |
74 | | - """The shape's solid fill colour as an (r,g,b) tuple, or None if it has no |
75 | | - readable solid fill (background / pattern / inherited).""" |
76 | | - try: |
77 | | - fill = shape.fill |
78 | | - if fill.type is not None and fill.fore_color.type is not None: |
79 | | - return _rgb_tuple(fill.fore_color.rgb) |
80 | | - except (TypeError, ValueError, AttributeError): |
81 | | - return None |
82 | | - return None |
83 | | - |
84 | | - |
85 | | -def _iter_runs(shape): |
86 | | - if not shape.has_text_frame: |
87 | | - return |
88 | | - for paragraph in shape.text_frame.paragraphs: |
89 | | - for run in paragraph.runs: |
90 | | - if run.text.strip(): |
91 | | - yield run |
92 | | - |
93 | | - |
94 | | -def audit_deck(path: str | Path) -> list[Issue]: |
95 | | - """Return every dark-mode / contrast / red-text issue in the deck.""" |
96 | | - prs = Presentation(str(path)) |
97 | | - issues: list[Issue] = [] |
98 | | - for idx, slide in enumerate(prs.slides, start=1): |
99 | | - for shape in slide.shapes: |
100 | | - name = getattr(shape, "name", "?") or "?" |
101 | | - fill_rgb = _shape_fill_rgb(shape) |
102 | | - fill_light = fill_rgb is not None and _luma(fill_rgb) > _LIGHT_LUMA |
103 | | - for run in _iter_runs(shape): |
104 | | - rgb = _rgb_tuple(run.font.color.rgb if run.font.color and run.font.color.type |
105 | | - else None) |
106 | | - if rgb is None or rgb == (0, 0, 0): |
107 | | - issues.append(Issue(idx, name, "invisible", |
108 | | - f"rgb={rgb} renders near-black on dark bg", True)) |
109 | | - continue |
110 | | - if rgb == _RED_ACCENT: |
111 | | - issues.append(Issue(idx, name, "red text", "#C0392B is banned", True)) |
112 | | - continue |
113 | | - if fill_light and _luma(rgb) > _LIGHT_LUMA: |
114 | | - issues.append(Issue(idx, name, "light-on-light", |
115 | | - f"text {rgb} on fill {fill_rgb}", True)) |
116 | | - continue |
117 | | - if rgb not in _ACCEPTED_DARK_RUN_COLORS: |
118 | | - issues.append(Issue(idx, name, "off-palette", |
119 | | - f"rgb={rgb} not a sanctioned dark-mode colour", False)) |
120 | | - return issues |
121 | | - |
122 | | - |
123 | | -def _report(path: str | Path) -> bool: |
124 | | - issues = audit_deck(path) |
125 | | - hard = [i for i in issues if i.hard] |
126 | | - warn = [i for i in issues if not i.hard] |
127 | | - print(f"dark-text audit — {path}") |
128 | | - print(f"hard issues: {len(hard)}") |
129 | | - for i in hard: |
130 | | - print(f" slide {i.slide}, shape \"{i.shape}\": {i.kind} — {i.detail}") |
131 | | - print(f"warnings: {len(warn)}") |
132 | | - for i in warn: |
133 | | - print(f" slide {i.slide}, shape \"{i.shape}\": {i.kind} — {i.detail}") |
134 | | - verdict = "PASS" if not hard else "FAIL" |
135 | | - print(f"verdict: {verdict}") |
136 | | - return not hard |
137 | 16 |
|
| 17 | +from thesisagents.exporters.audit import Issue, audit_deck, main # re-exported |
138 | 18 |
|
139 | | -def main(argv: list[str]) -> int: |
140 | | - if not argv: |
141 | | - print("usage: _audit_dark_text.py <deck.pptx> [more.pptx ...]") |
142 | | - return 2 |
143 | | - failed = 0 |
144 | | - for i, path in enumerate(argv): |
145 | | - if i: |
146 | | - print() |
147 | | - if not _report(path): |
148 | | - failed += 1 |
149 | | - return failed |
| 19 | +__all__ = ["Issue", "audit_deck", "main"] |
150 | 20 |
|
151 | 21 |
|
152 | 22 | if __name__ == "__main__": |
|
0 commit comments