Theme Management
Theme management in pptcraft centers on three baked templates (corporate, dark-tech, minimal) that are discovered via a helper module and built by rewriting the XML of a baseline python-pptx presentation. The build process injects specific color palettes and font schemes into the OpenXML theme parts, while also generating static taskpane icons for the Office add-in manifest.
Theme Discovery and Metadata
Section titled “Theme Discovery and Metadata”The ppt_craft.themes package exposes a discovery layer that identifies available templates and their build status. The KNOWN_THEMES constant defines the valid template names, and the list_themes() function iterates over them to report whether the .pptx file exists and its size, or indicates that it needs to be built 1. Each theme has a corresponding metadata file (.theme.json) that stores the palette, font names, and slide dimensions, allowing the engine to apply branding without re-parsing the binary .pptx structure 2.
Template Construction Strategy
Section titled “Template Construction Strategy”Themes are constructed by starting with a default Presentation() from python-pptx, which provides standard built-in layouts, and then rewriting the ppt/theme/theme1.xml content. The builder targets the <a:clrScheme> and <a:fontScheme> elements within the OpenXML structure to swap the color palette and font definitions 3. This approach avoids implementing a full slide-master authoring stack while ensuring the resulting files are valid PowerPoint templates 2.
The builder ensures that all theme parts (not just theme1.xml) are processed to handle multi-master decks correctly 3. It normalizes color slots to use <a:srgbClr> elements and updates the <a:latin> typeface attributes for both major (title) and minor (body) fonts. The output is saved as a .pptx file alongside a JSON metadata file containing the theme specifications, including the 16:9 widescreen dimensions (12,192,000 x 6,858,000 EMUs).
Icon Generation
Section titled “Icon Generation”The theme build process also generates PNG icons for the Office Add-in taskpane. The build_icons() function creates 16x16, 32x32, and 80x80 pixel images featuring a “C” glyph in a warm rust accent color (#C4763A) on a white background 4. These icons are saved to the static/taskpane directory and are idempotent, meaning existing files are preserved unless the force=True flag is passed. The icons are referenced by the OfficeApp manifest and served directly from the taskpane static assets.
"""Baked .pptx theme templates (corporate / dark-tech / minimal).
Real builder lives in `themes/build.py` and lands in P2. For now this
sub-package exposes a discovery helper so the CLI can list what's there.
"""
from __future__ import annotations
from pathlib import Path
THEMES_DIR = Path(__file__).parent
KNOWN_THEMES = ("corporate", "dark-tech", "minimal")
def theme_path(name: str) -> Path:
return THEMES_DIR / f"{name}.pptx"
def theme_meta_path(name: str) -> Path:
return THEMES_DIR / f"{name}.theme.json"
def list_themes() -> list[tuple[str, bool, str]]:
"""Return [(name, exists, info)]."""
out: list[tuple[str, bool, str]] = []
for name in KNOWN_THEMES:
p = theme_path(name)
if p.exists():
out.append((name, True, f"{p.stat().st_size // 1024}KB"))
else:
out.append((name, False, "(not built - run `sddc ppt themes build`)"))
return out
def build_one(name: str) -> tuple[Path, str]:
"""Build a single theme. Wraps `build.py` so the CLI can stay lazy."""
from ppt_craft.themes.build import build_one as _build_one
return _build_one(name)
"""Build the three baked theme `.pptx` templates.
Strategy: start from python-pptx's default `Presentation()` (gives the
standard built-in layouts), then rewrite `ppt/theme/theme1.xml`'s
`<a:clrScheme>` and `<a:fontScheme>` to swap palette + fonts. This is
the simplest path to "branded base templates" without implementing a
full slide-master authoring stack.
Per-theme metadata lands in `<name>.theme.json` so the engine knows the
palette + font names without re-parsing the .pptx.
Theme builder also writes the taskpane icons (one accent per theme is
overkill for the icon - ship one shared icon). 16:9 widescreen is the
default per Codex iteration 3 advisory (never assume 4:3).
LayoutName → built-in layout index mapping (default Office layouts):
title 0 Title Slide
title_content 1 Title and Content
divider 2 Section Header
two_column 3 Two Content
chart 5 Title Only (chart drawn programmatically below)
table 5 Title Only (table drawn programmatically below)
image_text 8 Picture with Caption
closing 5 Title Only
"""
from __future__ import annotations
import io
import json
import zipfile
from dataclasses import dataclass
from pathlib import Path
from lxml import etree
from pptx import Presentation
from pptx.util import Emu
from ppt_craft.themes import KNOWN_THEMES, theme_meta_path, theme_path
from ppt_craft.themes.icons import build_icons
# Layout name → built-in layout index in the default Office master.
LAYOUT_INDEX: dict[str, int] = {
"title": 0,
"title_content": 1,
"divider": 2,
"two_column": 3,
"chart": 5,
"table": 5,
"image_text": 8,
"closing": 5,
}
# OOXML namespaces (see ECMA-376 §11)
NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
}
A = lambda tag: f"{{{NS['a']}}}{tag}" # noqa: E731
P = lambda tag: f"{{{NS['p']}}}{tag}" # noqa: E731
def _rewrite_theme_palette(theme_xml_bytes: bytes, palette: dict[str, str], title_font: str, body_font: str) -> bytes:
"""Rewrite <a:clrScheme> + <a:fontScheme> in a theme part."""
root = etree.fromstring(theme_xml_bytes)
elems = root.find(f".//{A('themeElements')}")
if elems is None:
raise RuntimeError("themeElements missing - not a valid theme1.xml")
clr_scheme = elems.find(A("clrScheme"))
if clr_scheme is None:
raise RuntimeError("clrScheme missing")
for slot, hex_rgb in palette.items():
slot_elem = clr_scheme.find(A(slot))
if slot_elem is None:
continue
# Slot may hold <a:srgbClr> or <a:sysClr>; normalise to srgbClr.
for child in list(slot_elem):
slot_elem.remove(child)
srgb = etree.SubElement(slot_elem, A("srgbClr"))
srgb.set("val", hex_rgb.upper())
"""Generate the 16/32/80 PNG icons referenced by the OfficeApp manifest.
Simple "C" glyph in the chosen accent colour, baked at install time.
Idempotent: existing files at the right size are left alone unless
`force=True`. Lives under the taskpane static dir so the same files
serve directly from `https://host:port/icon-NN.png`.
"""
from __future__ import annotations
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
TASKPANE_STATIC = Path(__file__).parent.parent / "static" / "taskpane"
SIZES = (16, 32, 80)
ACCENT = "#C4763A" # warm rust - taskpane chrome accent
FG = "#FFFFFF"
def _draw_icon(size: int, accent: str = ACCENT, fg: str = FG) -> Image.Image:
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# Filled rounded square
radius = max(2, size // 6)
draw.rounded_rectangle((0, 0, size - 1, size - 1), radius=radius, fill=accent)
# "C" glyph - let Pillow fall back to the default bitmap font; sizing
# via font.size differs across Pillow versions, so we render and centre.
try:
font = ImageFont.truetype("DejaVuSans-Bold.ttf", int(size * 0.7))
except OSError:
font = ImageFont.load_default()
text = "C"
bbox = draw.textbbox((0, 0), text, font=font)
tw = bbox[2] - bbox[0]
th = bbox[3] - bbox[1]
draw.text(((size - tw) / 2 - bbox[0], (size - th) / 2 - bbox[1]), text, fill=fg, font=font)
return img