PowerPoint Decks (.pptx) skill for Claude Code
On-brand slides you've actually looked at. Claude writes a takeaway-titled outline first, then builds slides from your template's layouts by name, filling placeholders rather than scattering text boxes. Charts are native and editable, notes carry the detail, and theme fonts stay intact. A bundled linter flags missing titles, overflow and empty placeholders, and every deck is rendered to images and reviewed before handoff.
- name
pptx-decks - size194 lines · ≈1,950 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds and edits PowerPoint .pptx decks with python-pptx — starting from the brand template, filling layout placeholders instead of free-floating text boxes, native editable charts and tables, speaker notes, consistent theme fonts and colors, 16:9 sizing, and a render-and-look verification loop with LibreOffice. Use when the user wants slides generated from data, an outline, or a document; an existing deck updated or restyled; charts added to a presentation; or a .pptx checked for overflow and consistency. Try asking
- Turn this quarterly metrics CSV into a 10-slide deck using our brand template
- Update the charts in last quarter's deck with these new numbers
- Check this deck for overflowing text and slides without titles
Use it when
- A presentation must be generated from data, notes, or a document
- An existing deck needs new numbers, slides, or restyling
- Native editable charts or tables must go into slides
- A deck needs a quality pass before it is presented
Skip it when
- You need a diagram rendered from text rather than slides — use mermaid-diagrams instead
- The deliverable is a written document, not a presentation — use docx-documents instead
How to check the result
The lint script reports no unexplained warnings, the titles alone read as a coherent story, and slides rendered to PNG through LibreOffice show no overflow, overlap or empty prompts.
The common mistake it prevents
Adding free-floating text boxes instead of filling layout placeholders, which breaks the outline view, accessibility, and any later theme change.
Done well means: every slide comes from a layout in the user’s template, text sits in placeholders (so titles feed the outline, accessibility, and theme changes), charts are native and editable, nothing overflows or runs off the slide, and you rendered the deck to images and looked at it before calling it finished.
Setup
pip install python-pptx
# Rendering check (recommended): LibreOffice + poppler
# macOS: brew install --cask libreoffice && brew install poppler
# Debian/Ubuntu: apt install libreoffice-impress poppler-utils
Workflow
- [ ] 1. Get the content as a slide-by-slide outline (one message per slide)
- [ ] 2. Inspect the template: layouts and placeholder idx values
- [ ] 3. Map each outline slide to a layout
- [ ] 4. Generate: fill placeholders, add native charts/tables, add notes
- [ ] 5. Lint with the outline script
- [ ] 6. Render to PNG and review visually; fix and repeat
1. Outline first
Write the outline before code: for each slide, a title that states the takeaway (“Churn fell to 1.8% after onboarding changes”, not “Churn”), up to 3–5 short bullets or one visual, and speaker notes for detail. Long prose belongs in notes or an appendix.
2. Inspect the template
python3 scripts/pptx_outline.py template.pptx --layouts
Prints every master’s layouts with placeholder idx, type, and name. Placeholder idx
values are stable per layout; positional indexing (slide.placeholders[1]) means idx 1,
not “the second one”. Without a template, Presentation() gives a 4:3 default theme —
set 16:9 explicitly and expect a plain look.
3. Layout mapping
| Content | Layout (typical names) |
|---|---|
| Opening | Title Slide |
| Section break | Section Header |
| Message + bullets | Title and Content |
| Comparison | Two Content / Comparison |
| Chart or image with headline | Title Only (then add the chart below the title) |
| Full-bleed image | Blank or Picture with Caption |
Select layouts by name, not index, so template changes do not silently shift them:
def layout(prs, name):
for master in prs.slide_masters:
for lo in master.slide_layouts:
if lo.name == name:
return lo
raise KeyError(f"layout {name!r} not in template")
4. Generate
from pptx import Presentation
from pptx.chart.data import CategoryChartData
from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION
from pptx.util import Inches, Pt
prs = Presentation("brand-template.pptx") # inherits theme fonts, colors, masters
# For a blank start: prs = Presentation(); prs.slide_width, prs.slide_height = Inches(13.333), Inches(7.5)
s = prs.slides.add_slide(layout(prs, "Title Slide"))
s.shapes.title.text = "Q3 Business Review"
s.placeholders[1].text = "Finance | October 2026"
s = prs.slides.add_slide(layout(prs, "Title and Content"))
s.shapes.title.text = "Revenue grew 12% on enterprise expansion"
tf = s.placeholders[1].text_frame
tf.text = "Enterprise ARR +18% QoQ"
for text, level in [("Three new seven-figure contracts", 1), ("SMB flat; pricing test in Q4", 0)]:
p = tf.add_paragraph(); p.text = text; p.level = level
s.notes_slide.notes_text_frame.text = "Call out the Acme renewal; numbers are unaudited."
s = prs.slides.add_slide(layout(prs, "Title Only"))
s.shapes.title.text = "Enterprise now drives most new revenue"
data = CategoryChartData()
data.categories = ["Q4 25", "Q1 26", "Q2 26", "Q3 26"]
data.add_series("Enterprise", (1.1, 1.3, 1.6, 1.9))
data.add_series("SMB", (0.9, 0.9, 0.95, 0.94))
chart = s.shapes.add_chart(XL_CHART_TYPE.COLUMN_CLUSTERED, Inches(0.7), Inches(1.6),
Inches(11.9), Inches(5.3), data).chart
chart.has_legend = True
chart.legend.position = XL_LEGEND_POSITION.BOTTOM
chart.legend.include_in_layout = False
chart.value_axis.has_major_gridlines = False
chart.value_axis.tick_labels.number_format = '0.0"M"'
chart.value_axis.tick_labels.number_format_is_linked = False
prs.save("q3-review.pptx")
Tables:
rows, cols = len(table_data), len(table_data[0])
gt = s.shapes.add_table(rows, cols, Inches(0.7), Inches(1.6), Inches(11.9), Inches(0.4) * rows).table
for r, row in enumerate(table_data):
for c, value in enumerate(row):
cell = gt.cell(r, c)
cell.text = str(value)
cell.text_frame.paragraphs[0].font.size = Pt(14)
Keep tables to about 6 rows × 5 columns on a slide; move the rest to an appendix or a chart.
Images: s.shapes.add_picture("photo.jpg", left, top, width=Inches(6)) (set one
dimension to keep aspect ratio), and set alt text:
pic._element.nvPicPr.cNvPr.set("descr", "Bar chart of ...") — python-pptx has no public
alt-text API.
Styling rules:
- Let the theme supply fonts and colors. Set
font.size/boldsparingly for emphasis; avoidfont.nameand RGB colors unless matching an explicit brand spec. - Minimum readable sizes when projected: titles ≥ 28 pt, body ≥ 18 pt, chart labels ≥ 12 pt.
- One idea per slide; ≤ 6 bullets; ≤ ~40 words of body text on most slides.
- Align to the layout’s placeholder edges; do not scatter manual text boxes.
- Charts: remove chart junk (gridlines, 3D, redundant legends), label units, start bar axes at zero, and highlight the series that carries the message.
5. Lint
python3 scripts/pptx_outline.py q3-review.pptx
Prints the deck outline (layout, title, body first lines, notes, charts/tables/images per slide) and warnings: missing titles, empty placeholders that show “Click to add text”, likely text overflow, too many words or bullets, shapes off the slide, pictures without alt text, and more than three explicitly set fonts. Exit code 1 when warnings exist.
6. Render and look
soffice --headless --convert-to pdf --outdir render/ q3-review.pptx
pdftoppm -png -r 50 render/q3-review.pdf render/slide
View the PNGs (Claude can read images). Check for overflow, overlaps, unreadable contrast, inconsistent title positions, and orphan bullets. LibreOffice rendering differs slightly from PowerPoint (fonts substitute if the theme font is not installed); judge layout, not exact kerning, and mention font substitution if visible.
Editing an existing deck
- Change text inside existing runs to keep formatting: set
run.textonshape.text_frame.paragraphs[i].runs[j]rather thanshape.text = ..., which resets run formatting. - Replace chart data in place:
chart.replace_data(new_chart_data)keeps styling. - python-pptx has no public API to delete, duplicate, or reorder slides. Reorder via the
XML slide id list (
prs.slides._sldIdLst), and delete by removing the id and dropping the relationship. Keep these private-API helpers small and verify the output opens without repair prompts in PowerPoint (or at least renders in LibreOffice). - Save to a new filename; never overwrite the user’s original without being asked.
Gotchas
text_frame.fit_text()needs the font file available locally and can fail on servers; prefer shortening text or using a layout with more room.MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPEonly takes effect when PowerPoint re-renders; the file itself is not shrunk, so LibreOffice previews may still overflow.- A placeholder inherits its position from the layout; moving it on one slide breaks consistency. Change the layout instead.
- Charts embed an Excel workbook for their data; very large datasets bloat the file. Aggregate before charting.
- Slide size must be set before adding slides; changing it later does not reflow content.
Verification
- Lint script exits 0 or every remaining warning is deliberate and explained.
- Rendered PNGs reviewed: no overflow, overlap, off-slide content, or empty prompts.
- Outline printed by the script reads as a coherent story from titles alone.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/pptx_outline.py
#!/usr/bin/env python3
"""Outline and lint a .pptx deck: layouts, placeholders, text, notes, images, charts,
plus heuristics for overflowing text, empty placeholders, off-slide shapes, and font sprawl.
Requires: pip install python-pptx
Usage:
python3 pptx_outline.py deck.pptx
python3 pptx_outline.py deck.pptx --layouts # list the template's layouts and placeholder idx
python3 pptx_outline.py deck.pptx --json
Exit codes: 0 = no lint warnings, 1 = warnings found, 2 = cannot open / missing dependency.
"""
import argparse
import collections
import json
import sys
try:
from pptx import Presentation
from pptx.util import Emu
except ImportError:
print("error: python-pptx is required: pip install python-pptx", file=sys.stderr)
sys.exit(2)
MAX_WORDS_PER_SLIDE = 90 # body text beyond this rarely reads well when projected
MAX_BULLETS = 7
CHARS_PER_SQ_INCH = 22 # rough capacity at ~18pt; used only to flag likely overflow
def list_layouts(prs):
for m_i, master in enumerate(prs.slide_masters):
print(f"master {m_i}")
for l_i, layout in enumerate(master.slide_layouts):
phs = ", ".join(f"idx={p.placeholder_format.idx}:{p.placeholder_format.type}:{p.name}"
for p in layout.placeholders)
print(f" layout {l_i} '{layout.name}': {phs}")
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("pptx")
ap.add_argument("--layouts", action="store_true")
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
try:
prs = Presentation(args.pptx)
except Exception as exc: # noqa: BLE001
print(f"error: cannot open {args.pptx}: {exc}", file=sys.stderr)
return 2
if args.layouts:
list_layouts(prs)
return 0
sw, sh = prs.slide_width, prs.slide_height
fonts = collections.Counter()
slides, warnings = [], []
for n, slide in enumerate(prs.slides, start=1):
info = {"slide": n, "layout": slide.slide_layout.name, "title": None, "text": [],
"images": 0, "charts": 0, "tables": 0, "notes": ""}
words = bullets = 0
for shape in slide.shapes:
if shape.left is not None and shape.width is not None:
if shape.left < 0 or shape.top < 0 or shape.left + shape.width > sw or shape.top + shape.height > sh:
warnings.append(f"slide {n}: shape '{shape.name}' extends beyond the slide")
if shape.shape_type == 13: # PICTURE
info["images"] += 1
if getattr(shape, "has_chart", False) and shape.has_chart:
info["charts"] += 1
if getattr(shape, "has_table", False) and shape.has_table:
info["tables"] += 1
if shape.is_placeholder and shape.has_text_frame and not shape.text_frame.text.strip():
if shape.placeholder_format.type is not None and "PICTURE" not in str(shape.placeholder_format.type):
warnings.append(f"slide {n}: empty placeholder '{shape.name}' (shows prompt text in edit view)")
if not shape.has_text_frame:
continue
text = shape.text_frame.text.strip()
if not text:
continue
is_title = shape.is_placeholder and "TITLE" in str(shape.placeholder_format.type)
if is_title and info["title"] is None:
info["title"] = text
else:
info["text"].append(text)
words += len(text.split())
bullets += sum(1 for p in shape.text_frame.paragraphs if p.text.strip())
for p in shape.text_frame.paragraphs:
for r in p.runs:
if r.font.name:
fonts[r.font.name] += 1
area_sq_in = (Emu(shape.width).inches * Emu(shape.height).inches) if shape.width else 0
if area_sq_in and len(text) > area_sq_in * CHARS_PER_SQ_INCH:
warnings.append(f"slide {n}: '{shape.name}' may overflow ({len(text)} chars in {area_sq_in:.1f} sq in)")
if slide.has_notes_slide:
info["notes"] = slide.notes_slide.notes_text_frame.text.strip()
if info["title"] is None:
warnings.append(f"slide {n}: no title (hurts navigation and accessibility)")
if words > MAX_WORDS_PER_SLIDE:
warnings.append(f"slide {n}: {words} words of body text")
if bullets > MAX_BULLETS:
warnings.append(f"slide {n}: {bullets} bullet paragraphs")
for shape in slide.shapes:
if shape.shape_type == 13:
descr = shape._element.xpath("./p:nvPicPr/p:cNvPr/@descr")
if not descr or not descr[0].strip():
warnings.append(f"slide {n}: picture '{shape.name}' has no alt text")
slides.append(info)
if len(fonts) > 3:
warnings.append(f"deck uses {len(fonts)} explicitly set fonts: {dict(fonts.most_common())}")
report = {"file": args.pptx, "slide_size_in": [round(Emu(sw).inches, 3), round(Emu(sh).inches, 3)],
"slides": slides, "explicit_fonts": dict(fonts), "warnings": warnings}
if args.json:
print(json.dumps(report, indent=2))
else:
print(f"{args.pptx}: {len(slides)} slides, {report['slide_size_in'][0]} x {report['slide_size_in'][1]} in")
for s in slides:
extras = ", ".join(f"{k}={s[k]}" for k in ("images", "charts", "tables") if s[k])
print(f" {s['slide']:>3}. [{s['layout']}] {s['title'] or '(no title)'}" + (f" ({extras})" if extras else ""))
for t in s["text"]:
first = t.splitlines()[0][:100]
print(f" - {first}")
if s["notes"]:
print(f" notes: {s['notes'][:100]}")
print("\nwarnings:" if warnings else "\nwarnings: none")
for w in warnings:
print(f" - {w}")
return 1 if warnings else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.