betteroffice-pptx
Read, edit, and lay out PPTX presentations from Python. python-pptx reads a
deck and writes one back; this also lays slides out — line breaking, text
metrics, a display list — and merges edits across replicas, because the Rust
BetterOffice PPTX core is compiled into the wheel:
no PowerPoint, no LibreOffice subprocess, no COM.
pip install betteroffice-pptx
The distribution is hyphenated, the module is not: import betteroffice_pptx.
Read a deck
from betteroffice_pptx import Presentation
deck = Presentation.open_path("quarterly.pptx")
for slide in deck:
print(slide.index, slide.name, repr(slide.text))
shape = next((s for slide in deck for s in slide.shapes), None)
if shape is not None:
print(shape.kind, shape.geometry, shape.x, shape.y, shape.width, shape.height)
Geometry is in English Metric Units — 914400 to the inch. The module exports
EMU_PER_INCH, EMU_PER_CENTIMETER, and EMU_PER_POINT so you rarely have to
type the constant.
snapshot() returns the whole deck as plain data in one call; slide(key)
returns one slide by index or by ID. Both are values, not live views — read
them again after an edit.
Edit shapes and slides
from betteroffice_pptx import EMU_PER_INCH as INCH
deck = Presentation.open(open("deck.pptx", "rb").read())
slide_id = deck.slide_ids[0]
edit = deck.add_text_box(
slide_id, x=INCH, y=INCH, width=4 * INCH, height=INCH,
text="Revenue", bold=True, font_size=32.0,
)
deck.move_shape(slide_id, edit.shape_id, 2 * INCH, INCH)
deck.resize_shape(slide_id, edit.shape_id, 5 * INCH, 2 * INCH)
box = deck.add_shape(slide_id, "roundRect", x=INCH, y=3 * INCH,
width=2 * INCH, height=INCH, fill="#2563eb")
deck.set_shape_stroke(slide_id, box.shape_id, color="#111827", width_pt=2.0)
deck.set_shape_adjust(slide_id, box.shape_id, {"adj": 0.25})
Every mutating call returns a receipt naming what it touched: ShapeEdit has
the new shape's ID and z-order index, TransformEdit carries the rect before
and after, and AdjustEdit shows the values after the engine clamped them into
their guide's legal range.
An unsupported preset geometry, a non-positive size, or an unknown adjustment
guide raises ValueError instead of writing a shape PowerPoint would reject.
Edit text
Text lives in stories — one editable flow per text-bearing shape. Offsets are UTF-16 code units, and every paragraph ends with a pilcrow occupying one of them.
story = next(
(s for slide in deck for shape in slide.shapes for s in shape.stories), None
)
if story is not None:
print(story.text, story.length)
deck.insert_text(story.id, 0, "Q3 ", bold=True)
deck.format_text(story.id, 0, 3, color="#dc2626")
deck.insert_paragraph_break(story.id, 3)
removed = deck.delete_text(story.id, 0, 3)
print(removed.text) # 'Q3 '
A shape with no text has no story, so a deck of pictures alone yields none.
deck.story(id) looks one up directly and raises KeyError if it is gone.
format_text patches only the arguments you pass, and a range spanning several
paragraphs styles each of them as a single undoable edit. delete_text is the
strict one: a range crossing a paragraph boundary raises RangeError rather
than silently swallowing the break.
Agent proposals
Proposals stage a group of edits without modifying the deck, its saved file, or its undo history:
proposal = deck.propose("editor-agent", [{
"type": "setSlideNotes",
"slideId": deck.slide_ids[0],
"text": "Explain the customer outcome first.",
}], note="Clarify the opening")
preview = deck.preview_proposal(proposal.id)
deck.accept_proposal(proposal.id)
deck.undo()
Register fonts before render_proposal, just as for render_slide.
proposals() returns Proposal values with agent attribution, notes,
ProposalChange values, and current stale_targets. preview_proposal returns
a ProposalPreview containing fresh changes and the proposed deck snapshot.
Snapshot and edit dictionaries use the core's camelCase JSON fields; the
dataclass attributes use Python snake_case.
Edit objects support replaceText, formatText, setParagraphAlignment,
setShapeRect, setShapeFill, setShapeStroke, setShapeAdjust, and
setSlideNotes. They share the TypeScript edit contract.
Replacement ranges stay within one paragraph and use UTF-16 offsets; shape
geometry uses EMU. A group has 1–256 edits, applied in order, with up to 64
pending groups per session.
accept_proposal applies the entire validated group as one local undo step,
regardless of the current origin setting. A changed target raises
StaleProposalError with a targets list. Review the fresh preview before
retrying with force=True. Deleted targets and invalid ranges remain errors.
reject_proposal(id) removes the proposal without changing the deck.
Pending proposals are local to the open session and are excluded from saved PPTX files and collaboration updates. Accepted edits save and synchronize normally, and Undo preserves unrelated peer edits.
Edit in version-checked batches
read_content returns the slides and each story's text with the session
version it was read at; apply_edits applies a batch against that version as
one transaction and one undo step, or returns a typed refusal with nothing
changed:
read = deck.read_content()
story = read["stories"][0]
within = {key: story[key] for key in ("slideId", "shapeId", "storyId")}
result = deck.apply_edits({
"expectVersion": read["version"],
"steps": [
{"op": "replaceText", "target": {"kind": "search", "within": within, "text": "Q3"},
"text": "Q4"},
{"op": "setSlideNotes", "target": {"slideId": story["slideId"]}, "text": "Updated"},
],
})
if not result["ok"]:
print(result["failure"]["code"], result["failure"].get("stepIndex"))
Requests and results are the dictionaries of the
TypeScript batch contract, typed here as
PptxEditRequest, PptxEditResult and friends. A story reads as its
paragraphs joined by \n, with story-local UTF-16 offsets. find_text
searches exactly and within paragraphs, and validate_edits runs every check
without changing anything. Steps insert, replace and delete text within one
paragraph, format and align text, replace speaker notes, and set a top-level
shape's rectangle, fill or outline. "history": "none" keeps a batch out of
undo history; "source": "agent" records provenance only. Policy failures come
back with "ok": False and a code; a malformed request raises ValueError.
Only an applied batch sets is_edited. Versions and ids belong to the open
session.
Export structured content
export_structured returns the committed deck as structured content with the
version it was read at, and export_markdown renders that same read as
Markdown; neither changes anything:
read = deck.export_structured(include_notes=True)
for slide in read["content"]["slides"]:
for shape in slide["shapes"]:
for story in shape["stories"]:
for paragraph in story["paragraphs"]:
print(slide["index"], paragraph["list"], paragraph["anchor"])
markdown = deck.export_markdown()["content"]["markdown"]
The dictionaries are the
TypeScript export contract:
slides in deck order, shapes in shape-tree order, paragraphs with levels,
resolved list markers, runs, fields and links, tables with merges, and
placeholders with alternative text for pictures, media, charts, SmartArt and
embedded objects. Every record carries an anchor and, when it was read from the
file, its source part, SHA-256 and element path. A range anchor is a batch
text target: pass it as a step's "target" at the version it was read at.
Hidden slides and shapes, notes and comments are keyword options, and every
omission is listed in diagnostics; a slide whose visibility an older collaboration update does not
record is exported with hidden: None and a visibility-unknown diagnostic. Unusable limits come back with "ok": False.
export_pptx_structured(data, options=...) and export_pptx_markdown read
bytes with the camelCase wire options and return the content alone,
render_pptx_markdown(content) renders content read earlier, and these raise
ExportError (its failure is the refusal) for unusable limits and
ParseError for bytes that are not a PPTX.
Lay a slide out
Register a face before laying out slide text; until then, render_slide raises:
deck.render_slide(0)
# RenderError: no font has been registered for slide text
Register the faces the deck uses — one call per family, weight, and slant — and it lays out:
from pathlib import Path
deck.register_font("Inter", Path("Inter-Regular.ttf").read_bytes())
deck.register_font("Inter", Path("Inter-Bold.ttf").read_bytes(), bold=True)
layout = deck.render_slide(0)
print(layout.width, layout.height, len(layout)) # 1280.0 720.0 42
layout.write("slide-0.json")
scene = layout.to_dict()
Font selection tries the requested family's style, then that family without italic, without bold and plain, then the closest style in the first registered family. Known requested families supply their measured metrics.
render_slide returns the display list — the same drawing contract the browser
editor paints, as JSON — for hosts that paint it themselves. render_png
rasterizes a slide instead, resolving pictures out of the package so only fonts
need registering:
png = deck.render_png(0, scale=2.0)
print(png.width, png.height, png.skipped_images) # 2560 1440 0
png.write("slide-0.png")
background picks what fills the pixels the slide leaves uncovered: "slide"
(the default, opaque white under the slide's own background), "transparent",
or a #rrggbb color.
Collaboration
open_collaborative gives this replica a unique client ID, which peers need in
order to converge:
data = deck.save()
left = Presentation.open_collaborative(data)
right = Presentation.open_collaborative(data)
left.add_text_box(0, x=INCH, y=INCH, width=4 * INCH, height=INCH, text="Q3")
right.apply_update(left.diff(right.state_vector()))
joiner = Presentation.open_collaborative(data)
joiner.apply_update(left.state_as_update())
A deck from open or open_path is not a replica: it has no client ID of its
own, so two of them would author under the same identity and never converge.
state_vector, state_as_update, diff, and apply_update raise
NotCollaborativeError on such a deck rather than diverging silently, and
is_collaborative says which kind you are holding:
deck = Presentation.open(data)
deck.is_collaborative # False
deck.state_vector() # NotCollaborativeError
The binding generates a client ID when it is omitted and exposes it through the
read-only client_id property. Explicit IDs must be unique among connected
peers, because Yrs cannot detect duplicates once two replicas have started
authoring. Byte inputs accept bytes, bytearray, and memoryview, and an
oversized payload is refused before it is copied.
Undo, redo, and attribution
deck.author = "ana"
edit = deck.add_text_box(0, x=INCH, y=INCH, width=INCH, height=INCH, text="Q3")
deck.move_shape(0, edit.shape_id, 0, 0)
deck.add_undo_barrier() # the next edit starts a new undo step
deck.undo()
deck.redo()
Undo covers this replica's own local edits. Updates applied from a peer are not
in local history, so undo will not revert someone else's work. Consecutive edits
inside half a second coalesce into one step; add_undo_barrier() splits them.
Setting origin to "agent", "remote", or "system" tags edits for
attribution — and takes them out of the local undo stack, which is the point:
an agent's write is not something the user undoes by accident.
Writing
save() and save_path() serialize the deck with every accepted edit
applied. Slides you did not touch keep their exact source part bytes; edited
slides are patched at the XML level, so unmodeled markup survives:
deck = Presentation.open(data)
deck.insert_slide(1)
deck.is_edited # True
deck.save_path("copy.pptx") # edits included
reopened = Presentation.open_path("copy.pptx")
reopened.slide_count # one more than the source
is_edited reports whether the engine has accepted an edit since the deck was
opened. Only an edit the engine accepted sets it: an edit that raised leaves
the flag untouched.
Compared with python-pptx
python-pptx |
betteroffice-pptx |
|
|---|---|---|
| Read shapes and text | yes | yes |
| Write shapes and text back to a file | yes | yes — see Writing |
| Lay slides out (line breaking, text metrics) | no | yes, display list |
| Collaborative editing (CRDT) | no | yes, Yrs |
| Undo/redo | no | yes |
| Engine | pure Python | Rust, compiled |
Use betteroffice-pptx for slide layout and collaborative editing.
API
Presentation.open(data) / open_path(path) |
open from bytes or a path |
Presentation.open_collaborative(data) |
open a Yrs replica |
deck.snapshot() |
the whole deck as plain data |
deck[key] / deck.slide(key) |
a Slide by index or ID |
deck.slide_ids / slide_count / layouts |
deck metadata |
deck.width_emu / height_emu |
slide size in EMU |
deck.author / deck.origin |
who an edit is attributed to, and how |
deck.story(id) |
one text flow |
deck.media() |
embedded images and other binary parts |
insert_slide / delete_slide / move_slide |
slide order |
add_text_box / add_shape / remove_shape |
shape lifecycle |
move_shape / resize_shape / set_shape_rect |
shape geometry |
set_shape_fill / set_shape_stroke / set_shape_adjust |
shape styling |
insert_text / delete_text / format_text |
text editing |
insert_paragraph_break |
split a paragraph |
add_comment / reply_to_comment / set_comment_status / remove_comment |
comment threads |
comments / comment_flavor / set_comment_flavor |
read comments, pick the comment system |
export_structured / export_markdown |
versioned structured content and Markdown |
export_pptx_structured / export_pptx_markdown / render_pptx_markdown |
export bytes, render content |
propose / proposals / preview_proposal / render_proposal |
stage and preview agent edits |
accept_proposal / reject_proposal |
apply or drop a proposal |
register_font / render_slide / render_png |
layout and PNG export |
diff / apply_update / state_vector / state_as_update |
Yrs replicas |
deck.is_collaborative / deck.client_id |
whether this deck may exchange updates, and as whom |
deck.is_edited |
whether the engine has accepted an edit since open |
undo / redo / add_undo_barrier / can_undo / can_redo |
history |
deck.save() / save_path(path) |
serialize to PPTX — see Writing |
Errors raise PptxError or a more specific subclass: ParseError,
RangeError, RenderError, InvalidUpdateError, CollaborativeStateError,
NotCollaborativeError, StaleProposalError, ExportError.
An unknown slide, shape, or story ID raises KeyError; a bad argument — an
unsupported geometry, an out-of-range client ID, an unknown parse limit —
raises ValueError.
Parser bounds can be tightened for untrusted input:
Presentation.open_path("untrusted.pptx", limits={"max_shapes": 5_000, "max_runs": 50_000})
An unknown limit name raises ValueError rather than being ignored.
Threads
A Presentation is pinned to the thread that opened it, and must also be
released there. The engine's undo manager is not Send, so the class is
declared unsendable, and pyo3 enforces that in two ways worth knowing about:
- Touching one from another thread raises
pyo3_runtime.PanicException. That is a directBaseExceptionsubclass, soexcept Exceptiondoes not catch it — a worker that guards its work withexcept Exceptionwill die anyway. - Releasing one on another thread leaks it. pyo3 skips the Rust destructor
and writes an unraisable
RuntimeErrorinstead (visible only throughsys.unraisablehook), stranding roughly 1.5 MB per deck. Nothing is raised into your code.
The leak is easy to hit by accident, because the release does not have to be an
explicit del:
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor() as pool:
decks = list(pool.map(Presentation.open_path, ["deck.pptx", "quarterly.pptx"]))
# every deck was opened on a worker and is now dropped on the main thread
The cyclic garbage collector counts too. If a Presentation is caught in a
reference cycle — a traceback that reaches it, an object graph that points back
at itself — the collector frees it wherever it happens to run, which may be any
thread. Giving each worker its own Presentation therefore is not enough on
its own; the deck must also become garbage on its owning thread. Open, use, and
drop each deck inside one thread, and break any cycle holding it before that
thread finishes.
The heavy operations release the GIL while they run — open, open_path,
open_collaborative, render_slide, render_png, save, save_path,
register_font, and apply_update — as do the file writes in Media.write,
DisplayList.write, and Png.write. render_proposal holds the GIL.
Status
Pre-1.0: the API may change between minor versions. Saving patches edited XML, preserves untouched parts and rebuilds the ZIP container.
Wheels are built for Linux (x86_64, aarch64), macOS (arm64, x86_64), and Windows (x86_64) against the stable ABI for CPython 3.9 and up.
Links
- BetterOffice — the project
- Documentation
- Source —
bindings/python-pptx - betteroffice-pptx on crates.io — the engine this wraps
Apache-2.0.
Metadata
Release files for betteroffice-pptx 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| betteroffice_pptx-0.2.0.tar.gz | 2.0 MB | Details |
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| betteroffice_pptx-0.2.0-cp39-abi3-win_amd64.whl | CPython 3.9 | abi3 | Windows x86-64 | Details |
| betteroffice_pptx-0.2.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl | CPython 3.9 | abi3 | Linux glibc 2.17+ x86-64 | Details |
| betteroffice_pptx-0.2.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl | CPython 3.9 | abi3 | Linux glibc 2.17+ ARM64 | Details |
| betteroffice_pptx-0.2.0-cp39-abi3-macosx_11_0_arm64.whl | CPython 3.9 | abi3 | macOS 11.0+ ARM64 | Details |
| betteroffice_pptx-0.2.0-cp39-abi3-macosx_10_12_x86_64.whl | CPython 3.9 | abi3 | macOS 10.12+ x86-64 | Details |
Total release size: 25.6 MB
Release files / betteroffice_pptx-0.2.0.tar.gz
| Download URL | betteroffice_pptx-0.2.0.tar.gz |
|---|---|
| Size | 2.0 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4de4b7100f01800f55c21228b388de06cccf0eb6ca4c814048d402f9ea16a4ba
|
|
BLAKE2b-256 checksum How to use checksums |
4be39cd4e03ad24f12c9d7c5151a96ee9c645a9d837ed8b9ec0cbbacce6a22fa
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.
Transparency logRelease files / betteroffice_pptx-0.2.0-cp39-abi3-win_amd64.whl
| Download URL | betteroffice_pptx-0.2.0-cp39-abi3-win_amd64.whl |
|---|---|
| Size | 5.0 MB |
| Tags | CPython 3.9 Windows x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
11dd4a05f6bcd383ca91b01eabfb16581926afe2aa1ee3336f9c4c9f318d30e3
|
|
BLAKE2b-256 checksum How to use checksums |
af7c9fd95d79532652e572a80204a120cb920fb953aeb032144280b4cb71161e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.
Transparency logRelease files / betteroffice_pptx-0.2.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
| Download URL | betteroffice_pptx-0.2.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl |
|---|---|
| Size | 4.9 MB |
| Tags | CPython 3.9 Linux glibc 2.17+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
8a90d77f40f163c314f230cc5e2845a2674549d090524d08dd28889b1db6f893
|
|
BLAKE2b-256 checksum How to use checksums |
9c4eeda5b7402bcd43be849f5b2c10b07480ea5bbe6008ffa2a397d3446715fb
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.
Transparency logRelease files / betteroffice_pptx-0.2.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
| Download URL | betteroffice_pptx-0.2.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl |
|---|---|
| Size | 4.6 MB |
| Tags | CPython 3.9 Linux glibc 2.17+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
e65ff6b38cdd5cb9fb1e88c470ca0a4ebfd3bb782dd64dafc03f4714f74f7fed
|
|
BLAKE2b-256 checksum How to use checksums |
b888d072d989610236abb2653590652ac70a363f9f4c41e3e6e77e48e8c0f5d6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.
Transparency logRelease files / betteroffice_pptx-0.2.0-cp39-abi3-macosx_11_0_arm64.whl
| Download URL | betteroffice_pptx-0.2.0-cp39-abi3-macosx_11_0_arm64.whl |
|---|---|
| Size | 4.5 MB |
| Tags | CPython 3.9 abi3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
165d46daa75ed550348cab3220c8401dce1b43474d8b95e2de86fe06dff7f66f
|
|
BLAKE2b-256 checksum How to use checksums |
32355059c09af46af573278af098ec8c03338fcd89767039370aba44c5ed277e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.
Transparency logRelease files / betteroffice_pptx-0.2.0-cp39-abi3-macosx_10_12_x86_64.whl
| Download URL | betteroffice_pptx-0.2.0-cp39-abi3-macosx_10_12_x86_64.whl |
|---|---|
| Size | 4.7 MB |
| Tags | CPython 3.9 abi3 macOS 10.12+ x86-64 |
|
SHA-256 checksum How to use checksums |
af0dd1f476e20cc52f3a714db3344479e495e1a074fb02c6bc667a03e5f537c0
|
|
BLAKE2b-256 checksum How to use checksums |
25d832e6091dd48ebbee43464652ce7dff31da2b186ce6b03c0614aa60f62625
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.
Transparency log