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
41 changes: 41 additions & 0 deletions features/sld-sections.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
Feature: Slide sections — read/write `<p14:sectionLst>`
In order to organize slides into named groups in PowerPoint's slide pane
As a developer using python-pptx
I need to add, name, populate, and remove sections, with membership
surviving slide reorder and removal


Scenario: Presentation.sections is empty by default
Given a Slides object containing 3 slides
Then len(prs.sections) is 0


Scenario: Add a section to a presentation
Given a Slides object containing 3 slides
When I call prs.sections.add_section("Intro")
Then len(prs.sections) is 1
And prs.sections[0].name is "Intro"


Scenario: Add a slide to a section
Given a Slides object containing 3 slides
When I call prs.sections.add_section("Intro")
And I call section.add_slide(prs.slides[0])
Then len(section.slides) is 1


Scenario: Slide membership survives a slide move
Given a Slides object containing 3 slides
When I call prs.sections.add_section("Body")
And I call section.add_slide(prs.slides[0])
And I call slides.move(slides[0], 2)
Then section.slides still contains the moved slide
And the moved slide is at presentation index 2


Scenario: Remove a section cleans up extLst when last
Given a Slides object containing 3 slides
When I call prs.sections.add_section("Lonely")
And I call prs.sections.remove(section)
Then len(prs.sections) is 0
And prs._element.extLst is None
53 changes: 53 additions & 0 deletions features/steps/slides.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,3 +310,56 @@ def then_append_from_index_99_raises(context):

with pytest.raises(IndexError):
context.target_pres.append_from(context.source_pres, slide_indexes=[99])


# Sections (Phase 4) =======================================


@then("len(prs.sections) is {n:d}")
def then_len_prs_sections_is_n(context, n):
assert len(context.prs.sections) == n


@when('I call prs.sections.add_section("{name}")')
def when_prs_sections_add_section(context, name):
context.section = context.prs.sections.add_section(name)


@then('prs.sections[0].name is "{expected}"')
def then_prs_sections_0_name_is(context, expected):
assert context.prs.sections[0].name == expected


@when("I call section.add_slide(prs.slides[0])")
def when_section_add_slide_0(context):
context.tracked_slide_id = context.prs.slides[0].slide_id
context.section.add_slide(context.prs.slides[0])


@then("len(section.slides) is {n:d}")
def then_len_section_slides_is_n(context, n):
assert len(context.section.slides) == n


@then("section.slides still contains the moved slide")
def then_section_still_contains_moved_slide(context):
section_slide_ids = [s.slide_id for s in context.section.slides]
assert context.tracked_slide_id in section_slide_ids, (
"expected slide_id %r in section.slides %r"
% (context.tracked_slide_id, section_slide_ids)
)


@then("the moved slide is at presentation index {idx:d}")
def then_moved_slide_at_index(context, idx):
assert context.prs.slides[idx].slide_id == context.tracked_slide_id


@when("I call prs.sections.remove(section)")
def when_prs_sections_remove(context):
context.prs.sections.remove(context.section)


@then("prs._element.extLst is None")
def then_prs_element_extLst_is_None(context):
assert context.prs._element.extLst is None
12 changes: 12 additions & 0 deletions src/pptx/oxml/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,12 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]):

from pptx.oxml.presentation import ( # noqa: E402
CT_Presentation,
CT_PresentationExtension,
CT_PresentationExtensionList,
CT_Section,
CT_SectionList,
CT_SectionSlideId,
CT_SectionSlideIdList,
CT_SlideId,
CT_SlideIdList,
CT_SlideMasterIdList,
Expand All @@ -332,6 +338,12 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]):
register_element_cls("p:sldMasterId", CT_SlideMasterIdListEntry)
register_element_cls("p:sldMasterIdLst", CT_SlideMasterIdList)
register_element_cls("p:sldSz", CT_SlideSize)
register_element_cls("p:extLst", CT_PresentationExtensionList)
register_element_cls("p:ext", CT_PresentationExtension)
register_element_cls("p14:sectionLst", CT_SectionList)
register_element_cls("p14:section", CT_Section)
register_element_cls("p14:sldIdLst", CT_SectionSlideIdList)
register_element_cls("p14:sldId", CT_SectionSlideId)


