From ba59223f24dbc001fd7a9132e2a3fd5989220ffb Mon Sep 17 00:00:00 2001 From: Hadrien Mary Date: Sun, 27 Sep 2026 22:16:31 +0200 Subject: [PATCH 1/3] Resolve inherited placeholder geometry and keep it on edit A placeholder without its own a:xfrm, the normal case in a deck made in PowerPoint, reported None for left, top, width and height, although rendering already placed it from its layout and master. Setting one coordinate or the rotation then wrote an a:xfrm with a zero partner or with only a rotation, and no a:ext, which the PDF, the PNGs and text_layout skip, so the placeholder vanished. rpptx-layout now exposes inherited_xfrm, the layout and master part of the transform chain, and the resolver falls back to it, so rendering is unchanged. The facade adds Presentation::effective_geometry, which reports what rendering places, and materialize_geometry, which copies the missing parts of the inherited transform onto a placeholder. The Python Shape gains effective_geometry(), and its left, top, width, height and rotation setters call materialize_geometry first, so one assignment keeps the other values. The explicit getters still report only the shape's own values. GitHub issue #169. --- crates/rpptx-layout/src/context.rs | 132 +++++++--- crates/rpptx-layout/src/lib.rs | 2 +- crates/rpptx-py/python/rpptx/_rpptx.pyi | 1 + crates/rpptx-py/src/shape.rs | 43 +++- .../tests/test_documented_examples.py | 55 +++++ crates/rpptx-py/tests/typing_smoke.py | 1 + crates/rpptx/src/lib.rs | 135 ++++++++++- crates/rpptx/tests/integration.rs | 227 ++++++++++++++++++ docs/hld/06-presentationml-model.md | 14 ++ docs/hld/07-inheritance-and-resolution.md | 6 +- docs/hld/10-bindings-spec.md | 16 +- 11 files changed, 587 insertions(+), 45 deletions(-) diff --git a/crates/rpptx-layout/src/context.rs b/crates/rpptx-layout/src/context.rs index bf0f6db2..984f66f3 100644 --- a/crates/rpptx-layout/src/context.rs +++ b/crates/rpptx-layout/src/context.rs @@ -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 { @@ -232,13 +214,9 @@ impl<'a> ResolveCtx<'a> { /// Resolves an owned transform from the slide, layout, then master shape. pub fn effective_xfrm(&self, shape: &CT_Shape) -> Option { - 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. @@ -246,13 +224,9 @@ impl<'a> ResolveCtx<'a> { &self, picture: &rpptx_oxml::picture::CT_Picture, ) -> Option { - 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. @@ -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, @@ -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, @@ -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( diff --git a/crates/rpptx-layout/src/lib.rs b/crates/rpptx-layout/src/lib.rs index 7f63dbf3..aad08426 100644 --- a/crates/rpptx-layout/src/lib.rs +++ b/crates/rpptx-layout/src/lib.rs @@ -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}; diff --git a/crates/rpptx-py/python/rpptx/_rpptx.pyi b/crates/rpptx-py/python/rpptx/_rpptx.pyi index 333a35b8..d858b345 100644 --- a/crates/rpptx-py/python/rpptx/_rpptx.pyi +++ b/crates/rpptx-py/python/rpptx/_rpptx.pyi @@ -267,6 +267,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 diff --git a/crates/rpptx-py/src/shape.rs b/crates/rpptx-py/src/shape.rs index 7e1b0563..617b50e5 100644 --- a/crates/rpptx-py/src/shape.rs +++ b/crates/rpptx-py/src/shape.rs @@ -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; @@ -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::>(); + 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) { @@ -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>> { + self.validate(py)?; + let shape_path = shape_indices(&self.path).collect::>(); + 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::>>()?; + PyTuple::new(py, values).map(Some) + } + #[getter] fn shape_id(&self, py: Python<'_>) -> PyResult> { self.validate(py)?; @@ -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) })?; @@ -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) })?; @@ -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) })?; @@ -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) })?; @@ -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)) } diff --git a/crates/rpptx-py/tests/test_documented_examples.py b/crates/rpptx-py/tests/test_documented_examples.py index 9baf0f1d..a3b87eb3 100644 --- a/crates/rpptx-py/tests/test_documented_examples.py +++ b/crates/rpptx-py/tests/test_documented_examples.py @@ -1892,6 +1892,61 @@ def test_shape_geometry_name_and_rotation_setters_match_python_pptx(tmp_path): assert (oracle.name, oracle.rotation) == ("Renamed box", 30.0) +def test_placeholder_effective_geometry_matches_python_pptx_and_one_setter_keeps_the_rest(tmp_path): + import rpptx + + pptx = pytest.importorskip("pptx", reason="python-pptx is the differential oracle") + source = _python_pptx_deck( + tmp_path / "placeholders.pptx", + lambda deck: [deck.slides.add_slide(deck.slide_layouts[index]) for index in (0, 1, 5, 8)], + ) + oracle = pptx.Presentation(source) + prs = rpptx.Presentation(source) + for slide, oracle_slide in zip(prs.slides, oracle.slides, strict=True): + for shape, expected in zip(slide.shapes, oracle_slide.shapes, strict=True): + assert (shape.left, shape.top, shape.width, shape.height) == (None,) * 4 + geometry = shape.effective_geometry() + assert geometry == (expected.left, expected.top, expected.width, expected.height) + assert all(isinstance(value, rpptx.Length) for value in geometry) + assert prs.slides[2].shapes[0].effective_geometry() == (457200, 274638, 8229600, 1143000) + + prs.slides[0].shapes[0].text = "Rotated title" + prs.slides[2].shapes[0].text = "Moved title" + title = prs.slides[2].shapes[0] + title.left = title.effective_geometry()[0] + rpptx.Inches(0.1) + moved = (457200 + 91440, 274638, 8229600, 1143000) + assert (title.left, title.top, title.width, title.height) == moved + assert title.effective_geometry() == moved + body = prs.slides[1].shapes[1] + body.height = 1000000 + assert (body.left, body.top, body.width, body.height) == (457200, 1600200, 8229600, 1000000) + rotated = prs.slides[0].shapes[0] + rotated_geometry = rotated.effective_geometry() + rotated.rotation = 30.0 + assert rotated.rotation == 30.0 + assert rotated.effective_geometry() == rotated_geometry + assert (rotated.left, rotated.top, rotated.width, rotated.height) == rotated_geometry + assert [(frame.slide_index, frame.shape_id) for frame in prs.text_layout()] == [ + (0, rotated.shape_id), + (2, title.shape_id), + ] + output = tmp_path / "moved.pptx" + prs.save(output) + reread = pptx.Presentation(output) + oracle_title = reread.slides[2].shapes[0] + assert (oracle_title.left, oracle_title.top, oracle_title.width, oracle_title.height) == moved + oracle_body = reread.slides[1].shapes[1] + assert (oracle_body.left, oracle_body.width, oracle_body.height) == (457200, 8229600, 1000000) + oracle_rotated = reread.slides[0].shapes[0] + assert ( + oracle_rotated.left, + oracle_rotated.top, + oracle_rotated.width, + oracle_rotated.height, + ) == rotated_geometry + assert oracle_rotated.rotation == 30.0 + + def test_shape_type_reports_the_python_pptx_member_for_every_shape_kind(tmp_path): import rpptx from rpptx.enum.shapes import MSO_SHAPE_TYPE diff --git a/crates/rpptx-py/tests/typing_smoke.py b/crates/rpptx-py/tests/typing_smoke.py index 0de75a65..3e5c1390 100644 --- a/crates/rpptx-py/tests/typing_smoke.py +++ b/crates/rpptx-py/tests/typing_smoke.py @@ -194,6 +194,7 @@ def exercise_rpptx_types(path: Path) -> None: shape.top = Inches(2) shape.width = Inches(3) shape.height = Inches(1) + geometry: tuple[Length, Length, Length, Length] | None = shape.effective_geometry() shape.name = "Typed" shape.rotation = 15.0 rotation: float = shape.rotation diff --git a/crates/rpptx/src/lib.rs b/crates/rpptx/src/lib.rs index 362154d5..a3994d4b 100644 --- a/crates/rpptx/src/lib.rs +++ b/crates/rpptx/src/lib.rs @@ -75,7 +75,7 @@ use rpptx_layout::timeline::{ResolvedTimelineSlide, evaluate_media_playback}; use rpptx_layout::{ ChartResource, FlattenedItem, FlattenedSource, ResolveCtx, ResolvedAutofit, ResolvedContent, ResolvedSlide, ResolvedSlideTextDirections, ScopedChartResources, ScopedHyperlinkTargets, - ScopedMediaFailures, ScopedMediaIds, + ScopedMediaFailures, ScopedMediaIds, inherited_xfrm, }; pub use rpptx_oxml::comments::{Comment, CommentAuthor, CommentReply}; use rpptx_oxml::comments::{CommentAuthorList, CommentList}; @@ -1889,6 +1889,139 @@ impl Presentation { .position(|layout| layout.part_name.eq_ignore_ascii_case(&layout_part)) } + /// Returns one slide shape's effective offset and extent in EMU. + /// + /// `shape_path` holds the shape's index in the slide's shape tree, then + /// its index within each enclosing group. The shape's own transform wins + /// whole. A placeholder without one inherits the transform of its layout + /// placeholder, then of that placeholder's master counterpart, as + /// rendering resolves it. The result is `(left, top, width, height)`, and + /// a missing offset reads as zero, as rendering draws it. `None` means + /// the resolved transform has no extent, so the shape is not drawn. + /// [`ShapeRef::position`] and [`ShapeRef::size`] keep reporting only the + /// shape's own values. An empty `shape_path` or one that names no shape + /// is [`Error::InvalidShapeMutation`], as a missing picture is for + /// [`Self::picture_image`]. + #[cfg(feature = "render")] + pub fn effective_geometry( + &self, + slide_index: usize, + shape_path: &[usize], + ) -> Result> { + let child = self + .shape_at(slide_index, shape_path, "read effective geometry")? + .child; + let own = shape_transform(child); + let inherited = match (own, shape_placeholder(child)) { + (None, Some(placeholder)) => self.inherited_transform(slide_index, placeholder)?, + _ => None, + }; + Ok(own.or(inherited.as_ref()).and_then(|transform| { + let offset = transform.offset.unwrap_or_default(); + let extent = transform.extent?; + Some((offset.x, offset.y, extent.cx, extent.cy)) + })) + } + + /// Writes a placeholder's inherited geometry onto the shape itself. + /// + /// `shape_path` is read as in [`Self::effective_geometry`]. A placeholder + /// whose own transform lacks an offset or an extent takes the missing + /// part from the transform it inherits, and one without a transform + /// copies the inherited transform whole, rotation and flips included. + /// [`ShapeMut::set_position`] or [`ShapeMut::set_size`] then changes one + /// pair while the shape keeps the other, and [`ShapeMut::set_rotation`] + /// changes the angle while it keeps both, where an incomplete transform + /// would make rendering skip the shape. Shapes that are not placeholders, + /// placeholders with a complete transform and placeholders that inherit + /// none are left unchanged. + #[cfg(feature = "render")] + pub fn materialize_geometry(&mut self, slide_index: usize, shape_path: &[usize]) -> Result<()> { + const OPERATION: &str = "materialize geometry"; + let child = self.shape_at(slide_index, shape_path, OPERATION)?.child; + let own = shape_transform(child); + if own.is_some_and(|own| own.offset.is_some() && own.extent.is_some()) { + return Ok(()); + } + let Some(placeholder) = shape_placeholder(child) else { + return Ok(()); + }; + let Some(inherited) = self.inherited_transform(slide_index, placeholder)? else { + return Ok(()); + }; + let mut materialized = own.cloned().unwrap_or_else(|| inherited.clone()); + materialized.offset = materialized.offset.or(inherited.offset); + materialized.extent = materialized.extent.or(inherited.extent); + let mut indices = shape_path.iter(); + let mut shape = indices + .next() + .and_then(|index| slide_mut(&mut self.slides[slide_index]).into_shape_mut(*index)); + for index in indices { + shape = shape.and_then(|shape| shape.into_child_mut(*index)); + } + let mut shape = shape.ok_or_else(|| { + invalid_shape_mutation( + OPERATION, + format!("slide {slide_index} has no editable shape at path {shape_path:?}"), + ) + })?; + *shape.transform_mut(OPERATION)? = materialized; + Ok(()) + } + + #[cfg(feature = "render")] + fn shape_at( + &self, + slide_index: usize, + shape_path: &[usize], + operation: &'static str, + ) -> Result> { + self.require_slide_index(slide_index)?; + let slide = slide_ref(&self.slides[slide_index]); + let mut indices = shape_path.iter(); + let mut shape = indices.next().and_then(|index| slide.shape(*index)); + for index in indices { + shape = shape.and_then(|shape| shape.child(*index)); + } + shape.ok_or_else(|| { + invalid_shape_mutation( + operation, + format!("slide {slide_index} has no shape at path {shape_path:?}"), + ) + }) + } + + /// Returns the transform a slide placeholder inherits through its layout. + #[cfg(feature = "render")] + fn inherited_transform( + &self, + slide_index: usize, + placeholder: &CT_Placeholder, + ) -> Result> { + let Some(layout_index) = self.slide_layout_index(slide_index) else { + return Ok(None); + }; + let (layout, master) = self.layout_and_master(layout_index)?; + Ok(inherited_xfrm(placeholder, layout, &master).cloned()) + } + + #[cfg(feature = "render")] + fn layout_and_master(&self, layout_index: usize) -> Result<(&CT_SlideLayout, CT_SlideMaster)> { + let record = &self.layouts[layout_index]; + let master_part = + related_internal_part(&self.package, &record.part_name, rel_types::SLIDE_MASTER)? + .ok_or_else(|| Error::MalformedPart { + part_name: record.part_name.clone(), + message: "layout has no slide master relationship".to_owned(), + })?; + let master = CT_SlideMaster::from_xml(required_part(&self.package, &master_part)?) + .map_err(|error| Error::MalformedPart { + part_name: master_part, + message: error.to_string(), + })?; + Ok((&record.layout, master)) + } + /// Returns modern PowerPoint comment authors in producer order. pub fn comment_authors(&self) -> &[CommentAuthor] { &self.comment_authors.authors diff --git a/crates/rpptx/tests/integration.rs b/crates/rpptx/tests/integration.rs index cbf98946..b07b314b 100644 --- a/crates/rpptx/tests/integration.rs +++ b/crates/rpptx/tests/integration.rs @@ -9697,6 +9697,233 @@ fn text_layout_lines_match_the_glyph_runs_the_renderer_draws() { assert_eq!(matched + rotated_runs, drawn.len()); } +const MASTER_TITLE: (i64, i64, i64, i64) = (457_200, 274_638, 8_229_600, 1_143_000); +const MASTER_BODY: (i64, i64, i64, i64) = (457_200, 1_600_200, 8_229_600, 4_525_963); +const MASTER_SLIDE_NUMBER: (i64, i64, i64, i64) = (6_553_200, 6_356_350, 2_133_600, 365_125); +const TITLE_SLIDE_TITLE: (i64, i64, i64, i64) = (685_800, 2_130_425, 7_772_400, 1_470_025); +const TITLE_SLIDE_SUBTITLE: (i64, i64, i64, i64) = (1_371_600, 3_886_200, 6_400_800, 1_752_600); + +/// A geometry as `Presentation::effective_geometry` reports it. +fn emu_geometry((left, top, width, height): (i64, i64, i64, i64)) -> Option<(Emu, Emu, Emu, Emu)> { + Some((Emu(left), Emu(top), Emu(width), Emu(height))) +} + +/// One text-bearing slide shape with a `p:nvPr` payload and an `a:xfrm` payload. +fn geometry_shape(id: u32, placeholder: &str, transform: &str) -> String { + format!( + r#"{placeholder}{transform}Shape {id}"# + ) +} + +fn geometry_transform((x, y, cx, cy): (i64, i64, i64, i64)) -> String { + format!(r#""#) +} + +/// Asserts that every text frame is drawn at the shape's effective geometry. +fn assert_text_frames_use_effective_geometry(presentation: &Presentation, expected_frames: usize) { + let frames = presentation.text_layout_deterministic(1.0).unwrap(); + assert_eq!(frames.len(), expected_frames); + for frame in frames { + let index = presentation + .slide(frame.slide_index) + .unwrap() + .shapes() + .position(|shape| shape.non_visual_id() == frame.shape_id) + .unwrap(); + let (left, top, width, height) = presentation + .effective_geometry(frame.slide_index, &[index]) + .unwrap() + .unwrap(); + let drawn = frame.layout.frame; + for (points, emu) in [ + (drawn.x, left), + (drawn.y, top), + (drawn.width, width), + (drawn.height, height), + ] { + assert!((points - emu.0 as f64 / 12_700.0).abs() < 1e-9, "{frame:?}"); + } + } +} + +#[test] +fn effective_geometry_follows_the_placeholder_chain_as_rendering_does() { + let mut presentation = Presentation::new().unwrap(); + presentation.add_slide(1).unwrap(); + presentation.add_slide(0).unwrap(); + let expected = [ + [MASTER_TITLE, MASTER_BODY], + [TITLE_SLIDE_TITLE, TITLE_SLIDE_SUBTITLE], + ]; + for (slide_index, geometries) in expected.into_iter().enumerate() { + for (shape_index, geometry) in geometries.into_iter().enumerate() { + let shape = presentation + .slide(slide_index) + .unwrap() + .shape(shape_index) + .unwrap(); + assert_eq!((shape.position(), shape.size()), (None, None)); + assert_eq!( + presentation + .effective_geometry(slide_index, &[shape_index]) + .unwrap(), + emu_geometry(geometry) + ); + presentation + .slide_mut(slide_index) + .unwrap() + .shape_mut(shape_index) + .unwrap() + .set_text("Drawn") + .unwrap(); + } + } + assert_text_frames_use_effective_geometry(&presentation, 4); + + let picture_placeholder = r#""#; + let layout_picture = (1_000_000, 2_000_000, 3_000_000, 4_000_000); + let slide_shapes = [ + geometry_shape(2, r#""#, ""), + r#""#.to_owned(), + geometry_shape(4, r#""#, ""), + geometry_shape(5, "", ""), + geometry_shape(6, picture_placeholder, r#""#), + geometry_shape(7, picture_placeholder, r#""#), + format!( + r#"{}"#, + geometry_shape(9, picture_placeholder, "") + ), + ] + .join(""); + let presentation = text_layout_deck( + &slide_shapes, + &geometry_shape(20, picture_placeholder, &geometry_transform(layout_picture)), + ); + for (path, expected) in [ + (&[0][..], emu_geometry(MASTER_SLIDE_NUMBER)), + (&[1], emu_geometry(layout_picture)), + (&[2], None), + (&[3], None), + (&[4], None), + (&[5], emu_geometry((0, 0, 8, 9))), + (&[6, 0], emu_geometry(layout_picture)), + ] { + assert_eq!( + presentation.effective_geometry(0, path).unwrap(), + expected, + "{path:?}" + ); + } + for path in [&[][..], &[7], &[6, 1], &[0, 0]] { + assert!(matches!( + presentation.effective_geometry(0, path), + Err(Error::InvalidShapeMutation { .. }) + )); + } + assert!(matches!( + presentation.effective_geometry(1, &[0]), + Err(Error::UnknownSlideIndex { index: 1, .. }) + )); +} + +#[test] +fn materialized_placeholder_geometry_keeps_the_shape_drawn_after_one_coordinate_changes() { + let mut presentation = Presentation::new().unwrap(); + presentation.add_slide(1).unwrap(); + presentation + .slide_mut(0) + .unwrap() + .shape_mut(0) + .unwrap() + .set_text("Moved title") + .unwrap(); + let move_title = |presentation: &mut Presentation| { + presentation + .slide_mut(0) + .unwrap() + .shape_mut(0) + .unwrap() + .set_position(Emu(548_640), Emu(274_638)) + .unwrap(); + }; + + let mut unmaterialized = presentation.clone(); + move_title(&mut unmaterialized); + assert_eq!(unmaterialized.effective_geometry(0, &[0]).unwrap(), None); + assert_text_frames_use_effective_geometry(&unmaterialized, 0); + + presentation.materialize_geometry(0, &[0]).unwrap(); + let title = presentation.slide(0).unwrap().shape(0).unwrap(); + assert_eq!( + (title.position(), title.size()), + ( + Some((Emu(457_200), Emu(274_638))), + Some((Emu(8_229_600), Emu(1_143_000))) + ) + ); + let materialized = presentation.to_bytes().unwrap(); + presentation.materialize_geometry(0, &[0]).unwrap(); + assert_eq!(presentation.to_bytes().unwrap(), materialized); + move_title(&mut presentation); + assert_eq!( + presentation.effective_geometry(0, &[0]).unwrap(), + emu_geometry((548_640, 274_638, 8_229_600, 1_143_000)) + ); + assert_text_frames_use_effective_geometry(&presentation, 1); + + let picture_placeholder = r#""#; + let slide_shapes = [ + geometry_shape(2, picture_placeholder, ""), + geometry_shape(3, picture_placeholder, r#""#), + geometry_shape(4, picture_placeholder, r#""#), + geometry_shape(5, "", ""), + geometry_shape(6, r#""#, ""), + format!( + r#"{}"#, + geometry_shape(8, picture_placeholder, "") + ), + ] + .join(""); + let layout_shape = geometry_shape( + 20, + picture_placeholder, + r#""#, + ); + let mut presentation = text_layout_deck(&slide_shapes, &layout_shape); + for path in [&[0][..], &[1], &[2], &[3], &[4], &[5, 0]] { + presentation.materialize_geometry(0, path).unwrap(); + } + let slide = presentation.slide(0).unwrap(); + let geometry = |shape: ShapeRef<'_>| (shape.position(), shape.size(), shape.rotation()); + let layout_offset = Some((Emu(1_000_000), Emu(2_000_000))); + let layout_extent = Some((Emu(3_000_000), Emu(4_000_000))); + assert_eq!( + geometry(slide.shape(0).unwrap()), + (layout_offset, layout_extent, Some(Angle(5_400_000))), + "a placeholder without a transform copies the inherited one whole" + ); + assert_eq!( + geometry(slide.shape(1).unwrap()), + (Some((Emu(5), Emu(6))), layout_extent, Some(Angle(0))) + ); + assert_eq!( + geometry(slide.shape(2).unwrap()), + (layout_offset, Some((Emu(8), Emu(9))), Some(Angle(0))) + ); + assert_eq!(geometry(slide.shape(3).unwrap()), (None, None, None)); + assert_eq!(geometry(slide.shape(4).unwrap()), (None, None, None)); + assert_eq!( + geometry(slide.shape(5).unwrap().child(0).unwrap()), + (layout_offset, layout_extent, Some(Angle(5_400_000))) + ); + let xml = String::from_utf8(slide.shape(0).unwrap().xml().unwrap()).unwrap(); + assert!(xml.contains(r#""#), "{xml}"); + assert!(matches!( + presentation.materialize_geometry(0, &[6]), + Err(Error::InvalidShapeMutation { .. }) + )); +} + #[test] fn notes_and_handout_export_resolve_noncanonical_master_theme_and_media_targets() { let presentation = Presentation::from_bytes(&f226_fixture_bytes()).unwrap(); diff --git a/docs/hld/06-presentationml-model.md b/docs/hld/06-presentationml-model.md index e770428e..cb87d4bb 100644 --- a/docs/hld/06-presentationml-model.md +++ b/docs/hld/06-presentationml-model.md @@ -85,6 +85,8 @@ Presentation::to_bytes_as(&self, class: PresentationPackageClass) -> Result, class: PresentationPackageClass) -> Result<()>; Presentation::save_as_show(&self, path: impl AsRef) -> Result<()>; Presentation::slide_layout_index(&self, slide_index: usize) -> Option; +Presentation::effective_geometry(&self, slide_index: usize, shape_path: &[usize]) -> Result>; +Presentation::materialize_geometry(&mut self, slide_index: usize, shape_path: &[usize]) -> Result<()>; Presentation::set_notes_text(&mut self, slide_index: usize, text: &str) -> Result<()>; SlideRef::hidden(&self) -> bool; SlideRef::has_explicit_background(&self) -> bool; @@ -101,6 +103,18 @@ layout list the masters reach. `background_fill` reports only a direct a theme reference, while `remove_background` drops any `p:bg` so the slide follows its layout and master. +The two geometry operations need `render`, which carries the placeholder +matching rule. `shape_path` names a shape by its index in the slide tree, then +its index in each enclosing group. `effective_geometry` returns the shape's own +offset and extent, or for a placeholder without a transform, the one +`rpptx_layout::inherited_xfrm` resolves, which is the transform rendering uses. +A missing offset reads as zero and a transform without an extent reads as +`None`. `ShapeRef::position` and `size` keep reporting only direct values. +`materialize_geometry` copies the missing offset or extent of that inherited +transform onto a placeholder, or the whole transform with its rotation and +flips when the placeholder has none, so a later `set_position` or `set_size` +leaves the other pair in place. + `SlideMut::set_notes_text` edits an existing notes slide and fails without one. `Presentation::set_notes_text` also creates the notes slide when it is absent, as python-pptx does. The new part relates to the notes master and back to the diff --git a/docs/hld/07-inheritance-and-resolution.md b/docs/hld/07-inheritance-and-resolution.md index 2509ce7d..0d21b407 100644 --- a/docs/hld/07-inheritance-and-resolution.md +++ b/docs/hld/07-inheritance-and-resolution.md @@ -344,7 +344,11 @@ Six independent chains. Getting them separately right is the difference between Shape's own `a:xfrm`, then the matching layout placeholder, then the matching master placeholder, then none. `ResolveCtx::effective_xfrm` returns an owned clone from the first source that supplies a transform. A shape that resolves to -no extent is skipped rather than treated as an error. +no extent is skipped rather than treated as an error. The public +`inherited_xfrm(placeholder, layout, master)` resolves the layout and master +part of that chain without a `ResolveCtx`. `effective_xfrm` and +`effective_picture_xfrm` fall back to it, and the facade's effective geometry +and layout change call it, so all of them share one matching rule. ### 2. Body properties diff --git a/docs/hld/10-bindings-spec.md b/docs/hld/10-bindings-spec.md index a6a75eb7..cfb0b1d2 100644 --- a/docs/hld/10-bindings-spec.md +++ b/docs/hld/10-bindings-spec.md @@ -288,7 +288,11 @@ preset name as `xml_value`. `UP_ARROW` is absent because the generated preset table has no `upArrow`. Presentation `Shape` handles expose optional `Length` values for left, top, -width, and height plus optional non-visual id and name. +width, and height plus optional non-visual id and name. Those values are the +shape's own. `Shape.effective_geometry()` returns the four as rendering places +the shape: its own transform, or for a placeholder without one, the transform +it inherits from its layout placeholder, then from that placeholder's master +counterpart. It returns `None` when the resolved transform has no extent. `Presentation.slide_width` and `slide_height` read the optional `p:sldSz` as `Length` values. Assigning one keeps the other, and a deck without `p:sldSz` @@ -302,9 +306,13 @@ has no `p:bg`. `SlideCollection.remove` and `SlideCollection.move(from_, to)` use the native staged slide operations and advance the revision once. `Shape` geometry, `name`, and `rotation` are writable without a revision bump. -A missing partner coordinate becomes zero, as in python-pptx, and a negative -extent is a `ValueError`. `rotation` reads clockwise degrees normalized below -360 and writes them with round-half-even into the 60000-per-degree angle. +Assigning one coordinate or the rotation of a placeholder first copies the +missing parts of its inherited transform onto the shape, so the values not +assigned keep their effective values and rendering keeps drawing it. On any +other shape a missing partner coordinate becomes zero, as in python-pptx. A +negative extent is a `ValueError`. `rotation` reads clockwise degrees +normalized below 360 and writes them with round-half-even into the +60000-per-degree angle. `shape_type` reports an `MSO_SHAPE_TYPE` member or `None`. `fill` and `line` return live `FillFormat` and `LineFormat` views for ordinary shapes, pictures, and connectors, and raise `ValueError` for other kinds. `FillFormat` offers From 8f9dbc8702577da488acc2ffc1d4ed82f8c5e453 Mon Sep 17 00:00:00 2001 From: Hadrien Mary Date: Sun, 27 Sep 2026 22:17:45 +0200 Subject: [PATCH 2/3] Let a slide change its layout and keep unplaced placeholders Only the read side existed: slide_layout_index and the Python Slide.slide_layout getter followed the slide's layout relationship, and nothing could point it at another layout. Presentation::set_slide_layout retargets that relationship to any layout the masters reach, as a staged change that publishes only after the package reopens. Placeholders re-inherit from the new layout, while a placeholder without its own transform that the new layout chain does not place first receives the transform it inherited, so it stays where it was drawn instead of vanishing. Placeholders of the new layout that the slide lacks are not added. A layout of another master is accepted, because the slide only relates to its layout and follows the new master's theme and text styles, as it does in PowerPoint. The Python Slide.slide_layout becomes assignable and advances the revision once. GitHub issue #169. --- crates/rpptx-py/python/rpptx/_rpptx.pyi | 2 + crates/rpptx-py/src/slide.rs | 23 +++ .../tests/test_documented_examples.py | 37 ++++ crates/rpptx-py/tests/typing_smoke.py | 2 + crates/rpptx/src/lib.rs | 97 ++++++++++ crates/rpptx/tests/integration.rs | 165 ++++++++++++++++++ docs/hld/06-presentationml-model.md | 9 + docs/hld/10-bindings-spec.md | 3 + 8 files changed, 338 insertions(+) diff --git a/crates/rpptx-py/python/rpptx/_rpptx.pyi b/crates/rpptx-py/python/rpptx/_rpptx.pyi index d858b345..fe96948c 100644 --- a/crates/rpptx-py/python/rpptx/_rpptx.pyi +++ b/crates/rpptx-py/python/rpptx/_rpptx.pyi @@ -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 diff --git a/crates/rpptx-py/src/slide.rs b/crates/rpptx-py/src/slide.rs index 0c18ce7f..5775e294 100644 --- a/crates/rpptx-py/src/slide.rs +++ b/crates/rpptx-py/src/slide.rs @@ -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::>()?; + 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 { let index = self.validate(py)?; diff --git a/crates/rpptx-py/tests/test_documented_examples.py b/crates/rpptx-py/tests/test_documented_examples.py index a3b87eb3..e98d80ba 100644 --- a/crates/rpptx-py/tests/test_documented_examples.py +++ b/crates/rpptx-py/tests/test_documented_examples.py @@ -1847,6 +1847,43 @@ def test_slide_layout_hidden_and_background_round_trip_through_python_pptx(tmp_p assert oracle.slides[0]._element.get("show") in (None, "1") +def test_slide_layout_assignment_retargets_the_slide_and_keeps_unplaced_placeholders(tmp_path): + import rpptx + + source = _python_pptx_deck( + tmp_path / "layouts.pptx", lambda deck: deck.slides.add_slide(deck.slide_layouts[1]) + ) + prs = rpptx.Presentation(source) + for index, text in enumerate(("Title", "Body")): + prs.slides[0].shapes[index].text = text + slide = prs.slides[0] + body_geometry = slide.shapes[1].effective_geometry() + slide.slide_layout = prs.slide_layouts[5] + _assert_stale_after_exactly_one_bump(rpptx, lambda: slide.slide_layout) + slide = prs.slides[0] + assert slide.slide_layout == prs.slide_layouts[5] + title, body = slide.shapes + assert title.left is None + assert (body.left, body.top, body.width, body.height) == body_geometry + assert [frame.shape_id for frame in prs.text_layout()] == [title.shape_id, body.shape_id] + + prs.slides[0].slide_layout = prs.slide_layouts[0] + assert prs.slides[0].shapes[0].effective_geometry() == (685800, 2130425, 7772400, 1470025) + with pytest.raises(ValueError, match="slide layout is not in this presentation"): + prs.slides[0].slide_layout = rpptx.Presentation().slide_layouts[1] + with pytest.raises(TypeError): + prs.slides[0].slide_layout = 1 + output = tmp_path / "relaid.pptx" + prs.save(output) + + pptx = pytest.importorskip("pptx", reason="python-pptx is the differential oracle") + oracle = pptx.Presentation(output).slides[0] + assert oracle.slide_layout.name == "Title Slide" + title, body = oracle.shapes + assert (title.left, title.top, title.width, title.height) == (685800, 2130425, 7772400, 1470025) + assert (body.left, body.top, body.width, body.height) == body_geometry + + def test_shape_geometry_name_and_rotation_setters_match_python_pptx(tmp_path): import rpptx diff --git a/crates/rpptx-py/tests/typing_smoke.py b/crates/rpptx-py/tests/typing_smoke.py index 3e5c1390..815727e0 100644 --- a/crates/rpptx-py/tests/typing_smoke.py +++ b/crates/rpptx-py/tests/typing_smoke.py @@ -181,6 +181,8 @@ def exercise_rpptx_types(path: Path) -> None: slide_layout: SlideLayout = current_slide.slide_layout layout_index: int = presentation.slide_layouts.index(slide_layout) same_layout: bool = slide_layout == presentation.slide_layouts[0] + current_slide.slide_layout = presentation.slide_layouts[1] + current_slide = presentation.slides[0] hidden: bool = current_slide.hidden current_slide.hidden = True background: Background = current_slide.background diff --git a/crates/rpptx/src/lib.rs b/crates/rpptx/src/lib.rs index a3994d4b..2ca84d28 100644 --- a/crates/rpptx/src/lib.rs +++ b/crates/rpptx/src/lib.rs @@ -1969,6 +1969,68 @@ impl Presentation { Ok(()) } + /// Changes the layout one slide uses, by zero-based layout index. + /// + /// The slide's layout relationship is retargeted, so its placeholders + /// inherit from the new layout and that layout's master. A placeholder + /// without a transform of its own that the new layout does not place + /// first receives the transform it inherited from the old layout, so it + /// stays where it was drawn. Placeholders of the new layout that the + /// slide lacks are not added. The layout may belong to another master, + /// whose theme and text styles the slide then follows. The change is + /// staged and publishes only after the staged package reopens. + #[cfg(feature = "render")] + pub fn set_slide_layout(&mut self, slide_index: usize, layout_index: usize) -> Result<()> { + const OPERATION: &str = "set slide layout"; + self.require_slide_index(slide_index)?; + if layout_index >= self.layouts.len() { + return Err(Error::UnknownLayoutIndex { + index: layout_index, + layout_count: self.layouts.len(), + }); + } + let current = self.slide_layout_index(slide_index).ok_or_else(|| { + invalid_slide_mutation( + OPERATION, + format!("slide {slide_index} has no layout reachable through the slide masters"), + ) + })?; + if current == layout_index { + return Ok(()); + } + let (old_layout, old_master) = self.layout_and_master(current)?; + let (new_layout, new_master) = self.layout_and_master(layout_index)?; + let mut staged = self.clone(); + keep_unplaced_placeholder_transforms( + &mut staged.slides[slide_index] + .slide + .common_slide_data + .shape_tree + .children, + (old_layout, &old_master), + (new_layout, &new_master), + ); + let slide_part = &staged.slides[slide_index].part_name; + let target = relative_part_target(slide_part, &staged.layouts[layout_index].part_name); + staged + .package + .get_part_rels_mut(slide_part) + .and_then(|relationships| { + relationships + .items + .iter_mut() + .find(|relationship| relationship.rel_type == rel_types::SLIDE_LAYOUT) + }) + .ok_or_else(|| { + invalid_slide_mutation( + OPERATION, + format!("{slide_part} has no layout relationship"), + ) + })? + .target = target; + self.commit_candidate(staged) + } + #[cfg(feature = "render")] fn shape_at( &self, @@ -5394,6 +5456,41 @@ fn numeric_relationship_id(relationship_id: &str) -> u32 { .unwrap_or_default() } +/// Gives placeholders the new layout does not place the transform they inherited. +/// +/// Only placeholders without a transform of their own are touched, and +/// only when the old layout chain supplied one. +#[cfg(feature = "render")] +fn keep_unplaced_placeholder_transforms( + children: &mut [ShapeTreeChild], + old: (&CT_SlideLayout, &CT_SlideMaster), + new: (&CT_SlideLayout, &CT_SlideMaster), +) { + for child in children { + let (placeholder, transform) = match child { + ShapeTreeChild::Shape(shape) => ( + shape.placeholder.as_ref(), + &mut shape.shape_properties.transform, + ), + ShapeTreeChild::Picture(picture) => ( + picture.placeholder.as_ref(), + &mut picture.shape_properties.transform, + ), + ShapeTreeChild::GroupShape(group) => { + keep_unplaced_placeholder_transforms(&mut group.children, old, new); + continue; + } + _ => continue, + }; + if let Some(placeholder) = placeholder + && transform.is_none() + && inherited_xfrm(placeholder, new.0, new.1).is_none() + { + *transform = inherited_xfrm(placeholder, old.0, old.1).cloned(); + } + } +} + fn is_latent_placeholder(placeholder: &CT_Placeholder) -> bool { matches!( placeholder.ph_type.as_ref(), diff --git a/crates/rpptx/tests/integration.rs b/crates/rpptx/tests/integration.rs index b07b314b..14442698 100644 --- a/crates/rpptx/tests/integration.rs +++ b/crates/rpptx/tests/integration.rs @@ -9924,6 +9924,171 @@ fn materialized_placeholder_geometry_keeps_the_shape_drawn_after_one_coordinate_ )); } +/// Adds a second master, a copy of the first whose title sits higher, with one layout. +fn two_master_deck() -> Presentation { + let mut source = Presentation::new().unwrap(); + source.add_slide(1).unwrap(); + let mut package = open_opc(&source.to_bytes().unwrap(), "two-master deck"); + let master_xml = String::from_utf8( + package + .get_part("/ppt/slideMasters/slideMaster1.xml") + .unwrap() + .to_vec(), + ) + .unwrap(); + let layouts_start = master_xml.find("").unwrap(); + let layouts_end = master_xml.find("").unwrap() + "".len(); + let master_xml = format!( + r#"{}{}"#, + &master_xml[..layouts_start], + &master_xml[layouts_end..] + ) + .replacen( + r#""#, + r#""#, + 1, + ); + package.set_part( + "/ppt/slideMasters/slideMaster2.xml", + master_xml.into_bytes(), + ); + let mut master_relationships = oxml_opc::Relationships::new(); + master_relationships.add(rel_types::SLIDE_LAYOUT, "../slideLayouts/slideLayout12.xml"); + master_relationships.add(rel_types::THEME, "../theme/theme1.xml"); + package.set_part_rels("/ppt/slideMasters/slideMaster2.xml", master_relationships); + let layout_xml = package + .get_part("/ppt/slideLayouts/slideLayout2.xml") + .unwrap() + .to_vec(); + package.set_part("/ppt/slideLayouts/slideLayout12.xml", layout_xml); + let mut layout_relationships = oxml_opc::Relationships::new(); + layout_relationships.add(rel_types::SLIDE_MASTER, "../slideMasters/slideMaster2.xml"); + package.set_part_rels("/ppt/slideLayouts/slideLayout12.xml", layout_relationships); + for (part, content_type) in [ + ( + "/ppt/slideMasters/slideMaster2.xml", + content_types::SLIDE_MASTER, + ), + ( + "/ppt/slideLayouts/slideLayout12.xml", + content_types::SLIDE_LAYOUT, + ), + ] { + package.content_types.add_override(part, content_type); + } + let mut presentation_relationships = package + .get_part_rels("/ppt/presentation.xml") + .unwrap() + .clone(); + let master_id = + presentation_relationships.add(rel_types::SLIDE_MASTER, "slideMasters/slideMaster2.xml"); + package.set_part_rels("/ppt/presentation.xml", presentation_relationships); + let presentation_xml = + String::from_utf8(package.get_part("/ppt/presentation.xml").unwrap().to_vec()) + .unwrap() + .replacen( + "", + &format!( + r#""# + ), + 1, + ); + package.set_part("/ppt/presentation.xml", presentation_xml.into_bytes()); + Presentation::from_bytes(&package_bytes(package)).unwrap() +} + +#[test] +fn changing_a_slide_layout_retargets_it_and_keeps_unplaced_placeholders_in_place() { + let mut presentation = Presentation::new().unwrap(); + presentation.add_slide(1).unwrap(); + for (index, text) in ["Title", "Body"].into_iter().enumerate() { + presentation + .slide_mut(0) + .unwrap() + .shape_mut(index) + .unwrap() + .set_text(text) + .unwrap(); + } + + presentation.set_slide_layout(0, 5).unwrap(); + assert_eq!(presentation.slide_layout_index(0), Some(5)); + assert_eq!(presentation.layout_name(5), Some("Title Only")); + let slide = presentation.slide(0).unwrap(); + assert_eq!( + slide.shape(0).unwrap().position(), + None, + "Title Only still places the title" + ); + assert_eq!( + ( + slide.shape(1).unwrap().position(), + slide.shape(1).unwrap().size() + ), + ( + Some((Emu(457_200), Emu(1_600_200))), + Some((Emu(8_229_600), Emu(4_525_963))) + ), + "the body Title Only does not place keeps where it was drawn" + ); + assert_text_frames_use_effective_geometry(&presentation, 2); + assert!(presentation.validate().is_empty()); + let bytes = presentation.to_bytes().unwrap(); + let package = open_opc(&bytes, "changed layout"); + assert_eq!( + package + .get_part_rels("/ppt/slides/slide1.xml") + .unwrap() + .get_by_type(rel_types::SLIDE_LAYOUT) + .unwrap() + .target, + "../slideLayouts/slideLayout6.xml" + ); + + presentation.set_slide_layout(0, 0).unwrap(); + for (index, geometry) in [TITLE_SLIDE_TITLE, MASTER_BODY].into_iter().enumerate() { + assert_eq!( + presentation.effective_geometry(0, &[index]).unwrap(), + emu_geometry(geometry) + ); + } + let unchanged = presentation.to_bytes().unwrap(); + presentation.set_slide_layout(0, 0).unwrap(); + assert!(matches!( + presentation.set_slide_layout(0, 11), + Err(Error::UnknownLayoutIndex { + index: 11, + layout_count: 11 + }) + )); + assert!(matches!( + presentation.set_slide_layout(1, 0), + Err(Error::UnknownSlideIndex { index: 1, .. }) + )); + assert_eq!(presentation.to_bytes().unwrap(), unchanged); + + let mut presentation = two_master_deck(); + assert_eq!(presentation.layout_count(), 12); + presentation.set_slide_layout(0, 11).unwrap(); + assert_eq!(presentation.slide_layout_index(0), Some(11)); + for (index, geometry) in [(457_200, 137_319, 8_229_600, 1_143_000), MASTER_BODY] + .into_iter() + .enumerate() + { + assert_eq!( + presentation.effective_geometry(0, &[index]).unwrap(), + emu_geometry(geometry) + ); + } + assert!(presentation.validate().is_empty()); + assert!( + presentation + .to_pdf_deterministic() + .unwrap() + .starts_with(b"%PDF") + ); +} + #[test] fn notes_and_handout_export_resolve_noncanonical_master_theme_and_media_targets() { let presentation = Presentation::from_bytes(&f226_fixture_bytes()).unwrap(); diff --git a/docs/hld/06-presentationml-model.md b/docs/hld/06-presentationml-model.md index cb87d4bb..113ade1e 100644 --- a/docs/hld/06-presentationml-model.md +++ b/docs/hld/06-presentationml-model.md @@ -85,6 +85,7 @@ Presentation::to_bytes_as(&self, class: PresentationPackageClass) -> Result, class: PresentationPackageClass) -> Result<()>; Presentation::save_as_show(&self, path: impl AsRef) -> Result<()>; Presentation::slide_layout_index(&self, slide_index: usize) -> Option; +Presentation::set_slide_layout(&mut self, slide_index: usize, layout_index: usize) -> Result<()>; Presentation::effective_geometry(&self, slide_index: usize, shape_path: &[usize]) -> Result>; Presentation::materialize_geometry(&mut self, slide_index: usize, shape_path: &[usize]) -> Result<()>; Presentation::set_notes_text(&mut self, slide_index: usize, text: &str) -> Result<()>; @@ -115,6 +116,14 @@ transform onto a placeholder, or the whole transform with its rotation and flips when the placeholder has none, so a later `set_position` or `set_size` leaves the other pair in place. +`set_slide_layout` needs `render` as well. It retargets the slide's layout +relationship to any layout the masters reach, including one of another master. +A placeholder without its own transform that the new layout chain does not +place first receives the transform it inherited, and every other placeholder +follows the new layout. Placeholders of the new layout that the slide lacks +are not added. The change is staged and publishes only after the staged +package reopens. + `SlideMut::set_notes_text` edits an existing notes slide and fails without one. `Presentation::set_notes_text` also creates the notes slide when it is absent, as python-pptx does. The new part relates to the notes master and back to the diff --git a/docs/hld/10-bindings-spec.md b/docs/hld/10-bindings-spec.md index cfb0b1d2..26a2ac12 100644 --- a/docs/hld/10-bindings-spec.md +++ b/docs/hld/10-bindings-spec.md @@ -299,6 +299,9 @@ counterpart. It returns `None` when the resolved transform has no extent. pairs the assigned value with the bundled 16:9 size. `Slide.slide_layout` returns the layout the slide relates to, equal to the same entry of `slide_layouts`, and `SlideLayoutCollection.index` returns its position. +Assigning a layout of the same presentation to `slide_layout` uses the native +staged layout change and advances the revision once. A placeholder the new +layout does not place keeps the transform it inherited. `Slide.hidden` reads and writes `p:sld/@show`. `Slide.background.fill` is a live `FillFormat` over the direct background fill that never changes the slide when read, and `follow_master_background` reports and sets whether the slide From 1bc3aab6b3189ecd9bd539beeaf1bc4edf021a24 Mon Sep 17 00:00:00 2001 From: Hadrien Mary Date: Sun, 27 Sep 2026 22:19:52 +0200 Subject: [PATCH 3/3] Re-record the archive measurements of rpptx and rpptx-layout The inherited_xfrm function and its unit test grow the rpptx-layout package, and the effective geometry, geometry materialization and layout change methods with their integration tests grow the rpptx package, so the README archive rows of both crates and their ARCHIVE_MEASUREMENTS entries are re-measured. GitHub issue #169. --- crates/rpptx-layout/README.md | 2 +- crates/rpptx/README.md | 2 +- scripts/readme_doctests.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/rpptx-layout/README.md b/crates/rpptx-layout/README.md index 7371da86..24cb1969 100644 --- a/crates/rpptx-layout/README.md +++ b/crates/rpptx-layout/README.md @@ -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 diff --git a/crates/rpptx/README.md b/crates/rpptx/README.md index 34eb7caa..644d4771 100644 --- a/crates/rpptx/README.md +++ b/crates/rpptx/README.md @@ -22,7 +22,7 @@ presentation, notes, handout, PDF, and animation outputs. | Measurement | Value | Version | Platform | Build mode | Input | Command | Statistic | Measured on | |---|---|---|---|---|---|---|---|---| -| Crates.io archive: rpptx | 407,658 compressed bytes, 2,122,094 member bytes, 16 members | 0.12.1 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `rpptx` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-09-26 | +| Crates.io archive: rpptx | 413,000 compressed bytes, 2,147,469 member bytes, 16 members | 0.12.1 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `rpptx` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-09-26 | ## Use it when diff --git a/scripts/readme_doctests.py b/scripts/readme_doctests.py index e24c643b..7d318354 100644 --- a/scripts/readme_doctests.py +++ b/scripts/readme_doctests.py @@ -390,10 +390,10 @@ class ReadmeCase: "rdocx-opc": (3_655, 9_668, 6), "rdocx-oxml": (367_500, 2_380_047, 32), "rdocx-pdf": (8_111, 26_758, 6), - "rpptx": (407_658, 2_122_094, 16), + "rpptx": (413_000, 2_147_469, 16), "rpptx-chart": (6_648, 21_136, 6), "rpptx-cli": (36_709, 159_585, 8), - "rpptx-layout": (79_109, 458_112, 11), + "rpptx-layout": (79_594, 460_415, 11), "rpptx-oxml": (153_216, 1_042_644, 20), "rpptx-render": (59_928, 329_994, 8), }