CtrlK
BlogDocsLog inGet started
Tessl Logo
PluginPublic

jbaruch/speaker-toolkit

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

Quality

90%

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

build-expansion-manifest.pyskills/illustrations/scripts/

#!/usr/bin/env python3
"""Emit the build-expansion manifest for deck assembly.

Reads outline.yaml (the single source of truth) + the generated build frames and
produces a JSON manifest describing how each progressive-reveal parent slide
expands into its sequence of full-bleed frames. The manifest is consumed by the
ExpandBuilds VBA pass (via expand-builds.sh) which replaces each parent slide
with its frames.

Why a manifest (not direct deck edits): structural deck edits go through real
PowerPoint / RunDeckOps, never python-pptx — see rules/deck-editing-rules.md.
This script never touches the deck. Its one write besides ``--out`` is the
stamped frame copies described below, when the outline stamps its footer.

Manifest shape (schema_version 1):

    {
      "schema_version": 1,
      "builds": [
        {
          "parent": 7,
          "frames": ["/abs/builds/slide-07-build-00.jpg", ..., "slide-07-build-03.jpg"],
          "notes": ""
        }
      ]
    }

- `parent` — the 1-based position, in the built deck, of the slide that
  carries the `builds` block (`outline_schema.deck_positions`): the slide
  ExpandBuilds replaces. It equals the outline number only when numbering runs
  1..N; a zero-based or gapped outline still names the right slide.
- `frames` — absolute paths to build-00 .. build-N, in ascending step order.
  The parent slide is replaced by these frames (build-00 first, build-N last).
- `notes` — speaker notes for the FINAL frame only (empty when none); earlier
  frames get no notes (see skills/illustrations/references/builds.md).

When the outline stamps its footer (`style_anchor.footer_style`), each poster
parent's frames are stamped by `footer_stamp.stamp_footer_file` into
`BUILDS_DIR/footer-stamped/slide-NN-build-MM.png` and `frames` lists those
copies; the raw frames stay footer-free for editing. Every build parent of
such a deck must satisfy `footer_stamp.stamped_slide`, the same answer
apply-illustrations-to-deck.py uses for parent slides; any other parent exits
non-zero naming the slide.

ExpandBuilds processes parents in descending slide order so inserting a parent's
frames never shifts the indices of lower-numbered parents still to be expanded.

Usage:
    build-expansion-manifest.py OUTLINE_YAML BUILDS_DIR [--out manifest.json]

stdout: the manifest JSON (also written to --out when given).
Exit non-zero with a stderr diagnostic when a declared build parent has no
frames on disk, or a declared frame is not a file that decodes as an image.
"""

import argparse
import json
import sys
from pathlib import Path

# A sibling module: this script's own directory is already importable.
from footer_stamp import (
    FRAME_RE,
    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)

SCHEMA_VERSION = 1


def parse_build_specs(outline_path: Path) -> list[dict]:
    """Return build specs for each parent slide: {parent, count, steps, footer}.

    Read from outline.yaml's per-slide `builds`. The schema guarantees steps are
    contiguous from 0, so `steps` is [0, 1, ..., N-1] and `count` == len(steps).
    `footer` is the (text, style) to stamp on the parent's frames, or None.
    """
    outline = outline_schema.load_outline_partial(outline_path)
    footer = stamp_spec(outline.style_anchor)
    specs = []
    for slide in outline.slides:
        if not slide.builds:
            continue
        if footer is not None and not stamped_slide(slide):
            raise SystemExit(
                f"ERROR: slide {slide.n} is a build parent in a poster deck that "
                "stamps its footer, but it is not an illustrated FULL slide "
                "without safe_zone; poster-theatrical decks allow only those. "
                "Set it to format FULL with an image_prompt and remove safe_zone, "
                "or drop its builds."
            )
        steps = sorted(b.step for b in slide.builds)
        specs.append(
            {
                "parent": slide.n,
                "count": len(steps),
                "steps": steps,
                "footer": footer,
            }
        )
    return specs


def frames_for(builds_dir: Path, parent: int) -> dict[int, Path]:
    """Map step number -> build frame file for a parent (slide-NN-build-MM.<ext>)."""
    found = {}
    for p in builds_dir.glob(f"slide-{parent:02d}-build-*"):
        fm = FRAME_RE.search(p.name)
        if fm:
            found[int(fm.group(1))] = p
    return found


def _require_usable_frames(specs: list[dict], builds_dir: Path) -> None:
    """Every declared frame is on disk and decodes, before any is stamped or listed.

    parse_build_specs reads outline.yaml through the schema, which already
    guarantees each slide's build steps are contiguous from 0, so step count,
    ordering and contiguity need no re-checking here.
    """
    for spec in specs:
        parent, steps = spec["parent"], spec["steps"]
        found = frames_for(builds_dir, parent)
        missing = [s for s in steps if s not in found]
        if missing:
            raise SystemExit(
                f"ERROR: slide {parent} is missing build frame(s) for step(s) "
                f"{missing} (declared build-NN entries: {steps}). A partial "
                "sequence would expand into a broken reveal. Regenerate: "
                f"generate-illustrations.py <outline> --build {parent}"
            )
        unusable = [
            why for step in steps if (why := unusable_image(found[step])) is not None
        ]
        if unusable:
            raise SystemExit(
                f"ERROR: slide {parent}'s build frames cannot be placed: "
                f"{'; '.join(unusable)}. ExpandBuilds would fail on them inside "
                f"PowerPoint. Regenerate: generate-illustrations.py <outline> "
                f"--build {parent}"
            )


