Create PowerPoint Slides from Pipeline Data
The monthly review needs a deck, and the numbers already exist in the pipeline. Generating it looks simple until it has to match the company template:
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[5])
box = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(8), Inches(1))
box.text_frame.text = "Documents processed: 4,182"
The result is a white deck in Calibri that looks nothing like the brand template, every element is positioned by guesswork, and when the design team updates the master, nothing in the generated deck follows.
Root Cause
Presentation() with no argument starts from python-pptx's own default template, which has none of the company's layouts, colours or fonts. Opening the brand template instead gives access to its slide layouts — but the second half of the problem remains: add_textbox creates a free-floating shape at absolute coordinates, which is invisible to the master. Text in a placeholder inherits the layout's font, size, colour and position, and moves when the design changes; text in a text box does not. A generated deck that will be maintained needs to fill placeholders, not draw boxes.
Minimal Diagnostic
List what the template offers before writing any slide-building code.
# pip install python-pptx
from pathlib import Path
from pptx import Presentation
from pptx.util import Emu
TEMPLATE = Path("templates/brand.pptx")
PLACEHOLDER_KINDS = {0: "TITLE", 1: "BODY", 2: "CENTER_TITLE", 3: "SUBTITLE", 4: "DATE",
5: "SLIDE_NUMBER", 6: "FOOTER", 7: "OBJECT", 13: "PICTURE",
14: "CHART", 15: "TABLE"}
def template_report(path: Path) -> None:
prs = Presentation(path)
print(f"{path.name}: {Emu(prs.slide_width).inches:.2f} x "
f"{Emu(prs.slide_height).inches:.2f} inches, {len(prs.slide_layouts)} layout(s)")
for index, layout in enumerate(prs.slide_layouts):
placeholders = []
for placeholder in layout.placeholders:
kind = PLACEHOLDER_KINDS.get(placeholder.placeholder_format.type, "OTHER")
placeholders.append(f"idx{placeholder.placeholder_format.idx}:{kind}")
print(f" [{index}] {layout.name!r}: {', '.join(placeholders) or 'no placeholders'}")
if __name__ == "__main__":
template_report(TEMPLATE)
brand.pptx: 13.33 x 7.50 inches, 9 layout(s)
[0] 'Title Slide': idx0:CENTER_TITLE, idx1:SUBTITLE
[1] 'Title and Content': idx0:TITLE, idx1:BODY
[2] 'Section Header': idx0:TITLE, idx1:BODY
[5] 'Title Only': idx0:TITLE
[6] 'Blank': no placeholders
[7] 'Title and Chart': idx0:TITLE, idx10:CHART, idx11:BODY
The layout indices and placeholder idx values are what the slide-building code addresses. Note the 13.33-inch width — a 16:9 template, so charts sized for a 10-inch 4:3 slide will not fill it.
Fix: Build from the Template's Layouts and Placeholders
Open the brand file, pick layouts by name, and fill placeholders by index.
# pip install python-pptx
from dataclasses import dataclass
from pathlib import Path
from pptx import Presentation
from pptx.util import Emu, Pt
def layout_by_name(prs: Presentation, name: str):
for layout in prs.slide_layouts:
if layout.name == name:
return layout
raise KeyError(f"layout {name!r} not in template; have {[l.name for l in prs.slide_layouts]}")
def fill(slide, idx: int, text: str) -> None:
for placeholder in slide.placeholders:
if placeholder.placeholder_format.idx == idx:
placeholder.text_frame.text = text # changed: inherits the layout's styling
return
raise KeyError(f"no placeholder idx={idx} on this slide")
def bullets(slide, idx: int, items: list[tuple[str, int]]) -> None:
frame = next(p for p in slide.placeholders
if p.placeholder_format.idx == idx).text_frame
frame.clear()
for position, (text, level) in enumerate(items):
paragraph = frame.paragraphs[0] if position == 0 else frame.add_paragraph()
paragraph.text = text
paragraph.level = level # changed: the layout styles each level
@dataclass(frozen=True)
class RunSummary:
month: str
processed: int
failed: int
elapsed_minutes: float
top_failures: list[tuple[str, int]]
def build_deck(template: Path, out: Path, summary: RunSummary, chart_png: bytes) -> Path:
prs = Presentation(template) # changed: start from the brand file
title_slide = prs.slides.add_slide(layout_by_name(prs, "Title Slide"))
fill(title_slide, 0, f"Document pipeline — {summary.month}")
fill(title_slide, 1, f"{summary.processed:,} documents processed")
overview = prs.slides.add_slide(layout_by_name(prs, "Title and Content"))
fill(overview, 0, "Run summary")
rate = summary.failed / max(summary.processed, 1)
bullets(overview, 1, [
(f"{summary.processed:,} documents processed", 0),
(f"{summary.failed:,} quarantined ({rate:.1%})", 0),
(f"Wall clock {summary.elapsed_minutes:.0f} minutes", 0),
*[(f"{reason}: {count}", 1) for reason, count in summary.top_failures[:3]],
])
chart_slide = prs.slides.add_slide(layout_by_name(prs, "Title Only"))
fill(chart_slide, 0, "Throughput by hour")
add_picture_fitted(chart_slide, prs, chart_png)
out.parent.mkdir(parents=True, exist_ok=True)
prs.save(out)
return out
Looking layouts up by name rather than index is the difference between a script that survives a template update and one that silently starts producing section headers where content slides belong. Layout order changes whenever the design team adds one; names rarely do, and a missing name raises a clear error naming what is available.
Variant Fix 1: Fitting a Chart to the Slide
A picture added without sizing uses its native pixel dimensions and usually overflows. Compute the fit from the slide, not from constants:
# pip install python-pptx pillow
import io
from pptx.util import Emu, Inches
from PIL import Image
def add_picture_fitted(slide, prs, image_bytes: bytes, top_margin=Inches(1.6),
side_margin=Inches(0.8), bottom_margin=Inches(0.6)) -> None:
available_width = prs.slide_width - 2 * side_margin
available_height = prs.slide_height - top_margin - bottom_margin
with Image.open(io.BytesIO(image_bytes)) as image:
ratio = image.height / image.width
width = available_width
height = Emu(int(width * ratio))
if height > available_height: # too tall: fit the height instead
height = available_height
width = Emu(int(height / ratio))
left = Emu(int((prs.slide_width - width) / 2)) # centre horizontally
slide.shapes.add_picture(io.BytesIO(image_bytes), left, top_margin, width, height)
Reading prs.slide_width rather than assuming ten inches is what makes this work on both 4:3 and 16:9 templates. add_picture accepts a file-like object, so a chart rendered to a buffer — as described in matplotlib no display error — goes straight in with no temporary file.
Variant Fix 2: Tables of Figures
A table placeholder gives a branded table; without one, add_table still works but needs styling:
# pip install python-pptx
from pptx.util import Inches, Pt
def add_summary_table(slide, prs, headers: list[str], rows: list[list[str]],
left=Inches(0.8), top=Inches(1.6)) -> None:
width = prs.slide_width - 2 * left
height = Inches(0.4) * (len(rows) + 1)
shape = slide.shapes.add_table(len(rows) + 1, len(headers), left, top, width, height)
table = shape.table
for column, text in enumerate(headers):
cell = table.cell(0, column)
cell.text = text
cell.text_frame.paragraphs[0].runs[0].font.bold = True
cell.text_frame.paragraphs[0].runs[0].font.size = Pt(14)
for row_index, values in enumerate(rows, start=1):
for column, value in enumerate(values):
cell = table.cell(row_index, column)
cell.text = str(value)
cell.text_frame.paragraphs[0].runs[0].font.size = Pt(12)
Keep generated tables small. A slide is read from across a room, so more than about six rows and four columns belongs in an attached spreadsheet with the slide showing the three numbers that matter — the same discipline that keeps an HTML report readable.
Keeping the Template and the Code in Step
A generated deck depends on the template in ways that are easy to break from the design side. Someone renames "Title and Content" to "Content", and every run afterwards fails — which is the good outcome. The bad outcome is a layout whose placeholders were renumbered, so text lands in the wrong box without any error.
Checking the template at startup makes both cases loud:
# pip install python-pptx
from pathlib import Path
from pptx import Presentation
EXPECTED = {"Title Slide": {0, 1}, "Title and Content": {0, 1}, "Title Only": {0}}
def check_template(path: Path) -> list[str]:
prs = Presentation(path)
layouts = {layout.name: {p.placeholder_format.idx for p in layout.placeholders}
for layout in prs.slide_layouts}
problems = []
for name, wanted in EXPECTED.items():
if name not in layouts:
problems.append(f"layout {name!r} missing; have {sorted(layouts)}")
elif not wanted <= layouts[name]:
problems.append(f"layout {name!r} lacks placeholder(s) {sorted(wanted - layouts[name])}")
return problems
Run it as the first line of the job, and the failure names the layout and the placeholder rather than appearing three slides later as text in the wrong place. Keeping a copy of the template in the repository alongside the code, updated deliberately, turns a surprise into a pull request.
Deciding What Belongs on a Slide
The temptation with generated decks is to put everything in, because adding a slide costs one function call. That produces a forty-slide deck nobody reads, and it hides the two or three facts the meeting exists to discuss.
A useful rule is one claim per slide, with the number that supports it. "Throughput recovered after the 12th" is a claim; a chart of documents per hour supports it. "Here is the data" is not a claim, and a table of every hour in the month supports nothing. Anything that is reference material rather than argument belongs in an attachment the deck links to.
Generating the deck from a summary object rather than the raw data enforces this naturally. If a figure is not on the summary, it does not reach a slide, and deciding what goes on the summary is a design decision made once in code rather than an accumulation of slides added whenever someone asks.
Verification
Check the produced deck before it is circulated.
# pip install python-pptx
from pathlib import Path
from pptx import Presentation
def verify_deck(path: Path, expected_slides: int) -> None:
prs = Presentation(path)
assert len(prs.slides) == expected_slides, f"{len(prs.slides)} slide(s), expected {expected_slides}"
empty, overflow = [], []
for index, slide in enumerate(prs.slides, start=1):
for placeholder in slide.placeholders:
if placeholder.placeholder_format.idx in {5, 6, 4}: # slide number, footer, date
continue
if not placeholder.text_frame.text.strip():
empty.append(f"slide {index} placeholder {placeholder.placeholder_format.idx}")
for shape in slide.shapes:
right, bottom = shape.left + shape.width, shape.top + shape.height
if shape.left < 0 or shape.top < 0 or right > prs.slide_width or bottom > prs.slide_height:
overflow.append(f"slide {index} {shape.shape_type} extends past the slide")
assert not empty, f"unfilled placeholder(s): {empty[:5]}"
assert not overflow, f"shape(s) off-slide: {overflow[:5]}"
text = " ".join(shape.text_frame.text for slide in prs.slides for shape in slide.shapes
if shape.has_text_frame)
for token in ("nan", "None", "{", "TODO"):
assert token not in text.split(), f"placeholder text {token!r} left in the deck"
print(f"{path.name}: {len(prs.slides)} slides, all placeholders filled, nothing off-slide")
if __name__ == "__main__":
verify_deck(Path("out/pipeline-2026-09.pptx"), expected_slides=3)
The off-slide check is the one worth keeping. A chart sized for a 4:3 template on a 16:9 deck extends past the right edge, which looks fine in the thumbnail and is obvious the moment it is projected — usually during the meeting.
FAQ
Can I edit an existing deck instead of building one? Yes — open it, replace the text in the placeholders you own, and leave the rest. Keep generated slides in a clearly marked section.
How do I add a native PowerPoint chart rather than an image?slide.shapes.add_chart with a CategoryChartData object. It is editable in PowerPoint but harder to style to match a template than an embedded image.
Why is my text the wrong colour? Setting a run's font colour explicitly overrides the theme. Leave the colour unset to inherit from the master.
Can I delete a slide?
Not through the API directly; build the deck with only the slides needed, or manipulate prs.slides._sldIdLst as a last resort.
Related
- Generating Reports from Pipeline Data — the reporting workflow end to end
- Build HTML Reports with Jinja2 Templates — the same summary as a document
- Fix matplotlib No Display Error in Headless Jobs — rendering the charts the deck embeds
- Scheduling and Logging Automation Jobs — producing the deck on a schedule