
Pptx To Md
- 32 installs
- 154 repo stars
- Updated July 30, 2026
- sammcj/agentic-coding
Helps with ai & agent building tasks.
About
pptx-to-md is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- pptx-to-md
- AI & Agent Building
- AI-coding skill
Pptx To Md by the numbers
- 32 all-time installs (skills.sh)
- +3 installs in the week ending Jul 26, 2026 (Skillselion tracking)
- Ranked #9,000 of 16,556 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 1, 2026 (Skillselion catalog sync)
npx skills add https://github.com/sammcj/agentic-coding --skill pptx-to-mdAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 32 |
|---|---|
| repo stars | ★ 154 |
| Last updated | July 30, 2026 |
| Repository | sammcj/agentic-coding ↗ |
What it does
Helps with ai & agent building tasks.
Files
Extract PPTX to per-slide markdown
This skill turns a .pptx (or .pdf) file into one markdown file per slide, preserving layout context and image meaning. It does not paraphrase the text or describe images out of context. The output is suitable as input to a content uplift pass, a markdown-to-HTML build, or any other downstream transform.
When to use
The deck mixes text with embedded screenshots, diagrams, charts, or code samples in a layout that matters (columns, side-by-side panels, callouts). Plain text extraction would lose either the layout or the meaning of the images.
IMPORTANT: If the deck is: a PDF, text-only or if it has no images that are meaningful to the content, uvx 'markitdown[all]' <path-to-file> -o output.md is faster and usually sufficient without going through this skill's more complex pipeline as described below. You can try this and ask the user to review the output letting them know that if it's not sufficient you will continue with the more complex slide extraction pipeline.
Pipeline
PPTX -> prepare.py -> manifests + rendered JPGs + embedded PNGs
-> dispatch one sub-agent per slide
-> per-slide markdown files
-> concatenate.py -> deck.mdThe orchestrator (you, in the calling session) does two things: run the prepare and concatenate scripts, and dispatch one sub-agent per visible slide. Each sub-agent does the actual vision-and-text composition for one slide and writes one markdown file. Sub-agents are independent so they parallelise cleanly.
Step 1 - prepare the workspace
python <skill>/scripts/prepare.py <pptx-path> <workspace-dir>This unzips the PPTX, renders every visible slide to a JPG via LibreOffice and pdftoppm, then writes one manifest JSON per visible slide. After rendering, the script checks the JPG count matches the slide count and warns to stderr if they diverge - LibreOffice has been known to silently drop slides, so always read that warning before dispatching sub-agents.
Hidden slides (those with show="0" in the slide XML) are skipped by default. Pass --include-hidden to render and manifest them too; this enables the LibreOffice ExportHiddenSlides filter so hidden slides get real JPGs, not empty ones.
The workspace looks like:
workspace/
unpacked/ raw OOXML
rendered/ PDF + ordered slide JPGs
manifests/slideNN.json per-slide manifest
slide_images/slideNN.jpg rendered whole-slide JPG (stable name)
embedded_images/slideNN/ embedded PNGs grouped per slide
slides/ empty - sub-agents write slideNN.md here
deck_index.json {visible, hidden, src_to_render}Step 2 - pilot before scaling
Pick 2-4 slides that span the deck's variety: one dense, one with screenshots, one with a custom diagram, one with sparse text. Dispatch sub-agents for those first using the prompt template at references/sub_agent_prompt.md, and compare the output against the exemplar in references/example_slide.md. Adjust the deck-specific notes in the prompt if needed (terminology to keep verbatim, terms to flag, conventions to enforce). Only then fan out to the rest of the deck.
The pilot is not optional. Per-deck variation in image style, layout density, and terminology means a prompt that works perfectly for one deck may produce bland or duplicated output on another.
Step 3 - dispatch sub-agents in parallel
For each visible slide, dispatch one fresh sub-agent (named subagent type, not a fork - the agent only needs its manifest, so a fresh context is cheaper than inheriting the orchestrator's history). Each agent reads its manifest, the rendered slide JPG, and the embedded PNGs, then writes one slideNN.md.
To get the dispatch list, run:
python <skill>/scripts/dispatch_list.py <workspace-dir>This emits one tab-separated row per slide that still needs a sub-agent (slide_number, manifest_path, output_path), skipping slides whose markdown already exists. That makes reruns after a partial failure trivial - no slide is re-extracted unnecessarily.
Suggested batching: 6 sub-agents per wave. Larger waves work but produce more interleaved completion notifications, which is noisier without being faster. Each sub-agent typically takes 20-50 seconds.
The exact prompt to send each agent is in references/sub_agent_prompt.md. Substitute the placeholders before sending using str.replace() (not str.format() - the template contains literal braces). The prompt's rules - length-tuned image descriptions, external-links de-duplication, single-line reply - are not stylistic; they were each added after a real failure mode in earlier runs. See the prompt template for the rationale.
Step 4 - concatenate
python <skill>/scripts/concatenate.py <workspace-dir> [--out deck.md] [--title "Deck title"]This stitches the per-slide markdown into a single deck.md in source-slide order with a header noting any hidden slides that were excluded. It refuses to run if any expected slide markdown is missing, which is the right behaviour - a partial deck is rarely what's wanted.
Gotchas
LibreOffice's PDF export skips hidden slides by default. This means the rendered JPG index drifts from the source slide number. prepare.py builds a src_to_render map and copies each rendered JPG to a stable slide_images/slideNN.jpg path keyed by source slide number, so manifests reference a stable name. If you regenerate the renders later, rerun prepare.py so the mapping stays fresh.
Speaker notes mapping is not always 1:1. This skill reads the slide-to-notesSlide relationship from the slide's _rels file rather than assuming slideN.xml maps to notesSlideN.xml. Most decks happen to be 1:1 but it isn't guaranteed.
Source XML can have typos. The pipeline preserves them verbatim. Correct them in a later content-uplift pass, not during extraction - this keeps extraction deterministic.
Don't fork the sub-agents. A fork inherits the orchestrator's full conversation context, which is large and unrelated. The sub-agent only needs the manifest. Use a named subagent type (e.g. general-purpose) so each starts fresh.
Hidden slides are excluded by default. Most decks contain hidden slides for a reason (work-in-progress, deprecated content, internal-only notes). Pass --include-hidden only when you want them in the output - the script then renders them properly via LibreOffice's ExportHiddenSlides filter, so they get real JPGs in the manifests.
Dense screenshots benefit from higher DPI. The default is 150 DPI. For decks where text inside screenshots needs to be readable in the rendered JPG, pass --dpi 200 or --dpi 250 to prepare.py.
Dependencies
- LibreOffice (
soffice) on PATH - Poppler (
pdftoppm) on PATH - Python 3.10+
The prepare script checks for both binaries and exits early with a clear error if either is missing.
Example output: a single extracted slide
A synthetic example showing both image-description length tiers and the full slide structure. Use it during the pilot to calibrate output before fanning out.
The depicted slide has a title, a left column of bullets, a screenshot of a config file on the right, and a small brand icon in the corner.
---
---
slide: 7
title: Configuring MCP Servers in settings.json
---
# Configuring MCP Servers in settings.json
MCP servers are declared in the `mcpServers` block of your settings file. Each entry needs:
- A unique server name (used as the namespace prefix for its tools)
- A `command` and optional `args` to launch the server process
- Optional `env` for credentials or feature flags
Settings can live at the user level (applies everywhere) or the project level (applies to one repo). User-level settings load first; project-level settings override matching keys.
> **Image: settings.json with three MCP servers configured**
> A VS Code editor view of a settings.json file with the `mcpServers` object expanded. Three servers are configured: a `filesystem` server pointing at the repo root, a `github` server with a `GITHUB_TOKEN` env var, and a `postgres` server with a connection-string arg. The `github` token line is highlighted in yellow with a margin annotation reading "store secrets in 1Password, not the file". Conveys both the shape of a real config and the reviewer's primary security concern.
> Source: `embedded_images/slide07/image12.png`
> **Image: Brand mark**
> Small circular brand logo in the slide corner.
> Source: `embedded_images/slide07/image01.png`
## Speaker notes
Walk through one server entry line by line. The audience is usually unsure where the file lives; show both `~/.claude/settings.json` and `.claude/settings.json` paths before moving on. If anyone asks about secrets, redirect to slide 9.
## External links
- https://docs.claude.com/en/docs/agents-and-tools/mcp---
What this example demonstrates
- YAML frontmatter with the source slide number and a title inferred from the slide.
- Body text taken verbatim from
source_text_paragraphs, woven into the layout observed in the rendered JPG (left bullets, right screenshot). - Content-bearing image gets a description specific enough that a reader without the image still grasps what was on screen and why it matters. Note the call-out on the highlighted line - that comes from looking at the rendered slide, not just the standalone PNG.
- Decorative image gets one sentence. The brand mark adds no information, so a longer description would be noise.
- Speaker notes verbatim, even if terse.
- External links present because that URL appeared on the slide and is not already in the body.
Slide-extractor sub-agent prompt template
This prompt is dispatched to one fresh sub-agent per slide. Substitute the placeholders before sending. Each agent runs independently with no shared state - all context comes from its manifest.
Placeholders
{SLIDE_NUMBER}- source slide number (e.g. 12){TOTAL_SLIDES}- total source slide count (e.g. 66){DECK_TITLE}- human-readable deck title (e.g. "Effective Agentic Coding"){MANIFEST_PATH}- absolute path to slideNN.json{OUTPUT_PATH}- absolute path the agent should write slideNN.md to{EMBEDDED_DIR_RELATIVE}- relative path under the workspace where embedded images live (e.g.embedded_images/slideNN/){DECK_SPECIFIC_NOTES}- optional - any deck-specific guardrails (e.g. "keep references to 'Cline' verbatim; we modernise content in a later pass")
Substituting placeholders
The template body contains literal { and } braces inside the markdown structure shown to the agent (the YAML frontmatter, the image blockquote shape). Use str.replace() to fill in placeholders, not Python's str.format() or f-strings - those would try to parse every brace as a field and fail.
prompt = template.replace("{SLIDE_NUMBER}", str(n)).replace("{TOTAL_SLIDES}", str(total)) # etc.Template
Extract source slide {SLIDE_NUMBER} of {TOTAL_SLIDES} from a deck "{DECK_TITLE}" into a high-fidelity markdown file.
Inputs at `{MANIFEST_PATH}`:
- `rendered_slide_jpg`: full slide as JPG (use for layout AND text the XML missed)
- `embedded_images`: high-res PNGs for individual visuals
- `source_text_paragraphs`: authoritative verbatim text from slide XML - ground truth
- `speaker_notes_paragraphs`: speaker notes
- `external_links`: hyperlinks on the slide
Steps:
1. Read the manifest JSON.
2. Read the rendered slide JPG to understand layout.
3. Read each embedded image to understand its content.
4. Write `{OUTPUT_PATH}`:
---
slide: {SLIDE_NUMBER}
title: <inferred>
---
# <Title>
<Body using source_text_paragraphs verbatim, woven into the layout from the rendered JPG. Preserve columns, panels, callouts, ordering.>
<Where an embedded image sits in the layout, insert at that position:>
> **Image: <short label>**
> <Description of what's shown. Length depends on the image:
> - Decorative or repeating visuals (icons, brand motifs, generic graphics, repeated bricks/shapes): ONE sentence.
> - Screenshots, diagrams, code blocks, content-bearing visuals: 2-4 sentences with specifics so a reader without the image still grasps WHY it's on the slide.>
> Source: `{EMBEDDED_DIR_RELATIVE}<image filename>`
## Speaker notes
<Verbatim notes, or "_(none)_" if empty.>
## External links
<Bulleted list ONLY for links that appear on the slide but are NOT already in the body content above. Omit the section entirely if all links are already in the body, or if there are none.>
Rules:
- Use source_text_paragraphs as authoritative wording. Do not paraphrase.
- The rendered JPG is for layout, ordering, and reading text the XML missed (e.g. SmartArt, grouped shapes). Include such text inline.
- Australian English. No emojis. No marketing fluff. Plain hyphens, plain quotes, no em-dashes.
{DECK_SPECIFIC_NOTES}
Reply with one short line confirming the path written and the inferred title. Nothing else.Why each rule matters
- Three inputs in one agent. Source text alone loses image context. Image
captions alone lose layout context. Giving the agent text + rendered slide + standalone PNGs lets it use each input for what it's best at: source text as ground truth, rendered slide for layout and any text the XML missed (SmartArt, grouped shapes), individual PNGs for high-res image description.
- Length-tuned image descriptions. Without this rule, agents write the
same 2-4 sentence description for every visual, including decorative brand icons that appear on every slide. The "one sentence for decorative, 2-4 for content-bearing" rule was added after a pilot showed repetitive descriptions of identical lego-brick icons.
- External links de-duplication. Agents tend to copy URLs both inline
in the body (where they appear on the slide) and into a dedicated External links section. The rule keeps each URL in one place.
- Single-line reply. Each agent returns only a confirmation line so the
orchestrator's context stays clean across many parallel runs.
Optional deck-specific notes
When the source deck has terminology that should not be modernised during extraction (e.g. references to a deprecated tool that the user wants kept verbatim until a later content-uplift pass), append a line like:
- The deck references "Cline" (an older agent) in places - keep verbatim; do not modernise.Add it via {DECK_SPECIFIC_NOTES} rather than rewriting the template.
#!/usr/bin/env python3
"""Concatenate per-slide markdown into a single deck.md.
Reads deck_index.json from a workspace produced by prepare.py, then stitches
the per-slide markdown files in source-slide order with a brief header that
notes which hidden slides were excluded.
Usage:
python concatenate.py WORKSPACE_DIR [--out deck.md] [--title "Deck title"]
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("workspace", type=Path, help="workspace directory produced by prepare.py")
ap.add_argument("--out", type=Path, default=None, help="output path (default: WORKSPACE/deck.md)")
ap.add_argument("--title", type=str, default=None, help="optional deck title for the header")
args = ap.parse_args()
ws = args.workspace
index_path = ws / "deck_index.json"
if not index_path.exists():
sys.exit(f"error: {index_path} not found - run prepare.py first")
index = json.loads(index_path.read_text())
slides_dir = ws / "slides"
extracted = index.get("extracted") or index.get("visible") or []
hidden = index.get("hidden") or []
missing = [sn for sn in extracted if not (slides_dir / f"slide{sn:02d}.md").exists()]
if missing:
sys.exit(
f"error: missing slide markdown for: {missing}. "
"Dispatch sub-agents for these slides before concatenating."
)
out = args.out or (ws / "deck.md")
title = args.title or "Deck"
header_lines = [f"# {title}", ""]
if hidden:
header_lines += [
"Hidden slides excluded: " + ", ".join(str(h) for h in hidden) + ".",
"",
]
header_lines += ["---", "", ""]
parts = ["\n".join(header_lines)]
for sn in extracted:
body = (slides_dir / f"slide{sn:02d}.md").read_text().rstrip()
parts.append(body + "\n\n---\n\n")
out.write_text("".join(parts))
print(f"wrote {out} ({len(extracted)} slides, hidden: {hidden if hidden else 'none'})")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Print one tab-separated row per slide that still needs a sub-agent.
Each row is `slide_number<TAB>manifest_path<TAB>output_path`. Slides whose
output markdown already exists are skipped, so reruns only target missing
work. Pipe into a parallel dispatch loop, or read into the orchestrator to
batch sub-agent calls.
Usage:
python dispatch_list.py WORKSPACE_DIR [--all]
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("workspace", type=Path, help="workspace directory produced by prepare.py")
ap.add_argument("--all", action="store_true", help="emit all slides, not just missing ones")
args = ap.parse_args()
index_path = args.workspace / "deck_index.json"
if not index_path.exists():
sys.exit(f"error: {index_path} not found - run prepare.py first")
index = json.loads(index_path.read_text())
extracted = index.get("extracted") or index.get("visible") or []
manifests = args.workspace / "manifests"
slides = args.workspace / "slides"
for sn in extracted:
out = slides / f"slide{sn:02d}.md"
if out.exists() and not args.all:
continue
m = manifests / f"slide{sn:02d}.json"
print(f"{sn}\t{m}\t{out}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Prepare a PPTX for slide-by-slide extraction.
Unpacks the PPTX, renders each visible slide to a JPG via LibreOffice and
pdftoppm, then writes one manifest JSON per visible slide. Each manifest
contains everything a sub-agent needs to compose markdown for that slide:
verbatim text from XML, the rendered slide JPG, the embedded PNG paths in
layout order, speaker notes, and external links.
Usage:
python prepare.py PPTX_PATH WORKSPACE_DIR [--dpi 150] [--include-hidden]
Workspace layout produced:
WORKSPACE_DIR/
unpacked/ raw OOXML
rendered/ PDF + ordered slide JPGs from LibreOffice
manifests/slideNN.json one per visible slide
slide_images/slideNN.jpg rendered whole-slide JPG (stable name)
embedded_images/slideNN/ embedded PNGs grouped per slide
slides/ empty - sub-agents write slideNN.md here
deck_index.json {hidden, visible, src_to_render}
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
NS_A = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
def slide_num(filename: str) -> int:
m = re.search(r"(\d+)", filename)
return int(m.group(1)) if m else 0
def unpack_pptx(pptx_path: Path, dest: Path) -> None:
if dest.exists():
shutil.rmtree(dest)
dest.mkdir(parents=True)
with zipfile.ZipFile(pptx_path) as zf:
zf.extractall(dest)
def render_deck(pptx_path: Path, out_dir: Path, dpi: int, include_hidden: bool = False) -> int:
"""Convert PPTX -> PDF (LibreOffice) -> per-slide JPGs (pdftoppm).
Returns the number of JPGs produced. LibreOffice skips hidden slides
by default; pass include_hidden=True to render them too.
"""
out_dir.mkdir(parents=True, exist_ok=True)
soffice = shutil.which("soffice") or shutil.which("libreoffice")
if not soffice:
sys.exit("error: 'soffice' (LibreOffice) not found on PATH")
pdftoppm = shutil.which("pdftoppm")
if not pdftoppm:
sys.exit("error: 'pdftoppm' (poppler) not found on PATH")
if include_hidden:
# JSON-form filter argument keeps hidden slides in the PDF.
convert_to = (
'pdf:impress_pdf_Export:'
'{"ExportHiddenSlides":{"type":"boolean","value":"true"}}'
)
else:
convert_to = "pdf"
subprocess.run(
[soffice, "--headless", "--convert-to", convert_to, "--outdir", str(out_dir), str(pptx_path)],
check=True,
)
pdfs = list(out_dir.glob("*.pdf"))
if not pdfs:
sys.exit("error: PDF conversion produced no output")
pdf = pdfs[0]
subprocess.run([pdftoppm, "-jpeg", "-r", str(dpi), str(pdf), str(out_dir / "slide")], check=True)
return len(list(out_dir.glob("slide-*.jpg")))
def list_slides(unpacked: Path) -> tuple[list[int], list[int]]:
"""Return (visible_slide_nums, hidden_slide_nums) in source order."""
slides_dir = unpacked / "ppt" / "slides"
visible, hidden = [], []
files = sorted([f for f in slides_dir.iterdir() if f.suffix == ".xml"], key=lambda p: slide_num(p.name))
for f in files:
n = slide_num(f.name)
tree = ET.parse(f)
if tree.getroot().get("show") == "0":
hidden.append(n)
else:
visible.append(n)
return visible, hidden
def slide_text(unpacked: Path, n: int) -> list[str]:
tree = ET.parse(unpacked / "ppt" / "slides" / f"slide{n}.xml")
out: list[str] = []
for p in tree.getroot().iter(f"{NS_A}p"):
runs = [r.text for r in p.iter(f"{NS_A}t") if r.text]
line = "".join(runs).strip()
if line:
out.append(line)
return out
def slide_notes(unpacked: Path, slide_n: int) -> list[str]:
"""Read speaker notes for slide_n by following the slide's _rels."""
rels = unpacked / "ppt" / "slides" / "_rels" / f"slide{slide_n}.xml.rels"
if not rels.exists():
return []
notes_target = None
for rel in ET.parse(rels).getroot():
if "notesSlide" in rel.get("Type", ""):
notes_target = rel.get("Target", "")
break
if not notes_target:
return []
m = re.search(r"notesSlide(\d+)\.xml", notes_target)
if not m:
return []
notes_path = unpacked / "ppt" / "notesSlides" / f"notesSlide{m.group(1)}.xml"
if not notes_path.exists():
return []
out: list[str] = []
for p in ET.parse(notes_path).getroot().iter(f"{NS_A}p"):
runs = [r.text for r in p.iter(f"{NS_A}t") if r.text]
line = "".join(runs).strip()
if line and not re.fullmatch(r"\d+", line):
out.append(line)
return out
def slide_rels(unpacked: Path, n: int) -> tuple[list[str], list[str]]:
"""Return (image_filenames, external_link_urls) referenced by slide n."""
rels = unpacked / "ppt" / "slides" / "_rels" / f"slide{n}.xml.rels"
if not rels.exists():
return [], []
images, links = [], []
for rel in ET.parse(rels).getroot():
target = rel.get("Target", "")
rtype = rel.get("Type", "")
if "image" in rtype.lower() or target.startswith("../media/"):
images.append(os.path.basename(target))
elif "hyperlink" in rtype.lower():
links.append(target)
return images, links
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("pptx", type=Path, help="path to .pptx file")
ap.add_argument("workspace", type=Path, help="output workspace directory")
ap.add_argument("--dpi", type=int, default=150, help="JPG render DPI (default: 150)")
ap.add_argument(
"--include-hidden",
action="store_true",
help="treat hidden slides as visible (default: skip them)",
)
args = ap.parse_args()
if not args.pptx.is_file():
sys.exit(f"error: not a file: {args.pptx}")
ws = args.workspace
ws.mkdir(parents=True, exist_ok=True)
unpacked = ws / "unpacked"
rendered = ws / "rendered"
manifests = ws / "manifests"
slide_imgs = ws / "slide_images"
embedded = ws / "embedded_images"
slides_md = ws / "slides"
for d in (manifests, slide_imgs, embedded, slides_md):
d.mkdir(parents=True, exist_ok=True)
print(f"[1/4] unpacking {args.pptx.name}")
unpack_pptx(args.pptx, unpacked)
print(f"[2/4] rendering deck (LibreOffice + pdftoppm @ {args.dpi} dpi"
f"{', including hidden' if args.include_hidden else ''})")
jpg_count = render_deck(args.pptx, rendered, args.dpi, include_hidden=args.include_hidden)
print("[3/4] indexing slides")
visible, hidden = list_slides(unpacked)
# target is the set of slides we will produce manifests for, in source order.
# LibreOffice emits the rendered JPGs in the same order it processed slides,
# so target order maps 1:1 onto the JPG sequence whether hidden are included or not.
target = sorted(set(visible) | set(hidden)) if args.include_hidden else visible
src_to_render = {sn: i + 1 for i, sn in enumerate(target)}
if jpg_count != len(target):
print(
f"warning: rendered {jpg_count} JPGs but expected {len(target)}. "
"LibreOffice may have silently dropped a slide. "
"Check rendered/ before dispatching sub-agents.",
file=sys.stderr,
)
print(f"[4/4] writing manifests for {len(target)} slides")
media_dir = unpacked / "ppt" / "media"
written: list[str] = []
for sn in target:
text = slide_text(unpacked, sn)
notes = slide_notes(unpacked, sn)
imgs, links = slide_rels(unpacked, sn)
rendered_jpg_path = None
if sn in src_to_render:
rj = rendered / f"slide-{src_to_render[sn]:02d}.jpg"
if rj.exists():
stable = slide_imgs / f"slide{sn:02d}.jpg"
shutil.copy(rj, stable)
rendered_jpg_path = str(stable)
per_slide_dir = embedded / f"slide{sn:02d}"
per_slide_dir.mkdir(exist_ok=True)
embedded_paths: list[str] = []
for im in imgs:
src = media_dir / im
if src.exists():
dst = per_slide_dir / im
if not dst.exists():
shutil.copy(src, dst)
embedded_paths.append(str(dst))
manifest = {
"source_slide_number": sn,
"is_hidden": sn in hidden,
"rendered_slide_jpg": rendered_jpg_path,
"embedded_images": embedded_paths,
"external_links": links,
"source_text_paragraphs": text,
"speaker_notes_paragraphs": notes,
"output_markdown_path": str(slides_md / f"slide{sn:02d}.md"),
}
mpath = manifests / f"slide{sn:02d}.json"
mpath.write_text(json.dumps(manifest, indent=2))
written.append(str(mpath))
index = {
"pptx": str(args.pptx),
"workspace": str(ws),
"visible": visible,
"hidden": hidden,
"extracted": target,
"src_to_render": {str(k): v for k, v in src_to_render.items()},
}
(ws / "deck_index.json").write_text(json.dumps(index, indent=2))
print(f"done. workspace: {ws}")
print(f" visible slides: {len(visible)}")
print(f" hidden slides: {hidden if hidden else 'none'}")
print(f" manifests: {len(written)}")
print(f" next: dispatch sub-agents using references/sub_agent_prompt.md, one per manifest")
if __name__ == "__main__":
main()