diff --git a/crates/rpptx-py/README.md b/crates/rpptx-py/README.md index 3b240bdcd..e06265bea 100644 --- a/crates/rpptx-py/README.md +++ b/crates/rpptx-py/README.md @@ -51,8 +51,13 @@ 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. -- Speaker-note text plus modern comment authors, threads, replies, and ordered - comment movement. +- 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 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/__init__.py b/crates/rpptx-py/python/rpptx/__init__.py index 3ec6daaa6..c26a9cbe9 100644 --- a/crates/rpptx-py/python/rpptx/__init__.py +++ b/crates/rpptx-py/python/rpptx/__init__.py @@ -22,8 +22,20 @@ 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 +from ._rpptx import BoundingBox, TextFrameLayout, TextLineLayout, ValidationIssue __all__ = [ "BoundingBox", @@ -41,9 +53,11 @@ class StaleElementError(RpptxError): "Presentation", "Pt", "RGBColor", + "ReplacementCountError", "RpptxError", "StaleElementError", "TextFrameLayout", "TextLineLayout", + "ValidationIssue", "XmlError", ] diff --git a/crates/rpptx-py/python/rpptx/_rpptx.pyi b/crates/rpptx-py/python/rpptx/_rpptx.pyi index 333a35b84..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,9 +130,20 @@ 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: ... + @staticmethod + def from_bytes(bytes: bytes) -> Presentation: ... def save(self, path: _Path) -> None: ... def to_bytes(self) -> bytes: ... def to_pdf(self) -> bytes: ... @@ -141,6 +152,10 @@ 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: ... + def validate(self) -> tuple[ValidationIssue, ...]: ... @property def slide_width(self) -> _Length | None: ... @slide_width.setter @@ -223,6 +238,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: ... @@ -238,6 +255,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/lib.rs b/crates/rpptx-py/src/lib.rs index 8e2e4ab5b..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 { @@ -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()) } @@ -109,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 24a8b158d..00c22663d 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`. @@ -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, @@ -163,6 +196,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) @@ -229,6 +269,52 @@ 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) + } + + /// 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/src/slide.rs b/crates/rpptx-py/src/slide.rs index 0c18ce7fd..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); @@ -503,6 +527,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..b489c24d3 100644 --- a/crates/rpptx-py/tests/test_documented_examples.py +++ b/crates/rpptx-py/tests/test_documented_examples.py @@ -2094,6 +2094,216 @@ 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_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_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_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_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 0de75a650..137959f54 100644 --- a/crates/rpptx-py/tests/typing_smoke.py +++ b/crates/rpptx-py/tests/typing_smoke.py @@ -16,9 +16,11 @@ PP_ALIGN, Presentation, Pt, + ReplacementCountError, RGBColor, TextFrameLayout, TextLineLayout, + ValidationIssue, ) from rpptx._rpptx import ( AdjustmentCollection, @@ -152,6 +154,15 @@ 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) + 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) + 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() @@ -175,6 +186,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] @@ -227,6 +240,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 +300,11 @@ def exercise_rpptx_types(path: Path) -> None: blob, image.content_type, image.ext, + duplicated, + reopened, + replaced, + replacement_counts, + issue_lines, ) @@ -359,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 a6a75eb7c..fed6d385d 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 @@ -300,6 +302,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 @@ -383,6 +391,23 @@ 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. + +`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. @@ -408,7 +433,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. @@ -1474,10 +1503,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