CtrlK
BlogDocsLog inGet started
Tessl Logo

jbaruch/speaker-toolkit

Six-skill presentation system: ingest talks into a rhetoric vault, run interactive clarification, generate a speaker profile, create presentations that match your documented patterns, produce the deck illustrations + thumbnail visual layer, and publish talk pages to a Jekyll shownotes site. Includes a 111-entry Presentation Patterns taxonomy (81 observable: 62 patterns + 19 antipatterns; 30 unobservable: 21 patterns + 9 antipatterns) for scoring, brainstorming, and go-live preparation.

Quality

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

apply-illustrations-to-deck.pyskills/illustrations/scripts/

#!/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 — slide 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).
     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 — 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 + footer are 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.

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

import argparse
import json
import shutil
import sys
from pathlib import Path

from lxml import etree
from pptx import Presentation
from pptx.enum.shapes import MSO_SHAPE, MSO_SHAPE_TYPE
from pptx.oxml.ns import qn
from pptx.util import Inches

# 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 parse_zones(outline_path: Path) -> dict:
    """Read `safe_zone` from the outline.

    Returns {slide_num: zone_name} where zone_name is a key of ZONE_LAYOUT.
    Slides without a safe_zone are absent from the dict.
    """
    return {
        s.n: s.safe_zone.zone.value
        for s in outline_schema.load_outline_partial(outline_path).slides
        if s.safe_zone
    }


def parse_img_txt_slides(outline_path: Path) -> set:
    """Slide numbers whose format is IMG+TXT and which have no safe_zone.

    Safe zone takes precedence (it implies FULL), so a slide carrying both is
    handled by the zones path instead.
    """
    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
    }


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 s.format.value == "FULL" and not s.safe_zone
    }


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 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,
) -> 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. Returns ``(results, backgrounds)``.
    """
    if out_deck.exists():
        out_deck.unlink()
    shutil.copy2(deck, out_deck)

    prs = Presentation(str(out_deck))
    results = []
    backgrounds: dict[int, str] = {}
    poster_full_slides = poster_full_slides or set()

    # 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}"
        if not illust.exists():
            print(f"  [{n:02d}] SKIP: missing {illust.name}")
            continue
        if n > len(prs.slides):
            print(f"  [{n:02d}] SKIP: out of deck range")
            continue

        slide = prs.slides[n - 1]
        backgrounds[n] = str(illust.resolve())

        scrim_added = ensure_scrim(slide, zone, scrim_hex, scrim_alpha)
        moved = reposition_title(slide, zone)
        print(
            f"  [{n:02d}] zone={zone}  moved={moved} text  scrim+{scrim_added}  bg->VBA"
        )
        results.append(
            {
                "slide": 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}"
        if not illust.exists():
            print(f"  [{n:02d}] SKIP: missing {illust.name}")
            continue
        if n > len(prs.slides):
            print(f"  [{n:02d}] SKIP: out of deck range")
            continue

        slide = prs.slides[n - 1]
        swap_or_insert_picture(slide, illust)

        pic_moved, text_moved = apply_img_txt_layout(slide)
        print(f"  [{n:02d}] format=IMG+TXT  pic+{pic_moved}  text+{text_moved}")
        results.append(
            {
                "slide": n,
                "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}"
        if not illust.exists():
            print(f"  [{n:02d}] SKIP: missing {illust.name}")
            continue
        if n > len(prs.slides):
            print(f"  [{n:02d}] SKIP: out of deck range")
            continue
        backgrounds[n] = str(illust.resolve())
        print(f"  [{n:02d}] poster FULL  bg->VBA  (title+footer baked into image)")
        results.append({"slide": n, "format": "FULL-POSTER", "zone": None})

    prs.save(str(out_deck))
    return results, backgrounds


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()

    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:
        print(
            f"No `safe_zone`, `format: IMG+TXT`, or poster-theatrical FULL "
            f"slides found in {args.outline.name}. Nothing to do."
        )
        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}")

    results, backgrounds = apply(
        args.deck,
        args.illustrations,
        zones,
        img_txt_slides,
        out_deck,
        args.image_ext,
        scrim_hex,
        args.scrim_alpha,
        poster_full_slides,
    )
    print(f"\nSaved {out_deck}")
    print(f"Updated {len(results)}/{total_slides} slides")

    if backgrounds:
        manifest = {
            "backgrounds": {str(n): path for n, path in sorted(backgrounds.items())}
        }
        backgrounds_out.write_text(json.dumps(manifest, indent=2) + "\n")
        print(f"Wrote {len(backgrounds)} FULL-slide background(s) -> {backgrounds_out}")
        print(
            f"Apply them via: apply-backgrounds.sh <uniquely-named copy of {out_deck.name}> "
            f"<final.pptx> {backgrounds_out.name}"
        )
    else:
        print(
            "No FULL background slides (Safe zone or poster) — no background manifest written."
        )


if __name__ == "__main__":
    main()

skills

README.md

tile.json