Skip to content

Commit bf74306

Browse files
committed
Add one-stop deck review (overflow + contrast + section completeness)
Overflow and colour-contract checks lived in two scripts and the seven-section judgement was human-only. Move both checks into the package (exporters/overflow.py, exporters/audit.py; the scripts become thin re-export wrappers so existing imports keep working) and add exporters/review.py to bundle them with a paper_rule section-completeness check that reuses the exporter's own slide classifier. Exposed as the CLI `python -m thesisagents review <deck.pptx> [--lang] [--json]` and the MCP `pptx_review` tool. Completeness only fails a thesis-style deck, and references is gated only for multi-paper decks (a single-paper rich deck folds references into the cover, an own-thesis deck omits self-citation).
1 parent d950c14 commit bf74306

8 files changed

Lines changed: 899 additions & 373 deletions

File tree

scripts/_audit_dark_text.py

Lines changed: 7 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,10 @@
1-
"""Manual dark-mode text auditor for a single rendered ThesisAgents deck.
1+
"""Manual dark-mode text auditor CLI (thin wrapper).
22
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:
127
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:
268
.venv/Scripts/python.exe scripts/_audit_dark_text.py exports/<deck>.pptx [more.pptx ...]
279
2810
Exit code is the number of decks with at least one hard issue (checks 1-3); the
@@ -31,122 +13,10 @@
3113
from __future__ import annotations
3214

3315
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
13716

17+
from thesisagents.exporters.audit import Issue, audit_deck, main # re-exported
13818

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"]
15020

15121

15222
if __name__ == "__main__":

0 commit comments

Comments
 (0)