Skip to content

Commit a64faeb

Browse files
committed
Dark-mode pptx — opt-in via ExportOptions.dark_mode / --dark-mode flag /
GUI Deck-tab checkbox OLED projectors and low-light presentation rooms blow out the bright white slide background. Added a dark-mode rendering path that's non-invasive in the build pipeline: autopapertoppt/core/models.py ExportOptions gains `dark_mode: bool = False` (frozen dataclass — existing callers stay valid without source changes). autopapertoppt/exporters/pptx.py Two new module-level dicts: `_LIGHT_TO_DARK_TEXT` maps light-palette RGB triplets to dark equivalents for font.color.rgb swaps; `_LIGHT_TO_DARK_FILL` does the same for shape / cell fills + cell borders. `_DARK_SLIDE_BG = #12151B` is the dark slide bg. `_apply_dark_mode(prs)` runs after `_apply_typography` and `_decorate_with_accents`: 1. Solid-fills every slide's background with _DARK_SLIDE_BG. 2. Walks every shape; tables iterate cell-by-cell. 3. For each shape / cell: swap solid fill RGB if it's in the map. 4. For each run inside a text frame: swap font.color.rgb if mapped. 5. For each table cell: also walk `<a:lnX>/<a:solidFill>/<a:srgbClr>` border XML so the header rule + row dividers retake the dark palette's lighter grey-blue. Recoloring is intentionally non-invasive — we don't refactor the 100+ direct `_BRAND_*` constant references in builders. The post-pass finds them by the RGB they wrote and swaps. Adding a new palette variant in future is one new mapping dict + one new pass. autopapertoppt/cli.py `--dark-mode` store_true flag wired into ExportOptions. autopapertoppt/gui/pages/deck.py + gui/i18n.py Deck tab gains a "Dark mode" QCheckBox under the existing "Include abstract" toggle. New `deck.dark_mode_label` i18n key in all 14 supported languages. scripts/regen_speculative_decoding_zh_tw.py Now ships BOTH variants per paper — `<key>-zh-tw.pptx` (light) and `<key>-zh-tw-dark.pptx` (dark) — so the user can pick the right one for the venue's lighting. .claude/agents/deck-design.md New "Dark-mode palette" subsection with the full mapping table, the rationale per swap (#12151B not #000000 so OLED burn-in is gentler; warm-red accent unchanged because it's legible on both backgrounds), the exposure surfaces (CLI / GUI / programmatic / regen), and a note to update `_DARK_SLIDE_BG` + `test_pptx_dark_mode_swaps_palette` together when tuning the palette. tests/test_exporters.py New test_pptx_dark_mode_swaps_palette covers: slide bg = #12151B, at least one run swapped to #E5E7EB (near-white). Rendered the 4 speculative-decoding decks in both variants: xia2024unlocking-zh-tw / -dark 22 slides, overflow PASS spector2023accelerating-zh-tw / -dark 21 slides xu2024edgellm-zh-tw / -dark 27 slides, overflow PASS svirschevski2024specexec-zh-tw / -dark 21 slides 511/511 pytest pass; ruff clean.
1 parent 523fcd0 commit a64faeb

8 files changed

Lines changed: 307 additions & 17 deletions

File tree

.claude/agents/deck-design.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,47 @@ Do NOT introduce new brand colours casually — every additional colour
5656
fights for attention. Reuse the four above unless the user explicitly
5757
adds one.
5858

59+
#### Dark-mode palette (`ExportOptions.dark_mode=True`)
60+
61+
When dark mode is on, the exporter builds the deck with the light
62+
palette first then runs `_apply_dark_mode(prs)` as a post-build pass.
63+
The pass re-colours individual runs / shape fills / cell borders by
64+
looking up their current RGB in two mapping dicts. No builder needs
65+
to know about dark mode at construction time.
66+
67+
| Light → Dark | RGB swap | Why |
68+
|---|---|---|
69+
| Slide background | `#FFFFFF``#12151B` | Near-black so OLED screens save power + low-light rooms get less glare; not pure black to avoid the "burn-in" cliff |
70+
| `_BRAND_DARK` text | `#1F3A66``#E5E7EB` | Body text near-white |
71+
| `_BRAND_GREY` text | `#555555``#9CA3AF` | Metadata mid grey |
72+
| `_BRAND_LIGHT` text | `#AAAAAA``#6B7280` | Subtle dividers / page numbers |
73+
| `_BRAND_ACCENT` | `#C0392B` (unchanged) | Warm red is legible on both light and dark |
74+
| `_BRAND_DARK` fill (accent bars / table header) | `#1F3A66``#3B5AA0` | Lighter navy reads against the dark slide background |
75+
| `_TABLE_ROW_ALT` | `#F4F6F9``#1F232C` | Dark stripe |
76+
| Pure white table cell | `#FFFFFF``#161A22` | Near-black non-stripe rows |
77+
| `_TABLE_DIVIDER` | `#D0D7E2``#3D4452` | Muted grey-blue inter-row rule |
78+
79+
Two mapping dicts live in `pptx.py`: `_LIGHT_TO_DARK_TEXT` (for
80+
`<a:solidFill>/<a:srgbClr>` inside text runs) and `_LIGHT_TO_DARK_FILL`
81+
(for shape fills + table-cell fills + cell-border XML). The recoloring
82+
is intentionally non-invasive — we don't refactor the 100+ direct
83+
`_BRAND_*` references; instead we walk the rendered tree after the
84+
fact.
85+
86+
When tuning the dark palette, **adjust both mapping dicts** + the
87+
`_DARK_SLIDE_BG` constant; the existing test
88+
`test_pptx_dark_mode_swaps_palette` pins `#12151B` background +
89+
`#E5E7EB` text-on-slide, so update the test when the dark-bg or
90+
near-white colour changes.
91+
92+
Exposure surfaces:
93+
- CLI: `--dark-mode` flag
94+
- GUI: Deck tab `deck.dark_mode_label` checkbox
95+
- Programmatic: `ExportOptions(dark_mode=True)`
96+
- Regen script: pass `dark_mode=True` per variant — see
97+
`scripts/regen_speculative_decoding_zh_tw.py` which ships both light
98+
(`<key>-zh-tw.pptx`) and dark (`<key>-zh-tw-dark.pptx`) variants.
99+
59100
### Table styling (the second-biggest "AI-generated" tell after Calibri)
60101

61102
PowerPoint's default table style draws a heavy black grid on every cell.

autopapertoppt/cli.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,17 @@ def build_parser() -> argparse.ArgumentParser:
236236
"Default: claude-opus-4-7 (or AUTOPAPERTOPPT_LLM_MODEL)."
237237
),
238238
)
239+
parser.add_argument(
240+
"--dark-mode",
241+
action="store_true",
242+
help=(
243+
"Render the pptx with a dark slide background + light text. "
244+
"The post-build palette swap re-colours brand_dark text to "
245+
"near-white, table row stripes to dark variants, and slide "
246+
"backgrounds to #12151B. Use for projector / OLED-display / "
247+
"low-light presentation contexts."
248+
),
249+
)
239250
parser.add_argument(
240251
"--no-pdf",
241252
dest="download_pdf",
@@ -358,6 +369,7 @@ async def _run(args: argparse.Namespace) -> int:
358369
include_abstract=not args.no_abstract,
359370
language=args.lang,
360371
max_slides_per_paper=args.max_slides,
372+
dark_mode=args.dark_mode,
361373
)
362374
needs_pptx = EXPORT_PPTX in formats
363375
# ``--pdf`` already supplies the PDF — the paywall gate is irrelevant

