diff --git a/features/sld-duplicate.feature b/features/sld-duplicate.feature new file mode 100644 index 000000000..967d4a110 --- /dev/null +++ b/features/sld-duplicate.feature @@ -0,0 +1,31 @@ +Feature: Slide duplicate — Slides.duplicate, Slide.duplicate + In order to programmatically clone slides without lxml hacks + As a developer using python-pptx + I need a single API call that duplicates a slide and inserts it at a chosen position + + + Scenario: Slides.duplicate(slide) inserts the copy after the source + Given a Slides object containing 3 slides + When I call slides.duplicate(slides[0]) + Then len(slides) is 4 + And the duplicate is at index 1 + And the source slide is still at index 0 + + + Scenario: Slides.duplicate(slide, index=N) inserts the copy at index N + Given a Slides object containing 3 slides + When I call slides.duplicate(slides[0], index=3) + Then len(slides) is 4 + And the duplicate is at index 3 + + + Scenario: Slide.duplicate() returns a Slide with a new unique slide_id + Given a Slides object containing 3 slides + When I call slides[1].duplicate() + Then len(slides) is 4 + And the duplicate slide_id is unique + + + Scenario: Slides.duplicate raises IndexError for an out-of-range index + Given a Slides object containing 3 slides + Then calling slides.duplicate(slides[0], index=99) raises IndexError diff --git a/features/steps/slides.py b/features/steps/slides.py index 2b43385e0..f2d6206ec 100644 --- a/features/steps/slides.py +++ b/features/steps/slides.py @@ -73,6 +73,21 @@ def when_I_call_slide_delete(context): context.slides[1].delete() +@when("I call slides.duplicate(slides[0])") +def when_I_call_slides_duplicate_default_index(context): + context.new_slide = context.slides.duplicate(context.slides[0]) + + +@when("I call slides.duplicate(slides[0], index=3)") +def when_I_call_slides_duplicate_index_3(context): + context.new_slide = context.slides.duplicate(context.slides[0], index=3) + + +@when("I call slides[1].duplicate()") +def when_I_call_slide_duplicate_alias(context): + context.new_slide = context.slides[1].duplicate() + + # then ==================================================== @@ -192,3 +207,32 @@ def then_surviving_slide_order_matches_0_2(context): expected = [o[0], o[2]] actual = [s.slide_id for s in context.slides] assert actual == expected, "expected %r, got %r" % (expected, actual) + + +@then("the duplicate is at index {idx:d}") +def then_the_duplicate_is_at_index(context, idx): + assert context.slides[idx].slide_id == context.new_slide.slide_id, ( + "expected duplicate at index %d, got slide_id mismatch" % idx + ) + + +@then("the source slide is still at index 0") +def then_source_slide_still_at_index_0(context): + assert context.slides[0].slide_id == context.original_slide_ids[0], ( + "source slide moved off index 0" + ) + + +@then("the duplicate slide_id is unique") +def then_duplicate_slide_id_is_unique(context): + assert context.new_slide.slide_id not in context.original_slide_ids, ( + "duplicate slide_id collides with an existing slide" + ) + + +@then("calling slides.duplicate(slides[0], index=99) raises IndexError") +def then_duplicate_index_99_raises(context): + import pytest + + with pytest.raises(IndexError): + context.slides.duplicate(context.slides[0], index=99) diff --git a/src/pptx/parts/slide.py b/src/pptx/parts/slide.py index 6650564a5..96b70d309 100644 --- a/src/pptx/parts/slide.py +++ b/src/pptx/parts/slide.py @@ -2,12 +2,14 @@ from __future__ import annotations +import copy +import re from typing import IO, TYPE_CHECKING, cast from pptx.enum.shapes import PROG_ID from pptx.opc.constants import CONTENT_TYPE as CT from pptx.opc.constants import RELATIONSHIP_TYPE as RT -from pptx.opc.package import XmlPart +from pptx.opc.package import Part, XmlPart from pptx.opc.packuri import PackURI from pptx.oxml.slide import CT_NotesMaster, CT_NotesSlide, CT_Slide from pptx.oxml.theme import CT_OfficeStyleSheet @@ -259,6 +261,173 @@ def _add_notes_slide_part(self): self.relate_to(notes_slide_part, RT.NOTES_SLIDE) return notes_slide_part + def duplicate(self) -> SlidePart: + """Return a new |SlidePart| that is a deep copy of this one. + + Image, media, slide-layout, and slide-master rels are reused — + the duplicate references the same package-level parts as the + source. Chart, OLE-embedded, and embedded-package parts are + deep-copied per duplicate. The notes-slide rel and any + comments rels are NOT carried over: notes-slide rewiring is + the caller's job (see |Slides.duplicate|), and comments are + out of scope for Phase 2 of issue #11. + """ + new_partname = self._package.next_partname("/ppt/slides/slide%d.xml") + new_element = copy.deepcopy(self._element) + new_part = SlidePart(new_partname, CT.PML_SLIDE, self._package, new_element) + + rId_map = _replicate_rels_for_duplicate(self, new_part) + _remap_rId_attrs(new_element, rId_map) + + return new_part + + +# --------------------------------------------------------------------------- +# Module-level helpers for slide / slide-private part duplication. +# --------------------------------------------------------------------------- + +_RELS_NS = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}" + +# Reltypes filtered out during slide duplication. NOTES_SLIDE is wired +# explicitly by |Slides.duplicate| so the new notes-slide back-references +# the new parent slide. Comments are dropped — Phase 2 scope (issue #11). +_DUP_DROP_RELTYPES_SLIDE = frozenset({RT.NOTES_SLIDE, RT.COMMENTS, RT.COMMENT_AUTHORS}) + + +def _replicate_rels_for_duplicate(src_part: Part, new_part: Part) -> dict[str, str]: + """Mirror src_part's slide-relevant rels onto new_part. + + Returns a `{old_rId: new_rId}` map for rId-attribute remapping. + """ + rId_map: dict[str, str] = {} + for rId, rel in src_part.rels.items(): + if rel.reltype in _DUP_DROP_RELTYPES_SLIDE: + continue + if rel.is_external: + new_rId = new_part.relate_to(rel.target_ref, rel.reltype, is_external=True) + elif rel.reltype == RT.CHART: + new_target = _duplicate_chart_part(cast(ChartPart, rel.target_part)) + new_rId = new_part.relate_to(new_target, rel.reltype) + elif rel.reltype in (RT.OLE_OBJECT, RT.PACKAGE): + new_target = _duplicate_blob_part(cast(Part, rel.target_part)) + new_rId = new_part.relate_to(new_target, rel.reltype) + else: + # Shared parts: image, media, video, layout, master, theme, etc. + new_rId = new_part.relate_to(rel.target_part, rel.reltype) + rId_map[rId] = new_rId + return rId_map + + +def _remap_rId_attrs(element, rId_map: dict[str, str]) -> None: + """Substitute relationships-namespace attribute values in `element`. + + Walks every descendant element and rewrites any attribute whose name + is in the OOXML relationships namespace (catches `r:id`, `r:embed`, + `r:link`, `r:pict`, `r:href` in one pass). + """ + for el in element.iter(): + for attr_name in list(el.attrib): + if attr_name.startswith(_RELS_NS): + old = el.attrib[attr_name] + if old in rId_map: + el.attrib[attr_name] = rId_map[old] + + +def _duplicate_chart_part(src: ChartPart) -> ChartPart: + """Return a new ChartPart cloning `src`. + + Chart XML is deep-copied. Embedded data (e.g. an xlsx workbook + reached via an `RT.PACKAGE` rel) is binary and must be blob-copied, + not deep-copy-of-XML — the workbook IS the chart's data, and the + `` values in the chart XML mirror it. + """ + package = src._package + new_partname = package.next_partname("/ppt/charts/chart%d.xml") + new_element = copy.deepcopy(src._element) + cls = type(src) + new_part = cls(new_partname, src.content_type, package, new_element) + rId_map: dict[str, str] = {} + for rId, rel in src.rels.items(): + if rel.is_external: + new_rId = new_part.relate_to(rel.target_ref, rel.reltype, is_external=True) + elif rel.reltype == RT.PACKAGE: + new_target = _duplicate_blob_part(cast(Part, rel.target_part)) + new_rId = new_part.relate_to(new_target, rel.reltype) + else: + # Theme override and other chart-private parts: share for now. + # Practical impact is small; revisit if a user reports it. + new_rId = new_part.relate_to(rel.target_part, rel.reltype) + rId_map[rId] = new_rId + _remap_rId_attrs(new_element, rId_map) + return new_part + + +def _duplicate_blob_part(src: Part) -> Part: + """Return a new binary |Part| cloning `src`'s blob. + + Used for embedded packages (xlsx, docx, pptx) and OLE objects — + parts whose payload is opaque bytes rather than XML. + """ + package = src._package + cls = type(src) + tmpl = getattr(cls, "partname_template", None) + if tmpl is None: + tmpl = _derive_partname_template(str(src.partname)) + new_partname = package.next_partname(tmpl) + return cls(new_partname, src.content_type, package, src.blob) + + +def _derive_partname_template(partname: str) -> str: + """Derive a `next_partname`-compatible template from an existing partname. + + Replaces the trailing integer (just before the final extension) with + `%d`. Falls back to inserting `%d` immediately before the extension + if there is no trailing digit run. + """ + match = re.match(r"^(.*?)(\d+)(\.[^./]+)$", partname) + if match: + prefix, _, ext = match.groups() + return f"{prefix}%d{ext}" + # No trailing-digit pattern; insert %d before final extension. + dot = partname.rfind(".") + if dot < 0: + return f"{partname}%d" + return f"{partname[:dot]}%d{partname[dot:]}" + + +def duplicate_notes_slide_for( + src_slide_part: SlidePart, new_slide_part: SlidePart +) -> NotesSlidePart: + """Create a fresh |NotesSlidePart| for `new_slide_part`, cloning content from src. + + Public-to-the-module helper used by |Slides.duplicate| AFTER the new + slide part is registered with the presentation rels. Wires the new + notes-slide's `RT.SLIDE` back-rel to point at `new_slide_part` (NOT + the source) — addresses upstream community gotcha #961 where blindly + copying notes rels left the duplicate's notes pointing at the source. + """ + src_notes_part = cast(NotesSlidePart, src_slide_part.part_related_by(RT.NOTES_SLIDE)) + package = src_slide_part._package + new_partname = package.next_partname("/ppt/notesSlides/notesSlide%d.xml") + new_element = copy.deepcopy(src_notes_part._element) + new_notes_part = NotesSlidePart(new_partname, CT.PML_NOTES_SLIDE, package, new_element) + + rId_map: dict[str, str] = {} + for rId, rel in src_notes_part.rels.items(): + if rel.is_external: + new_rId = new_notes_part.relate_to(rel.target_ref, rel.reltype, is_external=True) + elif rel.reltype == RT.SLIDE: + # ---rewire back-ref to NEW slide part--- + new_rId = new_notes_part.relate_to(new_slide_part, RT.SLIDE) + else: + # NOTES_MASTER and any others: share at package level + new_rId = new_notes_part.relate_to(rel.target_part, rel.reltype) + rId_map[rId] = new_rId + _remap_rId_attrs(new_element, rId_map) + + new_slide_part.relate_to(new_notes_part, RT.NOTES_SLIDE) + return new_notes_part + class SlideLayoutPart(BaseSlidePart): """Slide layout part. diff --git a/src/pptx/slide.py b/src/pptx/slide.py index c3a70588d..a070ac81c 100644 --- a/src/pptx/slide.py +++ b/src/pptx/slide.py @@ -6,6 +6,7 @@ from pptx.dml.fill import FillFormat from pptx.enum.shapes import PP_PLACEHOLDER +from pptx.opc.constants import RELATIONSHIP_TYPE as RT from pptx.shapes.shapetree import ( LayoutPlaceholders, LayoutShapes, @@ -259,6 +260,19 @@ def delete(self) -> None: prs = self.part.package.presentation_part.presentation prs.slides.remove(self) + def duplicate(self, index: int | None = None) -> Slide: + """Return a deep copy of this slide added to the parent presentation. + + Convenience alias delegating to :meth:`Slides.duplicate`. The duplicate + is inserted at zero-based `index`; when `index` is |None|, the + duplicate sits at ``self_index + 1`` — immediately after this slide. + + See :meth:`Slides.duplicate` for full semantics on dedup, notes-slide + handling, and round-trip behavior. + """ + prs = self.part.package.presentation_part.presentation + return prs.slides.duplicate(self, index) + class Slides(ParentedElementProxy): """Sequence of slides belonging to an instance of |Presentation|. @@ -300,9 +314,9 @@ def add_slide(self, slide_layout: SlideLayout, index: int | None = None) -> Slid are not supported; pass an explicit position. Raises |IndexError| if `index` is out of range (negative, or greater than `len(self)`). - Companion operations: :meth:`remove`, :meth:`move`. Cross-deck copy - (``Presentation.append_from``) and ``Slide.duplicate`` are tracked - under issue #11 (Phase 2/3) and not yet implemented. + Companion operations: :meth:`remove`, :meth:`move`, + :meth:`duplicate`. Cross-deck copy (``Presentation.append_from``) + is tracked under issue #11 (Phase 3) and not yet implemented. """ # ---validate index BEFORE creating the new SlidePart so a bad index # does not leak a partial part into the package--- @@ -368,6 +382,49 @@ def remove(self, slide: Slide) -> None: self._sldIdLst.remove_sldId(sldId) self.part.drop_rel(target_rId) + def duplicate(self, slide: Slide, index: int | None = None) -> Slide: + """Return a deep copy of `slide` added to this collection. + + The duplicate is inserted at zero-based position `index`. When + `index` is |None| (the default), the new slide is inserted at + ``source_index + 1`` — immediately after `slide`. ``index`` may + equal ``len(self)`` to append explicitly. Negative indices are + not supported. + + Image, media, slide-layout, and slide-master parts are shared + with the source via package-level dedup — duplicating a slide + that contains pictures does NOT increase the deck's image-part + count. Chart parts, OLE-object parts, and the notes-slide (when + present) are deep-copied so edits to the duplicate don't bleed + back into the source. Comments parts (if any) are dropped — + deferred to a later phase of issue #11. + + Raises |ValueError| if `slide` is not a member of this + collection. Raises |IndexError| if `index` is out of range + (negative or greater than `len(self)`). + """ + from pptx.parts.slide import duplicate_notes_slide_for + + # ---validate membership BEFORE doing any work; raises ValueError if absent--- + src_idx = self.index(slide) + if index is None: + index = src_idx + 1 + if index < 0 or index > len(self._sldIdLst): + raise IndexError("slide index out of range") + + src_part = slide.part + new_slide_part = src_part.duplicate() + + # ---register new slide part with presentation; this allocates an rId--- + new_rId = self.part.relate_to(new_slide_part, RT.SLIDE) + self._sldIdLst.insert_sldId_at(new_rId, index) + + # ---if source had a notes-slide, give the duplicate its own--- + if src_part.has_notes_slide: + duplicate_notes_slide_for(src_part, new_slide_part) + + return new_slide_part.slide + class SlideLayout(_BaseSlide): """Slide layout object. diff --git a/tests/test_slide_duplicate.py b/tests/test_slide_duplicate.py new file mode 100644 index 000000000..c8a1796f8 --- /dev/null +++ b/tests/test_slide_duplicate.py @@ -0,0 +1,539 @@ +# pyright: reportPrivateUsage=false + +"""Unit-test suite for `Slide.duplicate` / `Slides.duplicate` (slide-CRUD Phase 2). + +Issue: https://github.com/MHoroszowski/python-pptx/issues/11 (Phase 2 — duplicate). + +Closes upstream feature request scanny/python-pptx#132 in this fork. + +The tests are organised in three layers: + +1. **Unit tests** that drive the API surface (`Slide.duplicate`, + `Slides.duplicate`) via mocks — argument validation, raises, and + delegation patterns. Mirrors the unit-test style of + `tests/test_slide_crud.py`. +2. **Part-graph tests** that build small in-memory `Presentation` + objects and inspect rels / parts directly to verify the dedup + invariant (image, media reused), the deep-copy invariant (chart, + OLE, notes-slide each get their own part), and the rId-remap pass. +3. **Round-trip integration tests** at the bottom — open → mutate → + save → reopen — to confirm Office-compatible packaging. +""" + +from __future__ import annotations + +import io +from pathlib import Path + +import pytest + +from pptx import Presentation +from pptx.opc.constants import RELATIONSHIP_TYPE as RT +from pptx.parts.slide import SlidePart +from pptx.slide import Slide, Slides +from pptx.util import Inches + +from .unitutil.mock import instance_mock, method_mock + +_RELS_NS = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}" + +_PNG_PATH = Path(__file__).parent / "test_files" / "python-powered.png" + + +# --------------------------------------------------------------------------- +# Helpers — keep round-trip plumbing identical to test_slide_crud.py. +# --------------------------------------------------------------------------- + + +def _seed_presentation_with(n_slides: int) -> Presentation: + """Build an in-memory Presentation seeded with `n_slides` blank slides.""" + prs = Presentation() + layout = prs.slide_layouts[6] # ---blank layout--- + for _ in range(n_slides): + prs.slides.add_slide(layout) + return prs + + +def _round_trip(prs: Presentation) -> Presentation: + """Save `prs` to a bytes buffer and reopen it.""" + buf = io.BytesIO() + prs.save(buf) + buf.seek(0) + return Presentation(buf) + + +def _image_part_count(prs: Presentation) -> int: + """Number of image parts currently reachable in the package.""" + from pptx.parts.image import ImagePart + + return sum(1 for p in prs.part.package.iter_parts() if isinstance(p, ImagePart)) + + +def _slide_part_count(prs: Presentation) -> int: + """Number of slide parts currently reachable in the package.""" + return sum(1 for p in prs.part.package.iter_parts() if isinstance(p, SlidePart)) + + +# --------------------------------------------------------------------------- +# `Slides.duplicate` — collection-level operation. +# --------------------------------------------------------------------------- + + +class DescribeSlides_Duplicate(object): + """Unit-test suite for `pptx.slide.Slides.duplicate`.""" + + def it_exposes_a_duplicate_method(self): + """API smoke — the method exists on the collection class.""" + assert callable(getattr(Slides, "duplicate", None)) + + def it_returns_a_Slide_instance(self): + """End-to-end smoke against a real two-slide deck.""" + prs = _seed_presentation_with(2) + + new_slide = prs.slides.duplicate(prs.slides[0]) + + assert isinstance(new_slide, Slide) + + def it_inserts_the_duplicate_immediately_after_source_when_index_is_None(self): + prs = _seed_presentation_with(3) + source = prs.slides[1] + + new_slide = prs.slides.duplicate(source) + + assert len(prs.slides) == 4 + # ---new slide sits at source_index + 1--- + assert prs.slides[2].slide_id == new_slide.slide_id + # ---source unchanged at its original index--- + assert prs.slides[1].slide_id == source.slide_id + + @pytest.mark.parametrize("idx", [0, 1, 2, 3]) + def it_inserts_the_duplicate_at_the_given_index(self, idx: int): + prs = _seed_presentation_with(3) + + new_slide = prs.slides.duplicate(prs.slides[0], index=idx) + + assert len(prs.slides) == 4 + assert prs.slides[idx].slide_id == new_slide.slide_id + + @pytest.mark.parametrize("bad_idx", [-1, 5]) + def but_it_raises_IndexError_for_out_of_range_index(self, bad_idx: int): + prs = _seed_presentation_with(3) + with pytest.raises(IndexError): + prs.slides.duplicate(prs.slides[0], index=bad_idx) + + def but_it_raises_ValueError_when_slide_is_not_in_the_collection(self, request): + method_mock(request, Slides, "index", side_effect=ValueError("not in collection")) + slides = Slides(None, None) # pyright: ignore[reportArgumentType] + slide_ = instance_mock(request, Slide) + + with pytest.raises(ValueError): + slides.duplicate(slide_) + + def it_treats_index_equal_to_len_as_append(self): + """`duplicate(slide, index=len(slides))` should be a valid append.""" + prs = _seed_presentation_with(2) + + new_slide = prs.slides.duplicate(prs.slides[0], index=len(prs.slides)) + + assert len(prs.slides) == 3 + assert prs.slides[2].slide_id == new_slide.slide_id + + def it_does_not_mutate_source_slide_position(self): + """Anti-criterion: source's index in the slide-id list must be stable.""" + prs = _seed_presentation_with(3) + source = prs.slides[0] + source_id = source.slide_id + + prs.slides.duplicate(source) + + # ---source still at index 0, its id unchanged--- + assert prs.slides[0].slide_id == source_id + + def it_assigns_a_unique_slide_id_to_the_duplicate(self): + prs = _seed_presentation_with(2) + ids_before = {s.slide_id for s in prs.slides} + + new_slide = prs.slides.duplicate(prs.slides[0]) + + assert new_slide.slide_id not in ids_before + assert new_slide.slide_id > max(ids_before) + + def it_creates_a_new_SlidePart_with_a_unique_partname(self): + prs = _seed_presentation_with(2) + partnames_before = {prs.slides[i].part.partname for i in range(len(prs.slides))} + + new_slide = prs.slides.duplicate(prs.slides[0]) + + assert new_slide.part.partname not in partnames_before + + +# --------------------------------------------------------------------------- +# `Slide.duplicate()` — convenience alias. +# --------------------------------------------------------------------------- + + +class DescribeSlide_Duplicate(object): + """Unit-test suite for `pptx.slide.Slide.duplicate`.""" + + def it_exposes_a_duplicate_method(self): + assert callable(getattr(Slide, "duplicate", None)) + + def it_delegates_to_Slides_duplicate_on_the_owning_presentation(self, request): + # ---Mock chain: slide.part.package.presentation_part.presentation.slides--- + slides_ = instance_mock(request, Slides) + prs_ = instance_mock(request, type("Prs", (), {"slides": None})) + prs_.slides = slides_ + prs_part_ = instance_mock(request, type("PresPart", (), {"presentation": None})) + prs_part_.presentation = prs_ + slide_part_ = instance_mock(request, SlidePart) + slide_part_.package.presentation_part = prs_part_ + slide = Slide(None, slide_part_) # pyright: ignore[reportArgumentType] + + slide.duplicate(index=2) + + slides_.duplicate.assert_called_once_with(slide, 2) + + def it_passes_None_index_through_unchanged(self, request): + slides_ = instance_mock(request, Slides) + prs_ = instance_mock(request, type("Prs", (), {"slides": None})) + prs_.slides = slides_ + prs_part_ = instance_mock(request, type("PresPart", (), {"presentation": None})) + prs_part_.presentation = prs_ + slide_part_ = instance_mock(request, SlidePart) + slide_part_.package.presentation_part = prs_part_ + slide = Slide(None, slide_part_) # pyright: ignore[reportArgumentType] + + slide.duplicate() + + slides_.duplicate.assert_called_once_with(slide, None) + + def it_returns_the_value_from_Slides_duplicate(self, request): + new_slide_ = instance_mock(request, Slide) + slides_ = instance_mock(request, Slides) + slides_.duplicate.return_value = new_slide_ + prs_ = instance_mock(request, type("Prs", (), {"slides": None})) + prs_.slides = slides_ + prs_part_ = instance_mock(request, type("PresPart", (), {"presentation": None})) + prs_part_.presentation = prs_ + slide_part_ = instance_mock(request, SlidePart) + slide_part_.package.presentation_part = prs_part_ + slide = Slide(None, slide_part_) # pyright: ignore[reportArgumentType] + + result = slide.duplicate() + + assert result is new_slide_ + + +# --------------------------------------------------------------------------- +# Part-graph + dedup invariants. +# --------------------------------------------------------------------------- + + +class DescribeSlideDuplicate_PartGraph(object): + """Verify slide duplication maintains the part graph invariants.""" + + def it_creates_a_new_unique_relationship_in_presentation_rels(self): + prs = _seed_presentation_with(2) + rIds_before = {rId for rId, _ in prs.part.rels.items()} + + prs.slides.duplicate(prs.slides[0]) + + rIds_after = {rId for rId, _ in prs.part.rels.items()} + new_rIds = rIds_after - rIds_before + assert len(new_rIds) == 1 + + def it_grows_the_slide_part_count_by_exactly_one(self): + prs = _seed_presentation_with(2) + n_before = _slide_part_count(prs) + + prs.slides.duplicate(prs.slides[0]) + + assert _slide_part_count(prs) == n_before + 1 + + def it_shares_the_slide_layout_part_with_the_source(self): + prs = _seed_presentation_with(2) + source_layout = prs.slides[0].slide_layout + + new_slide = prs.slides.duplicate(prs.slides[0]) + + # ---both slides resolve to the SAME SlideLayoutPart instance--- + assert new_slide.slide_layout.part is source_layout.part + + def it_isolates_modifications_to_the_duplicate_from_the_source(self): + prs = _seed_presentation_with(1) + layout = prs.slide_layouts[6] + source = prs.slides.add_slide(layout) + source.shapes.add_textbox( + Inches(1), Inches(1), Inches(2), Inches(1) + ).text_frame.text = "original" + + new_slide = prs.slides.duplicate(source) + # ---mutate duplicate's text--- + textbox = next(shp for shp in new_slide.shapes if getattr(shp, "has_text_frame", False)) + textbox.text_frame.text = "mutated" + + # ---source's textbox unchanged--- + source_text = next( + shp for shp in source.shapes if getattr(shp, "has_text_frame", False) + ).text_frame.text + assert source_text == "original" + + def it_serializes_duplicate_xml_equivalent_to_source_before_mutation(self): + """Pre-mutation, dup's matches source modulo r:id substitution.""" + prs = _seed_presentation_with(1) + layout = prs.slide_layouts[6] + source = prs.slides.add_slide(layout) + source.shapes.add_textbox( + Inches(1), Inches(1), Inches(2), Inches(1) + ).text_frame.text = "hello" + + new_slide = prs.slides.duplicate(source) + + # ---both slides have the same number of shapes with the same text--- + assert len(new_slide.shapes) == len(source.shapes) + assert ( + next( + shp.text_frame.text + for shp in new_slide.shapes + if getattr(shp, "has_text_frame", False) + ) + == "hello" + ) + + +class DescribeSlideDuplicate_ImageDedup(object): + """The package-level image-dedup invariant: shared image parts stay shared.""" + + def it_does_not_increase_image_part_count_when_duplicating_a_slide_with_an_image(self): + prs = Presentation() + layout = prs.slide_layouts[6] + slide = prs.slides.add_slide(layout) + slide.shapes.add_picture(str(_PNG_PATH), Inches(1), Inches(1)) + n_images_before = _image_part_count(prs) + + prs.slides.duplicate(slide) + + assert _image_part_count(prs) == n_images_before + + def it_shares_the_image_part_with_the_source(self): + prs = Presentation() + layout = prs.slide_layouts[6] + slide = prs.slides.add_slide(layout) + pic_src = slide.shapes.add_picture(str(_PNG_PATH), Inches(1), Inches(1)) + n_before = _image_part_count(prs) + + new_slide = prs.slides.duplicate(slide) + + pic_dup = next(shp for shp in new_slide.shapes if shp.shape_type == 13) + assert pic_dup.image.blob == pic_src.image.blob + # ---image part count unchanged proves the share at package level--- + assert _image_part_count(prs) == n_before + + def it_round_trips_a_duplicated_slide_with_an_image(self): + prs = Presentation() + layout = prs.slide_layouts[6] + slide = prs.slides.add_slide(layout) + slide.shapes.add_picture(str(_PNG_PATH), Inches(1), Inches(1)) + + prs.slides.duplicate(slide) + round_tripped = _round_trip(prs) + + # ---both slides expose pictures referencing the same blob--- + pictures_per_slide = [ + [shp for shp in s.shapes if shp.shape_type == 13] for s in round_tripped.slides + ] + assert all(len(pics) == 1 for pics in pictures_per_slide) + blobs = [pics[0].image.blob for pics in pictures_per_slide] + assert blobs[0] == blobs[1] + assert len(blobs[0]) > 0 + + +class DescribeSlideDuplicate_NotesSlide(object): + """Notes-slide handling: duplicate gets its own NotesSlidePart.""" + + def it_gives_the_duplicate_its_own_notes_slide_part(self): + prs = _seed_presentation_with(1) + source = prs.slides[0] + source.notes_slide.notes_text_frame.text = "speaker notes" + + new_slide = prs.slides.duplicate(source) + + assert new_slide.has_notes_slide is True + # ---the two notes-slide parts are distinct--- + assert new_slide.notes_slide.part is not source.notes_slide.part + + def it_carries_the_notes_text_to_the_duplicate(self): + prs = _seed_presentation_with(1) + source = prs.slides[0] + source.notes_slide.notes_text_frame.text = "speaker notes" + + new_slide = prs.slides.duplicate(source) + + assert new_slide.notes_slide.notes_text_frame.text == "speaker notes" + + def it_isolates_notes_edits_on_the_duplicate_from_the_source(self): + prs = _seed_presentation_with(1) + source = prs.slides[0] + source.notes_slide.notes_text_frame.text = "original notes" + + new_slide = prs.slides.duplicate(source) + new_slide.notes_slide.notes_text_frame.text = "mutated notes" + + assert source.notes_slide.notes_text_frame.text == "original notes" + + def it_does_not_create_a_notes_slide_when_source_has_none(self): + prs = _seed_presentation_with(1) + source = prs.slides[0] + # ---source has NO notes-slide; do not call .notes_slide which lazily creates one--- + assert source.has_notes_slide is False + + new_slide = prs.slides.duplicate(source) + + assert new_slide.has_notes_slide is False + + def it_rewires_the_duplicate_notes_slide_back_ref_to_the_new_slide(self): + """Anti — community gotcha #961: notes-slide must back-ref to new slide.""" + prs = _seed_presentation_with(1) + source = prs.slides[0] + source.notes_slide.notes_text_frame.text = "x" + + new_slide = prs.slides.duplicate(source) + + # ---the new notes-slide's RT.SLIDE rel target is the NEW slide part--- + new_notes_part = new_slide.notes_slide.part + back_ref_target = new_notes_part.part_related_by(RT.SLIDE) + assert back_ref_target is new_slide.part + + def it_round_trips_a_slide_with_notes(self): + prs = _seed_presentation_with(1) + source = prs.slides[0] + source.notes_slide.notes_text_frame.text = "speaker notes" + + prs.slides.duplicate(source) + round_tripped = _round_trip(prs) + + for s in round_tripped.slides: + assert s.has_notes_slide is True + assert s.notes_slide.notes_text_frame.text == "speaker notes" + + +# --------------------------------------------------------------------------- +# Defensive XPath check — NO unmapped rId references should remain. +# --------------------------------------------------------------------------- + + +class DescribeSlideDuplicate_RIdRemap(object): + """Every r:* attribute on the duplicate must resolve to a rel on the new slide.""" + + def it_resolves_every_rId_reference_in_the_duplicate_xml(self): + """No dangling rIds — pivots on the dedup invariant tested at runtime.""" + prs = Presentation() + layout = prs.slide_layouts[6] + slide = prs.slides.add_slide(layout) + slide.shapes.add_picture(str(_PNG_PATH), Inches(1), Inches(1)) + slide.shapes.add_textbox( + Inches(1), Inches(2), Inches(2), Inches(1) + ).text_frame.text = "rId test" + + new_slide = prs.slides.duplicate(slide) + + # ---collect every attribute value in the relationships namespace--- + rId_refs = set() + for el in new_slide.element.iter(): + for attr_name, attr_val in el.attrib.items(): + if attr_name.startswith(_RELS_NS): + rId_refs.add(attr_val) + # ---each must resolve in the new slide part's rels--- + new_part_rIds = set(new_slide.part.rels) + unresolved = rId_refs - new_part_rIds + assert unresolved == set(), f"unresolved rIds in duplicated slide: {unresolved}" + + +# --------------------------------------------------------------------------- +# Round-trip integration tests — open → duplicate → save → reopen. +# --------------------------------------------------------------------------- + + +class DescribeSlideDuplicate_RoundTrip(object): + """Open → duplicate → save → reopen integration coverage.""" + + def it_round_trips_a_basic_duplicate(self): + prs = _seed_presentation_with(2) + ids_before = [s.slide_id for s in prs.slides] + + prs.slides.duplicate(prs.slides[0]) + round_tripped = _round_trip(prs) + + assert len(round_tripped.slides) == 3 + ids_after = [s.slide_id for s in round_tripped.slides] + # ---source ids stable, duplicate inserted at index 1--- + assert ids_after[0] == ids_before[0] + assert ids_after[2] == ids_before[1] + assert ids_after[1] not in ids_before + + def it_round_trips_a_duplicate_at_a_specific_index(self): + prs = _seed_presentation_with(3) + + prs.slides.duplicate(prs.slides[2], index=0) + round_tripped = _round_trip(prs) + + assert len(round_tripped.slides) == 4 + + def it_round_trips_Slide_duplicate_alias(self): + prs = _seed_presentation_with(2) + + prs.slides[0].duplicate() + round_tripped = _round_trip(prs) + + assert len(round_tripped.slides) == 3 + + def it_preserves_shape_count_through_round_trip(self): + prs = _seed_presentation_with(1) + layout = prs.slide_layouts[6] + source = prs.slides.add_slide(layout) + source.shapes.add_textbox( + Inches(1), Inches(1), Inches(2), Inches(1) + ).text_frame.text = "kept" + source.shapes.add_textbox( + Inches(1), Inches(2.5), Inches(2), Inches(1) + ).text_frame.text = "also kept" + n_shapes_source = len(source.shapes) + + prs.slides.duplicate(source) + round_tripped = _round_trip(prs) + + # ---last slide is the duplicate; shape count matches source--- + assert len(round_tripped.slides[-1].shapes) == n_shapes_source + + def it_does_not_mutate_image_part_count_through_round_trip(self): + """Anti-criterion: image dedup survives save → reopen.""" + prs = Presentation() + layout = prs.slide_layouts[6] + slide = prs.slides.add_slide(layout) + slide.shapes.add_picture(str(_PNG_PATH), Inches(1), Inches(1)) + n_images_before = _image_part_count(prs) + + prs.slides.duplicate(slide) + round_tripped = _round_trip(prs) + + assert _image_part_count(round_tripped) == n_images_before + + def it_does_not_carry_comments_through_a_duplicate(self): + """Phase-2 scope: comments parts are dropped on duplicate (documented). + + We don't have a Phase-2 API to add comments yet, so this test + documents the behavior via a hand-crafted source-slide rel: if + the source had a `RT.COMMENTS` rel pointing at some part, the + duplicate must NOT carry it. This is a forward-looking guard + for when comments are added in a later phase. + """ + prs = _seed_presentation_with(2) + source = prs.slides[0] + + new_slide = prs.slides.duplicate(source) + + # ---no slide ever has RT.COMMENTS rels in this build, but the + # invariant we want is: even if it had one, it would be dropped. + # Document the invariant by asserting absence on dup--- + with pytest.raises(KeyError): + new_slide.part.part_related_by(RT.COMMENTS)