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.

Where a slide's appearance comes from The slide master defines theme colours, fonts and background for the whole deck. Each slide layout builds on the master and defines the placeholders a slide of that kind has. A slide created from a layout inherits those placeholders with their position and formatting. Filling a placeholder means the text follows the master when the design changes. A free-floating text box added by coordinates inherits nothing and must be restyled by hand whenever the template changes. Slide master theme colours, fonts, background set by the design team Slide layout defines the placeholders pick one per slide kind Placeholder on the slide inherits position and style fill these Free text box inherits nothing avoid these

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.

Placeholder versus free text box Filling a placeholder means the text inherits the master's font, size and colour, sits where the layout puts it, moves when the design changes and appears correctly in the outline view. Adding a text box at coordinates means the text uses default formatting, sits wherever the code put it, ignores template updates entirely and is invisible to the outline and to accessibility tools. add_textbox at coordinates default font and colour position hard-coded ignores template updates invisible in outline view fill a placeholder inherits master styling position from the layout follows template updates appears in outline view

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.

Generating the monthly deck Summarise the run into a small object holding only the figures the deck shows, so the arithmetic happens once and can be tested. Open the brand template rather than a blank presentation. Add each slide from a layout looked up by name and fill its placeholders. Embed charts rendered to memory, fitted to the slide dimensions. Save the file. Verify the slide count, that every placeholder was filled and that no picture overflows the slide. Summarise the run figures computed once Open the brand template not a blank deck Add slides by layout name fill placeholders Embed charts fitted to the slide Save one file per month Verify counts, fills, overflow

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.

Part of Generating Reports from Pipeline Data.