API: Presentation & Slides
power_pptx.Presentation
Factory entry point. Accepts a path/file-like object or nothing (blank default template).
from power_pptx import Presentation
prs = Presentation() # new deck from the default template
prs = Presentation("file.pptx") # open an existing deck
prs.save("out.pptx") # write to a path or file-like objectprs.slide_width # EMU width of the slide canvas
prs.slide_height # EMU height
prs.slides # Slides collection
prs.sections # fork: Sections collection (outline pane groupings)
prs.slide_layouts # layouts of the first master
prs.slide_masters # SlideMasters collection
prs.core_properties # document metadata (author, title, ...)
prs.theme # fork: Theme reader/writer
prs.notes_master # NotesMaster
prs.part.package # the OpcPackage
# fork: lint every slide on save - "off" (default) | "warn" | "raise"
prs.lint_on_save = "raise" # LintError before anything is writtenpower_pptx.slide
| Class | Notes |
|---|---|
Slides | Sequence of slides; add_slide(layout), iteration, indexing, index(), reordering via slide_id list |
Slide | A single slide; shapes, background, notes, transition, animations, lint |
Sections / Section fork | Named groupings of slides in the outline pane; see Deck sections |
SlideLayouts / SlideLayout | Layouts of a master; Slide instances clone placeholders from them |
SlideMasters / SlideMaster | Masters expose layouts, placeholders, theme via the part |
NotesSlide / NotesMaster | Speaker notes surface; notes_text_frame on NotesSlide |
slide.transition fork | kind (MSO_TRANSITION_TYPE, incl. MORPH), duration (ms), advance_on_click, advance_after; deck-wide via prs.set_transition(...) |
from power_pptx.util import Inches
slide = prs.slides.add_slide(prs.slide_layouts[5])
slide.name # str, mutable
slide.shapes # SlideShapes (shape tree)
slide.placeholders # SlidePlaceholders
slide.background # _Background (fill)
slide.slide_layout # the SlideLayout this slide follows
slide.transition # fork: transition proxy (kind, duration, ...)
slide.animations # fork: SlideAnimations
slide.has_notes_slide # bool, non-creating test
slide.notes_slide # NotesSlide (created on demand)
slide.follow_master_background # bool, read-only
slide.color_variant # fork: "light" | "dark" | None
# fork: space-aware helpers
report = slide.lint() # SlideLintReport
slide.tidy() # lint + safe auto-fixes
slide.slide_bbox() # fork: BBox of the whole canvas
slide.content_bbox() # fork: BBox hugging the real content
free = slide.find_empty_region(min_width=Inches(2), min_height=Inches(1))Deck sections fork
prs.sections is the collection of named slide groupings PowerPoint shows in the outline and slide-sorter panes. It supports len(), indexing and iteration, plusadd(name, start_slide_index=None) and remove(section). Reading the collection never modifies the deck — the underlying PowerPoint-2010 extension element is written only when the first section is added.
from power_pptx import Presentation
prs = Presentation()
for _ in range(6):
prs.slides.add_slide(prs.slide_layouts[6])
intro = prs.sections.add("Intro", start_slide_index=0)
body = prs.sections.add("Body", start_slide_index=2)
appendix = prs.sections.add("Appendix", start_slide_index=5)
for section in prs.sections:
print(section.name, section.slide_ids)
# Intro [256, 257]
# Body [258, 259, 260]
# Appendix [261]
body.name = "Main body" # read/write
body.id # brace-wrapped GUID, read-only
body.slides # tuple[Slide, ...], in deck order
body.add_slide(prs.slides[5]) # moves slide 5 out of "Appendix"
prs.sections.remove(intro) # identical to intro.delete()
len(prs.sections) # 2 - Intro's slides merged into "Main body"Sections are contiguous and non-overlapping, and the library keeps them that way for you rather than emitting a deck PowerPoint would offer to repair:
add(name, start_slide_index=N)claims every slide fromNto the end of the deck, and truncates any earlier section that held them atN-1.- If the first section of a deck starts past slide 0, the slides before it are swept into an auto-created
"Default Section", so no slide is left outside every section. Section.add_slide()first removes the slide from whichever section currently claims it, and is a no-op when the section already holds it.- Removing a section keeps its slides in the deck — they merge into the previous section (or the next one, when the first section goes), exactly like PowerPoint's own Remove Section command. Removing the only section drops the grouping entirely.
section.id is a brace-wrapped GUID; pass id= toadd() to pin it for deterministic output in tests. A malformed or already-used id raises ValueError. Sections reference slides by numericslide_id, not by position, so reordering slides does not scramble membership — and section.slides silently skips references to slides that have since been deleted. A section may legitimately end up empty; Section.remove_slide() is the one call that can leave a slide belonging to no section at all.
Slide geometry: slide_bbox() and content_bbox() fork
Both are methods, not properties, and both return aBBox. slide_bbox() is the whole canvas; content_bbox() is the union of the shapes actually on the slide, and is the one layout code wants — it answers "where did this slide's content really end up?" after a generator, a template, or an auto-fit pass has had its way with it.
from power_pptx import BBox, Presentation
from power_pptx.enum.shapes import MSO_SHAPE
from power_pptx.util import Emu, Inches
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[6])
# a full-bleed backdrop plus two real content shapes
slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0,
prs.slide_width, prs.slide_height).fill_hex("#101820")
slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE,
Inches(1), Inches(1.5), Inches(3), Inches(2))
slide.shapes.add_textbox(Inches(5), Inches(4), Inches(3), Inches(1))
canvas = slide.slide_bbox() # BBox of the whole slide canvas
content = slide.content_bbox() # BBox hugging the non-decorative shapes
canvas.width.inches # 10.0
content.as_tuple() # (914400, 1371600, 6400800, 3200400)
Emu(content.left - canvas.left).inches # 1.0 - the left margin
Emu(canvas.bottom - content.bottom).inches # 2.5 - the space still free
# The backdrop is skipped because it covers >95% of the slide in *both*
# axes. Opt it back in when you want the true painted extent.
slide.content_bbox(include_decorative=True) == canvas # True
# ...and there is nothing to bound on an empty slide.
prs.slides.add_slide(prs.slide_layouts[6]).content_bbox() is NoneThe decorative filter is what makes content_bbox() useful on real decks: any shape whose width and height each exceed 95% of the slide is treated as backdrop and excluded, so a full-bleed panel or photo doesn't collapse the answer back to the full canvas. Pass include_decorative=True for the unfiltered union. The return value isNone — not an empty box — when the slide has no qualifying shapes, so test for it before unpacking.
Three things this makes cheap:
from power_pptx import BBox, Presentation
from power_pptx.enum.shapes import MSO_SHAPE
from power_pptx.util import Emu, Inches
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[6])
for i in range(3):
slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE,
Inches(0.9 + i * 2.9), Inches(2.0),
Inches(2.6), Inches(2.2))
SAFE = Inches(0.5)
canvas = slide.slide_bbox()
content = slide.content_bbox()
# 1. Assert a safe margin before the deck is written.
assert canvas.inset(all=SAFE).contains(content), "content breaks the safe area"
# 2. Hang a source note off the bottom of whatever the slide ended up holding.
slide.shapes.add_text(
BBox(content.left, content.bottom + Inches(0.25), content.width, Inches(0.4)),
text="Source: internal telemetry, Q4 FY25", size_pt=11)
# 3. Re-centre the whole cluster horizontally, using the *new* content box.
dx = Emu(canvas.cx - slide.content_bbox().cx)
for shape in slide.shapes:
shape.left = Emu(shape.left + dx)For the inverse question — "where is there room for something?" — useslide.find_empty_region(...), which walks a coarse grid and returns the largest free cell cluster.
Background and colour variant fork
from power_pptx.dml.color import RGBColor
slide.follow_master_background # True - the slide has no <p:bg> of its own
slide.background.fill.solid() # giving it a background of its own...
slide.background.fill.fore_color.rgb = RGBColor.from_hex("#101820")
slide.follow_master_background # ...flips it to False
# Swap the background/text roles for this slide only, theme untouched.
slide.color_variant # "light" - inheriting the master map
slide.color_variant = "dark" # bg1->dk1, tx1->lt1, bg2->dk2, tx2->lt2
slide.color_variant = None # drop the override entirely
# Anything the two presets don't cover goes through the raw mapping.
slide.set_clr_map_override(bg1="dk2", tx1="lt2", bg2="dk1", tx2="lt1")
slide.color_variant # None - matches neither named preset
slide.set_clr_map_override(masterClrMapping=True) # back to inheritingfollow_master_background is a read-only bool: it isTrue exactly while the slide has no <p:bg> element of its own. Touching slide.background's fill is what gives the slide its own background and flips the flag.
color_variant reads and writes the slide's <p:clrMapOvr>, which re-points the deck's colour slots without touching the theme's colourvalues — the mechanism behind a single dark slide in an otherwise light deck. Reading returns "light" when the slide inherits the master map, "dark" when the override swaps backgrounds and text, and None when a custom map matches neither preset. Assigning None removes the override.set_clr_map_override(**mapping) is the escape hatch: each keyword is a slot name (bg1, tx1, bg2, tx2,accent1–accent6, hlink, folHlink) and each value is the palette slot it should resolve to.
Speaker notes
slide.has_notes_slide # False - a plain test, creates nothing
slide.notes # "" - reading notes text creates nothing either
slide.notes = "Open with the revenue headline."
slide.has_notes_slide # True - the notes part exists now
slide.notes_slide.notes_text_frame.textslide.notes_slide creates the notes part on first access, which is a real side effect when you are only inspecting a deck. has_notes_slide is the non-creating test, and reading slide.notes is non-creating too — it returns"" for a slide with no notes part. Assigning slide.notes creates the part on demand.
Grouping shapes for the linter fork
Three names sit next to each other here and they all set exactly one thing: thelint_group tag on a shape, which tellsthe linter that shapes sharing a non-empty tag aremeant to overlap. They are not three features — they are three ways to spell the same assignment, and which one to reach for depends only on when you know the membership:
| Reach for | When | Naming |
|---|---|---|
slide.design_group(name) | You are about to build the cluster and want to name it up front | Required; leaves any tag a shape already has alone, so nesting works |
slide.shapes.lint_group_scope(name=None) | Same, but you don't care what it's called — a helper emitting a composite widget | Optional, auto-numbered; overwrites existing tags |
slide.lint_group_overlaps(*shapes) | The shapes already exist and you're tagging them after the fact | Optional, auto-numbered; returns the name it used |
from power_pptx.enum.shapes import MSO_SHAPE
from power_pptx.util import Inches
# 1. design_group - tag as you build. Name required; never overwrites a tag
# a shape already carries, so in nested blocks the innermost name wins.
with slide.design_group("kpi-1"):
card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE,
Inches(0.8), Inches(1.5), Inches(3.4), Inches(2.0))
bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE,
Inches(0.8), Inches(1.5), Inches(3.4), Inches(0.2))
card.lint_group # "kpi-1"
# 2. lint_group_scope - same idea on the shape tree. Name optional, it yields
# the tree so you can add through it, and it overwrites existing tags.
with slide.shapes.lint_group_scope() as g:
track = g.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE,
Inches(5), Inches(1.5), Inches(4), Inches(0.3))
fill = g.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE,
Inches(5), Inches(1.5), Inches(2.4), Inches(0.3))
track.lint_group # "design-group-1", auto-numbered
# 3. lint_group_overlaps - tag shapes you already hold; returns the name.
value = slide.shapes.add_textbox(Inches(1.0), Inches(2.0), Inches(3.0), Inches(0.9))
name = slide.lint_group_overlaps(card, bar, value) # "design-group-2"
# The blunt form underneath all three: assign a name, or None to clear.
slide.lint_group("kpi-1", card, bar, value)
slide.lint_group(None, value)Underneath all three is slide.lint_group(name, *shapes), the plain batch form ofshape.lint_group = name; pass None to clear. Auto-generated names take the form "design-group-N" with the smallest N not already in use on that slide, so the two context managers and the after-the-fact call never collide.
A lint_group is n-ary and symmetric — every tagged shape may overlap every other one in the tag. When that is too broad, the narrower declarations live on the shapes themselves: badge.allow_overlap_with(card) licenses exactly one pair, andlayer / layer_above additionally assert the stacking order. SeeLint & Audit for the full comparison.
Core properties
prs.core_properties exposes Dublin Core metadata: author,title, subject, keywords, category,comments, created, modified, revision,version, last_modified_by.
power_pptx.audit fork
from power_pptx import audit
report = audit(prs) # lint + broken pictures + empty slides
# + uncommon fonts + oversized pictures
print(report.markdown())