Skip to content

Bind the missing rpptx facade methods of the deck workflow in Python - #181

Open
hadim wants to merge 5 commits into
tensorbee:mainfrom
hadim:feat/rpptx-py-facade-basics
Open

hadim wants to merge 5 commits into
tensorbee:mainfrom
hadim:feat/rpptx-py-facade-basics

Conversation

@hadim

@hadim hadim commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Binding only. crates/rpptx and rpptx-cli are unchanged, so no publishable
crate changes and there is no archive re-record.

  • SlideCollection.duplicate(slide) calls Presentation::duplicate_slide. The
    new slide lands right after its source with its speaker notes, the revision
    advances once, and the call returns the new slide captured at that revision.
    A slide from another presentation raises ValueError, as remove does. The
    facade refuses a slide that owns a modern comments part, even one left empty
    after its last comment was removed, and the package and revision then stay
    unchanged.
  • Presentation.from_bytes(bytes) is a static method over
    Presentation::from_bytes, with the same parameter name as rdocx
    Document.from_bytes. Bytes that are not a package raise PackageError.
  • Presentation.try_replace_text(placeholder, replacement, *, expect=None) -> int runs the facade's staged replacement over slides and notes with the GIL
    released. With expect, it runs on a clone, and a count that differs raises
    the new ReplacementCountError(RpptxError), which carries expected and
    found and uses the message of rpptx replace --expect. The clone is
    dropped, so the bytes stay the same and every handle stays live. Without
    expect, the facade call runs in place, as rdocx-py's try_replace_text
    does. The revision advances once when the count is nonzero.
  • Slide.resolve_comment(comment_id) and Slide.remove_comment(comment_id)
    follow the move_comment pattern over the facade methods rpptx comment
    uses, with one revision bump on success.
  • Presentation.validate() returns a tuple of frozen ValidationIssue
    snapshots, each with a snake_case kind (duplicate_shape_id, ...) and a
    message equal to the line rpptx validate prints.
  • ReplacementCountError and ValidationIssue are exported from rpptx. The
    new names are also in _rpptx.pyi, typing_smoke.py, the rpptx-py
    README capability list and HLD 10, next to the existing slide, replacement
    and comment paragraphs.

Part of #169: checklist items duplicate_slide, from_bytes,
try_replace_text, resolve_comment / remove_comment and validate of
"Exists in Rust, not bound". Still open: a Python replace_text (see Notes),
table cells (merge, split, fill, margins), inherited placeholder geometry, and
the whole "Not found in Rust" list.

Part of #158: with workflow_pptx.py as written, the "slide duplicated" row
now passes. The "comment added then resolved" row now passes too, but only
because the script fetches resolve_comment without calling it. The "counted
replacement" row passes once the script calls try_replace_text(..., expect=...) instead of replace_text. With the script calling both methods,
both rows pass (see Tests).

Why

The #158 deck workflow fell back to python-pptx or the CLI for five operations
the Rust facade already has. The CLI's --expect check is safe because the CLI
writes nothing on a mismatch. An in-memory binding needs a staged candidate to
be just as safe, or a failed count would still change the deck.

Notes

  • API choices: the duplicate lands right
    after its source (the Rust contract), from_bytes is a static method only
    (no file-like constructor), and validate() returns typed snapshots rather
    than Debug strings. The replacement contract is meant to be shared with the
    rdocx counterpart asked for in rdocx Python bindings: the complete list of what a production editing chain still needs, as one checklist #168: try_replace_text(placeholder, replacement, *, expect=None), a ReplacementCountError subclass of the
    base error, and zero matches without expect return 0 instead of raising,
    as rdocx-py already does. The CLI still refuses zero matches because it
    would write an unchanged file.
  • The parameter names are placeholder and replacement, taken from the
    existing rdocx-py try_replace_text, so both bindings share one keyword set.
  • Question for the maintainer: rpptx Python bindings: what is left for a production deck chain, as one checklist #169 names replace_text too, and
    workflow_pptx.py calls it. I did not bind it. rdocx-py has only the
    fallible form, and the native rpptx replace_text skips the staged
    serialization check that try_replace_text runs, so a Python alias would
    either lose that check or be a second name for the same call. Adding
    replace_text as an alias of try_replace_text is a one-line change if you
    want python-pptx-style naming.
  • Question for the maintainer: the rdocx Python bindings: the complete list of what a production editing chain still needs, as one checklist #168 counterpart is expected to carry the
    failing pair's index for an ordered multi-pair replacement. rpptx has no
    multi-pair replacement, so ReplacementCountError carries only expected
    and found, and an index would have nothing to point at yet. If both
    libraries must expose the same attributes today, an index fixed at 0 for
    the single pair is cheap to add. The Python class takes (message, expected, found) so that it pickles, and a test pins this because worker
    pools pickle exceptions.
  • With expect, the binding clones the presentation and the facade's staged
    try_replace_text clones it again, so a counted replacement briefly holds
    three copies of the package. That keeps the change binding only. A facade
    method that stages once and checks the count before publishing would drop
    one copy if it matters on large decks. Without expect there is no extra
    copy.
  • ValidationIssue.kind comes from an exhaustive match on the native enum. A
    new variant fails to compile until the binding names it. The native enum has
    no Display, so message is its Debug form, which is exactly what the CLI
    prints. rpptx-cli is untouched, which keeps this PR clear of rdocx convert --to pdf|md|html, rpptx convert --to pdf and rpptx thumbnail overwrite any existing output, the input included #156 and rdocx and rpptx CLIs panic on a closed standard output (| head) #166.
  • The facade refuses to duplicate any slide whose comments part exists, and
    remove_comment keeps the part when the last comment goes. Binding
    remove_comment makes that reachable from Python, so HLD 10 and a test now
    state it. Dropping an emptied comments part in the facade would lift the
    refusal. That is a facade change and is left out of this PR.
  • Merge order: the new Python tests form their own group after the slide
    removal and reordering test, away from the Run.text test of Keep rpptx handles valid when Python sets a run's text #173
    (fix/rpptx-run-text-keeps-handles). git merge-tree against that branch,
    feat/python-compare-options and fix/direct-body-index-coordinates (the
    other branches touching HLD 10) is clean.

