|
139 | 139 | # accent pass so a stock blank layout still reads as a designed deck). |
140 | 140 | _ACCENT_TOP_HEIGHT = Inches(0.08) |
141 | 141 | _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 | +} |
142 | 170 | _BRAND_RULE = RGBColor(0xCC, 0xCC, 0xCC) |
143 | 171 | _RQ_BOX_FILL = RGBColor(0xF3, 0xF6, 0xFA) |
144 | 172 | _RQ_BOX_BORDER = RGBColor(0x1F, 0x3A, 0x66) |
@@ -305,6 +333,8 @@ def _build( |
305 | 333 | # ``deck-design`` subagent doc for rationale. |
306 | 334 | _apply_typography(prs, ctx.language) |
307 | 335 | _decorate_with_accents(prs) |
| 336 | + if options.dark_mode: |
| 337 | + _apply_dark_mode(prs) |
308 | 338 | return prs |
309 | 339 |
|
310 | 340 |
|
@@ -1827,3 +1857,129 @@ def _send_shape_to_back(shape, slide) -> None: |
1827 | 1857 | # everything after that is a shape in z-order. Insert at index 2 so the |
1828 | 1858 | # band lands BEHIND every text shape but the metadata stays intact. |
1829 | 1859 | 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}") |
0 commit comments