power-pptx

Space-Aware Authoring

The single biggest reason this fork exists is making programmatically-generated decksphysically correct: text that doesn't overflow its container, shapes that don't slide off the edges. Three layered tools — used together — catch nearly all real-world layout issues.

The three layers

  1. TextFrame.fit_text(...) — measures the actual text with Pillow font metrics and bakes a fitting font size into the XML before save. Deterministic: the deck leaves your process already correct.
  2. text_frame.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE — lets PowerPoint shrink the text at render time as a fallback when someone edits the deck later.
  3. slide.lint() — reports what slipped through (text overflow, off-slide shapes, collisions). auto_fix() repairs the subset that needs no designer judgment; slide.tidy() is the one-call wrapper, andprs.lint_on_save = "raise" turns the whole thing into a gate onsave().

The pattern to reach for

from power_pptx import Presentation
from power_pptx.enum.text import MSO_AUTO_SIZE
from power_pptx.util import Inches

prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[5])
slide.shapes.title.text = "Q4 Review"

# Runtime-supplied text: an LLM answer, a DB row, a CLI argument...
user_supplied_body = (
    "Revenue grew 27% QoQ driven by the new enterprise tier, "
    "while churn fell below 2% for the first time."
)

# Body box that has to swallow that text
box = slide.shapes.add_textbox(Inches(0.6), Inches(1.6),
                               Inches(8.5), Inches(5))
tf = box.text_frame
tf.word_wrap = True
tf.text = user_supplied_body

# Belt: pick a determined size now using Pillow font metrics
tf.fit_text(font_family="Inter", max_size=24)

# Braces: let PowerPoint shrink on the way down if edited later
tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE

# Catch anything that slipped through
slide.tidy()

# Refuse to write a deck that still has errors in it
prs.lint_on_save = "raise"
prs.save("out.pptx")

fit_text and the text-fit estimator

fit_text measures your exact string against the box geometry using real font metrics — not heuristics. You can also use the estimator directly when you want the size without mutating the frame:

from power_pptx.text.layout import TextFitter
from power_pptx.util import Inches, Pt

# Size text for a known box but leave the styling to someone else
best_pt = TextFitter.best_fit_font_size(
    text="Q4 2026 Customer Outcomes Review",
    extents=(Inches(8), Inches(1.5)),
    max_size=44,
    font_file=None,               # falls back to Pillow's bundled font
)
if best_pt is not None:
    tf.paragraphs[0].runs[0].font.size = Pt(best_pt)

Font availability and the fit guarantee

fit_text's promise — "this text will not overflow" — rests on measuring the glyphs of the font you named. That only works when the font is installed on the machine running the build. Naming "Inter" in a container that has Liberation and DejaVu and nothing else does not fail: Pillow falls back to its bundled default face, the measurement becomes an estimate, and the guarantee quietly becomes a guess. Three helpers let you find out before it matters:

from power_pptx.text.fonts import (
    find_font_file, font_is_installed, installed_font_families,
)

font_is_installed("Inter")               # False on a bare CI container
font_is_installed("Inter", bold=True)    # asked per style, not per family
find_font_file("Inter")                  # the .ttf path, or None
installed_font_families()                # what this machine actually has

The gap is not academic. The same headline in the same box, measured two ways:

from power_pptx import Presentation
from power_pptx.util import Inches

prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[6])
tf = slide.shapes.add_textbox(
    Inches(1), Inches(1), Inches(6), Inches(1.5)).text_frame
tf.text = "Q4 2026 customer outcomes review and roadmap"

tf.fit_text("Inter", max_size=32)        # 32 - plus a FontMetricsWarning
tf.fit_text("DejaVu Sans", max_size=32)  # 28 - real metrics, 4pt smaller

The fallback picked 32pt where the real metrics allow only 28 — so the deck ships four points too large and the headline wraps or clips when it is finally opened on a machine thatdoes have the brand font. This is why generated decks look correct in CI and wrong on the reviewer's laptop.

Rather than let that pass silently, fit_text emitspower_pptx.exc.FontMetricsWarning whenever it falls back after younamed a family. Omitting font_family does not warn — you asked for no particular face — but passing one that is not installed always does, "Calibri"included. Run python examples/real_world/build_all.py on a bare container and you will see it fire on every fit_text call in the suite.

There are three sound responses, depending on how exact the build must be.

from power_pptx.text.fonts import find_font_file, font_is_installed

# 1. Pin the metrics to a font file shipped with the build
inter_ttf = find_font_file("Inter")      # or a path inside your repo
if inter_ttf is not None:
    tf.fit_text("Inter", max_size=44, font_file=inter_ttf)

# 2. Or degrade to a family you know is present
family = "Inter" if font_is_installed("Inter") else "Arial"
tf.fit_text(family, max_size=44)

font_file= is the strongest of the three: it pins measurement to that exact TrueType file whether or not it matches the family, bold and italic you also passed — which is what you want when the brand font is vendored in the repo rather than installed system-wide. Note that lookup is per style: a machine can have Inter Regular but not Inter Bold, and fit_text(..., bold=True) will fall back on the latter.

The third response is to refuse to ship the guess at all — the right default for a release build:

# Fail this call rather than bake in a guessed size
tf.fit_text("Inter", max_size=44, strict=True)
# ValueError: fit_text(strict=True): 'Inter' is not installed, so fit_text
# measured with Pillow's default font instead of real metrics ...

# Or promote every fallback anywhere in the build to an error
import warnings
from power_pptx.exc import FontMetricsWarning

warnings.simplefilter("error", FontMetricsWarning)

strict=True turns any fallback into a ValueError naming the missing family, and the message points at font_file= andinstalled_font_families() as the two ways out. PromotingFontMetricsWarning to an error does the same job process-wide, which is the more practical gate when the fit_text calls are buried inside helpers.

What the linter reports

IssueMeaningAuto-fixable
TextOverflowEstimated text extent exceeds the shape's frameYes — flipped to TEXT_TO_FIT_SHAPE; fit_text bakes a size in instead
OffSlideShape extends beyond the slide boundsYes — shrunk if oversized, then clamped back inside
ShapeCollisionTwo shapes overlap unintentionallyNo — fix placement with Grid/Stack, or declare the overlap
LayerOrderViolationA shape declares it sits above a layer but is drawn below itYes — restacked to match the declaration; geometry untouched

Each issue carries a LintSeverity ("error" / "warning") so CI can fail hard on errors while tolerating warnings.

Deliberate layering isn't a collision

A badge sitting on a KPI card looks exactly like a copy-paste bug from a bounding box alone, so the linter has to be told what you meant. Three declarations do that, from widest to narrowest: lint_group is n-ary and symmetric (everything sharing the tag may overlap everything else), an allowance licenses exactly one pair, and layer hints are the only form that also asserts a direction — and therefore the only one that can fail.

# n-ary: the whole KPI cluster may overlap internally
slide.lint_group("kpi-1", card, accent_bar, value_box)

# pairwise: this badge may sit on this card, and nothing else changes
badge.allow_overlap_with(card)

# directional: the badge asserts it is painted on top of the card layer
card.layer = "card"
badge.layer_above = "card"

Where the drawing order contradicts a layer_above declaration, you get aLayerOrderViolation at error severity rather than silence:auto_fix() restacks the shape so the z-order says what you said. Full treatment on the Lint & Audit page.

Whole-deck audit

from power_pptx import audit

report = audit(prs)          # lint + broken pictures + empty slides
                             # + uncommon fonts + oversized pictures
print(report.markdown())     # ready to paste into a chat reply

Next: Geometry & Arrows — the BBox value object and one-call helpers that make placement pleasant.