Tests

Added to crates/rpptx-py/tests/test_documented_examples.py:

  • test_slide_duplicate_inserts_after_the_source_with_its_notes: order and
    notes after duplicating the middle of three slides, the returned handle is
    live and writes the copy's notes, old handles stale after exactly one bump,
    a foreign slide raises ValueError, the commented-slide refusal leaves the
    bytes and handles unchanged, and python-pptx reads the saved deck with the
    expected texts and notes.
  • test_presentation_from_bytes_opens_like_a_path_and_rejects_other_bytes.
  • test_try_replace_text_checks_the_expected_count_before_publishing: text
    box, table cell and notes counted together, a mismatch raises with the CLI
    message, the counts and an unchanged to_bytes(), the error survives
    pickling, zero matches with and without expect leave handles live, an
    empty placeholder raises, and a match bumps once both with and without
    expect.
  • test_slide_resolve_and_remove_comment_match_the_cli_operations: unknown,
    reply-for-resolve and wrong-slide ids raise and leave the bytes unchanged,
    resolve marks only the thread, remove drops a reply then a thread, the
    result reopens through from_bytes, and once the last comment is removed
    duplicate still refuses the slide and leaves the bytes unchanged.
  • test_validate_returns_the_issues_the_cli_prints_as_frozen_snapshots: a
    clean deck returns (), and a duplicated shape id made by a zip edit returns
    one frozen issue with the CLI's line.

typing_smoke.py covers every new name and return type.

Run on this branch (macOS arm64, Python 3.12.14):

  • cargo fmt --all --check: pass.
  • cargo clippy -p rpptx-py --all-targets --all-features -- -D warnings: pass.
  • cargo test -p rpptx-py (with DYLD_LIBRARY_PATH set to the libpython the
    test binary links, the known local-only requirement): pass, 0 Rust tests.
  • maturin develop --locked then pytest crates/rpptx-py/tests: 45 passed,
    0 skipped (python-pptx 1.0.2 installed as the oracle). Each of the five
    commits was also built and tested on its own (41, 42, 43, 44, 45 passed).
  • mypy --strict crates/rpptx-py/tests/typing_smoke.py crates/rpptx-py/python/rpptx: no issues, also with
    --enable-error-code possibly-undefined. python -m mypy.stubtest rpptx:
    no issues.
  • python3 scripts/hash_harness.py --check: 49 entries match.
  • python3 scripts/prose_check.py: 0 violations.
    python3 scripts/readme_doctests.py: pass.
    python3 scripts/sync_agent_skills.py --check: in sync.

workflow_pptx.py from #158 on fixture-deck.pptx, unmodified, against this
build:

PASS  run text edited, font kept                   (None, 381000, None)
PASS  paragraph and text frame properties          ok
FAIL  shape moved and resized                      TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'
PASS  filled, outlined shape and connector added   9
PASS  picture added and replaced                   slide 0, shape 4
PASS  notes, hide, move, add, remove slides        7 slides
PASS  slide duplicated                             <builtins.Slide object at 0x...>
FAIL  counted replacement                          AttributeError: 'builtins.Presentation' object has no attribute 'replace_text'
PASS  comment added then resolved                  <built-in method resolve_comment of builtins.Slide object at 0x...>
PASS  overflow check (text_layout)                 [(5, 19)]
PASS  save                                         57483 bytes
PASS  validate                                     Validation passed: wf-edited.pptx
PASS  independent re-read                          python-pptx reads 8 slides
PASS  render                                       88979 bytes of PDF

