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)
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 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:

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.

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)

Source distribution for betteroffice-pptx 0.2.0
File Size Uploaded
betteroffice_pptx-0.2.0.tar.gz 2.0 MB Details

Built distributions (wheels)

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

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

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

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

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

Release 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

Release history Release notifications | RSS feed

This release

0.2.0 This release

6 release files

0.1.0

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