diff --git a/src/pptx/parts/presentation.py b/src/pptx/parts/presentation.py index 1413de457..17d4378c6 100644 --- a/src/pptx/parts/presentation.py +++ b/src/pptx/parts/presentation.py @@ -33,6 +33,15 @@ def add_slide(self, slide_layout: SlideLayout): rId = self.relate_to(slide_part, RT.SLIDE) return rId, slide_part.slide + def duplicate_slide(self, source_slide_part: SlidePart) -> tuple[str, "Slide"]: + """Return `(rId, slide)` pair for a copy of `source_slide_part`. + + The new slide part is a clone of `source_slide_part`, sharing its related parts. + """ + new_slide_part = SlidePart.clone(source_slide_part, self.package) + rId = self.relate_to(new_slide_part, RT.SLIDE) + return rId, new_slide_part.slide + @property def core_properties(self) -> CorePropertiesPart: """A |CoreProperties| object for the presentation. diff --git a/src/pptx/parts/slide.py b/src/pptx/parts/slide.py index 6650564a5..995c78710 100644 --- a/src/pptx/parts/slide.py +++ b/src/pptx/parts/slide.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy from typing import IO, TYPE_CHECKING, cast from pptx.enum.shapes import PROG_ID @@ -158,6 +159,29 @@ def _add_notes_slide_part(cls, package, slide_part, notes_master_part): class SlidePart(BaseSlidePart): """Slide part. Corresponds to package files ppt/slides/slide[1-9][0-9]*.xml.""" + @classmethod + def clone(cls, source: "SlidePart", package) -> "SlidePart": + """Return a new |SlidePart| that is a copy of `source`. + + The new part shares all related parts (images, charts, etc.) with `source` — only the + slide XML element itself is deep-copied. Relationships are reproduced in ascending rId + order so the rId values in the cloned XML remain valid. + """ + new_part = cls( + package.next_partname("/ppt/slides/slide%d.xml"), + CT.PML_SLIDE, + package, + copy.deepcopy(source._element), + ) + # Reproduce relationships in ascending rId order (rId1, rId2, …) so the rId + # values embedded in the cloned XML element continue to resolve correctly. + for rel in sorted(source.rels.values(), key=lambda r: r.rId): + if rel.is_external: + new_part.relate_to(rel.target_ref, rel.reltype, is_external=True) + else: + new_part.relate_to(rel.target_part, rel.reltype) + return new_part + @classmethod def new(cls, partname, package, slide_layout_part): """Return newly-created blank slide part. diff --git a/src/pptx/shapes/shapetree.py b/src/pptx/shapes/shapetree.py index 29623f1f5..0c57d6c8e 100644 --- a/src/pptx/shapes/shapetree.py +++ b/src/pptx/shapes/shapetree.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy import io import os from typing import IO, TYPE_CHECKING, Callable, Iterable, Iterator, cast @@ -599,6 +600,18 @@ def add_table( graphicFrame = self._add_graphicFrame_containing_table(rows, cols, left, top, width, height) return cast(GraphicFrame, self._shape_factory(graphicFrame)) + def add_copy_of(self, source_shape: BaseShape) -> BaseShape: + """Return a copy of `source_shape` added to the top of the z-order on this slide. + + `source_shape` may come from any slide in the same presentation. The shape element is + deep-copied so the two shapes are independent. Shapes that own OPC relationships (pictures + and charts) share the underlying parts with the source — changes to the image or chart + data will be reflected on both shapes. + """ + new_elm = copy.deepcopy(source_shape._element) + self._spTree.append(new_elm) + return self._shape_factory(new_elm) + def clone_layout_placeholders(self, slide_layout: SlideLayout) -> None: """Add placeholder shapes based on those in `slide_layout`. diff --git a/src/pptx/slide.py b/src/pptx/slide.py index 3b1b65d8e..85814027e 100644 --- a/src/pptx/slide.py +++ b/src/pptx/slide.py @@ -272,6 +272,36 @@ def add_slide(self, slide_layout: SlideLayout) -> Slide: self._sldIdLst.add_sldId(rId) return slide + def duplicate_slide(self, slide: Slide, position: int | None = None) -> Slide: + """Return a new slide that is a duplicate of `slide`. + + The new slide is inserted immediately after `slide` by default. Use `position` (0-based) + to place it at an explicit position in the slide sequence; other slides shift to + accommodate. + + Related parts (images, charts, etc.) are *shared*, not deep-copied. Changes to those + assets will be reflected on both the original and duplicate slides. + """ + source_idx = self.index(slide) + rId, new_slide = self.part.duplicate_slide(slide.part) + self._sldIdLst.add_sldId(rId) + target_idx = source_idx + 1 if position is None else position + self.move_slide(new_slide, target_idx) + return new_slide + + def move_slide(self, slide: Slide, position: int) -> None: + """Move `slide` so it appears at `position` in the slide sequence (0-based). + + Other slides shift to fill the vacated position. Equivalent to cutting a slide and + pasting it at `position`. + """ + current_idx = self.index(slide) + if current_idx == position: + return + sldId = self._sldIdLst.sldId_lst[current_idx] + self._sldIdLst.remove(sldId) + self._sldIdLst.insert(position, sldId) + def get(self, slide_id: int, default: Slide | None = None) -> Slide | None: """Return the slide identified by int `slide_id` in this presentation. diff --git a/tests/shapes/test_shapetree.py b/tests/shapes/test_shapetree.py index 3cf1ab225..baac4db96 100644 --- a/tests/shapes/test_shapetree.py +++ b/tests/shapes/test_shapetree.py @@ -1265,6 +1265,23 @@ def it_can_add_a_table(self, table_fixture): assert table is table_ assert shapes._element.xml == expected_xml + def it_can_add_a_copy_of_a_shape_from_another_slide(self, request, _shape_factory_, shape_): + source_sp = element("p:sp/p:nvSpPr/p:cNvPr{id=7,name=Foo}") + # Use a simple namespace object so we can set _element without spec_set restrictions + source_shape = type("_FakeShape", (), {"_element": source_sp})() + spTree = element("p:spTree") + shapes = SlideShapes(spTree, None) + + result = shapes.add_copy_of(source_shape) + + # A deep copy of the source element is appended to the shape tree + assert len(spTree) == 1 + new_elm = spTree[0] + assert new_elm is not source_sp # it's a copy, not the original + assert new_elm.xml == source_sp.xml + _shape_factory_.assert_called_once_with(shapes, new_elm) + assert result is shape_ + def it_can_clone_placeholder_shapes_from_a_layout(self, clone_fixture): shapes, slide_layout_, calls = clone_fixture shapes.clone_layout_placeholders(slide_layout_) diff --git a/tests/test_slide.py b/tests/test_slide.py index 74b528c3b..d2305220d 100644 --- a/tests/test_slide.py +++ b/tests/test_slide.py @@ -494,6 +494,45 @@ def it_finds_a_slide_by_slide_id(self, get_fixture): prs_part_.get_slide.assert_called_once_with(slide_id) assert slide is expected_value + def it_can_move_a_slide(self, request): + sldIdLst = element("p:sldIdLst/(p:sldId{r:id=a},p:sldId{r:id=b},p:sldId{r:id=c})") + slides = Slides(sldIdLst, None) + slide_ = instance_mock(request, Slide) + method_mock(request, Slides, "index", return_value=1) + + slides.move_slide(slide_, 0) + + assert [s.rId for s in sldIdLst.sldId_lst] == ["b", "a", "c"] + + def it_does_nothing_when_move_slide_target_equals_current_position(self, request): + sldIdLst = element("p:sldIdLst/(p:sldId{r:id=a},p:sldId{r:id=b})") + slides = Slides(sldIdLst, None) + slide_ = instance_mock(request, Slide) + method_mock(request, Slides, "index", return_value=0) + + slides.move_slide(slide_, 0) + + assert [s.rId for s in sldIdLst.sldId_lst] == ["a", "b"] + + def it_can_duplicate_a_slide(self, request): + sldIdLst = element("p:sldIdLst/(p:sldId{r:id=a},p:sldId{r:id=b})") + slides = Slides(sldIdLst, None) + slide_ = instance_mock(request, Slide) + new_slide_ = instance_mock(request, Slide) + part_ = property_mock(request, Slides, "part") + part_.return_value.duplicate_slide.return_value = ("rIdNew", new_slide_) + index_ = method_mock(request, Slides, "index") + index_.side_effect = [0, 2] + move_slide_ = method_mock(request, Slides, "move_slide") + + result = slides.duplicate_slide(slide_) + + assert part_.return_value.duplicate_slide.call_count == 1 + assert move_slide_.call_count == 1 + assert move_slide_.call_args.args[1] is new_slide_ + assert move_slide_.call_args.args[2] == 1 + assert result is new_slide_ + # fixtures ------------------------------------------------------- @pytest.fixture