"shape moved and resized" is inherited placeholder geometry, a separate item
of #169 and out of scope here. "counted replacement" fails because the script
calls prs.replace_text, which this PR does not bind (see Notes). The comment
row failed on main with AttributeError and now passes because the attribute
exists, without the script calling it. With the replacement step changed to
prs.try_replace_text("edited", "EDITED", expect=1), the comment step changed
to add a thread and call resolve_comment on it, and steps added for a
mismatch, in-process validate() and a from_bytes round trip, every row
passes except the geometry one:

PASS  slide duplicated                             <builtins.Slide object at 0x...>
PASS  counted replacement                          1
PASS  counted replacement mismatch raises          expected 5 replacement(s) of "EDITED", found 1 (5, 1)
PASS  comment added then resolved                  ['resolved']
PASS  in-process validate                          ()
PASS  from_bytes round trip                        8

On the fixture itself, duplicating slide 1 copies its notes, validate()
returns (), python-pptx reads the copy's notes, and a mismatched expect
leaves to_bytes() unchanged.

Environment-only failures: none other than the known libpython load path of
the rpptx-py Rust test binary.

The Rust facade has Presentation::duplicate_slide, which copies a slide
and its speaker notes to the position right after the source on a
staged candidate, but the Python SlideCollection offered only
add_slide, remove and move. A deck pass that clones a slide had to fall
back to python-pptx for that step.

SlideCollection.duplicate(slide) checks that the slide belongs to the
collection, calls the facade, advances the revision once because every
later slide moves down by one, and returns the new slide captured at
that revision. The facade refuses a slide that owns a modern comments
part, even one left empty after its last comment was removed, and that
refusal leaves the package and the revision unchanged.

GitHub issue tensorbee#169.
The Rust facade opens a deck from memory with Presentation::from_bytes,
and the rdocx binding exposes the same constructor as the static
Document.from_bytes, but the rpptx Python Presentation could only open
a path. A caller holding a downloaded or generated deck in memory had
to write it to a temporary file first.

The static Presentation.from_bytes(bytes) takes the same parameter as
the rdocx one, calls the facade and maps its errors like the path
constructor, so bytes that are not a package raise PackageError.

GitHub issue tensorbee#169.
The rpptx facade counts literal replacements across slides and speaker
notes with try_replace_text, and rpptx replace --expect refuses to
write when the count differs from the expected one. Python had no
binding for either, so a scripted deck pass could not replace text, let
alone check that a placeholder matched exactly as often as the template
promises.

Presentation.try_replace_text(placeholder, replacement, *, expect=None)
runs the staged facade replacement with the GIL released. With expect,
it runs on a clone, and a count that differs raises the new
ReplacementCountError, an RpptxError subclass carrying expected and
found whose message is the one rpptx replace prints. The presentation
and its revision then stay as they were. Without expect, the facade
call runs in place, as in the rdocx binding, because it already
publishes nothing on failure and a second copy of every part would buy
nothing. The revision advances once when anything was replaced, since
split runs move run paths. Zero matches without expect return zero, as
the rdocx binding does. The parameter names follow the rdocx
try_replace_text so that both libraries share one contract.

GitHub issue tensorbee#169.
The rpptx comment command lists, adds, replies to, resolves and removes
modern comments through Presentation::resolve_comment and
remove_comment, which were added to the facade for it. The Python Slide
offered list, add, reply and move only, so a review pass could not
close or delete a thread without leaving rpptx.

Slide.resolve_comment(comment_id) and Slide.remove_comment(comment_id)
follow the move_comment pattern: they call the staged facade operation
and advance the revision once, like every other collaboration
operation. As in the CLI, resolve accepts only a thread id, and remove
accepts a thread id, which removes its replies, or one reply id. An
unknown id raises RpptxError and leaves the package unchanged.

GitHub issue tensorbee#169.
Presentation::validate reports the package and PresentationML
invariant violations that make a saved deck unsafe, and rpptx validate
prints one line per issue. Python could not run it, so a deck pass had
to save to disk and shell out to the CLI to check its own output.

Presentation.validate() calls the facade with the GIL released and
returns a tuple of frozen ValidationIssue snapshots, exported from
rpptx. Each carries a kind that names the native variant in snake_case,
such as duplicate_shape_id, so callers can branch without parsing text,
and a message equal to the line rpptx validate prints. The kind mapping
is an exhaustive match, so a new native variant fails to compile until
the binding names it. The native enum has no Display form, and the CLI
is left untouched.

GitHub issue tensorbee#169.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant