Conversation
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 was referenced Sep 27, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Binding only.
crates/rpptxandrpptx-cliare unchanged, so no publishablecrate changes and there is no archive re-record.
SlideCollection.duplicate(slide)callsPresentation::duplicate_slide. Thenew 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, asremovedoes. Thefacade 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 overPresentation::from_bytes, with the same parameter name as rdocxDocument.from_bytes. Bytes that are not a package raisePackageError.Presentation.try_replace_text(placeholder, replacement, *, expect=None) -> intruns the facade's staged replacement over slides and notes with the GILreleased. With
expect, it runs on a clone, and a count that differs raisesthe new
ReplacementCountError(RpptxError), which carriesexpectedandfoundand uses the message ofrpptx replace --expect. The clone isdropped, so the bytes stay the same and every handle stays live. Without
expect, the facade call runs in place, as rdocx-py'stry_replace_textdoes. The revision advances once when the count is nonzero.
Slide.resolve_comment(comment_id)andSlide.remove_comment(comment_id)follow the
move_commentpattern over the facade methodsrpptx commentuses, with one revision bump on success.
Presentation.validate()returns a tuple of frozenValidationIssuesnapshots, each with a snake_case
kind(duplicate_shape_id, ...) and amessageequal to the linerpptx validateprints.ReplacementCountErrorandValidationIssueare exported fromrpptx. Thenew names are also in
_rpptx.pyi,typing_smoke.py, therpptx-pyREADME 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_commentandvalidateof"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.pyas written, the "slide duplicated" rownow passes. The "comment added then resolved" row now passes too, but only
because the script fetches
resolve_commentwithout calling it. The "countedreplacement" row passes once the script calls
try_replace_text(..., expect=...)instead ofreplace_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
--expectcheck is safe because the CLIwrites 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
after its source (the Rust contract),
from_bytesis a static method only(no file-like constructor), and
validate()returns typed snapshots ratherthan 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), aReplacementCountErrorsubclass of thebase error, and zero matches without
expectreturn 0 instead of raising,as rdocx-py already does. The CLI still refuses zero matches because it
would write an unchanged file.
placeholderandreplacement, taken from theexisting rdocx-py
try_replace_text, so both bindings share one keyword set.replace_texttoo, andworkflow_pptx.pycalls it. I did not bind it. rdocx-py has only thefallible form, and the native rpptx
replace_textskips the stagedserialization check that
try_replace_textruns, so a Python alias wouldeither lose that check or be a second name for the same call. Adding
replace_textas an alias oftry_replace_textis a one-line change if youwant python-pptx-style naming.
failing pair's
indexfor an ordered multi-pair replacement. rpptx has nomulti-pair replacement, so
ReplacementCountErrorcarries onlyexpectedand
found, and anindexwould have nothing to point at yet. If bothlibraries must expose the same attributes today, an
indexfixed at 0 forthe single pair is cheap to add. The Python class takes
(message, expected, found)so that it pickles, and a test pins this because workerpools pickle exceptions.
expect, the binding clones the presentation and the facade's stagedtry_replace_textclones it again, so a counted replacement briefly holdsthree 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
expectthere is no extracopy.
ValidationIssue.kindcomes from an exhaustive match on the native enum. Anew variant fails to compile until the binding names it. The native enum has
no
Display, somessageis its Debug form, which is exactly what the CLIprints.
rpptx-cliis untouched, which keeps this PR clear ofrdocx convert --to pdf|md|html,rpptx convert --to pdfandrpptx thumbnailoverwrite any existing output, the input included #156 andrdocxandrpptxCLIs panic on a closed standard output (| head) #166.remove_commentkeeps the part when the last comment goes. Bindingremove_commentmakes that reachable from Python, so HLD 10 and a test nowstate 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.
removal and reordering test, away from the
Run.texttest of Keep rpptx handles valid when Python sets a run's text #173(
fix/rpptx-run-text-keeps-handles).git merge-treeagainst that branch,feat/python-compare-optionsandfix/direct-body-index-coordinates(theother 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 andnotes 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 thebytes 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: textbox, table cell and notes counted together, a mismatch raises with the CLI
message, the counts and an unchanged
to_bytes(), the error survivespickling, zero matches with and without
expectleave handles live, anempty 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 removedduplicatestill refuses the slide and leaves the bytes unchanged.test_validate_returns_the_issues_the_cli_prints_as_frozen_snapshots: aclean deck returns
(), and a duplicated shape id made by a zip edit returnsone frozen issue with the CLI's line.
typing_smoke.pycovers 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(withDYLD_LIBRARY_PATHset to the libpython thetest binary links, the known local-only requirement): pass, 0 Rust tests.
maturin develop --lockedthenpytest 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.pyfrom #158 onfixture-deck.pptx, unmodified, against thisbuild:
"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 commentrow failed on main with
AttributeErrorand now passes because the attributeexists, without the script calling it. With the replacement step changed to
prs.try_replace_text("edited", "EDITED", expect=1), the comment step changedto add a thread and call
resolve_commenton it, and steps added for amismatch, in-process
validate()and afrom_bytesround trip, every rowpasses except the geometry one:
On the fixture itself, duplicating slide 1 copies its notes,
validate()returns
(), python-pptx reads the copy's notes, and a mismatchedexpectleaves
to_bytes()unchanged.Environment-only failures: none other than the known libpython load path of
the
rpptx-pyRust test binary.