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)
layout = deck.render_proposal(proposal.id, 0)
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.
Lay a slide out
No font is compiled into the wheel, so laying out a slide that has text
needs at least one registered face. Before that, 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()
Once at least one face exists nothing raises again: a family the deck names but you never registered resolves to the same family at regular weight, and failing that to the first face you registered at all. One registration therefore renders every slide — in that one typeface, at its metrics. Register the real faces when line breaking has to match what PowerPoint would do.
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:
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())) # right now agrees
joiner = Presentation.open_collaborative(data)
joiner.apply_update(left.state_as_update()) # catch up from nothing
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 |
python-pptx is a far broader library and covers plenty this does not —
charts, tables, and templating in particular. If you need slides laid out, or
edits that merge across replicas, that is the gap this fills.
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 |
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.
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(untrusted, 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:
with ThreadPoolExecutor() as pool:
decks = [f.result() for f in [pool.submit(load, p) for p in paths]]
# 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
0.1.x: the API may change before 1.0. save writes edits back at the
XML level and copies untouched parts through byte for byte; the container is
rebuilt, so output is not byte-identical to the source — see Writing.
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.1.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.1.0.tar.gz | 6.3 MB | Details |
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| betteroffice_pptx-0.1.0-cp39-abi3-win_amd64.whl | CPython 3.9 | abi3 | Windows x86-64 | Details |
| betteroffice_pptx-0.1.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.1.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl | CPython 3.9 | abi3 | Linux glibc 2.17+ ARM64 | Details |
| betteroffice_pptx-0.1.0-cp39-abi3-macosx_11_0_arm64.whl | CPython 3.9 | abi3 | macOS 11.0+ ARM64 | Details |
| betteroffice_pptx-0.1.0-cp39-abi3-macosx_10_12_x86_64.whl | CPython 3.9 | abi3 | macOS 10.12+ x86-64 | Details |
Total release size: 23.9 MB
Release files / betteroffice_pptx-0.1.0.tar.gz
| Download URL | betteroffice_pptx-0.1.0.tar.gz |
|---|---|
| Size | 6.3 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6cc01f0d91ff0347337f0371c605bea42a20c93a23a5b299e52a5145bdd6ae22
|
|
BLAKE2b-256 checksum How to use checksums |
c978088bf83b19f135bfcf924c163cc347557bde6383a5fd954dbf41b67ba341
|
| 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 16, 2026.
Transparency logRelease files / betteroffice_pptx-0.1.0-cp39-abi3-win_amd64.whl
| Download URL | betteroffice_pptx-0.1.0-cp39-abi3-win_amd64.whl |
|---|---|
| Size | 3.7 MB |
| Tags | CPython 3.9 Windows x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
1a3bbb7679f953407ccd45a9148277db18014ede833d41e887e54adf06b95c2e
|
|
BLAKE2b-256 checksum How to use checksums |
ce7dafe9510f59b9ed936413760144459d9bf7ee9270d1ceda157771b3590900
|
| 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 16, 2026.
Transparency logRelease files / betteroffice_pptx-0.1.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
| Download URL | betteroffice_pptx-0.1.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl |
|---|---|
| Size | 3.6 MB |
| Tags | CPython 3.9 Linux glibc 2.17+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
860dbebd354e0337732184be02e3d5670ac05c12ec29b0e7c75ab20185cb26a2
|
|
BLAKE2b-256 checksum How to use checksums |
44aec5bf20d16c8ccaa8b74d3a5ef1db1bad3250c337e53905b94ee77ade3070
|
| 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 16, 2026.
Transparency logRelease files / betteroffice_pptx-0.1.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
| Download URL | betteroffice_pptx-0.1.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl |
|---|---|
| Size | 3.4 MB |
| Tags | CPython 3.9 Linux glibc 2.17+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
feafbea9439cc548a2c0728d1253ab3dd6bd8a8c480d722c4bf2ed1365ce33d7
|
|
BLAKE2b-256 checksum How to use checksums |
87cefb551b2de9c6e1fdf506fcf24516bfafbda8325c99b6a097cc9b3f0d935c
|
| 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 16, 2026.
Transparency logRelease files / betteroffice_pptx-0.1.0-cp39-abi3-macosx_11_0_arm64.whl
| Download URL | betteroffice_pptx-0.1.0-cp39-abi3-macosx_11_0_arm64.whl |
|---|---|
| Size | 3.3 MB |
| Tags | CPython 3.9 abi3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
041d1a6d62c1c95d3fa6c85bc06cffd60334d8426757b3fccecb821adc5e1196
|
|
BLAKE2b-256 checksum How to use checksums |
bafee3f4e419c786ddc182f1009661f2f16e0eb520eaaec00d665e9401002d10
|
| 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 16, 2026.
Transparency logRelease files / betteroffice_pptx-0.1.0-cp39-abi3-macosx_10_12_x86_64.whl
| Download URL | betteroffice_pptx-0.1.0-cp39-abi3-macosx_10_12_x86_64.whl |
|---|---|
| Size | 3.5 MB |
| Tags | CPython 3.9 abi3 macOS 10.12+ x86-64 |
|
SHA-256 checksum How to use checksums |
6f223a5d93a62730b35e9755f66da0ae284b7d75c4f47553571d3f7c487d8641
|
|
BLAKE2b-256 checksum How to use checksums |
ac9f79eb55fe144d291cf2a6e6b67c85e34263ff12b87cd731165980bca413de
|
| 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 16, 2026.
Transparency log