from pptx.oxml.shapes.autoshape import ( # noqa: E402
Expand Down
1 change: 1 addition & 0 deletions src/pptx/oxml/ns.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"o": "urn:schemas-microsoft-com:office:office",
"op": "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"p14": "http://schemas.microsoft.com/office/powerpoint/2010/main",
"pd": "http://schemas.openxmlformats.org/drawingml/2006/presentationDrawing",
"pic": "http://schemas.openxmlformats.org/drawingml/2006/picture",
"pr": "http://schemas.openxmlformats.org/package/2006/relationships",
Expand Down
176 changes: 173 additions & 3 deletions src/pptx/oxml/presentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,31 @@

from typing import TYPE_CHECKING, Callable, cast

from pptx.oxml.ns import qn
from pptx.oxml.simpletypes import ST_SlideId, ST_SlideSizeCoordinate, XsdString
from pptx.oxml.xmlchemy import BaseOxmlElement, RequiredAttribute, ZeroOrMore, ZeroOrOne
from pptx.oxml.xmlchemy import (
BaseOxmlElement,
RequiredAttribute,
ZeroOrMore,
ZeroOrOne,
)

if TYPE_CHECKING:
from pptx.util import Length


# -- URI assigned by Microsoft for the section-list extension
# (PresentationML 2010 — see ECMA-376 / MS-OOXML Part 4, §13.7.5).
SECTION_LIST_EXT_URI = "{521415D9-36F7-43E2-AB2F-B90AF26B5E84}"


class CT_Presentation(BaseOxmlElement):
"""`p:presentation` element, root of the Presentation part stored as `/ppt/presentation.xml`."""

get_or_add_sldSz: Callable[[], CT_SlideSize]
get_or_add_sldIdLst: Callable[[], CT_SlideIdList]
get_or_add_sldMasterIdLst: Callable[[], CT_SlideMasterIdList]
get_or_add_extLst: Callable[[], CT_PresentationExtensionList]

sldMasterIdLst: CT_SlideMasterIdList | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
"p:sldMasterIdLst",
Expand All @@ -26,13 +38,171 @@ class CT_Presentation(BaseOxmlElement):
"p:sldIdLst",
"p:sldSz",
"p:notesSz",
"p:extLst",
),
)
sldIdLst: CT_SlideIdList | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
"p:sldIdLst", successors=("p:sldSz", "p:notesSz")
"p:sldIdLst", successors=("p:sldSz", "p:notesSz", "p:extLst")
)
sldSz: CT_SlideSize | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
"p:sldSz", successors=("p:notesSz",)
"p:sldSz", successors=("p:notesSz", "p:extLst")
)
extLst: CT_PresentationExtensionList | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
"p:extLst"
)

def get_or_add_section_list(self) -> CT_SectionList:
"""Return the `p14:sectionLst` element for this presentation, creating if needed.

Walks `p:extLst`/`p:ext` looking for the section-list extension URI; adds the
ext + nested `p14:sectionLst` if not present.
"""
extLst = self.get_or_add_extLst()
ext = extLst.get_or_add_ext_by_uri(SECTION_LIST_EXT_URI)
return ext.get_or_add_sectionLst()

@property
def section_list(self) -> CT_SectionList | None:
"""Return the existing `p14:sectionLst` element, or None if absent."""
if self.extLst is None:
return None
ext = self.extLst.ext_by_uri(SECTION_LIST_EXT_URI)
if ext is None:
return None
return ext.sectionLst

def remove_section_list(self) -> None:
"""Drop the section-list extension entirely.

Removes the wrapping `p:ext` (and the `p:extLst` if it becomes empty).
Idempotent — does nothing when no section list is present.
"""
if self.extLst is None:
return
ext = self.extLst.ext_by_uri(SECTION_LIST_EXT_URI)
if ext is None:
return
self.extLst.remove(ext)
if len(self.extLst.findall(qn("p:ext"))) == 0:
self.remove(self.extLst)


class CT_PresentationExtensionList(BaseOxmlElement):
"""`p:extLst` element, last child of `p:presentation`.

Container for `p:ext` elements; we only know how to interpret the
section-list extension, but other extensions (e.g. modification
tracking) round-trip through this container untouched.
"""

ext_lst: list[CT_PresentationExtension]

ext = ZeroOrMore("p:ext")

def ext_by_uri(self, uri: str) -> CT_PresentationExtension | None:
"""Return the `p:ext` child whose `uri` attribute matches `uri`, or None."""
for ext in self.ext_lst:
if ext.uri == uri:
return ext
return None

def get_or_add_ext_by_uri(self, uri: str) -> CT_PresentationExtension:
"""Return existing or newly-created `p:ext` matching `uri`."""
ext = self.ext_by_uri(uri)
if ext is None:
ext = self._add_ext(uri=uri)
return ext


