Skip to main content

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 direct BaseException subclass, so except Exception does not catch it — a worker that guards its work with except Exception will die anyway.
  • Releasing one on another thread leaks it. pyo3 skips the Rust destructor and writes an unraisable RuntimeError instead (visible only through sys.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.

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)

Source distribution for betteroffice-pptx 0.1.0
File Size Uploaded
betteroffice_pptx-0.1.0.tar.gz 6.3 MB Details

Built distributions (wheels)

Table of built distributions (wheels) for betteroffice-pptx 0.1.0
File
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 log

Release 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 log

Release 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 log

Release 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 log

Release 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 log

Release 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

Release history Release notifications | RSS feed

0.2.0

6 release files

This release

0.1.0 This release

6 release files

0.0.2

6 release files

0.0.1

6 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page