Eleven skills for conference speakers covering rhetoric analysis, talk authoring, deck building and export, CFP submissions, publishing, illustrations, shownotes, and screencast production, informed by a 113-entry Presentation Patterns taxonomy (83 observable: 64 patterns + 19 antipatterns; 30 unobservable: 21 patterns + 9 antipatterns).
90%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Low
Low-risk findings worth noting
#!/usr/bin/env python3
"""Apply generated illustrations to a deck.
Reads outline.yaml (the single source of truth — schema + loader in
skills/presentation-creator/scripts/outline_schema.py). Two slide formats are
supported, distinguished by the per-slide fields:
FULL — an illustrated slide (one with an `image_prompt`, which generation
draws) has a `safe_zone:` field (zone of upper_third / middle_third /
lower_third / left_half / right_half):
1. Record the slide + illustration in the backgrounds manifest (--backgrounds-out),
keyed by its deck slide number after ExpandBuilds (a build parent has none).
The illustration becomes the slide BACKGROUND FILL via a later ApplyBackgrounds
VBA pass (apply-backgrounds.sh) — NOT a python-pptx picture shape, which would
sit above the layout's halftone-dot overlay and which a python-pptx round-trip
would drop. See rules/deck-editing-rules.md.
2. Add a zone-sized semi-transparent scrim above the (later) background and below
the text (if not already present).
3. Reposition title text boxes into the designed safe zone:
upper_third/middle_third/lower_third -> full-width band at the matching Y;
left_half/right_half -> narrower column on that side.
IMG+TXT — an illustrated slide has `format: IMG+TXT` and no `safe_zone`:
1. Replace the background picture with the matching illustration.
2. Resize and position the picture as a left-column image (~60% of slide).
3. Reposition title and body placeholders into the right column.
FULL-POSTER — style_anchor sets `composition: poster-theatrical`; every FULL
slide is background-only. The title is baked into the image by
`generate-illustrations.py`, so there is no scrim and no overlaid title — just
the background manifest entry. QR (inserted later) is the only added shape.
With `style_anchor.footer_style` set, the footer is stamped here instead of
rendered by the model: each slide image is stamped by `footer_stamp.py` into
a PNG under `ILLUSTRATIONS_DIR/footer-stamped/`, and that copy becomes the
background, so the generated image stays footer-free for edits and builds.
`build-expansion-manifest.py` stamps the same slides' build frames.
The outline is the single source of truth for slide format and zone
assignments — the same fields that `generate-illustrations.py` reads.
See `rules/title-overlay-rules.md` for the title-overlay policy and
`skills/illustrations/references/generation.md` for the format vocabulary.
Usage:
apply-illustrations-to-deck.py DECK ILLUSTRATIONS_DIR OUTLINE_YAML \\
[--out OUT_DECK] [--image-ext jpg|jpeg|png] \\
[--scrim-color RRGGBB] [--scrim-alpha 0-100000]
Stdout is one JSON object: ``ok``, the written ``deck`` (null when the outline
has nothing to apply), ``updated`` and ``eligible`` slide counts, the per-slide
``results`` (each with its outline ``slide`` and post-ExpandBuilds
``deck_slide``, null for a build parent), and the ``backgrounds_manifest`` path
(null when no FULL slide needs a background). The manifest's keys are
post-ExpandBuilds deck slide numbers, so run the backgrounds pass after
``expand-builds.sh``. Deck access and keys count outline slides in order
(``outline_schema.deck_positions``), so outline numbering may start at 0. With ``footer_style``, every stamped slide image and every
declared build frame must share one size, checked before anything is written. Progress lines go to stderr. A refusal exits non-zero
with its message on stderr and names the fix: an eligible slide whose image is
missing, is not a file or does not decode as an image (``footer_stamp.unusable_image``),
or that lies past the deck's end (nothing is written), an unreadable
source deck, an unwritable output deck or backgrounds manifest, or a slide image
that cannot be read or stamped.
"""
import argparse
import json
import sys
from pathlib import Path
from lxml import etree
from pptx import Presentation
from pptx.exc import PackageNotFoundError
from pptx.enum.shapes import MSO_SHAPE, MSO_SHAPE_TYPE
from pptx.oxml.ns import qn
from pptx.util import Inches
# A sibling module: this script's own directory is already importable.
from footer_stamp import (
FooterStampError,
deck_stamp_set,
require_one_geometry,
stamp_footer_file,
stamp_spec,
stamped_slide,
unusable_image,
)
# outline.yaml is the single source of truth; its schema + loader live with the
# presentation-creator scripts.
sys.path.insert(
0,
str(
(
Path(__file__).resolve().parent.parent.parent
/ "presentation-creator"
/ "scripts"
)
),
)
import outline_schema # noqa: E402 (path appended above)
# 16:9 slide geometry (inches)
SLIDE_W_IN = 13.333
SLIDE_H_IN = 7.5
# Layout per zone: top/left of the title column, and column width.
# Horizontal bands use the full content width centered horizontally.
# Half-frame zones use a narrower column on the chosen side, with the
# title vertically centered.
TEXT_W_FULL_IN = 10.0
TEXT_W_HALF_IN = 5.5
HALF_MARGIN_IN = 0.4
_BAND_H_IN = 1.9 # horizontal-band title height (title + subtitle)
_HALF_H_IN = 4.5 # half-frame title column height
_HALF_TOP_IN = (SLIDE_H_IN - _HALF_H_IN) / 2
ZONE_LAYOUT = {
"upper_third": {
"top_in": 0.4,
"left_in": (SLIDE_W_IN - TEXT_W_FULL_IN) / 2,
"width_in": TEXT_W_FULL_IN,
"height_in": _BAND_H_IN,
},
"middle_third": {
"top_in": (SLIDE_H_IN - _BAND_H_IN) / 2,
"left_in": (SLIDE_W_IN - TEXT_W_FULL_IN) / 2,
"width_in": TEXT_W_FULL_IN,
"height_in": _BAND_H_IN,
},
"lower_third": {
"top_in": SLIDE_H_IN - _BAND_H_IN - 0.4,
"left_in": (SLIDE_W_IN - TEXT_W_FULL_IN) / 2,
"width_in": TEXT_W_FULL_IN,
"height_in": _BAND_H_IN,
},
"left_half": {
"top_in": _HALF_TOP_IN,
"left_in": HALF_MARGIN_IN,
"width_in": TEXT_W_HALF_IN,
"height_in": _HALF_H_IN,
},
"right_half": {
"top_in": _HALF_TOP_IN,
"left_in": SLIDE_W_IN - HALF_MARGIN_IN - TEXT_W_HALF_IN,
"width_in": TEXT_W_HALF_IN,
"height_in": _HALF_H_IN,
},
}
SUBTITLE_OFFSET_IN = 1.2
# IMG+TXT layout — image ~60% of slide on the left, text column on the right.
# Numbers chosen to leave a 0.3" outer margin, 0.2" gutter, and 0.6" footer band.
IMGTXT_IMG_LEFT_IN = 0.3
IMGTXT_IMG_TOP_IN = 0.8
IMGTXT_IMG_WIDTH_IN = 8.0 # ~60% of 13.333" slide width
IMGTXT_IMG_HEIGHT_IN = 5.9 # leaves a 0.8" footer band below
IMGTXT_TEXT_LEFT_IN = IMGTXT_IMG_LEFT_IN + IMGTXT_IMG_WIDTH_IN + 0.2 # 8.5
IMGTXT_TEXT_WIDTH_IN = SLIDE_W_IN - IMGTXT_TEXT_LEFT_IN - 0.3 # 4.533
IMGTXT_TITLE_TOP_IN = IMGTXT_IMG_TOP_IN
IMGTXT_TITLE_HEIGHT_IN = 1.9
IMGTXT_BODY_TOP_IN = IMGTXT_TITLE_TOP_IN + IMGTXT_TITLE_HEIGHT_IN + 0.1 # 2.8
IMGTXT_BODY_HEIGHT_IN = (
IMGTXT_IMG_TOP_IN + IMGTXT_IMG_HEIGHT_IN - IMGTXT_BODY_TOP_IN
) # aligns body bottom with image bottom — 3.9
# Default scrim: 45% black. Decks with a strong tonal style (warm sepia,
# cool night, etc.) should pass a sampled color via --scrim-color.
# See suggest-scrim-color.py (same directory) and rules/title-overlay-rules.md §5.
DEFAULT_SCRIM_HEX = "000000"
DEFAULT_SCRIM_ALPHA = 45000
SCRIM_SHAPE_NAME = "_title_scrim"
# Poster-theatrical composition (rules/title-overlay-rules.md): title + footer
# are baked into the image, so FULL slides get a background only — no scrim, no
# overlaid title. QR is the only thing inserted afterward.
POSTER_COMPOSITION = "poster-theatrical"
def parse_composition(outline_path: Path) -> str | None:
"""Deck-level composition from style_anchor; None means standard overlay."""
anchor = outline_schema.load_outline_partial(outline_path).style_anchor
if anchor and anchor.composition is not None:
return anchor.composition.value
return None
def _progress(message: str) -> None:
"""Human progress goes to stderr; stdout carries only the JSON summary."""
print(message, file=sys.stderr)
def parse_footer_stamp(outline_path: Path) -> tuple[str, dict] | None:
"""The footer text and style to stamp, or None when the model renders it."""
return stamp_spec(outline_schema.load_outline_partial(outline_path).style_anchor)
def parse_zones(outline_path: Path) -> dict:
"""Read `safe_zone` from the outline's illustrated slides.
Returns {slide_num: zone_name} where zone_name is a key of ZONE_LAYOUT.
Slides without a safe_zone are absent from the dict, and so is a slide
without an ``image_prompt``: generation skips it and the slide walk's
inserted asset stays, as for IMG+TXT.
"""
return {
s.n: s.safe_zone.zone.value
for s in outline_schema.load_outline_partial(outline_path).slides
if s.safe_zone and s.image_prompt
}
def parse_img_txt_slides(outline_path: Path) -> set:
"""Illustrated IMG+TXT slide numbers: an ``image_prompt`` and no safe_zone.
Safe zone takes precedence (it implies FULL), so a slide carrying both is
handled by the zones path instead. A slide without an ``image_prompt`` gets
no generated image (generation skips it) and keeps the asset the slide walk
inserted, so it is never eligible here.
"""
return {
s.n
for s in outline_schema.load_outline_partial(outline_path).slides
if s.format.value == "IMG+TXT" and not s.safe_zone and s.image_prompt
}
def parse_full_slides(outline_path: Path) -> set:
"""Slide numbers whose format is FULL with no safe_zone.
In poster-theatrical mode these are full-bleed backgrounds with the title +
footer baked into the image — no scrim, no overlaid title. Safe-zone FULL
slides are handled by the zones path instead (safe zone takes precedence).
"""
return {
s.n
for s in outline_schema.load_outline_partial(outline_path).slides
if stamped_slide(s)
}
def replace_picture_blob(picture_shape, new_image_path: Path) -> None:
"""Swap the picture's embedded image to the one at new_image_path.
Uses rel re-pointing rather than mutating ``image_part._blob`` in
place. A template may seed every slide's picture from a single
placeholder file; python-pptx dedupes identical source paths into
one image part shared across slides. Mutating that shared blob
would clobber every other slide referencing it — last swap wins
and every slide ends up with the same final image.
"""
slide_part = picture_shape.part
_, new_rId = slide_part.get_or_add_image_part(str(new_image_path))
picture_shape._element.blipFill.blip.set(qn("r:embed"), new_rId)
def swap_or_insert_picture(slide, illust_path: Path):
"""Return the slide's largest picture shape, swapping its image to illust_path.
If the slide has no picture shape (presentation-creator's slide walk leaves
illustrated slides without an image — the illustrations skill applies them
post-walk), insert a full-bleed picture and return it. The caller may then
reposition/resize as needed for the format.
"""
pictures = [s for s in slide.shapes if s.shape_type == MSO_SHAPE_TYPE.PICTURE]
if pictures:
bg = max(pictures, key=lambda s: (s.width or 0) * (s.height or 0))
replace_picture_blob(bg, illust_path)
return bg
return slide.shapes.add_picture(
str(illust_path),
left=Inches(0),
top=Inches(0),
width=Inches(SLIDE_W_IN),
height=Inches(SLIDE_H_IN),
)
def ensure_scrim(slide, zone: str, scrim_hex: str, scrim_alpha: int) -> int:
"""Add a zone-sized semi-transparent rectangle between picture and text.
Zone-scoped (not full-slide) — a full-slide scrim flattens the whole
illustration; scoping to the title box keeps the rest at full brightness.
OOXML spPr child order must be xfrm -> prstGeom -> solidFill -> ln.
"""
# Skip if a scrim was already added (identified by name)
if any(s.name == SCRIM_SHAPE_NAME for s in slide.shapes):
return 0
layout = ZONE_LAYOUT[zone]
shape = slide.shapes.add_shape(
MSO_SHAPE.RECTANGLE,
left=Inches(layout["left_in"]),
top=Inches(layout["top_in"]),
width=Inches(layout["width_in"]),
height=Inches(layout["height_in"]),
)
shape.name = SCRIM_SHAPE_NAME
sp = shape._element
style = sp.find(qn("p:style"))
if style is not None:
sp.remove(style)
spPr = sp.find(qn("p:spPr"))
keep = {qn("a:xfrm"), qn("a:prstGeom"), qn("a:custGeom")}
for child in list(spPr):
if child.tag not in keep:
spPr.remove(child)
solid = etree.SubElement(spPr, qn("a:solidFill"))
clr = etree.SubElement(solid, qn("a:srgbClr"))
clr.set("val", scrim_hex.upper())
alpha = etree.SubElement(clr, qn("a:alpha"))
alpha.set("val", str(scrim_alpha))
ln = etree.SubElement(spPr, qn("a:ln"))
etree.SubElement(ln, qn("a:noFill"))
# Insert scrim just before the first shape with a text frame
spTree = slide.shapes._spTree
spTree.remove(sp)
first_text_idx = None
last_pic_idx = None
for i, child in enumerate(spTree):
# Track last picture for fallback insertion point
if child.tag == qn("p:pic"):
last_pic_idx = i
# Detect any shape with text: textboxes (txBox="1") and placeholders with txBody
nvSpPr = child.find(qn("p:nvSpPr"))
if nvSpPr is not None:
cNvSpPr = nvSpPr.find(qn("p:cNvSpPr"))
has_txbox = cNvSpPr is not None and cNvSpPr.get("txBox") == "1"
has_placeholder = (
nvSpPr.find(qn("p:nvPr")) is not None
and nvSpPr.find(qn("p:nvPr")).find(qn("p:ph")) is not None
)
has_txbody = child.find(qn("p:txBody")) is not None
if has_txbox or (has_placeholder and has_txbody):
first_text_idx = i
break
if first_text_idx is not None:
spTree.insert(first_text_idx, sp)
elif last_pic_idx is not None:
spTree.insert(last_pic_idx + 1, sp)
else:
spTree.append(sp)
return 1
def reposition_title(slide, zone: str) -> int:
"""Reposition title and subtitle shapes into the designed zone.
Only moves title/subtitle placeholders and text boxes — leaves body
text, callouts, and other content shapes in their original positions.
"""
# Prefer the slide's explicit title/subtitle placeholders
title_shapes = []
if slide.shapes.title is not None:
title_shapes.append(slide.shapes.title)
for s in slide.placeholders:
if s.placeholder_format.idx == 1 and s not in title_shapes: # subtitle
title_shapes.append(s)
# Fall back to text boxes if no placeholders found
if not title_shapes:
title_shapes = [
s
for s in slide.shapes
if s.has_text_frame
and s.name != SCRIM_SHAPE_NAME
and s.shape_type == MSO_SHAPE_TYPE.TEXT_BOX
]
title_shapes.sort(key=lambda s: s.top)
if not title_shapes:
return 0
layout = ZONE_LAYOUT[zone]
title_top = Inches(layout["top_in"])
text_left = Inches(layout["left_in"])
text_width = Inches(layout["width_in"])
for j, shape in enumerate(title_shapes):
shape.left = int(text_left)
shape.width = int(text_width)
shape.top = int(title_top + Inches(SUBTITLE_OFFSET_IN * j))
return len(title_shapes)
def apply_img_txt_layout(slide) -> tuple[int, int]:
"""Position picture, title, and body for an IMG+TXT slide.
Image goes in the left column (~60% of slide); title and body placeholders
move to the right column. Returns (picture_repositioned, text_repositioned).
"""
pic_moved = 0
pictures = [s for s in slide.shapes if s.shape_type == MSO_SHAPE_TYPE.PICTURE]
if pictures:
bg = max(pictures, key=lambda s: (s.width or 0) * (s.height or 0))
bg.left = Inches(IMGTXT_IMG_LEFT_IN)
bg.top = Inches(IMGTXT_IMG_TOP_IN)
bg.width = Inches(IMGTXT_IMG_WIDTH_IN)
bg.height = Inches(IMGTXT_IMG_HEIGHT_IN)
pic_moved = 1
text_moved = 0
title = slide.shapes.title
if title is not None:
title.left = Inches(IMGTXT_TEXT_LEFT_IN)
title.top = Inches(IMGTXT_TITLE_TOP_IN)
title.width = Inches(IMGTXT_TEXT_WIDTH_IN)
title.height = Inches(IMGTXT_TITLE_HEIGHT_IN)
text_moved += 1
# Reposition any non-title placeholder (body/content) into the right column.
# Subtitle (idx 1) shares the body's column-and-stack treatment.
body_shapes = [
s
for s in slide.placeholders
if s.has_text_frame
and s is not title
and s.placeholder_format.idx != 0 # idx 0 is the title; already handled
]
body_shapes.sort(key=lambda s: s.top or 0)
for j, shape in enumerate(body_shapes):
shape.left = Inches(IMGTXT_TEXT_LEFT_IN)
shape.top = Inches(IMGTXT_BODY_TOP_IN + j * 0.1) # tiny stagger if multiple
shape.width = Inches(IMGTXT_TEXT_WIDTH_IN)
# Single body fills the column; multiple bodies share the height.
per_shape_height = IMGTXT_BODY_HEIGHT_IN / max(len(body_shapes), 1)
shape.height = Inches(per_shape_height)
text_moved += 1
return pic_moved, text_moved
def deck_slide_number(
n: int,
build_counts: dict[int, int],
positions: dict[int, int] | None = None,
) -> int | None:
"""Outline slide ``n``'s 1-based deck number after ExpandBuilds, or None.
``positions`` maps outline numbers to built-deck positions
(``outline_schema.deck_positions``); without it ``n`` is taken as the
position. ExpandBuilds runs before the backgrounds pass and replaces each
build parent with its frames, so every later slide moves by that parent's
frame count minus one. A parent itself has no slide left to key: its frames
carry their own backgrounds from the build manifest.
"""
if n in build_counts:
return None
place = positions or {}
here = place.get(n, n)
return here + sum(
count - 1
for parent, count in build_counts.items()
if place.get(parent, parent) < here
)
def parse_build_counts(outline_path: Path) -> dict[int, int]:
"""Frame count per build parent, from the outline's per-slide ``builds``."""
return {
s.n: len(s.builds)
for s in outline_schema.load_outline_partial(outline_path).slides
if s.builds
}
class ApplyError(RuntimeError):
"""A refusal whose message names the operation that failed and its fix."""
def _why(exc: BaseException) -> str:
return f"{type(exc).__name__}: {getattr(exc, 'strerror', None) or exc}"
def _position(place: dict[int, int], n: int) -> int:
"""Outline slide ``n``'s 1-based built-deck position; ``n`` itself if unmapped."""
return place.get(n, n)
def _unapplied(
illust_dir: Path,
ext: str,
slides: set[int],
slide_count: int,
positions: dict[int, int],
) -> list[str]:
"""Each eligible slide that cannot be applied, and why."""
problems = []
for n in sorted(slides):
illust = illust_dir / f"slide-{n:02d}.{ext}"
if _position(positions, n) > slide_count:
problems.append(f"slide {n} is past the deck's {slide_count} slides")
elif not illust.exists():
problems.append(f"slide {n} has no {illust.name}")
elif (why := unusable_image(illust)) is not None:
problems.append(f"slide {n}: {why}")
return problems
def apply(
deck: Path,
illust_dir: Path,
zones: dict,
img_txt_slides: set,
out_deck: Path,
ext: str,
scrim_hex: str,
scrim_alpha: int,
poster_full_slides: set | None = None,
footer_stamp: tuple[str, dict] | None = None,
build_counts: dict[int, int] | None = None,
positions: dict[int, int] | None = None,
) -> tuple[list[dict], dict]:
"""Apply scrim + title for FULL slides and the IMG+TXT layout.
FULL-slide illustrations are NOT inserted as picture shapes here; they are
recorded in the returned ``backgrounds`` map ({slide_num: abs_image_path})
for the ApplyBackgrounds VBA pass, which sets them as slide background fills
as the final write. Its keys are post-ExpandBuilds deck slide numbers
(``deck_slide_number``), and a build parent has no entry. Returns
``(results, backgrounds)``.
"""
build_counts = build_counts or {}
# Outline number -> built-deck position; identity when not given.
place: dict[int, int] = positions or {}
poster_full_slides = poster_full_slides or set()
try:
prs = Presentation(str(deck))
except (OSError, PackageNotFoundError) as exc:
raise ApplyError(
f"cannot read the source deck {deck} ({_why(exc)}); pass an existing "
".pptx, then re-run"
) from exc
# Every eligible slide, or nothing: a partial deck would ship with the
# skipped slides unillustrated.
problems = _unapplied(
illust_dir,
ext,
set(zones) | set(img_txt_slides) | poster_full_slides,
len(prs.slides),
place,
)
if problems:
raise ApplyError(
"not every eligible slide can be applied, so no deck was written: "
f"{'; '.join(problems)} — generate the missing slide images, or fix "
"the outline's slide numbers against the deck, then re-run"
)
results = []
backgrounds: dict[int, str] = {}
# FULL slides with Safe zone — scrim + title here; background via VBA pass
for n, zone in sorted(zones.items()):
illust = illust_dir / f"slide-{n:02d}.{ext}"
slide = prs.slides[_position(place, n) - 1]
deck_n = deck_slide_number(n, build_counts, place)
if deck_n is not None:
backgrounds[deck_n] = str(illust.resolve())
scrim_added = ensure_scrim(slide, zone, scrim_hex, scrim_alpha)
moved = reposition_title(slide, zone)
_progress(
f" [{n:02d}] zone={zone} moved={moved} text scrim+{scrim_added} bg->VBA"
)
results.append(
{
"slide": n,
"deck_slide": deck_n,
"format": "FULL",
"zone": zone,
"text_moved": moved,
"scrim_added": scrim_added,
}
)
# IMG+TXT slides — image-left + text-right layout
for n in sorted(img_txt_slides):
illust = illust_dir / f"slide-{n:02d}.{ext}"
slide = prs.slides[_position(place, n) - 1]
try:
swap_or_insert_picture(slide, illust)
except OSError as exc:
raise ApplyError(
f"cannot read the slide image {illust} ({_why(exc)}); regenerate "
"or replace it, then re-run"
) from exc
pic_moved, text_moved = apply_img_txt_layout(slide)
_progress(f" [{n:02d}] format=IMG+TXT pic+{pic_moved} text+{text_moved}")
results.append(
{
"slide": n,
"deck_slide": deck_slide_number(n, build_counts, place),
"format": "IMG+TXT",
"picture_repositioned": pic_moved,
"text_moved": text_moved,
}
)
# Poster-theatrical FULL slides — background only. Title + footer are baked
# into the image, so no scrim and no overlaid title; QR (added later) is the
# only inserted shape.
for n in sorted(poster_full_slides):
illust = illust_dir / f"slide-{n:02d}.{ext}"
deck_n = deck_slide_number(n, build_counts, place)
if deck_n is None:
# A build parent: its frames, stamped by the build manifest,
# replace it and carry their own backgrounds.
_progress(f" [{n:02d}] poster FULL build parent (frames carry it)")
elif footer_stamp is None:
backgrounds[deck_n] = str(illust.resolve())
_progress(
f" [{n:02d}] poster FULL bg->VBA (title+footer baked into image)"
)
else:
text, style = footer_stamp
stamped_dir = illust_dir / "footer-stamped"
try:
stamped = stamp_footer_file(illust, stamped_dir, text, style)
except OSError as exc:
raise ApplyError(
f"cannot stamp {illust.name} into {stamped_dir} ({_why(exc)}); "
"regenerate or replace the image if it is unreadable, or make "
"the directory writable, then re-run"
) from exc
backgrounds[deck_n] = str(stamped.resolve())
_progress(
f" [{n:02d}] poster FULL bg->VBA (title baked, footer stamped)"
)
results.append(
{
"slide": n,
"deck_slide": deck_n,
"format": "FULL-POSTER",
"zone": None,
"footer_stamped": footer_stamp is not None and deck_n is not None,
}
)
try:
prs.save(str(out_deck))
except OSError as exc:
raise ApplyError(
f"cannot write the output deck {out_deck} ({_why(exc)}); make its "
"directory writable or pass --out elsewhere, then re-run"
) from exc
return results, backgrounds
def _summary(
deck: Path | None,
results: list[dict],
eligible: int,
backgrounds_manifest: Path | None,
) -> dict:
"""The one JSON object main prints on stdout."""
return {
"ok": True,
"deck": str(deck) if deck is not None else None,
"updated": len(results),
"eligible": eligible,
"results": results,
"backgrounds_manifest": (
str(backgrounds_manifest) if backgrounds_manifest is not None else None
),
}
def main():
ap = argparse.ArgumentParser(description=(__doc__ or "").split("\n")[0])
ap.add_argument("deck", type=Path, help="Path to source .pptx")
ap.add_argument(
"illustrations", type=Path, help="Directory with slide-NN.<ext> files"
)
ap.add_argument(
"outline", type=Path, help="Path to outline.yaml (the single source of truth)"
)
ap.add_argument(
"--out",
type=Path,
default=None,
help="Output deck (default: <stem>-with-titles.pptx)",
)
ap.add_argument("--image-ext", default="jpg", choices=["jpg", "jpeg", "png"])
ap.add_argument(
"--scrim-color",
default=DEFAULT_SCRIM_HEX,
help="Scrim color as 6-digit hex (default: %(default)s). "
"Run suggest-scrim-color.py to sample one from the deck's illustrations.",
)
ap.add_argument(
"--scrim-alpha",
type=int,
default=DEFAULT_SCRIM_ALPHA,
help="Scrim opacity in OOXML thousandths (0-100000, default: %(default)s = 45%%).",
)
ap.add_argument(
"--backgrounds-out",
type=Path,
default=None,
help="Where to write the FULL-slide backgrounds manifest JSON "
"(default: <out_stem>.backgrounds.json). Feed it to "
"apply-backgrounds.sh to set the backgrounds via PowerPoint.",
)
args = ap.parse_args()
if not args.outline.is_file():
raise SystemExit(f"ERROR: outline not found: {args.outline}")
try:
outline_schema.require_current_outline(args.outline)
except outline_schema.OutlineVersionError as exc:
raise SystemExit(f"ERROR: {exc}") from exc
out_deck = args.out or args.deck.with_name(args.deck.stem + "-with-titles.pptx")
backgrounds_out = args.backgrounds_out or out_deck.with_name(
out_deck.stem + ".backgrounds.json"
)
poster = parse_composition(args.outline) == POSTER_COMPOSITION
zones = parse_zones(args.outline)
img_txt_slides = parse_img_txt_slides(args.outline)
# Poster-theatrical decks are all background-only FULL slides — no Safe zones,
# no IMG+TXT. Fail fast on a mix rather than apply contradictory layouts.
if poster and (zones or img_txt_slides):
offenders = sorted(set(zones) | img_txt_slides)
raise SystemExit(
"poster-theatrical composition does not allow `safe_zone` or "
f"`format: IMG+TXT` slides. Offending slide(s): "
f"{', '.join(map(str, offenders))}. Fix the outline or drop "
"`composition: poster-theatrical` from style_anchor."
)
# In poster-theatrical mode, FULL slides get a background only (text baked in).
poster_full_slides = parse_full_slides(args.outline) if poster else set()
total_slides = len(zones) + len(img_txt_slides) + len(poster_full_slides)
if total_slides == 0:
_progress(
f"No `safe_zone`, `format: IMG+TXT`, or poster-theatrical FULL "
f"slides found in {args.outline.name}. Nothing to do."
)
print(json.dumps(_summary(None, [], 0, None)))
return
scrim_hex = args.scrim_color.lstrip("#").upper()
if len(scrim_hex) != 6 or any(c not in "0123456789ABCDEF" for c in scrim_hex):
raise SystemExit(
f"--scrim-color must be a 6-digit hex, got {args.scrim_color!r}"
)
if not (0 <= args.scrim_alpha <= 100000):
raise SystemExit(f"--scrim-alpha must be 0..100000, got {args.scrim_alpha}")
footer = parse_footer_stamp(args.outline) if poster else None
try:
if footer is not None:
# One deck-wide set, the same one the build manifest checks, so a
# mixed deck is refused before either command stamps anything.
require_one_geometry(
deck_stamp_set(
outline_schema.load_outline_partial(args.outline).slides,
args.illustrations,
args.illustrations / "builds",
args.image_ext,
)
)
results, backgrounds = apply(
args.deck,
args.illustrations,
zones,
img_txt_slides,
out_deck,
args.image_ext,
scrim_hex,
args.scrim_alpha,
poster_full_slides,
footer,
parse_build_counts(args.outline),
outline_schema.deck_positions(
outline_schema.load_outline_partial(args.outline).slides
),
)
except (ApplyError, FooterStampError) as exc:
raise SystemExit(f"ERROR: {exc}") from exc
_progress(f"\nSaved {out_deck}")
_progress(f"Updated {len(results)}/{total_slides} slides")
if backgrounds:
manifest = {
"backgrounds": {str(n): path for n, path in sorted(backgrounds.items())}
}
try:
backgrounds_out.write_text(json.dumps(manifest, indent=2) + "\n")
except OSError as exc:
raise SystemExit(
f"ERROR: wrote {out_deck.name} but cannot write the backgrounds "
f"manifest {backgrounds_out} ({type(exc).__name__}: "
f"{exc.strerror or exc}); make its directory writable or pass "
"--backgrounds-out elsewhere, then re-run"
) from exc
_progress(
f"Wrote {len(backgrounds)} FULL-slide background(s) -> {backgrounds_out}"
)
_progress(
"Keys are post-ExpandBuilds slide numbers: run expand-builds.sh first "
"when the outline has builds, then apply them via: apply-backgrounds.sh "
f"<uniquely-named copy> <final.pptx> {backgrounds_out.name}"
)
else:
_progress(
"No FULL background slides (Safe zone or poster) — no background manifest written."
)
print(
json.dumps(
_summary(
out_deck,
results,
total_slides,
backgrounds_out if backgrounds else None,
)
)
)
if __name__ == "__main__":
main().tessl-plugin
rules
skills
cfp-writer
references
illustrations
presentation-builder
presentation-creator
references
patterns
build
deliver
prepare
scripts
presentation-publisher
screencast-editor
screencast-recorder
shownotes-publisher
vault-clarification
vault-ingress
references
scripts
vault-profile