class CT_PresentationExtension(BaseOxmlElement):
"""`p:ext` element under `p:extLst`, identified by its `uri` attribute.

The element body is namespace-extensible — any extension defined elsewhere
(e.g. `p14:sectionLst`) appears as a child here.
"""

get_or_add_sectionLst: Callable[[], CT_SectionList]

uri: str = RequiredAttribute("uri", XsdString) # pyright: ignore[reportAssignmentType]
sectionLst: CT_SectionList | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
"p14:sectionLst"
)


class CT_SectionList(BaseOxmlElement):
"""`p14:sectionLst` element under `p:ext` carrying section definitions."""

section_lst: list[CT_Section]

_add_section: Callable[..., CT_Section]
section = ZeroOrMore("p14:section")

def add_section(self, name: str, section_id: str) -> CT_Section:
"""Append a `p14:section` element with `name` and `id` (a GUID-with-braces)."""
return self._add_section(name=name, id=section_id)

def insert_section_at(self, name: str, section_id: str, idx: int) -> CT_Section:
"""Insert a new `p14:section` at zero-based position `idx`.

`idx` may equal `len(self.section_lst)` to append. Raises `IndexError`
if `idx` is out of range.
"""
if idx < 0 or idx > len(self.section_lst):
raise IndexError("section index out of range")
new_section = self.add_section(name, section_id)
if idx < len(self.section_lst) - 1:
target = self.section_lst[idx]
target.addprevious(new_section)
return new_section


class CT_Section(BaseOxmlElement):
"""`p14:section` element under `p14:sectionLst`.

Carries a human-readable `name`, a stable GUID `id`, and a `p14:sldIdLst`
listing slide ids (NOT relationship ids) belonging to this section.
"""

get_or_add_sldIdLst: Callable[[], CT_SectionSlideIdList]

name: str = RequiredAttribute("name", XsdString) # pyright: ignore[reportAssignmentType]
id: str = RequiredAttribute("id", XsdString) # pyright: ignore[reportAssignmentType]
sldIdLst: CT_SectionSlideIdList | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
"p14:sldIdLst"
)


class CT_SectionSlideIdList(BaseOxmlElement):
"""`p14:sldIdLst` element under `p14:section`.

Holds an ordered list of `p14:sldId` references identifying the slides
that belong to the parent section. References are by **slide id**
(the integer ``p:sldId/@id``), not by ``r:id``.
"""

sldId_lst: list[CT_SectionSlideId]

_add_sldId: Callable[..., CT_SectionSlideId]
sldId = ZeroOrMore("p14:sldId")

def add_sldId(self, slide_id: int) -> CT_SectionSlideId:
"""Append a `p14:sldId` referencing the slide whose `p:sldId/@id` equals `slide_id`."""
return self._add_sldId(id=slide_id)

def remove_sldId_for(self, slide_id: int) -> bool:
"""Remove the `p14:sldId` matching `slide_id`. Return True if removed, False if absent."""
for sldId in self.sldId_lst:
if sldId.id == slide_id:
self.remove(sldId)
return True
return False


class CT_SectionSlideId(BaseOxmlElement):
"""`p14:sldId` element under `p14:sldIdLst` of a `p14:section`."""

id: int = RequiredAttribute( # pyright: ignore[reportAssignmentType]
"id", ST_SlideId
)


Expand Down
20 changes: 20 additions & 0 deletions src/pptx/presentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,26 @@ def slides(self):
self.part.rename_slide_parts([cast("CT_SlideId", sldId).rId for sldId in sldIdLst])
return Slides(sldIdLst, self)

@lazyproperty
def sections(self):
"""|_Sections| collection of |Section| objects in this presentation.

The collection reads from the `p14:sectionLst` extension under
``p:presentation/p:extLst`` and supports ``len()``, iteration,
indexed access, ``index``, ``add_section(name, after=None)``, and
``remove(section)``. Section membership references slides by the
stable ``p:sldId/@id`` integer, so reordering or indexed insert
on the slide collection does not perturb section assignment.

For a presentation that does not yet declare any sections, the
collection reports ``len() == 0`` without forcing the extension
elements into existence; the wrapping XML is created on the first
``add_section`` call.
"""
from pptx.sections import _Sections

return _Sections(self)

def append_from(
self,
other_pres: Presentation,
Expand Down
Loading
Loading