From b04d6c100a286733e29e1e45be3f9e4fbbb0d7ba Mon Sep 17 00:00:00 2001 From: Hadrien Mary Date: Sun, 27 Sep 2026 17:23:08 +0200 Subject: [PATCH 1/5] Add SlideCollection.duplicate to the rpptx Python binding 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 #169. --- crates/rpptx-py/python/rpptx/_rpptx.pyi | 1 + crates/rpptx-py/src/slide.rs | 23 +++++++ .../tests/test_documented_examples.py | 65 +++++++++++++++++++ crates/rpptx-py/tests/typing_smoke.py | 2 + docs/hld/10-bindings-spec.md | 6 ++ 5 files changed, 97 insertions(+) diff --git a/crates/rpptx-py/python/rpptx/_rpptx.pyi b/crates/rpptx-py/python/rpptx/_rpptx.pyi index 333a35b84..47e80e9a2 100644 --- a/crates/rpptx-py/python/rpptx/_rpptx.pyi +++ b/crates/rpptx-py/python/rpptx/_rpptx.pyi @@ -238,6 +238,7 @@ class SlideCollection: def __iter__(self) -> _Iterator[Slide]: ... def add_slide(self, layout: SlideLayout) -> Slide: ... def remove(self, slide: Slide) -> None: ... + def duplicate(self, slide: Slide) -> Slide: ... def move(self, from_: int, to: int) -> None: ... diff --git a/crates/rpptx-py/src/slide.rs b/crates/rpptx-py/src/slide.rs index 0c18ce7fd..0bf06721b 100644 --- a/crates/rpptx-py/src/slide.rs +++ b/crates/rpptx-py/src/slide.rs @@ -503,6 +503,29 @@ impl PySlideCollection { Ok(()) } + /// Duplicates one slide of this presentation with its notes right after + /// the source, and returns the new slide. + fn duplicate(&self, py: Python<'_>, slide: &Bound<'_, PyAny>) -> PyResult> { + self.len(py)?; + let slide = slide.extract::>()?; + if !slide.presentation.is(&self.presentation) { + return Err(PyValueError::new_err("slide is not in this collection")); + } + let index = slide.validate(py)?; + let path = { + let mut presentation = self.presentation.borrow_mut(py); + presentation + .inner + .duplicate_slide(index) + .map_err(|error| crate::rpptx_to_pyerr(py, error))?; + presentation.revisions.bump(); + presentation + .revisions + .capture(smallvec![PathSeg::Slide(index + 1)]) + }; + Py::new(py, PySlide::new(self.presentation.clone_ref(py), path)) + } + /// Moves the slide at `from_` so that it ends up at index `to`. #[pyo3(name = "move")] fn move_slide(&self, py: Python<'_>, from_: isize, to: isize) -> PyResult<()> { diff --git a/crates/rpptx-py/tests/test_documented_examples.py b/crates/rpptx-py/tests/test_documented_examples.py index 9baf0f1df..6b7d81356 100644 --- a/crates/rpptx-py/tests/test_documented_examples.py +++ b/crates/rpptx-py/tests/test_documented_examples.py @@ -2094,6 +2094,71 @@ def test_shapes_and_slides_are_removed_and_reordered_with_stale_handles(tmp_path assert [len(slide.shapes) for slide in oracle.slides] == [2, 1] +def test_slide_duplicate_inserts_after_the_source_with_its_notes(tmp_path): + import rpptx + + prs = rpptx.Presentation() + for index in range(3): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + slide.shapes.add_textbox(0, 0, 10, 10).text = f"slide {index}" + prs.slides[1].notes_text = "note 1" + source = prs.slides[1] + held = prs.slides[2].shapes[0] + + duplicate = prs.slides.duplicate(source) + assert [slide.shapes[0].text for slide in prs.slides] == [ + "slide 0", + "slide 1", + "slide 1", + "slide 2", + ] + assert [slide.notes_text for slide in prs.slides] == [None, "note 1", "note 1", None] + assert (duplicate.shapes[0].text, duplicate.notes_text) == ("slide 1", "note 1") + _assert_stale_after_exactly_one_bump(rpptx, lambda: held.text) + _assert_stale_after_exactly_one_bump(rpptx, lambda: source.notes_text) + duplicate.notes_text = "copy note" + assert [slide.notes_text for slide in prs.slides] == [None, "note 1", "copy note", None] + + other = rpptx.Presentation() + other_slide = other.slides.add_slide(other.slide_layouts[6]) + with pytest.raises(ValueError, match="slide is not in this collection"): + prs.slides.duplicate(other_slide) + + prs.add_comment_author( + id="{11111111-1111-1111-1111-111111111111}", + name="Ada Lovelace", + user_id="ada@example.com", + provider_id="local", + ) + prs.slides[0].add_comment( + id="{22222222-2222-2222-2222-222222222222}", + author_id="{11111111-1111-1111-1111-111111111111}", + created="2026-09-14T10:30:00Z", + text="Blocks duplication", + ) + commented = prs.slides[0] + before = prs.to_bytes() + with pytest.raises(rpptx.RpptxError, match="modern comments"): + prs.slides.duplicate(commented) + assert prs.to_bytes() == before + assert (len(prs.slides), commented.shapes[0].text) == (4, "slide 0") + + output = tmp_path / "duplicated.pptx" + prs.save(output) + pptx = pytest.importorskip("pptx", reason="python-pptx is the differential oracle") + oracle = pptx.Presentation(output) + assert [slide.shapes[0].text for slide in oracle.slides] == [ + "slide 0", + "slide 1", + "slide 1", + "slide 2", + ] + assert [ + slide.notes_slide.notes_text_frame.text if slide.has_notes_slide else None + for slide in oracle.slides + ] == [None, "note 1", "copy note", None] + + def test_add_shape_accepts_preset_names_and_every_mso_shape_member(tmp_path): import rpptx from rpptx.enum.shapes import MSO_AUTO_SHAPE_TYPE, MSO_CONNECTOR, MSO_CONNECTOR_TYPE, MSO_SHAPE, MSO_SHAPE_TYPE diff --git a/crates/rpptx-py/tests/typing_smoke.py b/crates/rpptx-py/tests/typing_smoke.py index 0de75a650..f2ba703db 100644 --- a/crates/rpptx-py/tests/typing_smoke.py +++ b/crates/rpptx-py/tests/typing_smoke.py @@ -227,6 +227,7 @@ def exercise_rpptx_types(path: Path) -> None: picture.replace_image(b"") picture.replace_image(path) presentation.slides[0].shapes.remove(group) + duplicated: Slide = presentation.slides.duplicate(presentation.slides[0]) presentation.slides.move(0, -1) presentation.slides.remove(presentation.slides[0]) presentation.save(path) @@ -286,6 +287,7 @@ def exercise_rpptx_types(path: Path) -> None: blob, image.content_type, image.ext, + duplicated, ) diff --git a/docs/hld/10-bindings-spec.md b/docs/hld/10-bindings-spec.md index a6a75eb7c..f20f10102 100644 --- a/docs/hld/10-bindings-spec.md +++ b/docs/hld/10-bindings-spec.md @@ -300,6 +300,12 @@ live `FillFormat` over the direct background fill that never changes the slide when read, and `follow_master_background` reports and sets whether the slide has no `p:bg`. `SlideCollection.remove` and `SlideCollection.move(from_, to)` use the native staged slide operations and advance the revision once. +`SlideCollection.duplicate(slide)` copies a slide of the same presentation, +with its speaker notes, to the position right after it through the native +staged `duplicate_slide`, advances the revision once, and returns the new slide +captured at that revision. As in the facade, a slide that owns a modern +comments part is refused, even when removing its last comment left that part +empty, and the refusal leaves the package and the revision unchanged. `Shape` geometry, `name`, and `rotation` are writable without a revision bump. A missing partner coordinate becomes zero, as in python-pptx, and a negative From 5b6105a8498b67c8a9ed627ad7149ed871d0ef33 Mon Sep 17 00:00:00 2001 From: Hadrien Mary Date: Sun, 27 Sep 2026 17:24:11 +0200 Subject: [PATCH 2/5] Add Presentation.from_bytes to the rpptx Python binding 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 #169. --- crates/rpptx-py/python/rpptx/_rpptx.pyi | 2 ++ crates/rpptx-py/src/presentation.rs | 7 +++++++ .../tests/test_documented_examples.py | 19 +++++++++++++++++++ crates/rpptx-py/tests/typing_smoke.py | 2 ++ docs/hld/10-bindings-spec.md | 8 +++++--- 5 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/rpptx-py/python/rpptx/_rpptx.pyi b/crates/rpptx-py/python/rpptx/_rpptx.pyi index 47e80e9a2..d22e7bf4a 100644 --- a/crates/rpptx-py/python/rpptx/_rpptx.pyi +++ b/crates/rpptx-py/python/rpptx/_rpptx.pyi @@ -133,6 +133,8 @@ class TextFrameLayout: @_final class Presentation: def __new__(cls, path: _Path | None = None) -> Presentation: ... + @staticmethod + def from_bytes(bytes: bytes) -> Presentation: ... def save(self, path: _Path) -> None: ... def to_bytes(self) -> bytes: ... def to_pdf(self) -> bytes: ... diff --git a/crates/rpptx-py/src/presentation.rs b/crates/rpptx-py/src/presentation.rs index 24a8b158d..99327f3ce 100644 --- a/crates/rpptx-py/src/presentation.rs +++ b/crates/rpptx-py/src/presentation.rs @@ -163,6 +163,13 @@ impl PyPresentation { } } + #[staticmethod] + fn from_bytes(bytes: &[u8], py: Python<'_>) -> PyResult { + rpptx::Presentation::from_bytes(bytes) + .map(Self::from_presentation) + .map_err(|error| rpptx_to_pyerr(py, error)) + } + fn save(&self, path: PathBuf, py: Python<'_>) -> PyResult<()> { self.inner .save(path) diff --git a/crates/rpptx-py/tests/test_documented_examples.py b/crates/rpptx-py/tests/test_documented_examples.py index 6b7d81356..3f60d5a03 100644 --- a/crates/rpptx-py/tests/test_documented_examples.py +++ b/crates/rpptx-py/tests/test_documented_examples.py @@ -2159,6 +2159,25 @@ def test_slide_duplicate_inserts_after_the_source_with_its_notes(tmp_path): ] == [None, "note 1", "copy note", None] +def test_presentation_from_bytes_opens_like_a_path_and_rejects_other_bytes(tmp_path): + import rpptx + + prs = _textbox_presentation(rpptx) + prs.slides[0].notes_text = "note" + path = tmp_path / "source.pptx" + prs.save(path) + + reopened = rpptx.Presentation.from_bytes(path.read_bytes()) + assert len(reopened.slides) == 1 + assert (reopened.slides[0].shapes[0].text, reopened.slides[0].notes_text) == ( + "hello", + "note", + ) + assert reopened.to_bytes() == rpptx.Presentation(path).to_bytes() + with pytest.raises(rpptx.PackageError): + rpptx.Presentation.from_bytes(b"not a package") + + def test_add_shape_accepts_preset_names_and_every_mso_shape_member(tmp_path): import rpptx from rpptx.enum.shapes import MSO_AUTO_SHAPE_TYPE, MSO_CONNECTOR, MSO_CONNECTOR_TYPE, MSO_SHAPE, MSO_SHAPE_TYPE diff --git a/crates/rpptx-py/tests/typing_smoke.py b/crates/rpptx-py/tests/typing_smoke.py index f2ba703db..96e7a5cdc 100644 --- a/crates/rpptx-py/tests/typing_smoke.py +++ b/crates/rpptx-py/tests/typing_smoke.py @@ -152,6 +152,7 @@ def exercise_rpptx_types(path: Path) -> None: for current_shape in current_slide.shapes: current_shape.has_text_frame package_bytes: bytes = presentation.to_bytes() + reopened: Presentation = Presentation.from_bytes(package_bytes) pdf_bytes: bytes = presentation.to_pdf() slide_png: bytes | None = presentation.render_slide_to_png(0) slide_pngs: list[bytes] = presentation.render_all_slides() @@ -288,6 +289,7 @@ def exercise_rpptx_types(path: Path) -> None: image.content_type, image.ext, duplicated, + reopened, ) diff --git a/docs/hld/10-bindings-spec.md b/docs/hld/10-bindings-spec.md index f20f10102..4640e279d 100644 --- a/docs/hld/10-bindings-spec.md +++ b/docs/hld/10-bindings-spec.md @@ -273,9 +273,11 @@ document revision. A rejected image, filename, dimension pair, or stale path leaves package bytes and binding revisions unchanged. `rpptx` mirrors python-pptx through an unpublished mixed-layout `rpptx-py` -crate. `Presentation` owns the Rust facade and one revision counter. Lazy -layouts, slides, shapes, placeholders, text frames, paragraphs, runs, columns -and cells store only a presentation reference and `ContentPath`. The bounded +crate. `Presentation` owns the Rust facade and one revision counter. +`Presentation(path)` opens a file and the static `Presentation.from_bytes` +opens in-memory package bytes, as the rdocx `Document` does. Lazy layouts, +slides, shapes, placeholders, text frames, paragraphs, runs, columns and cells +store only a presentation reference and `ContentPath`. The bounded source-compatibility surface is the seven python-pptx 1.0.2 Getting Started workflows. They change the import namespace and re-fetch through the public path after each structural write, because strict global revision invalidation From d05ed5d47cc585137b202c9266644f3f9663a849 Mon Sep 17 00:00:00 2001 From: Hadrien Mary Date: Sun, 27 Sep 2026 17:28:52 +0200 Subject: [PATCH 3/5] Add counted text replacement to the rpptx Python binding 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 #169. --- crates/rpptx-py/README.md | 3 ++ crates/rpptx-py/python/rpptx/__init__.py | 13 ++++++ crates/rpptx-py/python/rpptx/_rpptx.pyi | 3 ++ crates/rpptx-py/src/lib.rs | 27 +++++++++--- crates/rpptx-py/src/presentation.rs | 42 +++++++++++++++++- .../tests/test_documented_examples.py | 44 +++++++++++++++++++ crates/rpptx-py/tests/typing_smoke.py | 9 ++++ docs/hld/10-bindings-spec.md | 11 +++++ 8 files changed, 146 insertions(+), 6 deletions(-) diff --git a/crates/rpptx-py/README.md b/crates/rpptx-py/README.md index 3b240bdcd..3e876b6f6 100644 --- a/crates/rpptx-py/README.md +++ b/crates/rpptx-py/README.md @@ -51,6 +51,9 @@ with open("review.pdf", "wb") as output: - Slide layouts, slides, placeholders, shapes, text frames, paragraphs, runs, pictures, preset shapes, and tables. - Deterministic PDF and PNG output for slides and speaker notes. +- Formatting-preserving text replacement across slides and speaker notes, + with an optional expected count that leaves the deck unchanged on a + mismatch. - Speaker-note text plus modern comment authors, threads, replies, and ordered comment movement. - Master, layout, placeholder, theme, shape, chart, media, and relationship diff --git a/crates/rpptx-py/python/rpptx/__init__.py b/crates/rpptx-py/python/rpptx/__init__.py index 3ec6daaa6..9c915b0b8 100644 --- a/crates/rpptx-py/python/rpptx/__init__.py +++ b/crates/rpptx-py/python/rpptx/__init__.py @@ -22,6 +22,18 @@ class StaleElementError(RpptxError): """A held content handle was invalidated by structural mutation.""" +class ReplacementCountError(RpptxError): + """A counted replacement matched a different number of times than expected.""" + + def __init__(self, message: str, expected: int, found: int) -> None: + super().__init__(message, expected, found) + self.expected = expected + self.found = found + + def __str__(self) -> str: + return str(self.args[0]) + + from ._rpptx import Comment, CommentAuthor, CommentReply, Presentation from ._rpptx import BoundingBox, TextFrameLayout, TextLineLayout @@ -41,6 +53,7 @@ class StaleElementError(RpptxError): "Presentation", "Pt", "RGBColor", + "ReplacementCountError", "RpptxError", "StaleElementError", "TextFrameLayout", diff --git a/crates/rpptx-py/python/rpptx/_rpptx.pyi b/crates/rpptx-py/python/rpptx/_rpptx.pyi index d22e7bf4a..52a1edfdd 100644 --- a/crates/rpptx-py/python/rpptx/_rpptx.pyi +++ b/crates/rpptx-py/python/rpptx/_rpptx.pyi @@ -143,6 +143,9 @@ class Presentation: def text_layout(self, *, width_factor: float = 1.0) -> tuple[TextFrameLayout, ...]: ... def to_notes_pdf(self) -> bytes: ... def render_all_notes(self, dpi: float = 150.0) -> list[bytes]: ... + def try_replace_text( + self, placeholder: str, replacement: str, *, expect: int | None = None + ) -> int: ... @property def slide_width(self) -> _Length | None: ... @slide_width.setter diff --git a/crates/rpptx-py/src/lib.rs b/crates/rpptx-py/src/lib.rs index 8e2e4ab5b..fffa5e6e8 100644 --- a/crates/rpptx-py/src/lib.rs +++ b/crates/rpptx-py/src/lib.rs @@ -27,17 +27,34 @@ pub(crate) fn normalize_index(index: isize, len: usize, kind: &str) -> PyResult< Ok(normalized as usize) } -fn public_error(py: Python<'_>, class_name: &str, message: String) -> PyErr { - let exception_type = py - .import("rpptx") +fn public_exception_type<'py>(py: Python<'py>, class_name: &str) -> PyResult> { + py.import("rpptx") .and_then(|module| module.getattr(class_name)) - .and_then(|class| class.cast_into::().map_err(Into::into)); - match exception_type { + .and_then(|class| class.cast_into::().map_err(Into::into)) +} + +fn public_error(py: Python<'_>, class_name: &str, message: String) -> PyErr { + match public_exception_type(py, class_name) { Ok(class) => PyErr::from_type(class, (message,)), Err(_) => PyRuntimeError::new_err(message), } } +/// A counted replacement that matched a different number of times than the +/// caller expected, worded like `rpptx replace --expect`. +pub(crate) fn replacement_count_to_pyerr( + py: Python<'_>, + placeholder: &str, + expected: usize, + found: usize, +) -> PyErr { + let message = format!("expected {expected} replacement(s) of \"{placeholder}\", found {found}"); + match public_exception_type(py, "ReplacementCountError") { + Ok(class) => PyErr::from_type(class, (message, expected, found)), + Err(_) => PyRuntimeError::new_err(message), + } +} + pub(crate) fn stale_to_pyerr(py: Python<'_>, error: StaleElementError) -> PyErr { public_error(py, "StaleElementError", error.to_string()) } diff --git a/crates/rpptx-py/src/presentation.rs b/crates/rpptx-py/src/presentation.rs index 99327f3ce..1e959bc37 100644 --- a/crates/rpptx-py/src/presentation.rs +++ b/crates/rpptx-py/src/presentation.rs @@ -8,7 +8,7 @@ use smallvec::smallvec; use crate::layout::PyTextFrameLayout; use crate::shape::length; use crate::slide::{PySlideCollection, PySlideLayoutCollection}; -use crate::{rpptx_to_pyerr, rpptx_value_to_pyerr}; +use crate::{replacement_count_to_pyerr, rpptx_to_pyerr, rpptx_value_to_pyerr}; /// The bundled 16:9 slide size, paired with the first dimension set on a deck /// that has no `p:sldSz`. @@ -236,6 +236,46 @@ impl PyPresentation { PyList::new(py, notes.iter().map(|page| PyBytes::new(py, page))) } + /// Replaces literal text in slides and speaker notes and returns the count. + /// + /// With `expect`, the replacement runs on a clone, so a count that + /// differs raises and leaves the presentation and its revision as they + /// were. Without it, the staged facade call runs in place. The revision + /// advances once only when something was replaced. + #[pyo3(signature = (placeholder, replacement, *, expect = None))] + fn try_replace_text( + &mut self, + py: Python<'_>, + placeholder: &str, + replacement: &str, + expect: Option, + ) -> PyResult { + let count = match expect { + None => py + .detach(|| self.inner.try_replace_text(placeholder, replacement)) + .map_err(|error| rpptx_to_pyerr(py, error))?, + Some(expected) => { + let (candidate, count) = py + .detach(|| { + let mut candidate = self.inner.clone(); + candidate + .try_replace_text(placeholder, replacement) + .map(|count| (candidate, count)) + }) + .map_err(|error| rpptx_to_pyerr(py, error))?; + if count != expected { + return Err(replacement_count_to_pyerr(py, placeholder, expected, count)); + } + self.inner = candidate; + count + } + }; + if count > 0 { + self.revisions.bump(); + } + Ok(count) + } + #[getter] fn slide_width(&self, py: Python<'_>) -> PyResult>> { length(py, self.inner.slide_size().map(|(width, _)| width)) diff --git a/crates/rpptx-py/tests/test_documented_examples.py b/crates/rpptx-py/tests/test_documented_examples.py index 3f60d5a03..561e3126e 100644 --- a/crates/rpptx-py/tests/test_documented_examples.py +++ b/crates/rpptx-py/tests/test_documented_examples.py @@ -2178,6 +2178,50 @@ def test_presentation_from_bytes_opens_like_a_path_and_rejects_other_bytes(tmp_p rpptx.Presentation.from_bytes(b"not a package") +def test_try_replace_text_checks_the_expected_count_before_publishing(): + import pickle + + import rpptx + + prs = _textbox_presentation(rpptx) + prs.slides[0].shapes[0].text = "NAME and NAME" + prs.slides[0].shapes.add_table(1, 1, 0, 0, 100, 100).table.cell(0, 0).text = "NAME" + prs.slides[0].notes_text = "Notes for NAME" + held = prs.slides[0].shapes[0] + before = prs.to_bytes() + + with pytest.raises(rpptx.ReplacementCountError) as raised: + prs.try_replace_text("NAME", "Ada", expect=3) + error = raised.value + assert isinstance(error, rpptx.RpptxError) + assert str(error) == 'expected 3 replacement(s) of "NAME", found 4' + assert (error.expected, error.found) == (3, 4) + # Worker pools pickle exceptions, so the counts must survive the trip. + copied = pickle.loads(pickle.dumps(error)) + assert (str(copied), copied.expected, copied.found) == (str(error), 3, 4) + assert prs.to_bytes() == before + assert held.text == "NAME and NAME" + + assert prs.try_replace_text("MISSING", "x") == 0 + assert prs.try_replace_text("MISSING", "x", expect=0) == 0 + with pytest.raises(rpptx.ReplacementCountError, match="found 0"): + prs.try_replace_text("MISSING", "x", expect=1) + with pytest.raises(rpptx.RpptxError, match="placeholder must not be empty"): + prs.try_replace_text("", "x") + assert prs.to_bytes() == before + assert held.text == "NAME and NAME" + + assert prs.try_replace_text("NAME", "Ada", expect=4) == 4 + _assert_stale_after_exactly_one_bump(rpptx, lambda: held.text) + shapes = prs.slides[0].shapes + assert (shapes[0].text, shapes[1].table.cell(0, 0).text) == ("Ada and Ada", "Ada") + assert prs.slides[0].notes_text == "Notes for Ada" + held = prs.slides[0].shapes[0] + assert prs.try_replace_text("Ada", "Grace") == 4 + _assert_stale_after_exactly_one_bump(rpptx, lambda: held.text) + assert prs.slides[0].shapes[0].text == "Grace and Grace" + + def test_add_shape_accepts_preset_names_and_every_mso_shape_member(tmp_path): import rpptx from rpptx.enum.shapes import MSO_AUTO_SHAPE_TYPE, MSO_CONNECTOR, MSO_CONNECTOR_TYPE, MSO_SHAPE, MSO_SHAPE_TYPE diff --git a/crates/rpptx-py/tests/typing_smoke.py b/crates/rpptx-py/tests/typing_smoke.py index 96e7a5cdc..e26e6c418 100644 --- a/crates/rpptx-py/tests/typing_smoke.py +++ b/crates/rpptx-py/tests/typing_smoke.py @@ -16,6 +16,7 @@ PP_ALIGN, Presentation, Pt, + ReplacementCountError, RGBColor, TextFrameLayout, TextLineLayout, @@ -153,6 +154,12 @@ def exercise_rpptx_types(path: Path) -> None: current_shape.has_text_frame package_bytes: bytes = presentation.to_bytes() reopened: Presentation = Presentation.from_bytes(package_bytes) + replaced: int = presentation.try_replace_text("typed", "checked") + replacement_counts: tuple[int, int] = (0, 0) + try: + replaced = presentation.try_replace_text("checked", "typed", expect=2) + except ReplacementCountError as error: + replacement_counts = (error.expected, error.found) pdf_bytes: bytes = presentation.to_pdf() slide_png: bytes | None = presentation.render_slide_to_png(0) slide_pngs: list[bytes] = presentation.render_all_slides() @@ -290,6 +297,8 @@ def exercise_rpptx_types(path: Path) -> None: image.ext, duplicated, reopened, + replaced, + replacement_counts, ) diff --git a/docs/hld/10-bindings-spec.md b/docs/hld/10-bindings-spec.md index 4640e279d..36aa4652b 100644 --- a/docs/hld/10-bindings-spec.md +++ b/docs/hld/10-bindings-spec.md @@ -391,6 +391,17 @@ place and do not advance the revision. `rpptx` and `rpptx.enum.text` export value in place, keeping transforms such as `a:alpha`, replaces any other colour, and `None` removes the direct fill. +`Presentation.try_replace_text(placeholder, replacement, *, expect=None)` +runs the native staged literal replacement over slides and speaker notes with +the GIL released and returns its count. When `expect` is given, the +replacement runs on a clone, and a count that differs raises +`ReplacementCountError`, an `RpptxError` subclass that carries `expected` and +`found` and words its message like `rpptx replace --expect`. The presentation +and its revision then stay unchanged. Otherwise the replacement is kept, and +the revision advances once when the count is nonzero. Without `expect`, zero +matches return zero rather than raise, as the rdocx `try_replace_text` does. +Only the CLI refuses zero matches, because it would write an unchanged copy. + The presentation binding exposes `to_pdf`, `render_slide_to_png`, `render_all_slides`, `to_notes_pdf`, and `render_all_notes` through the native deterministic facade. Every render call releases the GIL. From 37aa2c32c6ef8b412072ef2b6174c0f0c134af23 Mon Sep 17 00:00:00 2001 From: Hadrien Mary Date: Sun, 27 Sep 2026 17:30:38 +0200 Subject: [PATCH 4/5] Add comment resolution and removal to the rpptx Python binding 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 #169. --- crates/rpptx-py/README.md | 4 +- crates/rpptx-py/python/rpptx/_rpptx.pyi | 2 + crates/rpptx-py/src/slide.rs | 24 ++++++++ .../tests/test_documented_examples.py | 56 +++++++++++++++++++ crates/rpptx-py/tests/typing_smoke.py | 2 + docs/hld/10-bindings-spec.md | 15 +++-- 6 files changed, 96 insertions(+), 7 deletions(-) diff --git a/crates/rpptx-py/README.md b/crates/rpptx-py/README.md index 3e876b6f6..a246fa894 100644 --- a/crates/rpptx-py/README.md +++ b/crates/rpptx-py/README.md @@ -54,8 +54,8 @@ with open("review.pdf", "wb") as output: - Formatting-preserving text replacement across slides and speaker notes, with an optional expected count that leaves the deck unchanged on a mismatch. -- Speaker-note text plus modern comment authors, threads, replies, and ordered - comment movement. +- Speaker-note text plus modern comment authors, threads, replies, thread + resolution and removal, and ordered comment movement. - Master, layout, placeholder, theme, shape, chart, media, and relationship state remains inside the native presentation engine during package edits. - Read speaker-note text and inspect or mutate modern comment threads. diff --git a/crates/rpptx-py/python/rpptx/_rpptx.pyi b/crates/rpptx-py/python/rpptx/_rpptx.pyi index 52a1edfdd..b12058380 100644 --- a/crates/rpptx-py/python/rpptx/_rpptx.pyi +++ b/crates/rpptx-py/python/rpptx/_rpptx.pyi @@ -228,6 +228,8 @@ class Slide: created: str, text: str, ) -> None: ... + def resolve_comment(self, comment_id: str) -> None: ... + def remove_comment(self, comment_id: str) -> None: ... def move_comment(self, from_: int, to: int) -> None: ... def move_reply(self, comment_id: str, from_: int, to: int) -> None: ... diff --git a/crates/rpptx-py/src/slide.rs b/crates/rpptx-py/src/slide.rs index 0bf06721b..96c7d667c 100644 --- a/crates/rpptx-py/src/slide.rs +++ b/crates/rpptx-py/src/slide.rs @@ -380,6 +380,30 @@ impl PySlide { Ok(()) } + /// Marks one comment thread resolved. A reply id is an unknown id. + fn resolve_comment(&self, comment_id: &str, py: Python<'_>) -> PyResult<()> { + let index = self.validate(py)?; + let mut presentation = self.presentation.borrow_mut(py); + presentation + .inner + .resolve_comment(index, comment_id) + .map_err(|error| crate::rpptx_to_pyerr(py, error))?; + presentation.revisions.bump(); + Ok(()) + } + + /// Removes one comment thread with its replies, or one reply. + fn remove_comment(&self, comment_id: &str, py: Python<'_>) -> PyResult<()> { + let index = self.validate(py)?; + let mut presentation = self.presentation.borrow_mut(py); + presentation + .inner + .remove_comment(index, comment_id) + .map_err(|error| crate::rpptx_to_pyerr(py, error))?; + presentation.revisions.bump(); + Ok(()) + } + fn move_comment(&self, from_: usize, to: usize, py: Python<'_>) -> PyResult<()> { let index = self.validate(py)?; let mut presentation = self.presentation.borrow_mut(py); diff --git a/crates/rpptx-py/tests/test_documented_examples.py b/crates/rpptx-py/tests/test_documented_examples.py index 561e3126e..a9127214a 100644 --- a/crates/rpptx-py/tests/test_documented_examples.py +++ b/crates/rpptx-py/tests/test_documented_examples.py @@ -2222,6 +2222,62 @@ def test_try_replace_text_checks_the_expected_count_before_publishing(): assert prs.slides[0].shapes[0].text == "Grace and Grace" +def test_slide_resolve_and_remove_comment_match_the_cli_operations(): + import rpptx + + author_id = "{11111111-1111-1111-1111-111111111111}" + comment_id = "{22222222-2222-2222-2222-222222222222}" + reply_id = "{33333333-3333-3333-3333-333333333333}" + second_id = "{55555555-5555-5555-5555-555555555555}" + unknown_id = "{99999999-9999-9999-9999-999999999999}" + created = "2026-09-14T10:30:00Z" + prs = rpptx.Presentation() + for _ in range(2): + prs.slides.add_slide(prs.slide_layouts[6]) + prs.add_comment_author( + id=author_id, name="Ada Lovelace", user_id="ada@example.com", provider_id="local" + ) + prs.slides[0].add_comment(id=comment_id, author_id=author_id, created=created, text="Thread") + prs.slides[0].reply_to_comment( + comment_id, id=reply_id, author_id=author_id, created=created, text="Reply" + ) + prs.slides[0].add_comment(id=second_id, author_id=author_id, created=created, text="Second") + + slide = prs.slides[0] + before = prs.to_bytes() + for operation, comment in ( + (slide.resolve_comment, unknown_id), + (slide.resolve_comment, reply_id), + (slide.remove_comment, unknown_id), + (prs.slides[1].remove_comment, comment_id), + ): + with pytest.raises(rpptx.RpptxError, match="unknown comment id"): + operation(comment) + assert prs.to_bytes() == before + assert [comment.status for comment in slide.comments] == [None, None] + + slide.resolve_comment(comment_id) + _assert_stale_after_exactly_one_bump(rpptx, lambda: slide.comments) + thread, second = prs.slides[0].comments + assert (thread.id, thread.status, second.status) == (comment_id, "resolved", None) + assert [(reply.id, reply.status) for reply in thread.replies] == [(reply_id, None)] + + prs.slides[0].remove_comment(reply_id) + assert prs.slides[0].comments[0].replies == () + prs.slides[0].remove_comment(comment_id) + assert [comment.id for comment in prs.slides[0].comments] == [second_id] + reopened = rpptx.Presentation.from_bytes(prs.to_bytes()) + assert reopened.slides[0].comments == prs.slides[0].comments + + # The facade keeps the emptied comments part, so duplicate still refuses. + prs.slides[0].remove_comment(second_id) + assert prs.slides[0].comments == () + before = prs.to_bytes() + with pytest.raises(rpptx.RpptxError, match="modern comments"): + prs.slides.duplicate(prs.slides[0]) + assert (prs.to_bytes(), len(prs.slides)) == (before, 2) + + def test_add_shape_accepts_preset_names_and_every_mso_shape_member(tmp_path): import rpptx from rpptx.enum.shapes import MSO_AUTO_SHAPE_TYPE, MSO_CONNECTOR, MSO_CONNECTOR_TYPE, MSO_SHAPE, MSO_SHAPE_TYPE diff --git a/crates/rpptx-py/tests/typing_smoke.py b/crates/rpptx-py/tests/typing_smoke.py index e26e6c418..987ce409a 100644 --- a/crates/rpptx-py/tests/typing_smoke.py +++ b/crates/rpptx-py/tests/typing_smoke.py @@ -183,6 +183,8 @@ def exercise_rpptx_types(path: Path) -> None: ) comments: tuple[Comment, ...] = presentation.slides[0].comments reply: CommentReply = comments[0].replies[0] + presentation.slides[0].resolve_comment(comments[0].id) + presentation.slides[0].remove_comment(reply.id) slide_width: Length | None = presentation.slide_width presentation.slide_height = Inches(6) current_slide = presentation.slides[0] diff --git a/docs/hld/10-bindings-spec.md b/docs/hld/10-bindings-spec.md index 36aa4652b..7fffb86fc 100644 --- a/docs/hld/10-bindings-spec.md +++ b/docs/hld/10-bindings-spec.md @@ -427,7 +427,11 @@ snapshots. Each comment contains an ordered tuple of frozen `CommentReply` snapshots, and the presentation exposes an ordered tuple of frozen `CommentAuthor` snapshots. Author, comment, and reply additions accept native GUID and RFC 3339 strings. -Comment and reply moves retain native final-position semantics. A successful +Comment and reply moves retain native final-position semantics. +`Slide.resolve_comment(comment_id)` marks a thread resolved and treats a reply +id as unknown. `Slide.remove_comment(comment_id)` removes a thread with its +replies, or one reply. Both use the native staged operations of +`rpptx comment resolve` and `remove`. A successful collaboration operation advances the global revision once. Constructor or native validation failure publishes no candidate and leaves existing handles valid. @@ -1493,10 +1497,11 @@ The additive methods are `comment_authors`, `add_comment_author`, `comments`, `add_comment`, `reply_to_comment`, `resolve_comment`, `remove_comment`, `move_comment`, `move_reply`, `sections`, `set_sections`, `notes_header_footer_mut`, and `handout_header_footer_mut`. Python exposes the -comment snapshots, additions, and moves described with the presentation -binding, and `rpptx comment` exposes the comment operations described under -CLIs. WASM consumers gain no collaboration or navigation methods and continue -to preserve these package parts through their existing `Presentation` owner. +comment snapshots, additions, moves, resolution, and removal described with the +presentation binding, and `rpptx comment` exposes the comment operations +described under CLIs. WASM consumers gain no collaboration or navigation +methods and continue to preserve these package parts through their existing +`Presentation` owner. The low-level `rpptx-oxml` model adds the approved `comments` module and extends existing presentation, notes, slide, relationship, and content-type From c73ac59d8dc343173d56df2e925c0339a6172dd1 Mon Sep 17 00:00:00 2001 From: Hadrien Mary Date: Sun, 27 Sep 2026 17:32:58 +0200 Subject: [PATCH 5/5] Add Presentation.validate to the rpptx Python binding 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 #169. --- crates/rpptx-py/README.md | 2 + crates/rpptx-py/python/rpptx/__init__.py | 3 +- crates/rpptx-py/python/rpptx/_rpptx.pyi | 12 +++++- crates/rpptx-py/src/lib.rs | 3 +- crates/rpptx-py/src/presentation.rs | 39 +++++++++++++++++++ .../tests/test_documented_examples.py | 26 +++++++++++++ crates/rpptx-py/tests/typing_smoke.py | 5 +++ docs/hld/10-bindings-spec.md | 6 +++ 8 files changed, 93 insertions(+), 3 deletions(-) diff --git a/crates/rpptx-py/README.md b/crates/rpptx-py/README.md index a246fa894..e06265bea 100644 --- a/crates/rpptx-py/README.md +++ b/crates/rpptx-py/README.md @@ -54,6 +54,8 @@ with open("review.pdf", "wb") as output: - Formatting-preserving text replacement across slides and speaker notes, with an optional expected count that leaves the deck unchanged on a mismatch. +- Package and PresentationML validation that returns the issues + `rpptx validate` reports as typed values. - Speaker-note text plus modern comment authors, threads, replies, thread resolution and removal, and ordered comment movement. - Master, layout, placeholder, theme, shape, chart, media, and relationship diff --git a/crates/rpptx-py/python/rpptx/__init__.py b/crates/rpptx-py/python/rpptx/__init__.py index 9c915b0b8..c26a9cbe9 100644 --- a/crates/rpptx-py/python/rpptx/__init__.py +++ b/crates/rpptx-py/python/rpptx/__init__.py @@ -35,7 +35,7 @@ def __str__(self) -> str: from ._rpptx import Comment, CommentAuthor, CommentReply, Presentation -from ._rpptx import BoundingBox, TextFrameLayout, TextLineLayout +from ._rpptx import BoundingBox, TextFrameLayout, TextLineLayout, ValidationIssue __all__ = [ "BoundingBox", @@ -58,5 +58,6 @@ def __str__(self) -> str: "StaleElementError", "TextFrameLayout", "TextLineLayout", + "ValidationIssue", "XmlError", ] diff --git a/crates/rpptx-py/python/rpptx/_rpptx.pyi b/crates/rpptx-py/python/rpptx/_rpptx.pyi index b12058380..dab1fcb5a 100644 --- a/crates/rpptx-py/python/rpptx/_rpptx.pyi +++ b/crates/rpptx-py/python/rpptx/_rpptx.pyi @@ -21,7 +21,7 @@ _Path = str | _os.PathLike[str] _ImageFile = _Path | bytes | _IO[bytes] __all__ = [ "Presentation", "CommentAuthor", "Comment", "CommentReply", - "BoundingBox", "TextLineLayout", "TextFrameLayout", + "BoundingBox", "TextLineLayout", "TextFrameLayout", "ValidationIssue", "SlideLayout", "SlideLayoutCollection", "Slide", "Background", "SlideCollection", "Shape", "ShapeCollection", "PlaceholderCollection", "Image", "AdjustmentCollection", "FillFormat", "LineFormat", "ColorFormat", @@ -130,6 +130,15 @@ class TextFrameLayout: def lines(self) -> tuple[TextLineLayout, ...]: ... +@_final +class ValidationIssue: + def __new__(cls, *, _private: _Never) -> ValidationIssue: ... + @property + def kind(self) -> str: ... + @property + def message(self) -> str: ... + + @_final class Presentation: def __new__(cls, path: _Path | None = None) -> Presentation: ... @@ -146,6 +155,7 @@ class Presentation: def try_replace_text( self, placeholder: str, replacement: str, *, expect: int | None = None ) -> int: ... + def validate(self) -> tuple[ValidationIssue, ...]: ... @property def slide_width(self) -> _Length | None: ... @slide_width.setter diff --git a/crates/rpptx-py/src/lib.rs b/crates/rpptx-py/src/lib.rs index fffa5e6e8..ae6e8526f 100644 --- a/crates/rpptx-py/src/lib.rs +++ b/crates/rpptx-py/src/lib.rs @@ -13,7 +13,7 @@ use pyo3::prelude::*; use pyo3::types::PyType; use oxml_py_support::{ContentPath, PathSeg, StaleElementError}; -use presentation::{PyComment, PyCommentAuthor, PyCommentReply, PyPresentation}; +use presentation::{PyComment, PyCommentAuthor, PyCommentReply, PyPresentation, PyValidationIssue}; pub(crate) fn normalize_index(index: isize, len: usize, kind: &str) -> PyResult { let normalized = if index < 0 { @@ -126,6 +126,7 @@ fn _rpptx(module: &Bound<'_, PyModule>) -> PyResult<()> { module.add_class::()?; module.add_class::()?; module.add_class::()?; + module.add_class::()?; slide::register(module)?; shape::register(module)?; dml::register(module)?; diff --git a/crates/rpptx-py/src/presentation.rs b/crates/rpptx-py/src/presentation.rs index 1e959bc37..00c22663d 100644 --- a/crates/rpptx-py/src/presentation.rs +++ b/crates/rpptx-py/src/presentation.rs @@ -115,6 +115,39 @@ impl PyComment { } } +/// One issue `Presentation::validate` reports, with the snake_case variant +/// name and the line `rpptx validate` prints for it. +#[pyclass(name = "ValidationIssue", frozen, get_all, eq, skip_from_py_object)] +#[derive(Clone, PartialEq, Eq)] +pub struct PyValidationIssue { + pub kind: &'static str, + pub message: String, +} + +impl From<&rpptx::ValidationIssue> for PyValidationIssue { + fn from(issue: &rpptx::ValidationIssue) -> Self { + use rpptx::ValidationIssue as Issue; + let kind = match issue { + Issue::DuplicateShapeId { .. } => "duplicate_shape_id", + Issue::SlideIdOutOfRange { .. } => "slide_id_out_of_range", + Issue::DuplicateSlideId { .. } => "duplicate_slide_id", + Issue::MissingContentTypeOverride { .. } => "missing_content_type_override", + Issue::DanglingRelationship { .. } => "dangling_relationship", + Issue::UnreachableRelationshipTarget { .. } => "unreachable_relationship_target", + Issue::EmptyTextBody { .. } => "empty_text_body", + Issue::DuplicatePlaceholderIdx { .. } => "duplicate_placeholder_idx", + Issue::OrphanMedia { .. } => "orphan_media", + Issue::CustomShowReference { .. } => "custom_show_reference", + Issue::MissingLayoutRel { .. } => "missing_layout_rel", + Issue::MissingThemeRel { .. } => "missing_theme_rel", + }; + Self { + kind, + message: format!("{issue:?}"), + } + } +} + #[pyclass(name = "Presentation")] pub struct PyPresentation { pub(crate) inner: rpptx::Presentation, @@ -276,6 +309,12 @@ impl PyPresentation { Ok(count) } + /// Returns every package and PresentationML invariant violation. + fn validate<'py>(&self, py: Python<'py>) -> PyResult> { + let issues = py.detach(|| self.inner.validate()); + PyTuple::new(py, issues.iter().map(PyValidationIssue::from)) + } + #[getter] fn slide_width(&self, py: Python<'_>) -> PyResult>> { length(py, self.inner.slide_size().map(|(width, _)| width)) diff --git a/crates/rpptx-py/tests/test_documented_examples.py b/crates/rpptx-py/tests/test_documented_examples.py index a9127214a..b489c24d3 100644 --- a/crates/rpptx-py/tests/test_documented_examples.py +++ b/crates/rpptx-py/tests/test_documented_examples.py @@ -2278,6 +2278,32 @@ def test_slide_resolve_and_remove_comment_match_the_cli_operations(): assert (prs.to_bytes(), len(prs.slides)) == (before, 2) +def test_validate_returns_the_issues_the_cli_prints_as_frozen_snapshots(tmp_path): + import rpptx + + prs = rpptx.Presentation() + assert prs.validate() == () + prs.slides.add_slide(prs.slide_layouts[6]) + for text in ("first", "second"): + prs.slides[0].shapes.add_textbox(0, 0, 10, 10).text = text + assert prs.validate() == () + first, second = (shape.shape_id for shape in prs.slides[0].shapes) + source = tmp_path / "source.pptx" + broken = tmp_path / "broken.pptx" + prs.save(source) + _replace_in_slide(source, broken, f'id="{second}"', f'id="{first}"') + + broken_deck = rpptx.Presentation(broken) + (issue,) = broken_deck.validate() + assert (issue.kind, issue.message) == ( + "duplicate_shape_id", + f"DuplicateShapeId {{ slide: 0, id: {first} }}", + ) + assert broken_deck.validate() == (issue,) + with pytest.raises(AttributeError): + issue.kind = "other" + + def test_add_shape_accepts_preset_names_and_every_mso_shape_member(tmp_path): import rpptx from rpptx.enum.shapes import MSO_AUTO_SHAPE_TYPE, MSO_CONNECTOR, MSO_CONNECTOR_TYPE, MSO_SHAPE, MSO_SHAPE_TYPE diff --git a/crates/rpptx-py/tests/typing_smoke.py b/crates/rpptx-py/tests/typing_smoke.py index 987ce409a..137959f54 100644 --- a/crates/rpptx-py/tests/typing_smoke.py +++ b/crates/rpptx-py/tests/typing_smoke.py @@ -20,6 +20,7 @@ RGBColor, TextFrameLayout, TextLineLayout, + ValidationIssue, ) from rpptx._rpptx import ( AdjustmentCollection, @@ -160,6 +161,8 @@ def exercise_rpptx_types(path: Path) -> None: replaced = presentation.try_replace_text("checked", "typed", expect=2) except ReplacementCountError as error: replacement_counts = (error.expected, error.found) + issues: tuple[ValidationIssue, ...] = presentation.validate() + issue_lines: list[tuple[str, str]] = [(issue.kind, issue.message) for issue in issues] pdf_bytes: bytes = presentation.to_pdf() slide_png: bytes | None = presentation.render_slide_to_png(0) slide_pngs: list[bytes] = presentation.render_all_slides() @@ -301,6 +304,7 @@ def exercise_rpptx_types(path: Path) -> None: reopened, replaced, replacement_counts, + issue_lines, ) @@ -374,3 +378,4 @@ def exercise_rpptx_text_layout_types(presentation: Presentation) -> None: TextFrame() # type: ignore[call-arg] TextFrameLayout() # type: ignore[call-arg] TextLineLayout() # type: ignore[call-arg] + ValidationIssue() # type: ignore[call-arg] diff --git a/docs/hld/10-bindings-spec.md b/docs/hld/10-bindings-spec.md index 7fffb86fc..fed6d385d 100644 --- a/docs/hld/10-bindings-spec.md +++ b/docs/hld/10-bindings-spec.md @@ -402,6 +402,12 @@ the revision advances once when the count is nonzero. Without `expect`, zero matches return zero rather than raise, as the rdocx `try_replace_text` does. Only the CLI refuses zero matches, because it would write an unchanged copy. +`Presentation.validate()` runs the native `validate` with the GIL released and +returns a tuple of frozen `ValidationIssue` snapshots in native order. Each +carries a `kind` that names the native variant in snake_case, such as +`duplicate_shape_id`, and a `message` equal to the line `rpptx validate` +prints for that issue. A clean presentation returns an empty tuple. + The presentation binding exposes `to_pdf`, `render_slide_to_png`, `render_all_slides`, `to_notes_pdf`, and `render_all_notes` through the native deterministic facade. Every render call releases the GIL.