Skip to content
Open
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
2 changes: 1 addition & 1 deletion crates/rpptx-layout/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ resource state into owned shapes with concrete visual properties.

| Measurement | Value | Version | Platform | Build mode | Input | Command | Statistic | Measured on |
|---|---|---|---|---|---|---|---|---|
| Crates.io archive: rpptx-layout | 79,109 compressed bytes, 458,112 member bytes, 11 members | 0.12.1 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `rpptx-layout` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-09-19 |
| Crates.io archive: rpptx-layout | 79,594 compressed bytes, 460,415 member bytes, 11 members | 0.12.1 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `rpptx-layout` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-09-19 |

## Use it when

Expand Down
132 changes: 95 additions & 37 deletions crates/rpptx-layout/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,27 +190,9 @@ impl<'a> ResolveCtx<'a> {
&'ctx self,
placeholder: Option<&CT_Placeholder>,
) -> (Option<&'ctx CT_Shape>, Option<&'ctx CT_Shape>) {
let Some(slide_key) = placeholder.map(CT_Placeholder::key) else {
return (None, None);
};
let Some(layout_shape) = find_placeholder(
&self.layout.common_slide_data.shape_tree.children,
&slide_key,
) else {
return (None, None);
};
let Some(layout_key) = layout_shape
.placeholder
.as_ref()
.map(|placeholder| placeholder.key())
else {
return (None, None);
};
let master_shape = find_placeholder(
&self.master.common_slide_data.shape_tree.children,
&layout_key,
);
(Some(layout_shape), master_shape)
placeholder.map_or((None, None), |placeholder| {
placeholder_chain(placeholder, self.layout, self.master)
})
}

fn is_slide_number_placeholder(&self, shape: &CT_Shape) -> bool {
Expand All @@ -232,27 +214,19 @@ impl<'a> ResolveCtx<'a> {

/// Resolves an owned transform from the slide, layout, then master shape.
pub fn effective_xfrm(&self, shape: &CT_Shape) -> Option<CT_Transform2D> {
let (layout, master) = self.placeholder_chain(shape);
shape
.shape_properties
.transform
.clone()
.or_else(|| layout.and_then(|shape| shape.shape_properties.transform.as_ref().cloned()))
.or_else(|| master.and_then(|shape| shape.shape_properties.transform.as_ref().cloned()))
shape.shape_properties.transform.clone().or_else(|| {
inherited_xfrm(shape.placeholder.as_ref()?, self.layout, self.master).cloned()
})
}

/// Resolves picture bounds from the slide picture, layout placeholder, then master placeholder.
pub fn effective_picture_xfrm(
&self,
picture: &rpptx_oxml::picture::CT_Picture,
) -> Option<CT_Transform2D> {
let (layout, master) = self.placeholder_chain_for(picture.placeholder.as_ref());
picture
.shape_properties
.transform
.clone()
.or_else(|| layout.and_then(|shape| shape.shape_properties.transform.as_ref().cloned()))
.or_else(|| master.and_then(|shape| shape.shape_properties.transform.as_ref().cloned()))
picture.shape_properties.transform.clone().or_else(|| {
inherited_xfrm(picture.placeholder.as_ref()?, self.layout, self.master).cloned()
})
}

/// Resolves body properties per field over defaults, master, layout, and slide.
Expand Down Expand Up @@ -3826,6 +3800,46 @@ fn merge_body_properties(target: &mut CT_TextBodyProperties, source: &CT_TextBod
}
}

/// Returns the transform a slide placeholder inherits, ignoring its own.
///
/// The placeholder matches a layout placeholder, whose own key then matches
/// a master placeholder. The first of the two that carries a transform
/// supplies it whole, which is the fallback [`ResolveCtx::effective_xfrm`]
/// takes when the slide shape has no transform of its own.
pub fn inherited_xfrm<'a>(
placeholder: &CT_Placeholder,
layout: &'a CT_SlideLayout,
master: &'a CT_SlideMaster,
) -> Option<&'a CT_Transform2D> {
let (layout, master) = placeholder_chain(placeholder, layout, master);
[layout, master]
.into_iter()
.flatten()
.find_map(|shape| shape.shape_properties.transform.as_ref())
}

fn placeholder_chain<'a>(
placeholder: &CT_Placeholder,
layout: &'a CT_SlideLayout,
master: &'a CT_SlideMaster,
) -> (Option<&'a CT_Shape>, Option<&'a CT_Shape>) {
let Some(layout_shape) = find_placeholder(
&layout.common_slide_data.shape_tree.children,
&placeholder.key(),
) else {
return (None, None);
};
let Some(layout_key) = layout_shape
.placeholder
.as_ref()
.map(|placeholder| placeholder.key())
else {
return (None, None);
};
let master_shape = find_placeholder(&master.common_slide_data.shape_tree.children, &layout_key);
(Some(layout_shape), master_shape)
}

