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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/pptx/parts/presentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions src/pptx/parts/slide.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import copy
from typing import IO, TYPE_CHECKING, cast

from pptx.enum.shapes import PROG_ID
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions src/pptx/shapes/shapetree.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import copy
import io
import os
from typing import IO, TYPE_CHECKING, Callable, Iterable, Iterator, cast
Expand Down Expand Up @@ -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`.

Expand Down
30 changes: 30 additions & 0 deletions src/pptx/slide.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
17 changes: 17 additions & 0 deletions tests/shapes/test_shapetree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_)
Expand Down
39 changes: 39 additions & 0 deletions tests/test_slide.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down