autopapertoppt/core/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,10 @@ class ExportOptions:
391391
#: render the full deck regardless of size; ``None`` is treated
392392
#: identically to the default.
393393
max_slides_per_paper: int | None = 25
394+
#: When True, the pptx exporter applies a dark-mode palette
395+
#: post-build: dark slide background, light text, dark table-row
396+
#: stripe. Default off so existing renders are unchanged.
397+
dark_mode: bool = False
394398

395399
def __post_init__(self) -> None:
396400
if not self.formats:

autopapertoppt/exporters/pptx.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,34 @@
139139
# accent pass so a stock blank layout still reads as a designed deck).
140140
_ACCENT_TOP_HEIGHT = Inches(0.08)
141141
_ACCENT_LEFT_WIDTH = Inches(0.4)
142+
143+
# Dark-mode palette (post-build recolour, opt-in via
144+
# ``ExportOptions.dark_mode``).
145+
_DARK_SLIDE_BG = RGBColor(0x12, 0x15, 0x1B)
146+
147+
# Light-palette RGB → dark-palette RGB mapping for TEXT colours. Keys
148+
# are 3-tuples (R, G, B) since python-pptx's RGBColor is tuple-comparable
149+
# but we want to match by raw int components.
150+
_LIGHT_TO_DARK_TEXT: dict[tuple[int, int, int], tuple[int, int, int]] = {
151+
(0x1F, 0x3A, 0x66): (0xE5, 0xE7, 0xEB), # _BRAND_DARK → near-white text
152+
(0x55, 0x55, 0x55): (0x9C, 0xA3, 0xAF), # _BRAND_GREY → mid grey
153+
(0xAA, 0xAA, 0xAA): (0x6B, 0x72, 0x80), # _BRAND_LIGHT → muted grey
154+
# _BRAND_ACCENT (#C0392B) stays — warm red is legible on dark.
155+
}
156+
157+
# Light-palette RGB → dark-palette RGB mapping for SHAPE / CELL FILLS
158+
# and cell-border lines. Keeps the navy header on tables but lightens
159+
# its tone slightly so it reads against the dark slide background.
160+
_LIGHT_TO_DARK_FILL: dict[tuple[int, int, int], tuple[int, int, int]] = {
161+
# _BRAND_DARK accent bars + accent_left + table header fill
162+
(0x1F, 0x3A, 0x66): (0x3B, 0x5A, 0xA0),
163+
# _TABLE_ROW_ALT → dark row stripe
164+
(0xF4, 0xF6, 0xF9): (0x1F, 0x23, 0x2C),
165+
# Pure white table rows → near-black
166+
(0xFF, 0xFF, 0xFF): (0x16, 0x1A, 0x22),
167+
# _TABLE_DIVIDER → muted grey-blue rule
168+
(0xD0, 0xD7, 0xE2): (0x3D, 0x44, 0x52),
169+
}
142170
_BRAND_RULE = RGBColor(0xCC, 0xCC, 0xCC)
143171
_RQ_BOX_FILL = RGBColor(0xF3, 0xF6, 0xFA)
144172
_RQ_BOX_BORDER = RGBColor(0x1F, 0x3A, 0x66)
@@ -305,6 +333,8 @@ def _build(
305333
# ``deck-design`` subagent doc for rationale.
306334
_apply_typography(prs, ctx.language)
307335
_decorate_with_accents(prs)
336+
if options.dark_mode:
337+
_apply_dark_mode(prs)
308338
return prs
309339

310340

@@ -1827,3 +1857,129 @@ def _send_shape_to_back(shape, slide) -> None:
18271857
# everything after that is a shape in z-order. Insert at index 2 so the
18281858
# band lands BEHIND every text shape but the metadata stays intact.
18291859
sp_tree.insert(2, sp)
1860+
1861+
1862+
# ---------------------------------------------------------------------------
1863+
# Dark-mode pass (opt-in via ExportOptions.dark_mode)
1864+
# ---------------------------------------------------------------------------
1865+
1866+
1867+
def _apply_dark_mode(prs: Presentation) -> None:
1868+
"""Swap the light palette for the dark palette on every slide.
1869+
1870+
The exporter builds the deck with the light palette unconditionally,
1871+
then this post-pass re-colours individual shapes / runs / table cells
1872+
by looking up their current RGB in the light→dark mapping. The
1873+
approach is intentionally non-invasive — we don't refactor the 100+
1874+
direct ``_BRAND_*`` references into a palette-aware lookup; instead
1875+
we walk the rendered tree after the fact.
1876+
1877+
Steps per slide:
1878+
1. Solid-fill the slide background with ``_DARK_SLIDE_BG``.
1879+
2. Walk every shape:
1880+
- If it's a table, iterate the table's cells (fills + text + borders).
1881+
- Otherwise recolour the shape's own fill + text frame.
1882+
"""
1883+
for slide in prs.slides:
1884+
_set_slide_background(slide, _DARK_SLIDE_BG)
1885+
for shape in slide.shapes:
1886+
_recolor_shape(shape)
1887+
1888+
1889+
def _set_slide_background(slide, colour: RGBColor) -> None:
1890+
fill = slide.background.fill
1891+
fill.solid()
1892+
fill.fore_color.rgb = colour
1893+
1894+
1895+
def _recolor_shape(shape) -> None:
1896+
"""Single shape: swap its fill, text-run colours, and (if table) its
1897+
per-cell fills + borders + cell-level runs."""
1898+
if shape.has_table:
1899+
for cell in _iter_table_cells(shape.table):
1900+
_swap_fill(cell)
1901+
_swap_text_colors(cell)
1902+
_swap_cell_border_colors(cell)
1903+
return
1904+
_swap_fill(shape)
1905+
if shape.has_text_frame:
1906+
_swap_text_colors(shape)
1907+
1908+
1909+
def _iter_table_cells(table):
1910+
"""python-pptx exposes ``iter_cells`` on Table but the API name has
1911+
changed between versions; this wrapper falls through to the manual
1912+
row/col iteration when needed."""
1913+
iter_cells = getattr(table, "iter_cells", None)
1914+
if iter_cells is not None:
1915+
yield from iter_cells()
1916+
return
1917+
for row in table.rows:
1918+
yield from row.cells
1919+
1920+
1921+
def _swap_fill(shape_or_cell) -> None:
1922+
fill = getattr(shape_or_cell, "fill", None)
1923+
if fill is None:
1924+
return
1925+
try:
1926+
rgb = fill.fore_color.rgb
1927+
except (AttributeError, ValueError, TypeError):
1928+
return
1929+
if rgb is None:
1930+
return
1931+
key = (int(rgb[0]), int(rgb[1]), int(rgb[2]))
1932+
new = _LIGHT_TO_DARK_FILL.get(key)
1933+
if new is None:
1934+
return
1935+
fill.solid()
1936+
fill.fore_color.rgb = RGBColor(*new)
1937+
1938+
1939+
def _swap_text_colors(shape_or_cell) -> None:
1940+
text_frame = getattr(shape_or_cell, "text_frame", None)
1941+
if text_frame is None:
1942+
return
1943+
for paragraph in text_frame.paragraphs:
1944+
for run in paragraph.runs:
1945+
try:
1946+
rgb = run.font.color.rgb
1947+
except (AttributeError, ValueError, TypeError):
1948+
continue
1949+
if rgb is None:
1950+
continue
1951+
key = (int(rgb[0]), int(rgb[1]), int(rgb[2]))
1952+
new = _LIGHT_TO_DARK_TEXT.get(key)
1953+
if new is None:
1954+
continue
1955+
run.font.color.rgb = RGBColor(*new)
1956+
1957+
1958+
def _swap_cell_border_colors(cell) -> None:
1959+
"""Walk the cell's ``<a:lnX>`` border elements and recolour any
1960+
``<a:srgbClr>`` whose value matches the light-palette divider /
1961+
header-rule colours."""
1962+
tc_pr = cell._tc.find(qn("a:tcPr"))
1963+
if tc_pr is None:
1964+
return
1965+
for edge in ("L", "R", "T", "B"):
1966+
ln = tc_pr.find(qn(f"a:ln{edge}"))
1967+
if ln is None:
1968+
continue
1969+
solid = ln.find(qn("a:solidFill"))
1970+
if solid is None:
1971+
continue
1972+
clr = solid.find(qn("a:srgbClr"))
1973+
if clr is None:
1974+
continue
1975+
val = clr.get("val", "")
1976+
if len(val) != 6:
1977+
continue
1978+
try:
1979+
key = (int(val[0:2], 16), int(val[2:4], 16), int(val[4:6], 16))
1980+
except ValueError:
1981+
continue
1982+
new = _LIGHT_TO_DARK_FILL.get(key)
1983+
if new is None:
1984+
continue
1985+
clr.set("val", f"{new[0]:02X}{new[1]:02X}{new[2]:02X}")

autopapertoppt/gui/i18n.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1504,6 +1504,22 @@
15041504
"hi": "Abstract slides शामिल करें",
15051505
"id": "Sertakan slide abstrak",
15061506
},
1507+
"deck.dark_mode_label": {
1508+
"en": "Dark mode (dark background + light text)",
1509+
"zh-tw": "暗色模式(深色背景 + 淺色文字)",
1510+
"zh-cn": "暗色模式(深色背景 + 浅色文字)",
1511+
"ja": "ダークモード(暗背景 + 明テキスト)",
1512+
"es": "Modo oscuro (fondo oscuro + texto claro)",
1513+
"fr": "Mode sombre (fond sombre + texte clair)",
1514+
"de": "Dunkler Modus (dunkler Hintergrund + heller Text)",
1515+
"ko": "다크 모드 (어두운 배경 + 밝은 텍스트)",
1516+
"pt": "Modo escuro (fundo escuro + texto claro)",
1517+
"ru": "Тёмный режим (тёмный фон + светлый текст)",
1518+
"it": "Modalità scura (sfondo scuro + testo chiaro)",
1519+
"vi": "Chế độ tối (nền tối + chữ sáng)",
1520+
"hi": "Dark mode (गहरी पृष्ठभूमि + हल्का text)",
1521+
"id": "Mode gelap (latar gelap + teks terang)",
1522+
},
15071523
"deck.export_button": {
15081524
"en": "Export",
15091525
"zh-tw": "輸出",

autopapertoppt/gui/pages/deck.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,11 @@ def _build_ui(self) -> None:
137137
)
138138
self._include_abstract_check.setChecked(True)
139139
options_form.addRow(self._include_abstract_check)
140+
self._dark_mode_check = QCheckBox(
141+
t("deck.dark_mode_label", self._ui_language), self,
142+
)
143+
self._dark_mode_check.setChecked(False)
144+
options_form.addRow(self._dark_mode_check)
140145
outer.addWidget(options_box)
141146

