Metadata-Version: 2.4
Name: betteroffice-pptx
Version: 0.2.0
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Rust
Classifier: Topic :: Office/Business :: Office Suites
Classifier: Topic :: Software Development :: Libraries
Classifier: Typing :: Typed
License-File: LICENSE
License-File: NOTICE
License-File: THIRD-PARTY-NOTICES.md
Summary: Read, edit, and lay out PPTX presentations. Rust engine, no PowerPoint and no LibreOffice.
Keywords: pptx,powerpoint,presentation,ooxml,presentationml,slides,python-pptx,rust
Home-Page: https://betteroffice.dev
Author: OpenOOXML
License-Expression: Apache-2.0
Requires-Python: >=3.9
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Changelog, https://github.com/openooxml/betteroffice/blob/main/bindings/python-pptx/CHANGELOG.md
Project-URL: Documentation, https://docs.betteroffice.dev/docs/python
Project-URL: Homepage, https://betteroffice.dev
Project-URL: Issues, https://github.com/openooxml/betteroffice/issues
Project-URL: Source, https://github.com/openooxml/betteroffice

# 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](https://betteroffice.dev) PPTX core is compiled into the wheel:
no PowerPoint, no LibreOffice subprocess, no COM.

```bash
pip install betteroffice-pptx
```

The distribution is hyphenated, the module is not: `import betteroffice_pptx`.

## Read a deck

```python
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

```python
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.

```python
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:

```python
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](../../packages/pptx/src/proposals.ts).
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:

```python
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](../../packages/pptx/src/edits.ts), 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:

```python
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](../../packages/pptx/src/structuredExport.ts):
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:

```python
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:

```python
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:

```python
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:

```python
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:

```python
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

```python
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:

```python
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:

```python
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`:

```python
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](https://betteroffice.dev) — the project
- [Documentation](https://docs.betteroffice.dev/docs/python)
- [Source](https://github.com/openooxml/betteroffice) — `bindings/python-pptx`
- [betteroffice-pptx on crates.io](https://crates.io/crates/betteroffice-pptx) — the engine this wraps

Apache-2.0.

