From e40ee5c0cda8e56ef4b0818a8d31bf0a9ec6b080 Mon Sep 17 00:00:00 2001 From: jchha001 <51886135+jchha001@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:42:53 +0100 Subject: [PATCH] Add Presentation Talk Track Builder skill First-person spoken presenter scripts calibrated to a target duration and speaking pace. Treats existing speaker notes as authoritative when they contradict slide text; never modifies visible slide content. Targets Cowork, Copilot Studio, and Scout. --- .../presentation-talk-track-builder/SKILL.md | 239 +++++++++++ .../assets/example-notes.json | 12 + .../metadata.json | 10 + .../references/tone-examples.md | 59 +++ .../scripts/pptx_talk_track.py | 372 ++++++++++++++++++ 5 files changed, 692 insertions(+) create mode 100644 submissions/presentation-talk-track-builder/SKILL.md create mode 100644 submissions/presentation-talk-track-builder/assets/example-notes.json create mode 100644 submissions/presentation-talk-track-builder/metadata.json create mode 100644 submissions/presentation-talk-track-builder/references/tone-examples.md create mode 100644 submissions/presentation-talk-track-builder/scripts/pptx_talk_track.py diff --git a/submissions/presentation-talk-track-builder/SKILL.md b/submissions/presentation-talk-track-builder/SKILL.md new file mode 100644 index 00000000..47988251 --- /dev/null +++ b/submissions/presentation-talk-track-builder/SKILL.md @@ -0,0 +1,239 @@ +--- +name: presentation-talk-track-builder +description: | + Writes natural, first-person spoken presenter scripts (talk tracks) for the + slides in a PowerPoint deck, from slide images, or from pasted slide text. + Treats existing speaker notes as AUTHORITATIVE when they contradict slide + text. Produces one spoken script per slide with delivery cues, calibrated to + a target duration and speaking pace, plus a timing table. Use when the user + says "write a script for these slides", "I'm presenting slides X-Y, what + should I say", "create a presenter script", "turn my speaker notes into a + talk track", or "make a 6-minute script for these slides". Do NOT use to + create or edit the visible content of slides, to summarize a recorded + meeting or transcript, or for general document writing. +--- + +## Overview + +This skill turns slides into a spoken talk track. For each requested slide it +writes a first-person script the presenter can read aloud, grounded in the +slide's content and — above all — its **speaker notes**. It calibrates the +script length to a target time and speaking pace and returns a timing table +so the presenter knows whether they will land on time. + +**Core rule:** the speaker notes are the source of truth. When notes and +slide text disagree, the script follows the notes and silently drops the +contradicted slide text. This is what the author intended to say. + +Works across Scout, Copilot Studio, and Cowork. The user can attach a +`.pptx`, provide slide images, paste slide text, or provide an outline. + +## When to Use + +- "I'm presenting slides 8-11, write a script for each so I can present them." +- "Turn my speaker notes into what I should actually say." +- "Make a ~6-minute talk track for these four slides; I speak fast." +- "Write presenter narration for this deck / these slide images." +- "Give me a word-for-word script with pauses and a handoff to the next + speaker." + +## When NOT to Use + +- Creating, designing, or editing the visible content of the slides + themselves. +- Summarizing a recorded meeting, call, or transcript. +- Writing a standalone document, memo, or article. +- A quick one-line answer that isn't a spoken script — answer inline. + +## Quick Start + +``` +User: "I'm presenting slides 8-11, write a script for each. If the notes + contradict the slides, follow the notes. Target ~6 minutes, business + audience." +1. Capture inputs — audience & goals, slide content, optional supporting + context. +2. Extract BOTH slide text AND speaker notes for the requested slides. +3. Reconcile: where notes contradict slides, follow the notes. +4. Calibrate: target minutes × pace (words/min) = word budget; split by + slide. +5. Write a first-person script per slide with delivery cues + a handoff line. +6. Return the scripts + a per-slide timing table (words → spoken minutes). +7. Offer to save as speaker notes back into a copy of the deck, or as a doc. +``` + +## Core Instructions + +### Step 0 — Capture audience & goals (required) +Before writing anything, capture: +- **Audience**: who is in the room (or on the call)? Role, seniority, + technical depth, familiarity with the topic. +- **Event context**: keynote, internal review, customer meeting, training, + panel, webinar. +- **Presenter goal**: what should the audience *understand*, *decide*, or + *do* after the presentation? +- **Tone**: formal, conversational, energetic, deliberate. +- **Target duration** and **speaking pace** (see Step 4). + +If the user hasn't provided these, ask once. If they decline, use the +defaults below and label them as assumptions in the output: +- Audience: general business audience. +- Tone: clear, confident, conversational. +- Goal: help the audience understand the deck's main message and next step. + +### Step 1 — Locate the slides and any supporting context +- If a `.pptx`/`.pptm` is attached or on disk, use it. Check the user's + likely input folders first (Downloads, current workspace) even when the + user does not name a path. +- If only slide **images** are provided, work from what's visible and tell + the user there were no speaker notes to follow. +- If only pasted text or an outline is provided, use that directly. +- Confirm exactly which slide numbers to cover. If the user did not name + specific slides, cover the whole deck. +- Ask the user for **optional supporting context** the model can weave into + the narration (but not into the slides themselves): a product one-pager, + a prior version of the talk, a customer brief, an FAQ, source links, or + presenter background notes. This is especially useful when the existing + speaker notes are thin or missing. + +### Step 2 — Extract slide text AND speaker notes (both matter) +- Slide text and existing speaker notes can be extracted with the bundled + helper: + ``` + python scripts/pptx_talk_track.py extract .pptx .json + ``` + The output JSON contains `title`, `visibleText`, and `existingNotes` per + slide. Both fields matter. +- If you have python-pptx available you can also use + `slide.notes_slide.notes_text_frame.text` to read notes. +- Map notes to the correct slide numbers. + +### Step 3 — Reconcile notes vs. slides (notes win) +- Read both. Where the notes state something different from the slide (a + different figure, name, framing, date, or emphasis), write the script to + match the **notes** and drop the contradicted slide text. +- Where notes add context the slide omits, include it. Where notes are + sparse, lean on the slide content and any supporting context to fill the + spoken narrative — never invent. +- Note any contradiction you resolved in a one-line aside after the + scripts, so the presenter knows why the script diverges from what's on + screen. + +### Step 4 — Calibrate timing to the target +- Ask for (or infer) two things: target duration and speaking pace. If the + user says they "speak fast", use a brisk pace; otherwise default to + average. +- Pace reference (words per minute): + | Pace | WPM | Use when | + |------|-----|----------| + | Deliberate | 110-130 | Technical/dense content, non-native audience | + | Average | 130-150 | Default | + | Brisk / "I speak fast" | 165-185 | User says they rush or speak quickly | +- Word budget = target minutes × chosen WPM. Divide across slides by + weight (slides with denser notes get more words). Compute totals and + per-slide times with a code tool — never estimate arithmetic by hand. +- Presenters speed up live, so aim the *written* script slightly LONG of + the target (e.g. write to ~6:15 for a 6:00 goal) and add `[pause]` cues, + which both reinforce key points and naturally slow a fast speaker. + +### Step 5 — Write the script (spoken, first person) +- One clearly-headed section per slide: `## Slide N — `. +- Natural spoken language, first person ("Let me show you…", "Here's why + this matters…"). Contractions on. Short sentences. No bulleted + fragments — full spoken prose. +- Put delivery cues in italics or brackets: *[pause]*, *(gesture to the + diagram)*, *(click to reveal)*, *(hand off)*. +- Open each slide with a one-line bridge from the previous slide; close + the last covered slide with a handoff line to the next presenter or to + Q&A if relevant. +- Mirror the deck's terminology and the user's domain voice. +- Tailor the depth and framing to the audience captured in Step 0. + +### Step 6 — Deliver with a timing table +- After the scripts, show a table: Slide | approx. words | spoken time, + with a total and the estimated overall minutes at the chosen pace. +- Call out the easiest lines to cut if running long, and offer to tighten + any slide to a specific per-slide time. + +### Step 7 — Offer to save the output +- Offer to save the scripts back into a copy of the deck as speaker notes, + using: + ``` + python scripts/pptx_talk_track.py apply .pptx .json .pptx + ``` + The helper writes speaker notes only. It never changes visible slide + content, order, formatting, images, charts, or animations. +- Alternatively, offer to save as a Word document for presenter notes. +- Only save a file when the user asks for one. + +## Output + +- Markdown by default (inline), one section per slide, in slide order. +- First-person spoken prose with italic delivery cues; a handoff line + where relevant. +- A closing timing table (words + spoken minutes per slide, plus total) + and a one-line note of any note-vs-slide contradiction resolved. +- Tone matches the deck; length matches the time budget. + +See `references/tone-examples.md` for target voice samples. + +## Guardrails + +- **Notes are authoritative.** When speaker notes contradict slide text, + follow the notes and drop the contradicted slide content — this is the + whole point of the skill. +- **Never modify visible slide content.** Slide text, order, layouts, + images, charts, and animations must stay unchanged. If notes are written + back to a `.pptx`, only speaker notes in a copied file are updated. +- **Never fabricate facts.** Do not invent figures, names, dates, or + quotes that aren't in the slides, notes, or supporting context. Mark + genuine gaps as `[confirm: …]` rather than guessing. +- **Confidentiality and sensitivity.** If slides or notes carry a + confidentiality label or sensitivity marking (for example + "Confidential", "Internal Only", "Restricted", or an enterprise + information-protection label), or contain unreleased figures, + customer or partner identifiers, or names that aren't public yet, + flag them and confirm with the user before including that content in + the spoken script. Never add PII or customer identifiers the source + material doesn't already contain. +- **Compute timings with code**, not by hand; state the pace assumption + so the presenter can recalibrate. +- **Cover exactly the requested slides** — no more, no fewer — and + preserve their order. If the user asks for "the whole deck", cover + every slide. +- **Do not reproduce third-party copyrighted text** verbatim from slide + images; paraphrase into the presenter's own spoken words. +- If you can't read the deck after a couple of attempts, say so plainly + and ask the user to re-attach it — do not substitute placeholder slide + content. + +## Bundled helper script + +`scripts/pptx_talk_track.py` uses only the Python standard library. + +- `extract .pptx .json` — read slide titles, visible text, + and existing speaker notes. +- `template .pptx .json` — write an empty notes JSON template + with one entry per slide, ready to fill in with generated scripts. +- `apply .pptx .json .pptx` — write the generated + scripts into a copied deck as speaker notes. Never overwrites the + original. + +If a deck has no notes master, open and save it once in PowerPoint (or add +a note to one slide) before rerunning the helper. + +**Runtime note:** the helper needs Python 3 available in the runtime. +Scout, Copilot Studio, and Cowork all support this, so the `extract` / +`template` / `apply` commands work on all three platforms. If the +runtime doesn't have Python for any reason, the skill still works — +paste slide text, an outline, or slide images, and the model writes +the talk track and timing table inline. In that case, users who want +the scripts embedded back into a `.pptx` as speaker notes can run the +helper locally with Python 3 installed. + +## Assets and references + +- `references/tone-examples.md` — short excerpts of the target + first-person, spoken voice with delivery cues. +- `assets/example-notes.json` — example of the notes JSON shape the + `apply` command consumes. diff --git a/submissions/presentation-talk-track-builder/assets/example-notes.json b/submissions/presentation-talk-track-builder/assets/example-notes.json new file mode 100644 index 00000000..52862fa3 --- /dev/null +++ b/submissions/presentation-talk-track-builder/assets/example-notes.json @@ -0,0 +1,12 @@ +{ + "slides": [ + { + "slide": 1, + "speakerNotes": "Slide 1 — Opening\n\n\"Welcome, everyone, and thanks for joining. [pause] Today we're going to walk through three things: why this matters now, what we've built so far, and where we go next.\"" + }, + { + "slide": 2, + "speakerNotes": "Slide 2 — The problem\n\n\"Let me start with the problem, because it's the reason all of this exists. [pause] Our teams are spending most of their time on the mechanics of the work instead of the judgment inside the work — and that ratio has to change.\"" + } + ] +} diff --git a/submissions/presentation-talk-track-builder/metadata.json b/submissions/presentation-talk-track-builder/metadata.json new file mode 100644 index 00000000..952960da --- /dev/null +++ b/submissions/presentation-talk-track-builder/metadata.json @@ -0,0 +1,10 @@ +{ + "name": "Presentation Talk Track Builder", + "description": "Writes natural, first-person spoken presenter scripts for the slides in a deck. Treats existing speaker notes as authoritative when they contradict slide text. Calibrates length to a target duration and speaking pace and returns a timing table. Never modifies visible slide content.", + "platforms": ["Cowork", "Copilot Studio", "Scout"], + "tags": ["presentations", "speaker-notes", "pptx", "writing", "productivity"], + "author": "Jagmeet Chabra", + "version": "1.0.0", + "createdAt": "2026-07-16", + "updatedAt": "2026-07-17" +} diff --git a/submissions/presentation-talk-track-builder/references/tone-examples.md b/submissions/presentation-talk-track-builder/references/tone-examples.md new file mode 100644 index 00000000..9546c859 --- /dev/null +++ b/submissions/presentation-talk-track-builder/references/tone-examples.md @@ -0,0 +1,59 @@ +# Tone examples — Presentation Talk Track Builder + +These are short excerpts of the target voice the skill should produce. +Use them as calibration for how a slide script should sound. Do not copy +them verbatim into a talk track — they exist to anchor the *style*. + +## Bridging into a slide, then landing a "so what" + +> "Now let's draw the line between what most of us are using today and +> where the industry is heading. *[pause]* +> +> Traditional AI follows a simple pattern: one prompt, one response. You +> ask a question, the system gives you an answer, and then it waits. +> You're the one driving. +> +> Agentic AI works differently. Instead of just answering a question, it +> can take a goal, break it into smaller tasks, use tools on its own, +> and push a whole workflow forward with much less hand-holding from us. +> +> That's the shift I want you to take away from this slide — AI moving +> from an information source to a workflow participant." + +## Making a concept tangible with a domain example + +> "Let me make that concrete with something every one of us deals +> with — a state tax notice. +> +> With traditional AI, I might ask it to summarize the notice. That's +> one task, one answer — and then I take it from there. I figure out the +> jurisdiction, the deadline, the right response. +> +> With agentic AI, I could hand it the whole thing. Read the notice. +> Identify the jurisdiction. Determine the response deadline. Categorize +> the issue. Compare it against similar notices we've handled before. +> Draft a response. And then come back to me with a recommended action +> plan. *[pause]* +> +> Notice the difference. In the first case, AI is a research assistant +> answering one question. In the second, it's actually working through +> the steps of the engagement alongside me." + +## Handoff / closing beat + +> "So — we've covered why AI matters, how the technology is evolving, +> and where it fits into the SALT workflow. That leads us to the next +> logical question: how do we choose the right AI tool for the specific +> task in front of us? *(hand off)* And to walk us through the major AI +> platforms, I'll turn it over to Rob." + +## Style ground rules the skill follows + +- First person, contractions on, short sentences. +- One-line bridge into each slide from the previous one. +- Delivery cues in italics or brackets: *[pause]*, *(gesture)*, + *(click to reveal)*, *(hand off)*. +- A landed "so what" at the end of each content slide. +- Mirror the deck's terminology; never invent new terms or facts. +- Where speaker notes contradict the slide text, the script follows the + notes and silently drops the contradicted slide content. diff --git a/submissions/presentation-talk-track-builder/scripts/pptx_talk_track.py b/submissions/presentation-talk-track-builder/scripts/pptx_talk_track.py new file mode 100644 index 00000000..f7a9158d --- /dev/null +++ b/submissions/presentation-talk-track-builder/scripts/pptx_talk_track.py @@ -0,0 +1,372 @@ +#!/usr/bin/env python3 +"""Extract slide text from a PPTX and write generated speaker notes back. + +This helper intentionally uses only the Python standard library so it can run in +restricted agent sandboxes. It reads/writes PPTX packages directly as OOXML zip +files. +""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import sys +import tempfile +import zipfile +from html import escape +from pathlib import Path +from typing import Any +from xml.etree import ElementTree as ET + +NS = { + "a": "http://schemas.openxmlformats.org/drawingml/2006/main", + "p": "http://schemas.openxmlformats.org/presentationml/2006/main", + "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "rel": "http://schemas.openxmlformats.org/package/2006/relationships", + "ct": "http://schemas.openxmlformats.org/package/2006/content-types", +} + +REL_NOTES_SLIDE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" +REL_NOTES_MASTER = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" +REL_SLIDE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" +NOTES_SLIDE_CT = "application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml" + +for prefix, uri in NS.items(): + if prefix not in {"rel", "ct"}: + ET.register_namespace(prefix, uri) + + +def natural_slide_key(name: str) -> int: + match = re.search(r"slide(\d+)\.xml$", name) + return int(match.group(1)) if match else 0 + + +def read_xml(zf: zipfile.ZipFile, name: str) -> ET.Element: + return ET.fromstring(zf.read(name)) + + +def text_runs(root: ET.Element) -> list[str]: + return [node.text or "" for node in root.findall(".//a:t", NS)] + + +def paragraph_xml(text: str) -> str: + lines = [line.rstrip() for line in text.replace("\r\n", "\n").split("\n")] + if not lines: + lines = [""] + parts = [] + for line in lines: + if not line: + parts.append("") + else: + parts.append( + "" + f"{escape(line)}" + ) + return "".join(parts) + + +def get_slide_files(zf: zipfile.ZipFile) -> list[str]: + slides = [ + name + for name in zf.namelist() + if re.fullmatch(r"ppt/slides/slide\d+\.xml", name) + ] + return sorted(slides, key=natural_slide_key) + + +def slide_rels_path(slide_path: str) -> str: + slide_name = Path(slide_path).name + return f"ppt/slides/_rels/{slide_name}.rels" + + +def notes_rels_path(notes_path: str) -> str: + notes_name = Path(notes_path).name + return f"ppt/notesSlides/_rels/{notes_name}.rels" + + +def extract_deck(pptx: Path) -> list[dict[str, Any]]: + with zipfile.ZipFile(pptx, "r") as zf: + slides = [] + for index, slide_path in enumerate(get_slide_files(zf), start=1): + slide_root = read_xml(zf, slide_path) + visible_text = "\n".join(t for t in text_runs(slide_root) if t.strip()).strip() + title = next((t.strip() for t in text_runs(slide_root) if t.strip()), f"Slide {index}") + notes_text = "" + rel_path = slide_rels_path(slide_path) + if rel_path in zf.namelist(): + rel_root = read_xml(zf, rel_path) + for rel in rel_root.findall("rel:Relationship", NS): + if rel.get("Type") == REL_NOTES_SLIDE: + target = rel.get("Target", "") + notes_path = "ppt/notesSlides/" + Path(target).name + if notes_path in zf.namelist(): + notes_root = read_xml(zf, notes_path) + notes_text = "\n".join(t for t in text_runs(notes_root) if t.strip()).strip() + break + slides.append( + { + "slide": index, + "title": title, + "visibleText": visible_text, + "existingNotes": notes_text, + "speakerNotes": "", + } + ) + return slides + + +def load_notes(notes_file: Path) -> dict[int, str]: + data = json.loads(notes_file.read_text(encoding="utf-8")) + if isinstance(data, dict) and "slides" in data: + data = data["slides"] + if isinstance(data, dict): + return {int(key): str(value) for key, value in data.items()} + if isinstance(data, list): + notes: dict[int, str] = {} + for item in data: + if not isinstance(item, dict) or "slide" not in item: + raise ValueError("Each notes item must include a slide number.") + text = item.get("speakerNotes") or item.get("notes") or item.get("script") or "" + notes[int(item["slide"])] = str(text) + return notes + raise ValueError("Notes JSON must be an object, a {slides: [...]} object, or a list.") + + +def next_rid(rel_root: ET.Element) -> str: + ids = [] + for rel in rel_root.findall("rel:Relationship", NS): + rid = rel.get("Id", "") + match = re.fullmatch(r"rId(\d+)", rid) + if match: + ids.append(int(match.group(1))) + return f"rId{(max(ids) if ids else 0) + 1}" + + +def find_notes_master(zf: zipfile.ZipFile) -> str: + masters = sorted( + name for name in zf.namelist() if re.fullmatch(r"ppt/notesMasters/notesMaster\d+\.xml", name) + ) + if not masters: + raise RuntimeError( + "This deck does not contain a notes master. Open and save the deck in PowerPoint, " + "then rerun this command, or add notes to one slide first." + ) + return masters[0] + + +def existing_notes_slide(zf: zipfile.ZipFile, slide_path: str) -> str | None: + rel_path = slide_rels_path(slide_path) + if rel_path not in zf.namelist(): + return None + rel_root = read_xml(zf, rel_path) + for rel in rel_root.findall("rel:Relationship", NS): + if rel.get("Type") == REL_NOTES_SLIDE: + return "ppt/notesSlides/" + Path(rel.get("Target", "")).name + return None + + +def make_notes_slide_xml(notes: str) -> bytes: + xml = f""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {paragraph_xml(notes)} + + + + + + + + +""" + return xml.encode("utf-8") + + +def replace_notes_text(xml_bytes: bytes, notes: str) -> bytes: + root = ET.fromstring(xml_bytes) + body = None + for shape in root.findall(".//p:sp", NS): + placeholder = shape.find(".//p:ph", NS) + if placeholder is not None and placeholder.get("type") == "body": + body = shape.find("p:txBody", NS) + break + if body is None: + body = root.find(".//p:txBody", NS) + if body is None: + return make_notes_slide_xml(notes) + for child in list(body): + if child.tag.endswith("}p"): + body.remove(child) + for para in ET.fromstring(f"{paragraph_xml(notes)}"): + body.append(para) + return ET.tostring(root, encoding="utf-8", xml_declaration=True) + + +def add_content_type(content_types: bytes, part_name: str) -> bytes: + root = ET.fromstring(content_types) + wanted = "/" + part_name + for override in root.findall("ct:Override", NS): + if override.get("PartName") == wanted: + override.set("ContentType", NOTES_SLIDE_CT) + return ET.tostring(root, encoding="utf-8", xml_declaration=True) + override = ET.SubElement(root, f"{{{NS['ct']}}}Override") + override.set("PartName", wanted) + override.set("ContentType", NOTES_SLIDE_CT) + return ET.tostring(root, encoding="utf-8", xml_declaration=True) + + +def write_notes(pptx: Path, notes_file: Path, output: Path) -> None: + notes_by_slide = load_notes(notes_file) + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + with zipfile.ZipFile(pptx, "r") as source: + source.extractall(tmp_path) + slide_files = get_slide_files(source) + notes_master = find_notes_master(source) + + notes_master_target = f"../notesMasters/{Path(notes_master).name}" + + for slide_index, notes in notes_by_slide.items(): + if slide_index < 1 or slide_index > len(slide_files): + raise ValueError(f"Slide {slide_index} is outside the deck's slide range.") + slide_path = slide_files[slide_index - 1] + slide_abs = tmp_path / slide_path + rel_path = tmp_path / slide_rels_path(slide_path) + rel_path.parent.mkdir(parents=True, exist_ok=True) + if rel_path.exists(): + rel_root = ET.parse(rel_path).getroot() + else: + rel_root = ET.Element(f"{{{NS['rel']}}}Relationships") + + notes_path = existing_notes_slide_from_tree(rel_root) + if notes_path is None: + notes_path = next_available_notes_path(tmp_path) + rid = next_rid(rel_root) + rel = ET.SubElement(rel_root, f"{{{NS['rel']}}}Relationship") + rel.set("Id", rid) + rel.set("Type", REL_NOTES_SLIDE) + rel.set("Target", f"../notesSlides/{Path(notes_path).name}") + notes_abs = tmp_path / notes_path + notes_abs.parent.mkdir(parents=True, exist_ok=True) + + if notes_abs.exists(): + notes_abs.write_bytes(replace_notes_text(notes_abs.read_bytes(), notes)) + else: + notes_abs.write_bytes(make_notes_slide_xml(notes)) + + ET.ElementTree(rel_root).write(rel_path, encoding="utf-8", xml_declaration=True) + + notes_rel_abs = tmp_path / notes_rels_path(notes_path) + notes_rel_abs.parent.mkdir(parents=True, exist_ok=True) + notes_rel_root = ET.Element(f"{{{NS['rel']}}}Relationships") + slide_rel = ET.SubElement(notes_rel_root, f"{{{NS['rel']}}}Relationship") + slide_rel.set("Id", "rId1") + slide_rel.set("Type", REL_SLIDE) + slide_rel.set("Target", f"../slides/{slide_abs.name}") + master_rel = ET.SubElement(notes_rel_root, f"{{{NS['rel']}}}Relationship") + master_rel.set("Id", "rId2") + master_rel.set("Type", REL_NOTES_MASTER) + master_rel.set("Target", notes_master_target) + ET.ElementTree(notes_rel_root).write(notes_rel_abs, encoding="utf-8", xml_declaration=True) + + ct_path = tmp_path / "[Content_Types].xml" + ct_path.write_bytes(add_content_type(ct_path.read_bytes(), notes_path)) + + if output.exists(): + output.unlink() + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as dest: + for path in tmp_path.rglob("*"): + if path.is_file(): + dest.write(path, path.relative_to(tmp_path).as_posix()) + + +def existing_notes_slide_from_tree(rel_root: ET.Element) -> str | None: + for rel in rel_root.findall("rel:Relationship", NS): + if rel.get("Type") == REL_NOTES_SLIDE: + return "ppt/notesSlides/" + Path(rel.get("Target", "")).name + return None + + +def next_available_notes_path(tmp_path: Path) -> str: + notes_dir = tmp_path / "ppt" / "notesSlides" + notes_dir.mkdir(parents=True, exist_ok=True) + used = { + int(match.group(1)) + for path in notes_dir.glob("notesSlide*.xml") + if (match := re.fullmatch(r"notesSlide(\d+)\.xml", path.name)) + } + index = 1 + while index in used: + index += 1 + return f"ppt/notesSlides/notesSlide{index}.xml" + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + + extract = sub.add_parser("extract", help="Extract slide text to JSON.") + extract.add_argument("pptx", type=Path) + extract.add_argument("output_json", type=Path) + + template = sub.add_parser("template", help="Create a notes JSON template from a PPTX.") + template.add_argument("pptx", type=Path) + template.add_argument("output_json", type=Path) + + apply = sub.add_parser("apply", help="Write generated notes JSON back into a PPTX.") + apply.add_argument("pptx", type=Path) + apply.add_argument("notes_json", type=Path) + apply.add_argument("output_pptx", type=Path) + + args = parser.parse_args(argv) + + if args.command in {"extract", "template"}: + slides = extract_deck(args.pptx) + args.output_json.write_text( + json.dumps({"slides": slides}, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return 0 + + if args.command == "apply": + if args.output_pptx.resolve() == args.pptx.resolve(): + backup = args.pptx.with_suffix(".backup.pptx") + shutil.copy2(args.pptx, backup) + write_notes(args.pptx, args.notes_json, args.output_pptx) + return 0 + + return 2 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:]))