Skip to content
Merged
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
39 changes: 39 additions & 0 deletions features/sld-crud.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
Feature: Slide CRUD — remove, move, indexed add
In order to programmatically assemble decks without lxml hacks
As a developer using python-pptx
I need to add slides at a chosen position, reorder them, and remove them


Scenario: Slides.add_slide(slide_layout, index=0) inserts at the head
Given a Slides object containing 3 slides
When I call slides.add_slide(slide_layout, index=0)
Then len(slides) is 4
And the new slide is at index 0


Scenario: Slides.add_slide(slide_layout, index=2) inserts in the middle
Given a Slides object containing 3 slides
When I call slides.add_slide(slide_layout, index=2)
Then len(slides) is 4
And the new slide is at index 2


Scenario: Slides.move(slide, new_index) reorders slides
Given a Slides object containing 3 slides
When I call slides.move(slides[0], 2)
Then len(slides) is 3
And the slide order matches the original [1, 2, 0]


Scenario: Slides.remove(slide) drops a slide and its rel
Given a Slides object containing 3 slides
When I call slides.remove(slides[1])
Then len(slides) is 2
And the surviving slide order matches the original [0, 2]


Scenario: Slide.delete() removes the slide from its presentation
Given a Slides object containing 3 slides
When I call slides[1].delete()
Then len(slides) is 2
And the surviving slide order matches the original [0, 2]
31 changes: 31 additions & 0 deletions features/sld-duplicate.feature
Original file line number Diff line number Diff line change
@@ -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
96 changes: 96 additions & 0 deletions features/steps/slides.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ def given_a_Slides_object_containing_3_slides(context):
prs = Presentation(test_pptx("sld-slides"))
context.prs = prs
context.slides = prs.slides
# ---capture original slide ids for CRUD ordering assertions---
context.original_slide_ids = [s.slide_id for s in prs.slides]


# when ====================================================
Expand All @@ -44,6 +46,48 @@ def when_I_call_slide_layouts_remove(context):
slide_layouts.remove(slide_layouts[1])


@when("I call slides.add_slide(slide_layout, index=0)")
def when_I_call_slides_add_slide_index_0(context):
layout = context.prs.slide_masters[0].slide_layouts[0]
context.new_slide = context.slides.add_slide(layout, index=0)


@when("I call slides.add_slide(slide_layout, index=2)")
def when_I_call_slides_add_slide_index_2(context):
layout = context.prs.slide_masters[0].slide_layouts[0]
context.new_slide = context.slides.add_slide(layout, index=2)


@when("I call slides.move(slides[0], 2)")
def when_I_call_slides_move(context):
context.slides.move(context.slides[0], 2)


@when("I call slides.remove(slides[1])")
def when_I_call_slides_remove(context):
context.slides.remove(context.slides[1])


@when("I call slides[1].delete()")
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 ====================================================


Expand Down Expand Up @@ -140,3 +184,55 @@ def then_slides_get_666_default_slides_2_is_slides_2(context):
def then_slides_2_is_a_Slide_object(context):
slides = context.slides
assert type(slides[2]).__name__ == "Slide"


@then("the new slide is at index {idx:d}")
def then_the_new_slide_is_at_index(context, idx):
assert context.slides[idx].slide_id == context.new_slide.slide_id, (
"expected new slide at index %d, got slide_id mismatch" % idx
)


@then("the slide order matches the original [1, 2, 0]")
def then_slide_order_matches_1_2_0(context):
o = context.original_slide_ids
expected = [o[1], o[2], o[0]]
actual = [s.slide_id for s in context.slides]
assert actual == expected, "expected %r, got %r" % (expected, actual)


@then("the surviving slide order matches the original [0, 2]")
def then_surviving_slide_order_matches_0_2(context):
o = context.original_slide_ids
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)
44 changes: 44 additions & 0 deletions src/pptx/oxml/presentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,50 @@ def add_sldId(self, rId: str) -> CT_SlideId:
"""
return self._add_sldId(id=self._next_id, rId=rId)

def insert_sldId_at(self, rId: str, idx: int) -> CT_SlideId:
"""Insert a new `p:sldId` child element at position `idx`.

The new `p:sldId` element has its `r:id` attribute set to `rId` and
receives the next available `id` value. `idx` may equal the current
length to append. Raises `IndexError` if `idx` is out of range.
"""
if idx < 0 or idx > len(self.sldId_lst):
raise IndexError("slide index out of range")
new_sldId = self.add_sldId(rId)
if idx < len(self.sldId_lst) - 1:
target = self.sldId_lst[idx]
target.addprevious(new_sldId)
return new_sldId

def move_sldId_to(self, sldId: CT_SlideId, new_idx: int) -> None:
"""Reposition `sldId` to zero-based position `new_idx` in this list.

`sldId` must already be a child of this element. Raises `IndexError`
if `new_idx` is out of range.
"""
sldId_lst = self.sldId_lst
if new_idx < 0 or new_idx >= len(sldId_lst):
raise IndexError("slide index out of range")
if sldId_lst[new_idx] is sldId:
return
# -- detach from current position --
self.remove(sldId)
# -- re-fetch list so index reflects post-removal state --
sldId_lst = self.sldId_lst
if new_idx >= len(sldId_lst):
self.append(sldId)
else:
sldId_lst[new_idx].addprevious(sldId)

def remove_sldId(self, sldId: CT_SlideId) -> None:
"""Remove `sldId` child element from this list.

Raises `ValueError` if `sldId` is not a child of this element.
"""
if sldId.getparent() is not self:
raise ValueError("sldId is not a child of this sldIdLst")
self.remove(sldId)

@property
def _next_id(self) -> int:
"""The next available slide ID as an `int`.
Expand Down
Loading
Loading