CtrlK
BlogDocsLog inGet started
Tessl Logo

jbaruch/speaker-toolkit

Seven-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, create and publish talk-content Agent Skills with talk pages to a Jekyll shownotes site, and verify a recorded screencast against its storyboard. Includes a 113-entry Presentation Patterns taxonomy (83 observable: 64 patterns + 19 antipatterns; 30 unobservable: 21 patterns + 9 antipatterns) for scoring, brainstorming, and go-live preparation.

75

Quality

94%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

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