Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions crates/rpptx-py/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 15 additions & 1 deletion crates/rpptx-py/python/rpptx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -41,9 +53,11 @@ class StaleElementError(RpptxError):
"Presentation",
"Pt",
"RGBColor",
"ReplacementCountError",
"RpptxError",
"StaleElementError",
"TextFrameLayout",
"TextLineLayout",
"ValidationIssue",
"XmlError",
]
20 changes: 19 additions & 1 deletion crates/rpptx-py/python/rpptx/_rpptx.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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: ...
Expand All @@ -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
Expand Down Expand Up @@ -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: ...

Expand All @@ -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: ...


Expand Down
30 changes: 24 additions & 6 deletions crates/rpptx-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize> {
let normalized = if index < 0 {
Expand All @@ -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<Bound<'py, PyType>> {
py.import("rpptx")
.and_then(|module| module.getattr(class_name))
.and_then(|class| class.cast_into::<PyType>().map_err(Into::into));
match exception_type {
.and_then(|class| class.cast_into::<PyType>().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())
}
Expand Down Expand Up @@ -109,6 +126,7 @@ fn _rpptx(module: &Bound<'_, PyModule>) -> PyResult<()> {
module.add_class::<PyCommentAuthor>()?;
module.add_class::<PyComment>()?;
module.add_class::<PyCommentReply>()?;
module.add_class::<PyValidationIssue>()?;
slide::register(module)?;
shape::register(module)?;
dml::register(module)?;
Expand Down
88 changes: 87 additions & 1 deletion crates/rpptx-py/src/presentation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -163,6 +196,13 @@ impl PyPresentation {
}
}

#[staticmethod]
fn from_bytes(bytes: &[u8], py: Python<'_>) -> PyResult<Self> {
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)
Expand Down Expand Up @@ -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<usize>,
) -> PyResult<usize> {
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<Bound<'py, PyTuple>> {
let issues = py.detach(|| self.inner.validate());
PyTuple::new(py, issues.iter().map(PyValidationIssue::from))
}

#[getter]
fn slide_width(&self, py: Python<'_>) -> PyResult<Option<Py<PyAny>>> {
length(py, self.inner.slide_size().map(|(width, _)| width))
Expand Down
47 changes: 47 additions & 0 deletions crates/rpptx-py/src/slide.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<Py<PySlide>> {
self.len(py)?;
let slide = slide.extract::<PyRef<'_, PySlide>>()?;
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<()> {
Expand Down
Loading
Loading