From 649dd730f1bd74aa0a8e1ad52f4d6b67387c3ace Mon Sep 17 00:00:00 2001 From: Matthew Horoszowski Date: Thu, 7 May 2026 23:06:22 -0400 Subject: [PATCH] =?UTF-8?q?feat(slides):=20add=20Presentation.sections=20?= =?UTF-8?q?=E2=80=94=20slide=20CRUD=20Phase=204=20(closes=20#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of issue #11 (slide CRUD epic). Implements PowerPoint Sections support — read, write, and round-trip the `p:extLst/p:ext{uri=521415D9-…}/p14:sectionLst` extension that PowerPoint uses to organize slides into named groups in the slide pane. The fork's slide CRUD epic is now complete: Phase 1 (#33), Phase 2 (#34), Phase 3 (#35), and Phase 4 (this PR) collectively shipped duplicate / delete / reorder / copy-between-decks / sections across the originally listed sub-features. New public API -------------- - `Presentation.sections` → `_Sections` collection. - `_Sections` is sequence-like: `__len__`, `__iter__`, `__getitem__`, `index()`. Adds: * `add_section(name, after=None) -> Section` — append, or insert immediately after an existing section when `after` is given. * `remove(section)` — drop section; cleans up the wrapping `p14:sectionLst` / `p:ext` / `p:extLst` chain when the last section goes. - `Section` exposes: * `name` (read/write str) * `id` (read-only GUID-with-braces, e.g. `{ABC-…}`) * `slides` (tuple of |Slide|, in section order) * `add_slide(slide)` — assign or move slide into this section (a slide can belong to at most one section) * `remove_slide(slide)` — drop a slide's section assignment; slide remains in the presentation. Membership invariants --------------------- Section membership references slides by their stable `p:sldId/@id` integer (NOT by `r:id` and NOT by position), so `Slides.move(...)`, indexed `add_slide(...)`, and `Slides.remove(...)` preserve assignment without any extra plumbing. Removed slides become orphan ids in the section's `p14:sldIdLst`; `Section.slides` silently skips them on read but the XML round-trips them untouched (deliberate — matching python-pptx's "preserve foreign data" doctrine). PowerPoint compatibility ------------------------ - Empty sections emit `` so all PowerPoint versions treat them consistently (some interpret an omitted `sldIdLst` as "all unsectioned slides," which is not what we mean). - Section ids generated as `{}` matching PowerPoint's wire shape. - Foreign `p:extLst/p:ext` siblings (`p15:*`, modification tracking, etc.) round-trip untouched — the section ext lives alongside, not in place of. Internal additions ------------------ - New `pptx/sections.py` module hosting `Section` and `_Sections` proxy classes. - `pptx/oxml/presentation.py` extended with `CT_PresentationExtensionList`, `CT_PresentationExtension`, `CT_SectionList`, `CT_Section`, `CT_SectionSlideIdList`, `CT_SectionSlideId`, plus `SECTION_LIST_EXT_URI` constant and `CT_Presentation.{section_list, get_or_add_section_list, remove_section_list}` helpers. - `pptx/oxml/ns.py` registers the `p14` prefix (`http://schemas.microsoft.com/office/powerpoint/2010/main`). - `pptx/oxml/__init__.py` registers the new element classes. - `p:extLst` added as a successor entry on the existing ZeroOrOne `sldMasterIdLst`/`sldIdLst`/`sldSz` slots so insert ordering is correct. Tangential test-infra fix ------------------------- `tests/unitutil/cxml.py` namespace-prefix grammar widened from `Word(alphas)` to `Word(alphas, alphanums)` so test fixtures can address `p14:`, `w14:`, `o15:`, etc. Local-name grammar already supported alphanums. Test coverage ------------- - 56 new unit tests in `tests/test_sections.py` covering oxml helpers, the `Section`/`_Sections` proxies, and 8 round-trip integration tests (no-sections baseline, names + membership, GUID preservation, membership-survives-move, removal pruning, orphan preservation, empty-section, unicode/XML-special name, unsectioned-on-section-remove, sibling-ext preservation). - 5 new behave scenarios in `features/sld-sections.feature` (default empty, add, slide membership, move-preserves-membership, remove cleans up extLst). - New `uat_slide_sections.py` (untracked per repo §6) builds a 6-slide / 3-section deck, demonstrates membership preservation through a slide move, and prints a structural read-back. Verification ------------ ``` $ python3 -m pytest tests/ -q | tail -3 3222 passed in 4.33s $ ruff check src tests | tail -3 All checks passed! $ python3 -m behave features/ --no-color | tail -3 999 scenarios passed, 0 failed, 0 skipped 3000 steps passed, 0 failed, 0 skipped ``` Closes #11 --- features/sld-sections.feature | 41 +++ features/steps/slides.py | 53 +++ src/pptx/oxml/__init__.py | 12 + src/pptx/oxml/ns.py | 1 + src/pptx/oxml/presentation.py | 176 +++++++++- src/pptx/presentation.py | 20 ++ src/pptx/sections.py | 235 +++++++++++++ tests/test_sections.py | 603 ++++++++++++++++++++++++++++++++++ tests/unitutil/cxml.py | 4 +- 9 files changed, 1141 insertions(+), 4 deletions(-) create mode 100644 features/sld-sections.feature create mode 100644 src/pptx/sections.py create mode 100644 tests/test_sections.py diff --git a/features/sld-sections.feature b/features/sld-sections.feature new file mode 100644 index 000000000..e17788b72 --- /dev/null +++ b/features/sld-sections.feature @@ -0,0 +1,41 @@ +Feature: Slide sections — read/write `` + 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 diff --git a/features/steps/slides.py b/features/steps/slides.py index 55fc1850a..def2c804e 100644 --- a/features/steps/slides.py +++ b/features/steps/slides.py @@ -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 diff --git a/src/pptx/oxml/__init__.py b/src/pptx/oxml/__init__.py index 1763b4048..3531db14c 100644 --- a/src/pptx/oxml/__init__.py +++ b/src/pptx/oxml/__init__.py @@ -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, @@ -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 diff --git a/src/pptx/oxml/ns.py b/src/pptx/oxml/ns.py index 6af77e059..4b0ff62da 100644 --- a/src/pptx/oxml/ns.py +++ b/src/pptx/oxml/ns.py @@ -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", diff --git a/src/pptx/oxml/presentation.py b/src/pptx/oxml/presentation.py index 05ca37587..c1f89a08b 100644 --- a/src/pptx/oxml/presentation.py +++ b/src/pptx/oxml/presentation.py @@ -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", @@ -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 ) diff --git a/src/pptx/presentation.py b/src/pptx/presentation.py index 0ffae2816..921111fea 100644 --- a/src/pptx/presentation.py +++ b/src/pptx/presentation.py @@ -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, diff --git a/src/pptx/sections.py b/src/pptx/sections.py new file mode 100644 index 000000000..825e95f84 --- /dev/null +++ b/src/pptx/sections.py @@ -0,0 +1,235 @@ +"""Section objects — read/write `` (PowerPoint Sections). + +Sections group slides for organizational purposes in PowerPoint's +slide-organizer pane. They are stored in the presentation extension list +under `p:extLst/p:ext{uri="{521415D9-…}"}/p14:sectionLst` and reference +slides by their stable `p:sldId/@id` integer — meaning section +membership survives reorder, indexed insert, and remove operations on +the slide collection. + +Public surface: + +- |Presentation.sections| → |_Sections| +- |_Sections| supports ``len()``, iteration, indexed access, ``index``, + ``add_section(name, after=None)``, and ``remove(section)``. +- |Section| exposes ``name`` (read/write), ``id`` (read-only GUID), + ``slides`` (tuple of |Slide|), ``add_slide(slide)``, and + ``remove_slide(slide)``. + +Issue: https://github.com/MHoroszowski/python-pptx/issues/11 (Phase 4). +""" + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING, Iterator + +if TYPE_CHECKING: + from pptx.oxml.presentation import CT_Section + from pptx.presentation import Presentation + from pptx.slide import Slide + + +def _new_section_id() -> str: + """Generate a fresh section GUID in the canonical PowerPoint shape. + + PowerPoint stores section ids as GUIDs surrounded by braces and + upper-cased, e.g. ``{4080CFE4-F95C-449C-8898-95C81DD3D8B4}``. + """ + return "{%s}" % str(uuid.uuid4()).upper() + + +class Section: + """A single PowerPoint section — a named group of slides.""" + + def __init__(self, section_elm: CT_Section, sections: _Sections): + self._element = section_elm + self._sections = sections + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Section): + return NotImplemented + return self._element.id == other._element.id + + def __hash__(self) -> int: + return hash(self._element.id) + + def __repr__(self) -> str: + return "
" % (self._element.name, self._element.id) + + @property + def name(self) -> str: + """Display name of this section.""" + return self._element.name + + @name.setter + def name(self, value: str) -> None: + self._element.name = value + + @property + def id(self) -> str: + """Stable GUID identifier of this section (e.g. ``{ABC...}``). + + Read-only; assigned at creation and persists across save / reopen. + """ + return self._element.id + + @property + def slides(self) -> tuple[Slide, ...]: + """Tuple of |Slide| objects belonging to this section, in section order. + + Slides whose ids are listed in this section's ``p14:sldIdLst`` but + no longer present in the presentation (orphaned references — should + not occur in a well-formed deck, but possible in corrupt files) are + silently skipped. + """ + if self._element.sldIdLst is None: + return () + prs_slides = self._sections._prs.slides + slide_by_id = {s.slide_id: s for s in prs_slides} + result: list[Slide] = [] + for sldId in self._element.sldIdLst.sldId_lst: + slide = slide_by_id.get(sldId.id) + if slide is not None: + result.append(slide) + return tuple(result) + + def add_slide(self, slide: Slide) -> None: + """Move `slide` into this section. + + If `slide` is currently assigned to another section, it is removed + from that section first — a slide can belong to at most one + section. Appending a slide already in this section is a no-op. + Raises |ValueError| if `slide` is not part of the presentation that + owns this section. + """ + prs = self._sections._prs + if slide.part.package is not prs.part.package: + raise ValueError("slide is not in this presentation") + # ---ensure slide is not in any other section--- + self._sections._unassign_slide(slide.slide_id, except_section=self) + # ---add to this section if not already present--- + sldIdLst = self._element.get_or_add_sldIdLst() + for sldId in sldIdLst.sldId_lst: + if sldId.id == slide.slide_id: + return # ---already a member, no-op--- + sldIdLst.add_sldId(slide.slide_id) + + def remove_slide(self, slide: Slide) -> None: + """Remove `slide` from this section. + + The slide remains in the presentation. Raises |ValueError| if + `slide` is not currently a member of this section. + """ + if self._element.sldIdLst is None: + raise ValueError("slide is not in this section") + if not self._element.sldIdLst.remove_sldId_for(slide.slide_id): + raise ValueError("slide is not in this section") + + +class _Sections: + """Sequence-like collection of |Section| objects on a |Presentation|. + + An empty presentation (one with no ``) reports + ``len() == 0`` without forcing the extension into existence; the + ``p:extLst`` / ``p:ext`` / ``p14:sectionLst`` chain is only created + on the first ``add_section`` call. + """ + + def __init__(self, prs: Presentation): + self._prs = prs + + def __len__(self) -> int: + sectionLst = self._prs._element.section_list + if sectionLst is None: + return 0 + return len(sectionLst.section_lst) + + def __iter__(self) -> Iterator[Section]: + sectionLst = self._prs._element.section_list + if sectionLst is None: + return iter(()) + return (Section(s, self) for s in sectionLst.section_lst) + + def __getitem__(self, idx: int) -> Section: + sectionLst = self._prs._element.section_list + if sectionLst is None: + raise IndexError("section index out of range") + try: + section_elm = sectionLst.section_lst[idx] + except IndexError: + raise IndexError("section index out of range") + return Section(section_elm, self) + + def index(self, section: Section) -> int: + """Return the zero-based position of `section` in this collection. + + Raises |ValueError| if `section` is not in the collection. + """ + for idx, this_section in enumerate(self): + if this_section == section: + return idx + raise ValueError("%s is not in this Sections collection" % section) + + def add_section(self, name: str, after: Section | None = None) -> Section: + """Add a new section named `name`. + + When `after` is |None| (default), the new section is appended at + the end. When `after` is an existing |Section|, the new section is + inserted immediately after it. Raises |ValueError| if `after` is + not a member of this collection. + + The new section is created with a fresh GUID id and starts with + an empty slide list. Assign slides via |Section.add_slide|. The + empty `` is materialized at creation time so the + section is emitted in the same shape PowerPoint produces (some + PowerPoint versions interpret a section that omits `sldIdLst` + differently from one that includes an empty `sldIdLst`). + """ + sectionLst = self._prs._element.get_or_add_section_list() + new_id = _new_section_id() + if after is None: + section_elm = sectionLst.add_section(name=name, section_id=new_id) + else: + after_idx = self.index(after) + section_elm = sectionLst.insert_section_at( + name=name, section_id=new_id, idx=after_idx + 1 + ) + # ---force-create the (empty) sldIdLst to match PowerPoint's wire shape + section_elm.get_or_add_sldIdLst() + return Section(section_elm, self) + + def remove(self, section: Section) -> None: + """Remove `section` from this collection. + + Slides previously belonging to `section` remain in the presentation + but are no longer assigned to any section. Raises |ValueError| if + `section` is not a member of this collection. When the last + section is removed, the wrapping `p14:sectionLst` and its + containing `p:ext` / `p:extLst` chain are cleaned up. + """ + sectionLst = self._prs._element.section_list + if sectionLst is None: + raise ValueError("%s is not in this Sections collection" % section) + for section_elm in sectionLst.section_lst: + if section_elm.id == section.id: + sectionLst.remove(section_elm) + if len(sectionLst.section_lst) == 0: + self._prs._element.remove_section_list() + return + raise ValueError("%s is not in this Sections collection" % section) + + def _unassign_slide(self, slide_id: int, except_section: Section | None = None) -> None: + """Remove `slide_id` from every section's sldIdLst (with optional skip). + + Internal helper called by |Section.add_slide| to enforce the + "a slide belongs to at most one section" invariant. + """ + sectionLst = self._prs._element.section_list + if sectionLst is None: + return + for section_elm in sectionLst.section_lst: + if except_section is not None and section_elm.id == except_section.id: + continue + if section_elm.sldIdLst is not None: + section_elm.sldIdLst.remove_sldId_for(slide_id) diff --git a/tests/test_sections.py b/tests/test_sections.py new file mode 100644 index 000000000..b4f64142b --- /dev/null +++ b/tests/test_sections.py @@ -0,0 +1,603 @@ +# pyright: reportPrivateUsage=false + +"""Unit-test suite for slide-CRUD Phase 4 — Presentation.sections. + +Covers: + +- The new oxml types in `pptx.oxml.presentation` (extLst plumbing, + sectionLst, section, section-sldIdLst, section-sldId). +- The `pptx.sections` proxy classes — `Section` and `_Sections`. +- Round-trip preservation: open → mutate → save → reopen. +- Anti-criteria: section membership survives `Slides.move` and + `Slides.remove` (because sections key on slide_id, not position). + +Issue: https://github.com/MHoroszowski/python-pptx/issues/11 (Phase 4). +""" + +from __future__ import annotations + +import io + +import pytest + +from pptx import Presentation +from pptx.oxml.presentation import ( + SECTION_LIST_EXT_URI, + CT_Presentation, + CT_Section, + CT_SectionList, + CT_SectionSlideIdList, +) +from pptx.sections import Section, _new_section_id, _Sections + +from .unitutil.cxml import element + +# --------------------------------------------------------------------------- +# Section ID generator +# --------------------------------------------------------------------------- + + +class Describe_new_section_id(object): + """Unit-test suite for `pptx.sections._new_section_id`.""" + + def it_returns_a_GUID_in_braces(self): + sid = _new_section_id() + assert sid.startswith("{") + assert sid.endswith("}") + assert len(sid) == 38 # ---{ + 36 GUID chars + } + + def it_returns_unique_ids_on_each_call(self): + ids = {_new_section_id() for _ in range(50)} + assert len(ids) == 50 + + +# --------------------------------------------------------------------------- +# OXML LAYER — sectionLst, section, section-sldIdLst. +# --------------------------------------------------------------------------- + + +class DescribeCT_SectionList(object): + """Unit-test suite for `CT_SectionList` add/insert helpers.""" + + def it_can_add_a_section(self): + sectionLst = element("p14:sectionLst") + assert isinstance(sectionLst, CT_SectionList) + + sec = sectionLst.add_section(name="Intro", section_id="abc") + + assert isinstance(sec, CT_Section) + assert sec.name == "Intro" + assert sec.id == "abc" + assert len(sectionLst.section_lst) == 1 + + @pytest.mark.parametrize( + ("idx", "expected_position"), + [(0, 0), (1, 1), (2, 2)], # ---append==len, head, middle + ) + def it_can_insert_a_section_at_a_specific_index(self, idx, expected_position): + sectionLst = element("p14:sectionLst/(p14:section{name=A,id=a},p14:section{name=B,id=b})") + + sectionLst.insert_section_at(name="X", section_id="x", idx=idx) + + assert sectionLst.section_lst[expected_position].id == "x" + + @pytest.mark.parametrize("bad_idx", [-1, 99]) + def but_it_raises_on_insert_out_of_range(self, bad_idx): + sectionLst = element("p14:sectionLst/p14:section{name=A,id=a}") + with pytest.raises(IndexError): + sectionLst.insert_section_at(name="X", section_id="x", idx=bad_idx) + + +class DescribeCT_SectionSlideIdList(object): + """Unit-test suite for `CT_SectionSlideIdList` add/remove helpers.""" + + def it_can_add_a_sldId(self): + sldIdLst = element("p14:sldIdLst") + assert isinstance(sldIdLst, CT_SectionSlideIdList) + + sldIdLst.add_sldId(256) + + assert len(sldIdLst.sldId_lst) == 1 + assert sldIdLst.sldId_lst[0].id == 256 + + def it_can_remove_a_sldId_for_a_slide_id(self): + sldIdLst = element("p14:sldIdLst/(p14:sldId{id=256},p14:sldId{id=257})") + + removed = sldIdLst.remove_sldId_for(256) + + assert removed is True + assert [s.id for s in sldIdLst.sldId_lst] == [257] + + def it_returns_False_when_sldId_is_absent(self): + sldIdLst = element("p14:sldIdLst/p14:sldId{id=256}") + assert sldIdLst.remove_sldId_for(999) is False + + +class DescribeCT_Presentation_section_helpers(object): + """Unit-test suite for `CT_Presentation` section-list traversal helpers.""" + + def it_returns_None_for_section_list_when_no_extLst(self): + prs = element("p:presentation/p:sldIdLst/p:sldId{id=256,r:id=rId1}") + assert isinstance(prs, CT_Presentation) + assert prs.section_list is None + + def it_returns_None_for_section_list_when_no_section_ext(self): + # ---build programmatically because cxml attr_val cannot embed the + # GUID URI with literal `{` and `}`--- + prs = element("p:presentation") + assert isinstance(prs, CT_Presentation) + extLst = prs.get_or_add_extLst() + extLst._add_ext(uri="some-other-uri-not-the-section-one") + + assert prs.section_list is None + + def it_returns_the_sectionLst_when_present(self): + prs = element("p:presentation") + assert isinstance(prs, CT_Presentation) + prs.get_or_add_section_list() # ---creates extLst/ext/sectionLst chain + + sectionLst = prs.section_list + assert isinstance(sectionLst, CT_SectionList) + + def it_creates_extLst_ext_and_sectionLst_on_get_or_add(self): + prs = element("p:presentation") + + sectionLst = prs.get_or_add_section_list() + + assert isinstance(sectionLst, CT_SectionList) + assert prs.extLst is not None + ext = prs.extLst.ext_by_uri(SECTION_LIST_EXT_URI) + assert ext is not None + assert ext.sectionLst is sectionLst + + def it_is_a_noop_to_remove_section_list_when_already_absent(self): + prs = element("p:presentation") + prs.remove_section_list() # ---should not raise--- + assert prs.section_list is None + + def it_drops_only_the_section_ext_keeping_other_extensions(self): + prs = element("p:presentation") + extLst = prs.get_or_add_extLst() + extLst._add_ext(uri="other-uri-keep-me") + prs.get_or_add_section_list() # ---adds the section ext alongside + + prs.remove_section_list() + + assert prs.section_list is None + # ---other ext is preserved, extLst is preserved--- + assert prs.extLst is not None + assert prs.extLst.ext_by_uri("other-uri-keep-me") is not None + + def it_removes_extLst_entirely_when_only_section_ext_was_present(self): + prs = element("p:presentation") + prs.get_or_add_section_list() + assert prs.extLst is not None # ---sanity check before remove + + prs.remove_section_list() + + assert prs.extLst is None + + +# --------------------------------------------------------------------------- +# `Section` proxy — name, id, equality, slides view. +# --------------------------------------------------------------------------- + + +def _empty_sections() -> _Sections: + """Build a `_Sections` proxy bound to a fresh empty presentation.""" + return _Sections(Presentation()) + + +class DescribeSection(object): + """Unit-test suite for `pptx.sections.Section`.""" + + def it_knows_its_name(self): + ct = element("p14:section{name=Intro,id=abc}") + section = Section(ct, _empty_sections()) + assert section.name == "Intro" + + def it_can_change_its_name(self): + ct = element("p14:section{name=Old,id=abc}") + section = Section(ct, _empty_sections()) + + section.name = "New" + + assert ct.name == "New" + + def it_knows_its_id(self): + ct = element("p14:section{name=Intro,id=abc}") + section = Section(ct, _empty_sections()) + assert section.id == "abc" + + def it_returns_an_empty_tuple_for_slides_when_sldIdLst_is_absent(self): + ct = element("p14:section{name=Intro,id=abc}") + section = Section(ct, _empty_sections()) + assert section.slides == () + + def it_compares_equal_to_another_proxy_with_the_same_id(self): + ct1 = element("p14:section{name=A,id=same-id}") + ct2 = element("p14:section{name=B,id=same-id}") + sections = _empty_sections() + assert Section(ct1, sections) == Section(ct2, sections) + + def it_compares_unequal_to_another_proxy_with_a_different_id(self): + ct1 = element("p14:section{name=A,id=id1}") + ct2 = element("p14:section{name=A,id=id2}") + sections = _empty_sections() + assert Section(ct1, sections) != Section(ct2, sections) + + def it_is_hashable_by_id(self): + ct = element("p14:section{name=A,id=abc}") + section = Section(ct, _empty_sections()) + assert hash(section) == hash("abc") + + +# --------------------------------------------------------------------------- +# `_Sections` proxy — read-only collection semantics on empty deck. +# --------------------------------------------------------------------------- + + +class DescribeSections_EmptyPresentation(object): + """Unit-test suite for `_Sections` over a presentation without sections.""" + + def it_reports_zero_length(self): + prs = Presentation() + assert len(prs.sections) == 0 + + def it_returns_an_empty_iterator(self): + prs = Presentation() + assert list(prs.sections) == [] + + def it_does_not_create_extLst_just_to_report_zero(self): + prs = Presentation() + _ = len(prs.sections) + assert prs._element.extLst is None + + def it_raises_IndexError_on_indexed_access(self): + prs = Presentation() + with pytest.raises(IndexError): + prs.sections[0] + + +# --------------------------------------------------------------------------- +# `_Sections` — add / remove / index / membership. +# --------------------------------------------------------------------------- + + +class DescribeSections_AddRemove(object): + """Unit-test suite for `_Sections` mutation surface.""" + + def it_can_add_a_section(self): + prs = Presentation() + section = prs.sections.add_section("Intro") + + assert len(prs.sections) == 1 + assert section.name == "Intro" + assert prs.sections[0] == section + + def it_can_add_multiple_sections_in_order(self): + prs = Presentation() + s1 = prs.sections.add_section("First") + s2 = prs.sections.add_section("Second") + s3 = prs.sections.add_section("Third") + + assert [s.name for s in prs.sections] == ["First", "Second", "Third"] + assert prs.sections.index(s1) == 0 + assert prs.sections.index(s2) == 1 + assert prs.sections.index(s3) == 2 + + def it_can_insert_a_section_after_another(self): + prs = Presentation() + first = prs.sections.add_section("First") + last = prs.sections.add_section("Last") + + middle = prs.sections.add_section("Middle", after=first) + + assert [s.name for s in prs.sections] == ["First", "Middle", "Last"] + assert prs.sections.index(middle) == 1 + assert prs.sections.index(last) == 2 + + def it_assigns_unique_ids_to_each_section(self): + prs = Presentation() + s1 = prs.sections.add_section("A") + s2 = prs.sections.add_section("B") + assert s1.id != s2.id + + def but_it_raises_on_add_after_a_section_not_in_collection(self): + prs1 = Presentation() + prs2 = Presentation() + foreign = prs2.sections.add_section("Foreign") + + with pytest.raises(ValueError): + prs1.sections.add_section("X", after=foreign) + + def it_can_remove_a_section(self): + prs = Presentation() + s1 = prs.sections.add_section("Keep") + s2 = prs.sections.add_section("Drop") + + prs.sections.remove(s2) + + assert len(prs.sections) == 1 + assert prs.sections[0] == s1 + + def it_cleans_up_extLst_when_last_section_removed(self): + prs = Presentation() + only = prs.sections.add_section("Lonely") + assert prs._element.extLst is not None # ---created during add + + prs.sections.remove(only) + + assert len(prs.sections) == 0 + assert prs._element.extLst is None + + def but_it_raises_on_remove_of_section_not_in_collection(self): + prs1 = Presentation() + prs2 = Presentation() + foreign = prs2.sections.add_section("Foreign") + + with pytest.raises(ValueError): + prs1.sections.remove(foreign) + + def but_index_raises_on_section_not_in_collection(self): + prs1 = Presentation() + prs2 = Presentation() + foreign = prs2.sections.add_section("Foreign") + + with pytest.raises(ValueError): + prs1.sections.index(foreign) + + +# --------------------------------------------------------------------------- +# Slide assignment — Section.add_slide / remove_slide invariants. +# --------------------------------------------------------------------------- + + +def _seed_with_slides(n: int) -> Presentation: + prs = Presentation() + layout = prs.slide_layouts[6] + for _ in range(n): + prs.slides.add_slide(layout) + return prs + + +class DescribeSection_SlideAssignment(object): + """Unit-test suite for slide membership operations on Section.""" + + def it_can_add_a_slide_to_a_section(self): + prs = _seed_with_slides(2) + section = prs.sections.add_section("Intro") + + section.add_slide(prs.slides[0]) + + assert len(section.slides) == 1 + assert section.slides[0].slide_id == prs.slides[0].slide_id + + def it_unassigns_slide_from_other_section_on_add(self): + prs = _seed_with_slides(1) + first = prs.sections.add_section("First") + second = prs.sections.add_section("Second") + slide = prs.slides[0] + + first.add_slide(slide) + second.add_slide(slide) + + assert first.slides == () + assert len(second.slides) == 1 + assert second.slides[0].slide_id == slide.slide_id + + def it_is_a_noop_to_add_a_slide_already_in_this_section(self): + prs = _seed_with_slides(1) + section = prs.sections.add_section("Intro") + slide = prs.slides[0] + + section.add_slide(slide) + section.add_slide(slide) # ---should not duplicate + + assert len(section.slides) == 1 + + def but_it_raises_on_add_of_slide_not_in_presentation(self): + prs1 = _seed_with_slides(1) + prs2 = _seed_with_slides(1) + section = prs1.sections.add_section("Intro") + + with pytest.raises(ValueError): + section.add_slide(prs2.slides[0]) + + def it_can_remove_a_slide(self): + prs = _seed_with_slides(2) + section = prs.sections.add_section("Intro") + slide = prs.slides[0] + section.add_slide(slide) + + section.remove_slide(slide) + + assert section.slides == () + + def but_it_raises_on_remove_of_slide_not_in_section(self): + prs = _seed_with_slides(2) + section = prs.sections.add_section("Intro") + + with pytest.raises(ValueError): + section.remove_slide(prs.slides[0]) + + def but_it_raises_on_remove_when_section_has_no_sldIdLst(self): + # ---freshly-added section starts without a sldIdLst child + prs = _seed_with_slides(1) + section = prs.sections.add_section("Empty") + + with pytest.raises(ValueError): + section.remove_slide(prs.slides[0]) + + def it_returns_slides_in_section_order_not_presentation_order(self): + prs = _seed_with_slides(3) + section = prs.sections.add_section("Intro") + + section.add_slide(prs.slides[2]) + section.add_slide(prs.slides[0]) + section.add_slide(prs.slides[1]) + + section_ids = [s.slide_id for s in section.slides] + prs_ids = [s.slide_id for s in prs.slides] + assert section_ids == [prs_ids[2], prs_ids[0], prs_ids[1]] + + +# --------------------------------------------------------------------------- +# Round-trip integration tests — open → mutate → save → reopen. +# --------------------------------------------------------------------------- + + +def _round_trip(prs: Presentation) -> Presentation: + buf = io.BytesIO() + prs.save(buf) + buf.seek(0) + return Presentation(buf) + + +class DescribeSectionsRoundTrip(object): + """Save → reopen preservation of section structure.""" + + def it_round_trips_a_presentation_with_no_sections(self): + prs = _seed_with_slides(2) + + round_tripped = _round_trip(prs) + + assert len(round_tripped.sections) == 0 + + def it_round_trips_sections_with_their_names_and_membership(self): + prs = _seed_with_slides(3) + intro = prs.sections.add_section("Intro") + body = prs.sections.add_section("Body") + intro.add_slide(prs.slides[0]) + body.add_slide(prs.slides[1]) + body.add_slide(prs.slides[2]) + + rt = _round_trip(prs) + + assert [s.name for s in rt.sections] == ["Intro", "Body"] + assert len(rt.sections[0].slides) == 1 + assert len(rt.sections[1].slides) == 2 + + def it_preserves_section_id_through_round_trip(self): + prs = _seed_with_slides(1) + section = prs.sections.add_section("Intro") + original_id = section.id + + rt = _round_trip(prs) + + assert rt.sections[0].id == original_id + + def it_preserves_section_membership_through_a_slides_move(self): + """The whole point of keying sections by slide_id (not position).""" + prs = _seed_with_slides(3) + section = prs.sections.add_section("Intro") + section.add_slide(prs.slides[0]) + target_slide_id = prs.slides[0].slide_id + + prs.slides.move(prs.slides[0], 2) # ---move to last position + rt = _round_trip(prs) + + section_slide_ids = [s.slide_id for s in rt.sections[0].slides] + assert section_slide_ids == [target_slide_id] + assert rt.slides[2].slide_id == target_slide_id + + def it_preserves_other_assignments_when_a_slide_is_removed(self): + prs = _seed_with_slides(3) + section = prs.sections.add_section("Body") + section.add_slide(prs.slides[0]) + section.add_slide(prs.slides[1]) + section.add_slide(prs.slides[2]) + keep_id = prs.slides[0].slide_id + also_keep_id = prs.slides[2].slide_id + + prs.slides.remove(prs.slides[1]) # ---middle slide goes + rt = _round_trip(prs) + + section_ids = {s.slide_id for s in rt.sections[0].slides} + # ---removed slide's id is now stale and silently skipped + assert keep_id in section_ids + assert also_keep_id in section_ids + assert len(rt.sections[0].slides) == 2 + + def it_preserves_sibling_extLst_extensions_when_adding_a_section(self): + """User-authored ext alongside our section ext must round-trip intact.""" + prs = Presentation() + # ---author a foreign extension before any section work--- + extLst = prs._element.get_or_add_extLst() + extLst._add_ext(uri="user-foreign-ext-uri-keep-me") + + prs.sections.add_section("Intro") + rt = _round_trip(prs) + + # ---both the section ext and the foreign ext survive--- + assert len(rt.sections) == 1 + rt_extLst = rt._element.extLst + assert rt_extLst is not None + assert rt_extLst.ext_by_uri("user-foreign-ext-uri-keep-me") is not None + assert rt_extLst.ext_by_uri(SECTION_LIST_EXT_URI) is not None + + def it_preserves_orphan_section_sldId_entries_on_round_trip(self): + """`Slides.remove` does NOT auto-prune section sldIdLst entries. + + Per PowerPoint compatibility doctrine: leave foreign data alone. + The orphan id stays in the section XML; `Section.slides` + silently skips it on read but the file content is preserved. + """ + prs = _seed_with_slides(3) + section = prs.sections.add_section("Body") + section.add_slide(prs.slides[0]) + section.add_slide(prs.slides[1]) + section.add_slide(prs.slides[2]) + orphan_id = prs.slides[1].slide_id + + prs.slides.remove(prs.slides[1]) + rt = _round_trip(prs) + + # ---visible API skips the orphan--- + assert len(rt.sections[0].slides) == 2 + # ---raw XML still contains the orphan id (no auto-prune)--- + rt_section_lst = rt._element.section_list + assert rt_section_lst is not None + rt_section = rt_section_lst.section_lst[0] + rt_sldIdLst = rt_section.sldIdLst + assert rt_sldIdLst is not None + raw_ids = [s.id for s in rt_sldIdLst.sldId_lst] + assert orphan_id in raw_ids + + def it_round_trips_an_empty_section_with_zero_slides(self): + """Empty sections must round-trip with their `` intact.""" + prs = _seed_with_slides(1) + section = prs.sections.add_section("Empty") + original_id = section.id + + rt = _round_trip(prs) + + assert len(rt.sections) == 1 + assert rt.sections[0].name == "Empty" + assert rt.sections[0].id == original_id + assert rt.sections[0].slides == () + + def it_round_trips_unicode_and_xml_special_chars_in_section_name(self): + """Section name must survive round-trip for non-ASCII and XML-reserved chars.""" + prs = _seed_with_slides(1) + tricky_name = '§ec†ion <1> & "intro" 🎯' + prs.sections.add_section(tricky_name) + + rt = _round_trip(prs) + + assert rt.sections[0].name == tricky_name + + def it_makes_slides_unsectioned_when_their_section_is_removed(self): + """Slides survive section removal; they just lose their assignment.""" + prs = _seed_with_slides(2) + section = prs.sections.add_section("Body") + section.add_slide(prs.slides[0]) + section.add_slide(prs.slides[1]) + slide_ids_before = {s.slide_id for s in prs.slides} + + prs.sections.remove(section) + + # ---slides themselves remain in the presentation--- + assert {s.slide_id for s in prs.slides} == slide_ids_before + # ---no section claims them now--- + assert len(prs.sections) == 0 diff --git a/tests/unitutil/cxml.py b/tests/unitutil/cxml.py index 79e217c20..63dd459a5 100644 --- a/tests/unitutil/cxml.py +++ b/tests/unitutil/cxml.py @@ -246,7 +246,9 @@ def grammar(): close_brace = Suppress("}") # np:tagName --------------------------------- - nspfx = Word(alphas) + # ---namespace prefix may contain digits after the leading letter + # (e.g. `p14`, `w14`, `o15`); local-name allows alphanums--- + nspfx = Word(alphas, alphanums) local_name = Word(alphanums) tagname = Combine(nspfx + colon + local_name)