142147
# Action row
@@ -257,6 +262,7 @@ def _on_export_clicked(self) -> None:
257262
include_abstract=self._include_abstract_check.isChecked(),
258263
language=language,
259264
max_slides_per_paper=self._max_slides_spin.value(),
265+
dark_mode=self._dark_mode_check.isChecked(),
260266
)
261267
collection = self._collection
262268
self._export_button.setEnabled(False)

scripts/regen_speculative_decoding_zh_tw.py

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,14 @@ def _fig(paper_key: str, filename: str) -> str:
960960
def main() -> None:
961961
out_dir = ROOT / "exports" / _RUN_DIR_NAME
962962
out_dir.mkdir(parents=True, exist_ok=True)
963+
# Two variants per paper: a light deck `<key>-zh-tw.pptx` and a
964+
# dark deck `<key>-zh-tw-dark.pptx`. Same content, palette swapped
965+
# via ExportOptions.dark_mode — useful when presenting on OLED
966+
# screens / in low-light rooms where the light deck would glare.
967+
variants: tuple[tuple[bool, str], ...] = (
968+
(False, ""),
969+
(True, "-dark"),
970+
)
963971
for paper in ALL_PAPERS:
964972
collection = PaperCollection(
965973
query=Query(
@@ -969,23 +977,26 @@ def main() -> None:
969977
),
970978
papers=(paper,),
971979
)
972-
options = ExportOptions(
973-
formats=("pptx",),
974-
out_dir=str(out_dir),
975-
# Language-variant filename is the explicit exception to the
976-
# canonical-stem rule, so the user can keep zh-tw and English
977-
# decks side-by-side without collision.
978-
filename_stem=f"{paper.bibtex_key()}-zh-tw",
979-
include_abstract=True,
980-
language="zh-tw",
981-
# Disable the 25-slides-per-paper cap so every curated
982-
# figure makes it into the deck even when the rich-tier
983-
# body content already consumes most of the budget.
984-
max_slides_per_paper=0,
985-
)
986-
written = export_collection(collection, options)
987-
for fmt, path in written.items():
988-
print(f" - {paper.bibtex_key()} {fmt}: {path}")
980+
for dark, suffix in variants:
981+
options = ExportOptions(
982+
formats=("pptx",),
983+
out_dir=str(out_dir),
984+
# Language-variant filename is the explicit exception to the
985+
# canonical-stem rule, so the user can keep zh-tw and English
986+
# decks side-by-side without collision. Same exception
987+
# applies to the `-dark` variant suffix.
988+
filename_stem=f"{paper.bibtex_key()}-zh-tw{suffix}",
989+
include_abstract=True,
990+
language="zh-tw",
991+
# Disable the 25-slides-per-paper cap so every curated
992+
# figure makes it into the deck even when the rich-tier
993+
# body content already consumes most of the budget.
994+
max_slides_per_paper=0,
995+
dark_mode=dark,
996+
)
997+
written = export_collection(collection, options)
998+
for fmt, path in written.items():
999+
print(f" - {paper.bibtex_key()}{suffix} {fmt}: {path}")
9891000

9901001

9911002
if __name__ == "__main__":

0 commit comments

Comments
 (0)