Text & Typography
Text is the thing a generated deck gets wrong most often — wrong face, wrong size, styling applied one run at a time until the code is longer than the content. This page covers the text model end to end. For the terse signature list seeAPI: Text; for keeping text inside its box seeSpace-Aware Authoring.
Three levels, and when to reach for each
Every piece of text in a PowerPoint file lives three levels deep. Shapes that can hold text expose a text frame; a frame holds one or more paragraphs; a paragraph holds zero or more runs. Only runs actually carry characters.
from power_pptx import Presentation
from power_pptx.util import Inches, Pt
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[6])
box = slide.shapes.add_textbox(Inches(0.8), Inches(1.2), Inches(8), Inches(2))
tf = box.text_frame # 1. the frame - wrap, anchor, margins, columns
tf.word_wrap = True
p = tf.paragraphs[0] # 2. the paragraph - alignment, level, spacing
p.text = "Revenue grew 27% QoQ"
p.space_after = Pt(6)
p2 = tf.add_paragraph()
lead = p2.add_run() # 3. the run - one span of uniform character format
lead.text = "Enterprise tier "
lead.font.bold = True
tail = p2.add_run()
tail.text = "drove the increase."
prs.save("out.pptx")| Level | Owns | Reach for it when |
|---|---|---|
TextFrame | Wrapping, auto-fit, vertical anchor, the four margins, column count and gutter | The decision is about the box — how text sits inside the shape |
_Paragraph | Alignment, indent level, bullets and numbering, line spacing, space before/after, tab stops, RTL | The decision is about a line or block — the usual place to work |
_Run | The string itself, plus a Font and a hyperlink | Part of a paragraph must look different from the rest of it |
A paragraph also has a font. It is not a run's font: it writesa:defRPr, the paragraph's defaults, which runs inherit unless they override. Setting p.font.size = Pt(14) before adding runs is the cheapest way to style a whole paragraph; setting it after only affects runs that never set a size of their own.
Assigning to .text at any level is a shortcut that goes all the way down.tf.text = "a\nb" replaces the frame's content with two paragraphs;p.text = "a" replaces the paragraph's runs with a single run. A vertical-tab character ("\v") in an assigned string becomes a line break rather than a new paragraph, and reads back the same way.
Frame-level formatting
from power_pptx.enum.text import MSO_ANCHOR
from power_pptx.util import Inches, Pt
tf.word_wrap = True # True | False | None (inherit)
tf.vertical_anchor = MSO_ANCHOR.MIDDLE # TOP | MIDDLE | BOTTOM
tf.margin_left = Inches(0.15) # also margin_right / _top / _bottom
tf.margin_top = 0 # plain 0 is accepted, no helper needed
tf.column_count = 2 # 1..16; assigning 1 removes the setting
tf.column_spacing = Pt(18) # gutter between columnsAll of these are tri-state where the XML allows it: None removes the explicit setting so the value is inherited from the placeholder, layout, master or theme. That matters more than it sounds — a hard-coded word_wrap = True on a placeholder overrides a template that had already made the right choice.
column_count accepts 1 through 16 and raisesValueError outside that range. Assigning 1 removes the attribute rather than writing an explicit single column. Columns are laid out by PowerPoint at render time, so the linter and fit_text — which measure the frame as one box — will under-estimate how much text a two-column frame can hold.
set_paragraph_defaults: stop styling run by run
This is the single biggest ergonomics win in the library, and the fix for the most tedious pattern in deck generation. Branded body copy wants the same four properties — typeface, size, weight, colour — on every paragraph of every card. Written per-run, it looks like this:
# The anti-pattern: the styling grows with the content
for p in tf.paragraphs:
for run in p.runs:
run.font.name = "Inter"
run.font.size = Pt(14)
run.font.color.rgb = "#222222"That loop is not just verbose, it is destructive: it flattens any deliberate exception you set earlier, and it has to be re-run every time you append a paragraph.TextFrame.set_paragraph_defaults() does the same job in one keyword-only call, and fills in only the properties that are currently unset:
from power_pptx.util import Pt
tf.text = "Total contract value\nUp 27% QoQ\nChurn below 2%"
# One explicit decision that has to survive the defaults pass
tf.paragraphs[0].runs[0].font.bold = True
tf.paragraphs[0].runs[0].font.size = Pt(28)
tf.set_paragraph_defaults(
font_name="Inter",
size=Pt(14),
color="#222222",
)
[(p.runs[0].font.size.pt, p.runs[0].font.bold) for p in tf.paragraphs]
# [(28.0, True), (14.0, None), (14.0, None)]Paragraph 0 keeps its explicit 28pt bold; the other two pick up Inter 14pt in#222222. Every argument is optional — pass just the ones you want to enforce.color takes any colour-like value (RGBColor, a"#RRGGBB" string, or an (r, g, b) tuple), and a run that already carries any explicit colour — including a theme colour likeMSO_THEME_COLOR.ACCENT_1 — is left alone rather than overwritten.
Two things to know about the timing. First, the call stamps both the paragraph-levela:defRPr and every existing run, so an empty paragraph you fill in later still inherits the defaults. Second, it is a one-shot pass over the paragraphs that existwhen you call it — build the text first, then apply defaults last, immediately beforefit_text orsave().
Bullets, levels and numbering
Bullet glyphs come from the layout's list style; what you control per paragraph is the indent level it is drawn at. level is an integer 0 through 8, and the level's bullet character, indent and size are whatever the master says they are for that depth.
tf.clear()
tf.paragraphs[0].text = "Findings"
for text, level in [
("Churn fell below 2%", 1),
("Driven by the new onboarding flow", 2),
("ARR up 27% QoQ", 1),
]:
p = tf.add_paragraph()
p.text = text
p.level = level # 0..8 inclusive; 0 is top levelOrdered lists are explicit. set_numbered() replaces whatever bullet the paragraph had with an auto-number, defaulting to the "arabicPeriod" scheme. Other useful scheme tokens are "romanLcPeriod" (i. ii.),"alphaUcParenR" (A) B)) and"arabicParenR" (1) 2)).
p = tf.add_paragraph()
p.text = "Ship the migration"
p.set_numbered() # 1. 2. 3. (arabicPeriod)
q = tf.add_paragraph()
q.text = "Then backfill history"
q.set_numbered("romanLcPeriod", start_at=3) # iii. iv. v.
q.start_at # 3
q.start_at = 4 # renumber, scheme untouched
q.start_at = None # back to 1 - still a numbered liststart_at is the read/write shortcut for the first number. Reading it returnsNone both when the paragraph is not a numbered list and when it starts at the default of 1 — so treat it as "is there an explicit start", not as "is this a numbered list". Assigning an integer to a paragraph that is not yet numbered turns it into an"arabicPeriod" list; assigning None clears the explicit start but leaves the list numbered.
A line break is not a paragraph. add_line_break() appends a soft return inside the current paragraph, so the two lines share one bullet, one alignment and onespace_before/space_after — which is exactly what you want for a two-line list item. It reads back as a vertical-tab character.
from power_pptx.util import Inches
p = tf.add_paragraph()
first = p.add_run()
first.text = "Acme Corp"
p.add_line_break() # soft return: same paragraph, no space_before
second = p.add_run()
second.text = "Enterprise plan"
p.text # 'Acme Corp\x0bEnterprise plan'
p.tab_stops.add_tab_stop(Inches(3.0), "right") # left|center|right|decimal
p.tab_stops.add_tab_stop(Inches(4.5), "decimal")
len(p.tab_stops) # 2
rtl = tf.add_paragraph()
rtl.text = "arabic or hebrew body copy"
rtl.rtl = True # True | False | None (inherit)tab_stops is a live collection over a:tabLst: iterable, sized withlen(), indexable, and each TabStop has a read/writeposition and alignment. rtl is the tri-state right-to-left switch for Hebrew, Arabic and Farsi paragraphs — it changes layout direction, not the characters, so you still supply the string in logical order.
The Font surface
font — on a run, or on a paragraph for its defaults — is where a deck stops looking generated. The familiar four are bold, italic,size and name, plus underline (which acceptsTrue/False or an MSO_UNDERLINE member for wavy, dotted and double variants) and color. Reads are non-mutating: touchingfont.color without assigning will not insert an empty fill and clobber theme inheritance. font.fill exposes the full FillFormat underneath, for gradient or pattern glyphs.
from power_pptx.util import Pt
eyebrow = tf.paragraphs[0].runs[0].font
eyebrow.all_caps = True # writes cap="all"
eyebrow.letter_spacing = Pt(2) # tracking; negative values tighten
eyebrow.size = Pt(11)
eyebrow.color.rgb = "#6B7280" # hex string, (r, g, b) tuple, or RGBColor
price = tf.add_paragraph()
old = price.add_run()
old.text = "$1,200"
old.font.strikethrough = True
new = price.add_run()
new.text = " $960"
mark = price.add_run()
mark.text = "1"
mark.font.superscript = True # or .subscript - they share one attribute| Property | Type | Writes | Use for |
|---|---|---|---|
all_caps | tri-state bool | cap="all" | Eyebrows, section labels, small nav text — without shouting in your source strings |
small_caps | tri-state bool | cap="small" | Editorial lead-ins and acronyms. Shares one attribute with all_caps, so they are mutually exclusive |
strikethrough | tri-state bool | strike="sngStrike" | Was-now pricing, superseded figures, changelog entries |
superscript | tri-state bool | baseline="30%" | Footnote markers, units, ordinals |
subscript | tri-state bool | baseline="-25%" | Chemical and mathematical notation. Shares baseline with superscript |
outline | LineFormat | a:ln on the run | Stroked display type over imagery; set .color and .width |
letter_spacing | Length | spc | Tracking. A little positive spacing is what makes an all-caps eyebrow read as designed; negative tightens headlines |
language_id | MSO_LANGUAGE_ID | lang | Telling PowerPoint which dictionary to spell-check a run against |
from power_pptx.enum.lang import MSO_LANGUAGE_ID
from power_pptx.util import Pt
title = tf.paragraphs[0].runs[0].font
title.outline.color.rgb = "FF0000" # coloured glyph stroke...
title.outline.width = Pt(1) # ...of a given width
title.small_caps = True # mutually exclusive with all_caps
title.language_id = MSO_LANGUAGE_ID.FRENCH # drives PowerPoint's spell-checkEverything above is tri-state in the same way bold is: None means "inherit", and it is the default. Setting False is not the same asNone — it writes an explicit negative override (cap="none",strike="noStrike") that beats the theme. Two exceptions worth knowing:superscript and subscript only clear the sharedbaseline attribute when the run is actually in that state, so turning one off cannot silently cancel the other; and language_id reads back asMSO_LANGUAGE_ID.NONE rather than Python's None when unset.
Glyph shadow and glow live on font too, mirroring the shape-level effects — see Effects & Gradients.
Hyperlinks
Hyperlinks are a run-level property, which is why link text usually wants to be its own run: split the sentence so only the clickable words carry the link, and style that run yourself — PowerPoint does not colour or underline it for you.
from power_pptx import Presentation
from power_pptx.util import Inches
prs = Presentation()
overview = prs.slides.add_slide(prs.slide_layouts[6])
detail = prs.slides.add_slide(prs.slide_layouts[6])
tf = overview.shapes.add_textbox(
Inches(1), Inches(1), Inches(6), Inches(1)).text_frame
p = tf.paragraphs[0]
lead = p.add_run()
lead.text = "Full methodology: "
link = p.add_run()
link.text = "read the appendix"
link.font.underline = True
link.font.color.rgb = "#0B5CFF"
link.hyperlink.address = "https://example.com/appendix" # external
jump = tf.add_paragraph().add_run()
jump.text = "Jump to the detail slide"
jump.hyperlink.target_slide = detail # internal
link.hyperlink.address = None # removes the link and drops the relationshiphyperlink.address handles add, change and remove in one setter: assigning a URL replaces any existing link, assigning None removes it and drops the underlying relationship. http, https, mailto andfile schemes all work.
hyperlink.target_slide is the internal counterpart, writing a slide-jump action instead of a URI — the mechanism behind agenda slides, "back to contents" affordances and appendix links. Assign a Slide; read it back to get that slide, orNone when the run has no link or an external one. One asymmetry to know: for an internal jump, address does not return None, it returns the relationship's target part name ("slide2.xml"). Test withtarget_slide, not with address, when you need to tell the two apart.
Pitfalls
tf.paragraphs and p.runs build fresh wrapper objectson every access. They are views onto the XML, not cached objects, so identity comparison always fails and so does equality:
tf.paragraphs[0] is tf.paragraphs[0] # False - fresh wrapper each access
tf.paragraphs[0] == tf.paragraphs[0] # also False - there is no __eq__
# Hold the reference you need rather than re-indexing
p = tf.paragraphs[0]
p.font.bold = True
# Swap the string, keep the formatting already on the runs
box.set_text_preserving_format("Real headline from the database")The wrappers are cheap and always current, so re-indexing is correct — it is onlycomparison that misleads. The related trap is rebuilding text you meant to restyle: assigning tf.text or p.text discards the existing runs and every font property on them. shape.set_text_preserving_format(new_text) is the one to reach for when a template already carries the styling and you only want to swap the string.
Finally, remember that the font you name and the font your machine has are different questions. fit_text measures with real metrics only when the family is installed — seeSpace-Aware Authoring forfont_is_installed() and the strict=True gate.