def _require_one_stamped_geometry(
    outline_path: Path, specs: list[dict], builds_dir: Path
) -> None:
    """The whole deck's stamp set shares one size before any frame is stamped.

    The same set the apply step checks: every stamped slide image and every
    declared build frame, so whichever command runs first refuses a mixed deck.
    A stale frame outside the declared steps is never stamped, so it never
    constrains geometry.
    """
    if not any(spec["footer"] is not None for spec in specs):
        return
    slides = outline_schema.load_outline_partial(outline_path).slides
    try:
        require_one_geometry(deck_stamp_set(slides, builds_dir.parent, builds_dir))
    except FooterStampError as exc:
        raise SystemExit(f"ERROR: cannot stamp the build frames: {exc}") from exc


def build_manifest(
    outline_path: Path, builds_dir: Path, notes_map: dict | None = None
) -> dict:
    """Build the expansion manifest.

    notes_map: optional {"<0-based deck slide #>": "notes"} (the historical
    inject-notes format). A build parent at 1-based deck position P
    (``outline_schema.deck_positions``, so outline numbering may start at 0)
    maps to 0-based key str(P-1), and P is the manifest's ``parent``, the index
    ExpandBuilds replaces; its notes ride onto the FINAL build frame so expansion
    doesn't drop them. When omitted, notes are empty and the post-expansion
    notes pass must carry them instead.
    """
    notes_map = notes_map or {}
    # ExpandBuilds and the notes map index the built deck, not outline numbers.
    positions = outline_schema.deck_positions(
        outline_schema.load_outline_partial(outline_path).slides
    )
    specs = sorted(parse_build_specs(outline_path), key=lambda s: s["parent"])
    _require_usable_frames(specs, builds_dir)
    _require_one_stamped_geometry(outline_path, specs, builds_dir)
    builds = []
    for spec in specs:
        parent, steps = spec["parent"], spec["steps"]
        found = frames_for(builds_dir, parent)
        # A null/absent note is empty; a non-string note is an error — never
        # coerce it, or a JSON null would become the literal "None" on the final
        # build frame (mirrors notes-to-packed.py's drop-empty contract).
        position = positions[parent]
        raw_note = notes_map.get(str(position - 1))
        if raw_note is None:
            raw_note = ""
        elif not isinstance(raw_note, str):
            raise SystemExit(
                f"ERROR: note for slide {parent} (notes key {position - 1}) must be a "
                f"string, got {type(raw_note).__name__} — fix the --notes JSON."
            )
        frames = [found[s] for s in steps]
        if spec["footer"] is not None:
            text, style = spec["footer"]
            stamped_dir = builds_dir / "footer-stamped"
            stamped: list[Path] = []
            for frame in frames:
                try:
                    stamped.append(stamp_footer_file(frame, stamped_dir, text, style))
                except FooterStampError as exc:
                    raise SystemExit(
                        f"ERROR: cannot stamp the footer on slide {parent}'s build "
                        f"frames: {exc}"
                    ) from exc
                except OSError as exc:
                    raise SystemExit(
                        f"ERROR: cannot stamp {frame.name} into {stamped_dir} "
                        f"({type(exc).__name__}: {exc.strerror or exc}); regenerate "
                        "or replace that frame if it is unreadable, or make the "
                        "directory writable, then re-run"
                    ) from exc
            frames = stamped
        builds.append(
            {
                "parent": position,
                "frames": [str(frame.resolve()) for frame in frames],
                "notes": raw_note,
            }
        )
    return {"schema_version": SCHEMA_VERSION, "builds": builds}


def main(argv=None):
    ap = argparse.ArgumentParser(
        description="Emit the build-expansion manifest for deck assembly."
    )
    ap.add_argument(
        "outline", type=Path, help="Path to outline.yaml (the single source of truth)"
    )
    ap.add_argument(
        "builds_dir", type=Path, help="Directory with slide-NN-build-MM.<ext> frames"
    )
    ap.add_argument(
        "--out", type=Path, default=None, help="Also write the manifest JSON here"
    )
    ap.add_argument(
        "--notes",
        type=Path,
        default=None,
        help='Optional notes JSON {"<0-based slide #>": "text"}; a build '
        "parent's notes ride onto its final frame so expansion keeps them",
    )
    args = ap.parse_args(argv)
    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

    notes_map = None
    if args.notes is not None:
        try:
            notes_map = json.loads(args.notes.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError) as exc:
            raise SystemExit(f"ERROR: cannot read notes JSON {args.notes}: {exc}")
        if not isinstance(notes_map, dict):
            raise SystemExit(
                f"ERROR: notes JSON {args.notes} must be an object mapping slide # -> text"
            )

    manifest = build_manifest(args.outline, args.builds_dir, notes_map)
    text = json.dumps(manifest, indent=2)
    if args.out is not None:
        try:
            args.out.write_text(text + "\n", encoding="utf-8")
        except OSError as exc:
            print(
                f"ERROR: cannot write manifest {args.out}: {exc.strerror or exc} — "
                "check the path and that its directory is writable",
                file=sys.stderr,
            )
            return 1
    print(text)
    return 0


if __name__ == "__main__":
    sys.exit(main())

skills

README.md

tile.json