fn find_placeholder<'a>(
children: &'a [ShapeTreeChild],
key: &PlaceholderKey,
Expand Down Expand Up @@ -3870,8 +3884,8 @@ mod tests {
use rpptx_oxml::slide_parts::{CT_Slide, CT_SlideLayout, CT_SlideMaster, ColorMapOverrideKind};

use super::{
BackgroundSource, FlattenedItem, FlattenedSource, ResolveCtx, resolved_shape_page_bounds,
resolved_shape_page_transform, transform_values,
BackgroundSource, FlattenedItem, FlattenedSource, ResolveCtx, inherited_xfrm,
resolved_shape_page_bounds, resolved_shape_page_transform, transform_values,
};
use crate::{
ChartResource, Diagnostic, ParagraphAlignment, ResolvedAutofit, ResolvedBackground,
Expand Down Expand Up @@ -4342,6 +4356,50 @@ mod tests {
assert!(context.effective_xfrm(fixture.slide_shape(3)).is_none());
}

#[test]
fn inherited_transform_skips_the_slide_shape_and_follows_the_layout_key() {
let slide_children = [
shape_with_details(Some("body"), Some(1), &transform(11), None),
shape(Some("body"), Some(3)),
shape(Some("ftr"), Some(9)),
shape(Some("body"), Some(5)),
]
.join("");
let layout_children = [
shape_with_details(Some("body"), Some(1), &transform(21), None),
shape(Some("body"), Some(3)),
shape(Some("ftr"), Some(10)),
]
.join("");
let master_children = [
shape_with_details(Some("body"), Some(1), &transform(31), None),
shape_with_details(Some("body"), Some(3), &transform(33), None),
shape_with_details(Some("ftr"), Some(11), &transform(34), None),
shape_with_details(Some("body"), Some(5), &transform(35), None),
]
.join("");
let fixture = Fixture::new(&slide_children, &layout_children, &master_children);
let context = fixture.context();
let inherited = |index: usize| {
let placeholder = fixture.slide_shape(index).placeholder.as_ref().unwrap();
inherited_xfrm(placeholder, &fixture.layout, &fixture.master)
.map(|transform| transform.offset.unwrap().x.0)
};

assert_eq!(inherited(0), Some(21));
assert_eq!(inherited(1), Some(33));
assert_eq!(inherited(2), Some(34));
assert_eq!(inherited(3), None, "a master match alone is not inherited");
for index in 1..4 {
assert_eq!(
context
.effective_xfrm(fixture.slide_shape(index))
.map(|transform| transform.offset.unwrap().x.0),
inherited(index)
);
}
}

#[test]
fn body_properties_merge_per_field_across_the_chain() {
let fixture = Fixture::new(
Expand Down
2 changes: 1 addition & 1 deletion crates/rpptx-layout/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ pub mod timeline;

pub use context::{
BackgroundContent, BackgroundSource, EffectiveBackground, FlattenedItem, FlattenedSource,
ResolveCtx,
ResolveCtx, inherited_xfrm,
};
pub use style::{EffectiveShapeStyle, ResolveError};
pub use text::{EffectiveListStyle, EffectiveTextProperties};
Expand Down
3 changes: 3 additions & 0 deletions crates/rpptx-py/python/rpptx/_rpptx.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ class Slide:
def notes_text(self, value: str) -> None: ...
@property
def slide_layout(self) -> SlideLayout: ...
@slide_layout.setter
def slide_layout(self, value: SlideLayout) -> None: ...
@property
def hidden(self) -> bool: ...
@hidden.setter
Expand Down Expand Up @@ -267,6 +269,7 @@ class Shape:
def height(self) -> _Length | None: ...
@height.setter
def height(self, value: int) -> None: ...
def effective_geometry(self) -> tuple[_Length, _Length, _Length, _Length] | None: ...
@property
def shape_id(self) -> int | None: ...
@property
Expand Down
43 changes: 42 additions & 1 deletion crates/rpptx-py/src/shape.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::path::PathBuf;
use oxml_py_support::{ContentPath, PathSeg};
use pyo3::exceptions::{PyIndexError, PyTypeError, PyValueError};
use pyo3::prelude::*;
use pyo3::types::{PyAny, PyByteArray, PyBytes, PyIterator, PyList, PySlice, PyString};
use pyo3::types::{PyAny, PyByteArray, PyBytes, PyIterator, PyList, PySlice, PyString, PyTuple};

use crate::dml::{FillTarget, PyFillFormat, PyLineFormat};
use crate::normalize_index;
Expand Down Expand Up @@ -168,6 +168,17 @@ impl PyShape {
}
}

/// Copies a placeholder's inherited transform onto it before a geometry or rotation edit.
fn materialize_geometry(&self, py: Python<'_>) -> PyResult<()> {
self.validate(py)?;
let shape_path = shape_indices(&self.path).collect::<Vec<_>>();
self.presentation
.borrow_mut(py)
.inner
.materialize_geometry(slide_index(&self.path)?, &shape_path)
.map_err(|error| rpptx_to_pyerr(py, error))
}

fn picture_id(&self, py: Python<'_>) -> PyResult<(usize, u32)> {
let (kind, id) = self.read(py, |shape| (shape.kind(), shape.non_visual_id()))?;
match (kind, id) {
Expand Down Expand Up @@ -223,6 +234,31 @@ impl PyShape {
length(py, value)
}

/// Left, top, width and height as rendering places the shape.
///
/// A placeholder without its own transform reports the one it inherits
/// from its layout and master, where `left` and the other properties
/// report `None`.
fn effective_geometry<'py>(&self, py: Python<'py>) -> PyResult<Option<Bound<'py, PyTuple>>> {
self.validate(py)?;
let shape_path = shape_indices(&self.path).collect::<Vec<_>>();
let geometry = self
.presentation
.borrow(py)
.inner
.effective_geometry(slide_index(&self.path)?, &shape_path)
.map_err(|error| rpptx_to_pyerr(py, error))?;
let Some((left, top, width, height)) = geometry else {
return Ok(None);
};
let length = py.import("rpptx")?.getattr("Length")?;
let values = [left, top, width, height]
.into_iter()
.map(|value| length.call1((value.0,)))
.collect::<PyResult<Vec<_>>>()?;
PyTuple::new(py, values).map(Some)
}

#[getter]
fn shape_id(&self, py: Python<'_>) -> PyResult<Option<u32>> {
self.validate(py)?;
Expand All @@ -235,6 +271,7 @@ impl PyShape {
#[setter]
fn set_left(&self, py: Python<'_>, value: i64) -> PyResult<()> {
check_coordinate("left", value, MIN_COORDINATE)?;
self.materialize_geometry(py)?;
let top = self.read(py, |shape| {
shape.position().map_or(rpptx::Emu(0), |(_, top)| top)
})?;
Expand All @@ -244,6 +281,7 @@ impl PyShape {
#[setter]
fn set_top(&self, py: Python<'_>, value: i64) -> PyResult<()> {
check_coordinate("top", value, MIN_COORDINATE)?;
self.materialize_geometry(py)?;
let left = self.read(py, |shape| {
shape.position().map_or(rpptx::Emu(0), |(left, _)| left)
})?;
Expand All @@ -253,6 +291,7 @@ impl PyShape {
#[setter]
fn set_width(&self, py: Python<'_>, value: i64) -> PyResult<()> {
check_coordinate("width", value, 0)?;
self.materialize_geometry(py)?;
let height = self.read(py, |shape| {
shape.size().map_or(rpptx::Emu(0), |(_, height)| height)
})?;
Expand All @@ -262,6 +301,7 @@ impl PyShape {
#[setter]
fn set_height(&self, py: Python<'_>, value: i64) -> PyResult<()> {
check_coordinate("height", value, 0)?;
self.materialize_geometry(py)?;
let width = self.read(py, |shape| {
shape.size().map_or(rpptx::Emu(0), |(width, _)| width)
})?;
Expand Down Expand Up @@ -301,6 +341,7 @@ impl PyShape {
let units = ((value * ANGLE_UNITS_PER_DEGREE).round_ties_even() as i64)
.rem_euclid(ANGLE_UNITS_PER_TURN);
let angle = rpptx::Angle(i32::try_from(units).expect("a normalized angle fits in i32"));
self.materialize_geometry(py)?;
self.edit(py, |shape| shape.set_rotation(angle))
}

Expand Down
23 changes: 23 additions & 0 deletions crates/rpptx-py/src/slide.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,29 @@ impl PySlide {
)
}

/// Moves the slide to another layout of this presentation.
///
/// Placeholders the new layout does not place keep the geometry they
/// inherited, and the revision advances once.
#[setter]
fn set_slide_layout(&self, py: Python<'_>, layout: &Bound<'_, PyAny>) -> PyResult<()> {
let index = self.validate(py)?;
let layout = layout.extract::<PyRef<'_, PySlideLayout>>()?;
if !layout.presentation.is(&self.presentation) {
return Err(PyValueError::new_err(
"slide layout is not in this presentation",
));
}
layout.validate(py)?;
let mut presentation = self.presentation.borrow_mut(py);
presentation
.inner
.set_slide_layout(index, layout.index)
.map_err(|error| crate::rpptx_to_pyerr(py, error))?;
presentation.revisions.bump();
Ok(())
}

#[getter]
fn hidden(&self, py: Python<'_>) -> PyResult<bool> {
let index = self.validate(py)?;
Expand Down
Loading
Loading