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-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..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 @@ -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 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/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 9baf0f1d..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 @@ -1892,6 +1929,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..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 @@ -194,6 +196,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/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/crates/rpptx/src/lib.rs b/crates/rpptx/src/lib.rs index 362154d5..2ca84d28 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,201 @@ 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(()) + } + + /// 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, + 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 @@ -5261,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 cbf98946..14442698 100644 --- a/crates/rpptx/tests/integration.rs +++ b/crates/rpptx/tests/integration.rs @@ -9697,6 +9697,398 @@ 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 { .. }) + )); +} + +/// 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 e770428e..113ade1e 100644 --- a/docs/hld/06-presentationml-model.md +++ b/docs/hld/06-presentationml-model.md @@ -85,6 +85,9 @@ 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<()>; SlideRef::hidden(&self) -> bool; SlideRef::has_explicit_background(&self) -> bool; @@ -101,6 +104,26 @@ 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. + +`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/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..26a2ac12 100644 --- a/docs/hld/10-bindings-spec.md +++ b/docs/hld/10-bindings-spec.md @@ -288,13 +288,20 @@ 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` 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 @@ -302,9 +309,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 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), }