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
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.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.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 hasThe 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 smallerThe 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
| Issue | Meaning | Auto-fixable |
|---|---|---|
TextOverflow | Estimated text extent exceeds the shape's frame | Yes — flipped to TEXT_TO_FIT_SHAPE; fit_text bakes a size in instead |
OffSlide | Shape extends beyond the slide bounds | Yes — shrunk if oversized, then clamped back inside |
ShapeCollision | Two shapes overlap unintentionally | No — fix placement with Grid/Stack, or declare the overlap |
LayerOrderViolation | A shape declares it sits above a layer but is drawn below it | Yes — 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 replyNext: Geometry & Arrows — the BBox value object and one-call helpers that make placement pleasant.