From c68c89009236d47fe3bf97e6949b94b9195f7851 Mon Sep 17 00:00:00 2001 From: Matthew Horoszowski Date: Sat, 16 May 2026 23:25:14 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(masters):=20Slide=20Masters,=20Layouts?= =?UTF-8?q?=20&=20.potx=20Templates=20=E2=80=94=20issue=20#19=20epic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves all nine sub-features of the #19 epic, additively, manual semantic ports of upstream prior art (never cherry-pick; repo §2): - SF1 .potx read: api.py _is_pptx_package allowlists PML_TEMPLATE_MAIN (port of scanny/python-pptx#1071). ValueError on .potx → opens. - SF2 Presentation.save_as_potx(file): non-mutating content-type rewrite. - SF3 SlideLayouts.add_layout(name): new part + rels (port of scanny/python-pptx#1091; also closes upstream #1044 via LayoutShapes→_BaseGroupShapes). Uses next_partname not len+1. - SF4 SlideLayouts.copy_from(other): deep layout duplication, rel re-relate. - SF5 SlideMaster.shapes.add_* : MasterShapes→_BaseGroupShapes. - SF6 SlideLayout.placeholders.add(idx, ph_type, ...): dup-idx rejected. - SF7 Slide.slide_layout setter / apply_layout(): cross-master rel repoint. - SF8 chart-into-placeholder: clean TypeError on non-chart (scanny#199). - SF9 SlideMaster.get_layout(slide_layout_id, default=None) (scanny#269). ID-POOL FIX: _next_id now allocates high-range (>=0x80000000, max(existing)+1) — a low-256 allocation collided with the first slide's sldId(256) in PowerPoint's shared id pool and triggered the repair dialog. Caught by Interceptor visual verification; the entire green trinity missed it. SF3 tests strengthened to assert full-pool disjointness (sldMasterId + sldId). Tests: +54 pytest (tests/test_issue19_masters_layouts.py, tests/test_api.py), +9 behave scenarios. Trinity green: pytest 3708, ruff check + format clean, behave 1057 scenarios 0 failed. uat_issue19.py authored (15/15 PASS as script-QA). Combined-deck PowerPoint visual signoff deferred to maintainer UAT per repo §6a; agent does not claim signoff. Refs #19 Refs scanny/python-pptx#1071 #1091 #1044 #199 #269 --- features/environment.py | 4 +- features/sld-add-layout.feature | 74 +++ features/steps/add_layout.py | 227 +++++++++ features/steps/slides.py | 12 +- features/steps/tbl_merge.py | 3 +- src/pptx/api.py | 14 +- src/pptx/oxml/slide.py | 82 ++- src/pptx/parts/slide.py | 112 +++- src/pptx/presentation.py | 54 ++ src/pptx/shapes/placeholder.py | 14 + src/pptx/shapes/shapetree.py | 88 +++- src/pptx/slide.py | 88 +++- tests/test_api.py | 7 + tests/test_issue19_masters_layouts.py | 708 ++++++++++++++++++++++++++ uat_issue19.py | 153 ++++++ 15 files changed, 1611 insertions(+), 29 deletions(-) create mode 100644 features/sld-add-layout.feature create mode 100644 features/steps/add_layout.py create mode 100644 tests/test_issue19_masters_layouts.py create mode 100644 uat_issue19.py diff --git a/features/environment.py b/features/environment.py index 92a18669c..a5b47da04 100644 --- a/features/environment.py +++ b/features/environment.py @@ -14,9 +14,7 @@ import os -scratch_dir = os.path.abspath( - os.path.join(os.path.split(__file__)[0], '_scratch') -) +scratch_dir = os.path.abspath(os.path.join(os.path.split(__file__)[0], "_scratch")) def before_all(context): diff --git a/features/sld-add-layout.feature b/features/sld-add-layout.feature new file mode 100644 index 000000000..304b8361b --- /dev/null +++ b/features/sld-add-layout.feature @@ -0,0 +1,74 @@ +Feature: Add a slide layout to a slide master + In order to build presentation templates programmatically + As a developer using python-pptx + I need to create new slide layouts on a slide master (issue #19 SF3) + + + Scenario: SlideLayouts.add_layout() with no name + Given a default presentation + When I call slide_layouts.add_layout() + Then the slide-master layout count increased by exactly 1 + And the new layout has a non-empty name + + + Scenario: SlideLayouts.add_layout(name) sets the name + Given a default presentation + When I call slide_layouts.add_layout("Acceptance Layout") + Then slide_layouts.get_by_name("Acceptance Layout") is the new layout + + + Scenario: A presentation survives reopen after add_layout + Given a default presentation + When I call slide_layouts.add_layout("Persisted Layout") + And I save and reopen the presentation + Then the reopened presentation has a layout named "Persisted Layout" + + + Scenario: A new layout is usable as the basis for a slide + Given a default presentation + When I call slide_layouts.add_layout("Slide Basis") + And I add a slide based on the new layout + Then the slide count increased by exactly 1 + + + Scenario: Presentation.save_as_potx writes a template content-type (SF2) + Given a default presentation + When I save the presentation as a potx + Then the saved potx declares the template content-type + And the in-memory presentation content-type is unchanged + And the saved potx reopens as a valid presentation + + + Scenario: Authoring a textbox directly on a slide master (SF5) + Given a default presentation + When I add a textbox to the slide master + And I save and reopen the presentation + Then the reopened slide master has the master textbox text + + + Scenario: Duplicating a layout with copy_from (SF4) + Given a default presentation + When I call slide_layouts.add_layout("Copy Origin") + And I add a textbox to the new layout + And I copy the new layout with copy_from + Then the copied layout has the same shape count as its source + And the source layout is unchanged after copy_from + + + Scenario: Applying a different layout to a slide (SF7) + Given a default presentation + When I add a slide on the default layout + And I call slide_layouts.add_layout("Reassigned Layout") + And I apply the new layout to that slide + And I save and reopen the presentation + Then the reopened slide uses the layout named "Reassigned Layout" + And the reopened slide still resolves its slide master + + + Scenario: Inserting a chart into a chart placeholder (SF8) + Given a default presentation + When I add a layout with a chart placeholder + And I add a slide on that chart-placeholder layout + And I insert a chart into the slide's chart placeholder + And I save and reopen the presentation + Then the reopened slide has exactly one chart diff --git a/features/steps/add_layout.py b/features/steps/add_layout.py new file mode 100644 index 000000000..6bc3843e0 --- /dev/null +++ b/features/steps/add_layout.py @@ -0,0 +1,227 @@ +"""Gherkin step implementations for issue #19 SF3 — SlideLayouts.add_layout().""" + +from __future__ import annotations + +import io + +from behave import given, then, when + +from pptx import Presentation + +# given =================================================== + + +@given("a default presentation") +def given_a_default_presentation(context): + context.prs = Presentation() + master = context.prs.slide_masters[0] + context.slide_layouts = master.slide_layouts + context.layout_count_before = len(context.slide_layouts) + context.slide_count_before = len(context.prs.slides) + + +# when ==================================================== + + +@when("I call slide_layouts.add_layout()") +def when_I_call_add_layout_no_name(context): + context.new_layout = context.slide_layouts.add_layout() + + +@when('I call slide_layouts.add_layout("{name}")') +def when_I_call_add_layout_with_name(context, name): + context.new_layout = context.slide_layouts.add_layout(name) + + +@when("I save and reopen the presentation") +def when_I_save_and_reopen(context): + buf = io.BytesIO() + context.prs.save(buf) + buf.seek(0) + context.reopened = Presentation(buf) + + +@when("I add a slide based on the new layout") +def when_I_add_a_slide_based_on_the_new_layout(context): + context.prs.slides.add_slide(context.new_layout) + + +# then ==================================================== + + +@then("the slide-master layout count increased by exactly 1") +def then_layout_count_increased_by_1(context): + assert len(context.slide_layouts) == context.layout_count_before + 1 + + +@then("the new layout has a non-empty name") +def then_new_layout_has_non_empty_name(context): + assert context.new_layout.name not in (None, "") + + +@then('slide_layouts.get_by_name("{name}") is the new layout') +def then_get_by_name_returns_new_layout(context, name): + assert context.slide_layouts.get_by_name(name) is not None + assert context.slide_layouts.get_by_name(name).name == context.new_layout.name + + +@then('the reopened presentation has a layout named "{name}"') +def then_reopened_has_layout_named(context, name): + layouts = context.reopened.slide_masters[0].slide_layouts + assert layouts.get_by_name(name) is not None + + +@then("the slide count increased by exactly 1") +def then_slide_count_increased_by_1(context): + assert len(context.prs.slides) == context.slide_count_before + 1 + + +# SF2 — save_as_potx ====================================== + +_TEMPLATE_CT = b"application/vnd.openxmlformats-officedocument.presentationml.template.main+xml" +_PRESENTATION_CT = ( + b"application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml" +) + + +@when("I save the presentation as a potx") +def when_I_save_as_potx(context): + context.ct_before = context.prs.part.content_type + context.potx_buf = io.BytesIO() + context.prs.save_as_potx(context.potx_buf) + + +@then("the saved potx declares the template content-type") +def then_potx_declares_template_ct(context): + import zipfile + + context.potx_buf.seek(0) + with zipfile.ZipFile(context.potx_buf) as z: + ct_xml = z.read("[Content_Types].xml") + assert _TEMPLATE_CT in ct_xml + assert _PRESENTATION_CT not in ct_xml + + +@then("the in-memory presentation content-type is unchanged") +def then_in_memory_ct_unchanged(context): + assert context.prs.part.content_type == context.ct_before + assert context.prs.part.content_type == _PRESENTATION_CT.decode("ascii") + + +@then("the saved potx reopens as a valid presentation") +def then_potx_reopens_valid(context): + context.potx_buf.seek(0) + reopened = Presentation(context.potx_buf) + assert len(reopened.slide_masters) >= 1 + + +# SF5 — master shape authoring ============================ + + +@when("I add a textbox to the slide master") +def when_I_add_textbox_to_master(context): + master = context.prs.slide_masters[0] + tb = master.shapes.add_textbox(0, 0, 914400, 457200) + tb.text_frame.text = "ACCEPTANCE MASTER TEXT" + + +@then("the reopened slide master has the master textbox text") +def then_reopened_master_has_textbox_text(context): + master = context.reopened.slide_masters[0] + texts = [s.text_frame.text for s in master.shapes if s.has_text_frame] + assert "ACCEPTANCE MASTER TEXT" in texts + + +# SF4 — copy_from ========================================= + + +@when("I add a textbox to the new layout") +def when_I_add_textbox_to_new_layout(context): + context.new_layout.shapes.add_textbox(0, 0, 914400, 457200) + + +@when("I copy the new layout with copy_from") +def when_I_copy_layout_with_copy_from(context): + context.source_shape_count = len(list(context.new_layout.shapes)) + context.copied_layout = context.slide_layouts.copy_from(context.new_layout) + + +@then("the copied layout has the same shape count as its source") +def then_copied_layout_same_shape_count(context): + assert len(list(context.copied_layout.shapes)) == context.source_shape_count + + +@then("the source layout is unchanged after copy_from") +def then_source_layout_unchanged(context): + assert len(list(context.new_layout.shapes)) == context.source_shape_count + + +# SF7 — cross-master / apply layout ======================= + + +@when("I add a slide on the default layout") +def when_I_add_slide_on_default_layout(context): + context.sf7_slide = context.prs.slides.add_slide(context.prs.slide_layouts[0]) + + +@when("I apply the new layout to that slide") +def when_I_apply_new_layout_to_slide(context): + context.sf7_slide.slide_layout = context.new_layout + + +@then('the reopened slide uses the layout named "{name}"') +def then_reopened_slide_uses_layout(context, name): + assert context.reopened.slides[0].slide_layout.name == name + + +@then("the reopened slide still resolves its slide master") +def then_reopened_slide_resolves_master(context): + assert context.reopened.slides[0].slide_layout.slide_master is not None + + +# SF8 — chart into placeholder ============================ + + +@when("I add a layout with a chart placeholder") +def when_I_add_layout_with_chart_placeholder(context): + from pptx.enum.shapes import PP_PLACEHOLDER + + master = context.prs.slide_masters[0] + context.chart_layout = master.slide_layouts.add_layout("Chart PH Layout") + context.chart_layout.placeholders.add( + 10, + PP_PLACEHOLDER.CHART, + left=914400, + top=914400, + width=4572000, + height=2743200, + ) + + +@when("I add a slide on that chart-placeholder layout") +def when_I_add_slide_on_chart_layout(context): + context.chart_slide = context.prs.slides.add_slide(context.chart_layout) + + +@when("I insert a chart into the slide's chart placeholder") +def when_I_insert_chart_into_placeholder(context): + from pptx.chart.data import CategoryChartData + from pptx.enum.chart import XL_CHART_TYPE + from pptx.enum.shapes import PP_PLACEHOLDER + + chart_ph = next( + p + for p in context.chart_slide.placeholders + if p.placeholder_format.type == PP_PLACEHOLDER.CHART + ) + chart_data = CategoryChartData() + chart_data.categories = ["A", "B", "C"] + chart_data.add_series("S1", (1.0, 2.0, 3.0)) + chart_ph.insert_chart(XL_CHART_TYPE.COLUMN_CLUSTERED, chart_data) + + +@then("the reopened slide has exactly one chart") +def then_reopened_slide_has_one_chart(context): + slide = context.reopened.slides[0] + charts = [s for s in slide.shapes if s.has_chart] + assert len(charts) == 1 diff --git a/features/steps/slides.py b/features/steps/slides.py index def2c804e..b39c98677 100644 --- a/features/steps/slides.py +++ b/features/steps/slides.py @@ -267,9 +267,7 @@ def when_target_append_from_source_all(context): @when("I call target.append_from(source, slide_indexes=[0, 1])") def when_target_append_from_source_indexes_0_1(context): - context.new_slides = context.target_pres.append_from( - context.source_pres, slide_indexes=[0, 1] - ) + context.new_slides = context.target_pres.append_from(context.source_pres, slide_indexes=[0, 1]) @when("I call target.append_from(source, slide_indexes=[])") @@ -287,8 +285,9 @@ def then_target_grew_by_source_slide_count(context): @then("target's master count grew by 1") def then_target_master_count_grew_by_1(context): actual = sum(1 for _ in context.target_pres.slide_masters) - assert actual == context.target_masters_before + 1, ( - "expected %d masters, got %d" % (context.target_masters_before + 1, actual) + assert actual == context.target_masters_before + 1, "expected %d masters, got %d" % ( + context.target_masters_before + 1, + actual, ) @@ -345,8 +344,7 @@ def then_len_section_slides_is_n(context, n): 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) + "expected slide_id %r in section.slides %r" % (context.tracked_slide_id, section_slide_ids) ) diff --git a/features/steps/tbl_merge.py b/features/steps/tbl_merge.py index 194020786..cd7f75003 100644 --- a/features/steps/tbl_merge.py +++ b/features/steps/tbl_merge.py @@ -30,8 +30,7 @@ def when_merge_cells(context, r1, r2, c1, c2): @when( - "I call table.merge_cells with range({r_start:d},{r_stop:d}) " - "and range({c_start:d},{c_stop:d})" + "I call table.merge_cells with range({r_start:d},{r_stop:d}) and range({c_start:d},{c_stop:d})" ) def when_merge_cells_with_range(context, r_start, r_stop, c_start, c_stop): context.table_.merge_cells(range(r_start, r_stop), range(c_start, c_stop)) diff --git a/src/pptx/api.py b/src/pptx/api.py index 69269ea33..c9c338274 100644 --- a/src/pptx/api.py +++ b/src/pptx/api.py @@ -53,6 +53,16 @@ def _default_pptx_path() -> str: def _is_pptx_package(prs_part: PresentationPart): - """Return |True| if *prs_part* is a valid main document part, |False| otherwise.""" - valid_content_types = (CT.PML_PRESENTATION_MAIN, CT.PML_PRES_MACRO_MAIN) + """Return |True| if *prs_part* is a valid main document part, |False| otherwise. + + The allowlist includes ``PML_TEMPLATE_MAIN`` so ``.potx`` template packages + open as ordinary presentations (issue #19 / scanny/python-pptx#1070, + #1095). A ``.potx`` differs from a ``.pptx`` only in this content-type; + every downstream part graph is identical. + """ + valid_content_types = ( + CT.PML_PRESENTATION_MAIN, + CT.PML_PRES_MACRO_MAIN, + CT.PML_TEMPLATE_MAIN, + ) return prs_part.content_type in valid_content_types diff --git a/src/pptx/oxml/slide.py b/src/pptx/oxml/slide.py index c1054e32b..1ecd2dfdc 100644 --- a/src/pptx/oxml/slide.py +++ b/src/pptx/oxml/slide.py @@ -7,7 +7,7 @@ from pptx.oxml import parse_from_template, parse_xml from pptx.oxml.dml.fill import CT_GradientFillProperties from pptx.oxml.ns import nsdecls -from pptx.oxml.simpletypes import XsdBoolean, XsdString +from pptx.oxml.simpletypes import XsdBoolean, XsdString, XsdUnsignedInt from pptx.oxml.xmlchemy import ( BaseOxmlElement, Choice, @@ -304,6 +304,31 @@ class CT_SlideLayout(_BaseSlideElement): ) del _tag_seq + @classmethod + def new(cls) -> CT_SlideLayout: + """Return a new `p:sldLayout` element configured as a base slide layout.""" + return cast(CT_SlideLayout, parse_xml(cls._sld_xml())) + + @staticmethod + def _sld_xml(): + return ( + "\n" + " \n" + " \n" + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "" % nsdecls("a", "p", "r") + ) + class CT_SlideLayoutIdList(BaseOxmlElement): """`p:sldLayoutIdLst` element, child of `p:sldMaster`. @@ -312,17 +337,70 @@ class CT_SlideLayoutIdList(BaseOxmlElement): """ sldLayoutId_lst: list[CT_SlideLayoutIdListEntry] + _add_sldLayoutId: Callable[..., CT_SlideLayoutIdListEntry] sldLayoutId = ZeroOrMore("p:sldLayoutId") + def add_sldLayoutId(self, rId: str) -> CT_SlideLayoutIdListEntry: + """Create and append a new `p:sldLayoutId` child referencing `rId`. + + The allocated `@id` is an ``ST_SlideLayoutId`` in the high uint range + ``>= 2147483648`` (``0x80000000``), allocated as + ``max(existing layout ids) + 1``. This matches the convention every + real PowerPoint file uses (the default template's own layout ids run + ``2147483649..2147483659``). + + ``p:sldMasterId/@id``, ``p:sldLayoutId/@id`` AND ``p:sldId/@id`` form + ONE shared logical id pool in PowerPoint's repair heuristic. Slide + ids start at ``256``; an earlier design that allocated layout ids + from a low ``256`` floor collided with the first slide's ``sldId`` + and triggered the "PowerPoint found a problem" repair dialog — caught + by Interceptor visual verification, NOT by byte-level round-trip + tests. Allocating in the high range, above every slide id and at/above + the master-id floor, structurally prevents the collision. + """ + return self._add_sldLayoutId(id=self._next_id, rId=rId) + + @property + def _next_id(self) -> int: + """Return an unused high-range ``ST_SlideLayoutId`` for a new layout. + + ``max(existing layout ids, _ID_FLOOR - 1) + 1`` where ``_ID_FLOOR`` is + the ``0x80000000`` boundary PowerPoint uses for layout/master ids — + mirrors the master-id allocator in ``parts/slide.py``. Scans upward + only in the pathological exhausted-pool case. + """ + _ID_FLOOR = 2147483648 # 0x80000000 — ECMA-376 §19.2.1.27 master/layout id base + _ID_CEIL = 4294967295 # uint32 max + used = { + int(raw) + for sli in self.sldLayoutId_lst + if (raw := sli.get("id")) is not None and raw.lstrip("-").isdigit() + } + candidate = max(used | {_ID_FLOOR - 1}) + 1 + if candidate <= _ID_CEIL: + return candidate + # ---pool exhausted (pathological): scan upward for the lowest free slot--- + for n in range(_ID_FLOOR, _ID_CEIL + 1): + if n not in used: + return n + raise ValueError("slide-layout id pool exhausted") + class CT_SlideLayoutIdListEntry(BaseOxmlElement): """`p:sldLayoutId` element, child of `p:sldLayoutIdLst`. - Contains a reference to a slide layout. + Contains a reference to a slide layout. ``id`` is the OOXML + ``ST_SlideLayoutId`` (PresentationML §19.3.1.43) — distinct from the + relationship ``r:id``; it is the stable identifier PowerPoint uses to + address a layout and is what ``SlideMaster.get_layout(slide_layout_id)`` + matches against (issue #19 / scanny/python-pptx#269). """ rId: str = RequiredAttribute("r:id", XsdString) # pyright: ignore[reportAssignmentType] + id: int | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "id", XsdUnsignedInt + ) class CT_SlideMaster(_BaseSlideElement): diff --git a/src/pptx/parts/slide.py b/src/pptx/parts/slide.py index 2b0910031..6477fb3de 100644 --- a/src/pptx/parts/slide.py +++ b/src/pptx/parts/slide.py @@ -14,7 +14,7 @@ from pptx.opc.constants import RELATIONSHIP_TYPE as RT 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.slide import CT_NotesMaster, CT_NotesSlide, CT_Slide, CT_SlideLayout from pptx.oxml.theme import CT_OfficeStyleSheet from pptx.parts.chart import ChartPart from pptx.parts.embeddedpackage import EmbeddedPackagePart @@ -272,6 +272,25 @@ def slide_layout(self) -> SlideLayout: slide_layout_part = self.part_related_by(RT.SLIDE_LAYOUT) return slide_layout_part.slide_layout + def apply_slide_layout(self, slide_layout_part: SlideLayoutPart) -> None: + """Repoint this slide's ``SLIDE_LAYOUT`` relationship at `slide_layout_part`. + + The existing slide→layout relationship is dropped and a fresh one to + `slide_layout_part` is created. Dropping the rel only removes *this + slide's* edge to the prior layout — the prior layout and its master + remain in the package (other slides may still reference them, and the + master still lists the layout in its ``p:sldLayoutIdLst``). The target + layout part already carries its own ``SLIDE_MASTER`` back-rel (wired + by ``SlideMasterPart.add_layout``), so the slide→layout→master chain + is intact and dangling-rel-free. Idempotent: applying the + currently-related layout drops then recreates the same edge with the + same effect (issue #19 SF7; ISC-44..49). + """ + for rId, rel in list(self.rels.items()): + if not rel.is_external and rel.reltype == RT.SLIDE_LAYOUT: + self.drop_rel(rId) + self.relate_to(slide_layout_part, RT.SLIDE_LAYOUT) + def _add_notes_slide_part(self): """ Return a newly created |NotesSlidePart| object related to this slide @@ -342,6 +361,42 @@ def _replicate_rels_for_duplicate(src_part: Part, new_part: Part) -> dict[str, s return rId_map +# Reltypes filtered out during layout copy_from. SLIDE_MASTER is the +# layout→master back-rel — the destination layout already owns its own +# (wired by `SlideMasterPart.add_layout`), so copying it would create a +# duplicate, conflicting master relationship. +_LAYOUT_COPY_DROP_RELTYPES = frozenset({RT.SLIDE_MASTER}) + + +def _replicate_rels_for_layout_copy(src_part: Part, new_part: Part) -> dict[str, str]: + """Mirror `src_part`'s non-structural rels onto `new_part`. + + Used by `SlideLayoutPart.copy_shapes_from` (issue #19 SF4). Image, + media, and external-target rels are reused/recreated so the copied + shapes resolve; the `SLIDE_MASTER` back-rel is skipped because the + destination layout already has its own. Returns a `{old_rId: new_rId}` + map for rId-attribute remapping on the copied shape XML. + """ + rId_map: dict[str, str] = {} + for rId, rel in src_part.rels.items(): + if rel.reltype in _LAYOUT_COPY_DROP_RELTYPES: + 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, theme, etc. — reuse the + # same package-level part (SHA1 dedup already happened on add). + 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`. @@ -1022,6 +1077,15 @@ class SlideLayoutPart(BaseSlidePart): Corresponds to package files ``ppt/slideLayouts/slideLayout[1-9][0-9]*.xml``. """ + @classmethod + def new(cls, partname: PackURI, package) -> SlideLayoutPart: + """Return a newly-created blank |SlideLayoutPart| with `partname`. + + The part has a minimal valid `p:sldLayout` XML body and no + relationships yet; the caller wires it to its slide-master. + """ + return cls(partname, CT.PML_SLIDE_LAYOUT, package, CT_SlideLayout.new()) + @lazyproperty def slide_layout(self): """ @@ -1034,6 +1098,29 @@ def slide_master(self) -> SlideMaster: """Slide master from which this slide layout inherits properties.""" return self.part_related_by(RT.SLIDE_MASTER).slide_master + def copy_shapes_from(self, source_layout_part: SlideLayoutPart) -> None: + """Deep-copy `source_layout_part`'s shape tree into this layout. + + Every shape child of the source `p:spTree` (shapes, placeholders, + pictures, group shapes) is deep-copied and appended to this + layout's `p:spTree`. Non-structural relationships on the source + layout part (images, media, external hyperlinks — everything + except the `SLIDE_MASTER` back-relationship, which this layout + already owns from `add_layout`) are replicated onto this part and + the copied shape XML has its relationship-id attributes remapped so + no dangling rels remain. + + The source layout part is NOT mutated (issue #19 SF4; ISC-23..29). + """ + rId_map = _replicate_rels_for_layout_copy(source_layout_part, self) + + dest_spTree = self._element.spTree + src_spTree = source_layout_part._element.spTree + for shape_elm in src_spTree.iter_shape_elms(): + new_elm = copy.deepcopy(shape_elm) + _remap_rId_attrs(new_elm, rId_map) + dest_spTree.append(new_elm) + class SlideMasterPart(BaseSlidePart): """Slide master part. @@ -1041,6 +1128,29 @@ class SlideMasterPart(BaseSlidePart): Corresponds to package files ppt/slideMasters/slideMaster[1-9][0-9]*.xml. """ + def add_layout(self) -> tuple[str, SlideLayout]: + """Create a new blank slide-layout part bound to this master. + + Returns ``(rId, slide_layout)`` where `rId` is the master→layout + relationship id (to be registered in `p:sldLayoutIdLst`) and + `slide_layout` is the |SlideLayout| proxy for the new part. + + The package partname is allocated via `Package.next_partname` + rather than upstream #1091's naive `len(layouts) + 1` scheme: this + fork supports `SlideLayouts.remove`, so a layout count can lag the + highest extant slideLayoutN.xml index and `len + 1` would collide + with a surviving part. `next_partname` scans for the first free + index and is collision-safe. + """ + layout_part = SlideLayoutPart.new( + self._package.next_partname("/ppt/slideLayouts/slideLayout%d.xml"), + self._package, + ) + rId = self.relate_to(layout_part, RT.SLIDE_LAYOUT) + # ---back-relationship layout→master, required for inheritance--- + layout_part.relate_to(self, RT.SLIDE_MASTER) + return rId, layout_part.slide_layout + def related_slide_layout(self, rId: str) -> SlideLayout: """Return |SlideLayout| related to this slide-master by key `rId`.""" return self.related_part(rId).slide_layout diff --git a/src/pptx/presentation.py b/src/pptx/presentation.py index 5d9404e03..ead2fbf1f 100644 --- a/src/pptx/presentation.py +++ b/src/pptx/presentation.py @@ -2,9 +2,12 @@ from __future__ import annotations +import io import os +import zipfile from typing import IO, TYPE_CHECKING, Iterable, cast +from pptx.opc.constants import CONTENT_TYPE as CT from pptx.opc.constants import RELATIONSHIP_TYPE as RT from pptx.shared import PartElementProxy from pptx.slide import SlideMasters, Slides @@ -87,6 +90,57 @@ def save(self, file: str | os.PathLike[str] | IO[bytes]): pkg_file: str | IO[bytes] = os.fspath(file) if isinstance(file, os.PathLike) else file self.part.save(pkg_file) + def save_as_potx(self, file: str | os.PathLike[str] | IO[bytes]): + """Write this presentation to `file` as a PowerPoint template (.potx). + + The output package is byte-identical to a normal :meth:`save` except + that the `[Content_Types].xml` override for the main presentation + part (``/ppt/presentation.xml``) declares the template content-type + (``...presentationml.template.main+xml``) instead of the regular + presentation content-type. The in-memory |Presentation| is NOT + mutated — ``prs.part.content_type`` is unchanged after this call, + and a subsequent :meth:`save` still produces a normal ``.pptx`` + (issue #19 SF2; ISC-6..11). + + `file` accepts a file-path (|str| or |os.PathLike|) or a file-like + object open for writing bytes. Implemented by serializing to an + in-memory buffer, then rewriting only the presentation part's + content-type override into the destination; the package object + graph is never touched. + """ + # ---serialize normally into a buffer; this leaves the in-memory + # package (and prs.part.content_type) completely untouched--- + buffer = io.BytesIO() + self.part.save(buffer) + buffer.seek(0) + + presentation_main = CT.PML_PRESENTATION_MAIN + template_main = CT.PML_TEMPLATE_MAIN + + out_buffer = io.BytesIO() + with zipfile.ZipFile(buffer) as zin: + infos = zin.infolist() + with zipfile.ZipFile(out_buffer, "w", zipfile.ZIP_DEFLATED) as zout: + for info in infos: + data = zin.read(info.filename) + if info.filename == "[Content_Types].xml": + # ---swap ONLY the presentation-part override; leave + # every other declared content-type intact--- + data = data.replace( + presentation_main.encode("utf-8"), + template_main.encode("utf-8"), + ) + zout.writestr(info, data) + + out_buffer.seek(0) + blob = out_buffer.getvalue() + + if isinstance(file, (str, os.PathLike)): + with open(os.fspath(file), "wb") as f: + f.write(blob) + else: + file.write(blob) + @property def slide_height(self) -> Length | None: """Height of slides in this presentation, in English Metric Units (EMU). diff --git a/src/pptx/shapes/placeholder.py b/src/pptx/shapes/placeholder.py index c44837bef..128d7e368 100644 --- a/src/pptx/shapes/placeholder.py +++ b/src/pptx/shapes/placeholder.py @@ -157,6 +157,20 @@ def _base_placeholder(self): layout, idx = self.part.slide_layout, self._element.ph_idx return layout.placeholders.get(idx=idx) + def insert_chart(self, chart_type, chart_data): + """Reject chart insertion on a non-chart placeholder. + + Only a chart-capable placeholder (``ChartPlaceholder``, which + overrides this method with the real implementation) accepts a + chart. Calling ``insert_chart`` on any other placeholder raises + |TypeError| explicitly rather than silently corrupting the + shape tree (issue #19 SF8; ISC-50..55 anti-case). + """ + raise TypeError( + "insert_chart() is only valid on a chart placeholder; this is a %s" + % type(self).__name__ + ) + def _replace_placeholder_with(self, element): """ Substitute *element* for this placeholder element in the shapetree. diff --git a/src/pptx/shapes/shapetree.py b/src/pptx/shapes/shapetree.py index c8cacb3fd..ba6af035c 100644 --- a/src/pptx/shapes/shapetree.py +++ b/src/pptx/shapes/shapetree.py @@ -812,32 +812,45 @@ def _shape_factory(self, shape_elm: ShapeElement) -> BaseShape: return SlideShapeFactory(shape_elm, self) -class LayoutShapes(_BaseShapes): +class LayoutShapes(_BaseGroupShapes): """Sequence of shapes appearing on a slide layout. The first shape in the sequence is the backmost in z-order and the last shape is topmost. - Supports indexed access, len(), index(), and iteration. + Supports indexed access, len(), index(), and iteration. Inherits + `add_textbox`/`add_shape`/`add_picture`/`add_group_shape` from + `_BaseGroupShapes` (this also closes upstream issue #1044 — + textbox-on-layout — for free) plus `add_placeholder` defined below. """ + def add_placeholder(self, ph_type: PP_PLACEHOLDER, orient: str, sz: str) -> LayoutPlaceholder: + """Return a newly added placeholder appended to this layout shape tree. + + `ph_type` is a member of `PP_PLACEHOLDER`, `orient` is one of + `'horz'`/`'vert'`, and `sz` is the placeholder size token + (e.g. `'full'`, `'half'`, `'quarter'`). + """ + id_ = self._next_shape_id + ph_name = self._next_ph_name(ph_type, id_, orient) + sp = self._spTree.add_placeholder(id_, ph_name, ph_type, orient, sz, id_) + return cast(LayoutPlaceholder, self._shape_factory(sp)) + def _shape_factory(self, shape_elm: ShapeElement) -> BaseShape: """Return an instance of the appropriate shape proxy class for `shape_elm`.""" return _LayoutShapeFactory(shape_elm, self) -class MasterShapes(_BaseShapes): +class MasterShapes(_BaseGroupShapes): """Sequence of shapes appearing on a slide master. The first shape in the sequence is the backmost in z-order and the last shape is topmost. - Supports indexed access, len(), and iteration. + Supports indexed access, len(), index(), and iteration. Reparented to + `_BaseGroupShapes` (issue #19 SF5) so a slide master gains the same + shape-authoring surface as a slide/layout — + `add_textbox`/`add_shape`/`add_picture`/`add_group_shape` — while + keeping the master-specific `_MasterShapeFactory` so placeholders on a + master proxy as |MasterPlaceholder|. """ - def add_textbox(self, left: Length, top: Length, width: Length, height: Length) -> Shape: - """Return newly added text box shape appended to this master shape tree.""" - shape_id = self._next_shape_id - name = "TextBox %d" % (shape_id - 1) - sp = self._spTree.add_textbox(shape_id, name, left, top, width, height) - return cast(Shape, self._shape_factory(sp)) - def _shape_factory(self, shape_elm: ShapeElement) -> BaseShape: """Return an instance of the appropriate shape proxy class for `shape_elm`.""" return _MasterShapeFactory(shape_elm, self) @@ -891,6 +904,59 @@ class LayoutPlaceholders(BasePlaceholders): [], Iterator[LayoutPlaceholder] ] + def add( + self, + idx: int, + ph_type: PP_PLACEHOLDER, + name: str | None = None, + left: Length | None = None, + top: Length | None = None, + width: Length | None = None, + height: Length | None = None, + ) -> LayoutPlaceholder: + """Add and return a new placeholder of `ph_type` with `idx` to this layout. + + Creates a `` carrying a `` and + appends it to this layout's shape tree, then returns the + |LayoutPlaceholder| proxy. The new placeholder is immediately + visible via iteration/``len`` over this collection and survives a + save→reopen round-trip. + + Geometry is optional — when `left`/`top`/`width`/`height` are all + |None| the placeholder inherits position and size from the slide + master (the normal case for layout placeholders). When any of the + four is given the others default to sane EMU values and an explicit + `a:xfrm` is written. + + Raises |ValueError| if a placeholder with `idx` already exists on + this layout — `idx` must be unique within a layout's shape tree + (issue #19 SF6; ISC-37..43). + """ + # ---reject duplicate idx on this layout (ISC-43 anti)--- + for existing in self: + if existing.element.ph_idx == idx: + raise ValueError("layout already has a placeholder with idx %d" % idx) + + id_ = self._next_shape_id + ph_name = name if name else self._next_ph_name(ph_type, id_, ST_Direction.HORZ) + # ---reuse the existing groupshape placeholder machinery; orient/sz + # default to horz/full, then override idx/type on the p:ph--- + sp = self._spTree.add_placeholder(id_, ph_name, ph_type, ST_Direction.HORZ, "full", idx) + ph = sp.ph + if ph is not None: + ph.idx = idx + ph.type = ph_type + + # ---only write an explicit xfrm when caller specified geometry; + # otherwise let the placeholder inherit from the master--- + if any(v is not None for v in (left, top, width, height)): + sp.x = Emu(0) if left is None else left + sp.y = Emu(0) if top is None else top + sp.cx = Emu(914400) if width is None else width + sp.cy = Emu(457200) if height is None else height + + return cast("LayoutPlaceholder", self._shape_factory(sp)) + def get(self, idx: int, default: LayoutPlaceholder | None = None) -> LayoutPlaceholder | None: """The first placeholder shape with matching `idx` value, or `default` if not found.""" for placeholder in self: diff --git a/src/pptx/slide.py b/src/pptx/slide.py index 4a5e48775..b560b29cb 100644 --- a/src/pptx/slide.py +++ b/src/pptx/slide.py @@ -417,9 +417,44 @@ def slide_id(self) -> int: @property def slide_layout(self) -> SlideLayout: - """|SlideLayout| object this slide inherits appearance from.""" + """|SlideLayout| object this slide inherits appearance from. + + Assigning a |SlideLayout| re-points this slide at that layout — see + :meth:`apply_layout` for the full semantics. The target layout may be + owned by a *different* slide-master than the one this slide currently + inherits from (issue #19 SF7). + """ return self.part.slide_layout + @slide_layout.setter + def slide_layout(self, slide_layout: SlideLayout) -> None: + self.apply_layout(slide_layout) + + def apply_layout(self, slide_layout: SlideLayout) -> None: + """Re-point this slide so it inherits appearance from `slide_layout`. + + `slide_layout` may belong to a slide-master *other* than the one this + slide currently inherits from — cross-master layout application + (issue #19 SF7; ISC-44..49). The slide→layout relationship is + repointed to the target layout's part and the rel chain + slide→layout→master(theme) stays intact: the target layout already + owns its own ``SLIDE_MASTER`` back-relationship, so no dangling rel + is created. + + The slide's *prior* layout (and that layout's master) is NOT removed + from the package — only this slide's relationship to it is dropped. + Other slides that still reference the prior layout are unaffected, + and the prior layout remains discoverable through its master's + ``slide_layouts`` collection. + + Raises |TypeError| if `slide_layout` is not a |SlideLayout|. + """ + if not isinstance(slide_layout, SlideLayout): + raise TypeError( + "apply_layout() requires a SlideLayout, got %s" % type(slide_layout).__name__ + ) + self.part.apply_slide_layout(slide_layout.part) + def _first_ph_of_type(self, ph_type: PP_PLACEHOLDER) -> SlidePlaceholder | None: """Return the first SlidePlaceholder of `ph_type` in document order, or |None|. @@ -698,6 +733,41 @@ def __len__(self) -> int: """Support len() built-in function, e.g. `len(slides) == 4`.""" return len(self._sldLayoutIdLst) + def add_layout(self, name: str | None = None) -> SlideLayout: + """Create and return a new blank |SlideLayout| on this master. + + The new layout is appended to the master's `p:sldLayoutIdLst` + (making it discoverable via indexing, iteration, and + `get_by_name`) and is usable immediately as the basis for a new + slide. When `name` is omitted a sensible default of the form + ``"Layout N"`` is assigned. Manual semantic port of upstream + scanny/python-pptx#1091 (issue #19 SF3). + """ + rId, layout = self.part.add_layout() + self._sldLayoutIdLst.add_sldLayoutId(rId) + layout.name = name if name else "Layout %d" % len(self) + return layout + + def copy_from(self, other_layout: SlideLayout) -> SlideLayout: + """Create and return a deep copy of `other_layout` on this master. + + A fresh blank layout is created via the SF3 `add_layout` + machinery, then every shape (including placeholders) in + `other_layout`'s shape tree is deep-copied into it. Placeholder + `idx`/`type` are preserved exactly. Image/media relationships are + re-related so the copy has no dangling rels; the source layout is + NOT mutated (issue #19 SF4; ISC-23..29). + + The new layout's name defaults to ``" Copy"``; assign + ``.name`` afterward to override. The returned |SlideLayout| is + immediately discoverable via indexing, iteration, and + `get_by_name`, and survives a save→reopen round-trip. + """ + source_name = other_layout.name or "Layout" + new_layout = self.add_layout(name="%s Copy" % source_name) + new_layout.part.copy_shapes_from(other_layout.part) + return new_layout + def get_by_name(self, name: str, default: SlideLayout | None = None) -> SlideLayout | None: """Return SlideLayout object having `name`, or `default` if not found.""" for slide_layout in self: @@ -786,6 +856,22 @@ def slide_layouts(self) -> SlideLayouts: """|SlideLayouts| object providing access to this slide-master's layouts.""" return SlideLayouts(self._element.get_or_add_sldLayoutIdLst(), self) + def get_layout( + self, slide_layout_id: int, default: SlideLayout | None = None + ) -> SlideLayout | None: + """Return the |SlideLayout| identified by `slide_layout_id`, else `default`. + + `slide_layout_id` is the OOXML ``p:sldLayoutId/@id`` value (NOT the + relationship id and NOT the collection index). Returns `default` + (``None`` unless specified) when no layout in this master carries that + id — never raises. Closes scanny/python-pptx#269 (issue #19). + """ + sldLayoutIdLst = self._element.get_or_add_sldLayoutIdLst() + for sldLayoutId in sldLayoutIdLst.sldLayoutId_lst: + if sldLayoutId.id == slide_layout_id: + return self.part.related_slide_layout(sldLayoutId.rId) + return default + class SlideMasters(ParentedElementProxy): """Sequence of |SlideMaster| objects belonging to a presentation. diff --git a/tests/test_api.py b/tests/test_api.py index a48f48912..519746622 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -21,6 +21,13 @@ def it_opens_default_template_on_no_path_provided(self, call_fixture): Package_.open.assert_called_once_with(path) assert prs is prs_ + def it_accepts_a_potx_template_content_type(self, Package_, prs_, prs_part_): + # issue #19 / scanny#1070 — .potx main part is template.main+xml + Package_.open.return_value.main_document_part = prs_part_ + prs_part_.content_type = CT.PML_TEMPLATE_MAIN + prs_part_.presentation = prs_ + assert Presentation("template.potx") is prs_ + # fixtures ------------------------------------------------------- @pytest.fixture diff --git a/tests/test_issue19_masters_layouts.py b/tests/test_issue19_masters_layouts.py new file mode 100644 index 000000000..485900b8b --- /dev/null +++ b/tests/test_issue19_masters_layouts.py @@ -0,0 +1,708 @@ +"""Integration tests for issue #19 — Slide Masters, Layouts & .potx Templates. + +These exercise the public API end-to-end (build → mutate → save → reopen) +rather than mocking, because the failure modes here (PowerPoint repair +dialogs, dangling rels, content-type mismatches) only surface on a real +round-trip. Mirrors the test discipline used for the customXml and +slide-CRUD epics in this fork. +""" + +from __future__ import annotations + +import io +import zipfile + +import pytest + +from pptx import Presentation + + +def _craft_potx_bytes() -> io.BytesIO: + """Return an in-memory .potx: a default deck with the template content-type.""" + base = io.BytesIO() + Presentation().save(base) + base.seek(0) + src = {} + with zipfile.ZipFile(base) as z: + for n in z.namelist(): + src[n] = z.read(n) + src["[Content_Types].xml"] = src["[Content_Types].xml"].replace( + b"presentationml.presentation.main+xml", + b"presentationml.template.main+xml", + ) + out = io.BytesIO() + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z: + for n, b in src.items(): + z.writestr(n, b) + out.seek(0) + return out + + +class DescribePotxRead: + """SF1 — Presentation() accepts .potx files.""" + + def it_opens_a_potx_template_without_error(self): + prs = Presentation(_craft_potx_bytes()) + assert prs is not None + + def it_exposes_masters_and_layouts_from_a_potx(self): + prs = Presentation(_craft_potx_bytes()) + assert len(prs.slide_masters) >= 1 + assert len(prs.slide_layouts) >= 1 + + def but_it_still_rejects_a_genuinely_non_pptx_payload(self): + bogus = io.BytesIO(b"PK\x03\x04 not really an office file") + with pytest.raises(Exception): + Presentation(bogus) + + +class DescribePotxRoundTrip: + """SF1 regression — a .potx survives an open→save→reopen cycle.""" + + def it_round_trips_a_potx_through_save(self): + prs = Presentation(_craft_potx_bytes()) + buf = io.BytesIO() + prs.save(buf) + buf.seek(0) + reopened = Presentation(buf) + assert len(reopened.slide_masters) >= 1 + + +class DescribeSlideMasterGetLayout: + """SF9 — SlideMaster.get_layout(slide_layout_id, default=None).""" + + def _master_and_first_layout_id(self): + prs = Presentation() + master = prs.slide_masters[0] + sldLayoutIdLst = master._element.get_or_add_sldLayoutIdLst() + first = sldLayoutIdLst.sldLayoutId_lst[0] + return prs, master, first.id + + def it_returns_the_layout_matching_a_known_id(self): + prs, master, layout_id = self._master_and_first_layout_id() + if layout_id is None: + pytest.skip("default template layout has no @id attribute") + got = master.get_layout(layout_id) + assert got is not None + assert got == master.slide_layouts[0] + + def it_returns_default_for_an_unknown_id(self): + prs = Presentation() + master = prs.slide_masters[0] + sentinel = object() + assert master.get_layout(999999, default=sentinel) is sentinel + + def and_it_returns_None_by_default_for_an_unknown_id(self): + prs = Presentation() + master = prs.slide_masters[0] + assert master.get_layout(424242) is None + + def it_does_not_raise_on_a_bad_id(self): + prs = Presentation() + master = prs.slide_masters[0] + # must not raise KeyError/IndexError — returns default + assert master.get_layout(-1) is None + + +def _P_NS(): + from pptx.oxml.ns import qn + + return qn + + +class DescribeAddLayout: + """SF3 — SlideLayouts.add_layout(name=None) creates a new p:sldLayout part. + + Manual semantic port of upstream scanny/python-pptx#1091 onto the + fork's ruff-formatted base (issue #19 SF3, ISA ISC-12..22). + """ + + def it_returns_a_SlideLayout_object(self): + prs = Presentation() + layouts = prs.slide_masters[0].slide_layouts + new_layout = layouts.add_layout() + from pptx.slide import SlideLayout + + assert isinstance(new_layout, SlideLayout) + + def it_increments_the_master_layout_count_by_exactly_one(self): + prs = Presentation() + layouts = prs.slide_masters[0].slide_layouts + before = len(layouts) + layouts.add_layout() + assert len(layouts) == before + 1 + + def it_sets_the_name_when_given(self): + prs = Presentation() + layouts = prs.slide_masters[0].slide_layouts + new_layout = layouts.add_layout(name="My Custom Layout") + assert new_layout.name == "My Custom Layout" + + def it_assigns_a_sensible_default_name_when_none_given(self): + prs = Presentation() + layouts = prs.slide_masters[0].slide_layouts + new_layout = layouts.add_layout() + assert new_layout.name != "" + assert new_layout.name is not None + + def it_finds_the_new_layout_by_name(self): + prs = Presentation() + layouts = prs.slide_masters[0].slide_layouts + layouts.add_layout(name="Lookup Target") + assert layouts.get_by_name("Lookup Target") is not None + + def it_allocates_a_sldLayoutId_in_the_high_uint_range(self): + """@id must be >= 2147483648 (0x80000000), PowerPoint's convention. + + The default template's own layout ids run 2147483649..2147483659. + A new layout id below that floor would collide with the low + ``p:sldId/@id`` pool (slide ids start at 256) — the regression that + produced a "PowerPoint found a problem" repair dialog and was caught + only by Interceptor visual verification. + """ + from pptx.parts.slide import _OOXML_LAYOUT_ID_FLOOR + + prs = Presentation() + master = prs.slide_masters[0] + layouts = master.slide_layouts + layouts.add_layout(name="Range Probe") + qn = _P_NS() + idLst = master._element.get_or_add_sldLayoutIdLst() + ids = [ + int(sli.get("id")) + for sli in idLst.findall(qn("p:sldLayoutId")) + if sli.get("id") is not None + ] + assert ids, "expected at least one sldLayoutId with an @id" + new_id = ids[-1] + assert new_id >= _OOXML_LAYOUT_ID_FLOOR + assert new_id <= 4294967295 + + def it_does_not_collide_with_the_shared_id_pool(self): + """Regression guard for the repair-dialog bug. + + ``p:sldMasterId/@id``, ``p:sldLayoutId/@id`` AND ``p:sldId/@id`` are + ONE shared pool in PowerPoint's repair heuristic. The new layout id + must be disjoint from BOTH the master ids AND the slide ids. The + original SF3 test only checked sldMasterId and so missed the + sldId(256) collision. + """ + prs = Presentation() + master = prs.slide_masters[0] + layouts = master.slide_layouts + new_layout = layouts.add_layout(name="Collision Probe") + # a slide must exist so a low sldId (256) is present in the pool + prs.slides.add_slide(new_layout) + qn = _P_NS() + + idLst = master._element.get_or_add_sldLayoutIdLst() + layout_ids = { + int(sli.get("id")) + for sli in idLst.findall(qn("p:sldLayoutId")) + if sli.get("id") is not None + } + + pres_el = prs.part._element + pool = set() + smIdLst = pres_el.find(qn("p:sldMasterIdLst")) + if smIdLst is not None: + for smi in smIdLst.findall(qn("p:sldMasterId")): + raw = smi.get("id") + if raw is not None: + pool.add(int(raw)) + sldIdLst = pres_el.find(qn("p:sldIdLst")) + if sldIdLst is not None: + for sid in sldIdLst.findall(qn("p:sldId")): + raw = sid.get("id") + if raw is not None: + pool.add(int(raw)) + + assert pool, "fixture expected at least one sldMasterId + sldId" + assert 256 in pool, "the slide's sldId(256) must be in the pool" + assert layout_ids.isdisjoint(pool), ( + "new layout id collides with sldMasterId/sldId pool — repair-dialog bug" + ) + + def it_survives_a_save_reopen_round_trip(self): + prs = Presentation() + layouts = prs.slide_masters[0].slide_layouts + layouts.add_layout(name="RoundTrip Layout") + buf = io.BytesIO() + prs.save(buf) + buf.seek(0) + prs2 = Presentation(buf) + layouts2 = prs2.slide_masters[0].slide_layouts + assert layouts2.get_by_name("RoundTrip Layout") is not None + + def it_can_be_used_as_the_basis_for_a_new_slide(self): + prs = Presentation() + new_layout = prs.slide_masters[0].slide_layouts.add_layout(name="Slide Source") + before = len(prs.slides) + prs.slides.add_slide(new_layout) + assert len(prs.slides) == before + 1 + + def it_allows_adding_a_placeholder_to_the_new_layout(self): + """LayoutShapes now inherits group-shape add_* (closes upstream #1044).""" + from pptx.enum.shapes import PP_PLACEHOLDER + + prs = Presentation() + new_layout = prs.slide_masters[0].slide_layouts.add_layout(name="PH Layout") + before = len(new_layout.placeholders) + ph = new_layout.shapes.add_placeholder(PP_PLACEHOLDER.BODY, "horz", "full") + assert ph is not None + assert len(new_layout.placeholders) == before + 1 + + def it_round_trips_a_placeholder_added_to_a_new_layout(self): + from pptx.enum.shapes import PP_PLACEHOLDER + + prs = Presentation() + new_layout = prs.slide_masters[0].slide_layouts.add_layout(name="PH RT") + new_layout.shapes.add_placeholder(PP_PLACEHOLDER.BODY, "horz", "full") + buf = io.BytesIO() + prs.save(buf) + buf.seek(0) + prs2 = Presentation(buf) + layout2 = prs2.slide_masters[0].slide_layouts.get_by_name("PH RT") + assert layout2 is not None + assert len(layout2.placeholders) >= 1 + + +def _content_types_xml(buf: io.BytesIO) -> bytes: + """Return the raw `[Content_Types].xml` bytes from a saved package buffer.""" + buf.seek(0) + with zipfile.ZipFile(buf) as z: + return z.read("[Content_Types].xml") + + +_PML_PRESENTATION_MAIN = ( + b"application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml" +) +_PML_TEMPLATE_MAIN = ( + b"application/vnd.openxmlformats-officedocument.presentationml.template.main+xml" +) + + +class DescribeSaveAsPotx: + """SF2 — Presentation.save_as_potx(path) writes a template content-type. + + The output `[Content_Types].xml` carries the template main+xml override + for the presentation part, WITHOUT mutating the in-memory package + (ISC-6..11). Manual semantic port (issue #19 SF2). + """ + + def it_exposes_a_save_as_potx_method(self): + prs = Presentation() + assert hasattr(prs, "save_as_potx") + assert callable(prs.save_as_potx) + + def it_writes_the_template_content_type_to_the_output(self): + prs = Presentation() + buf = io.BytesIO() + prs.save_as_potx(buf) + ct_xml = _content_types_xml(buf) + assert _PML_TEMPLATE_MAIN in ct_xml + assert _PML_PRESENTATION_MAIN not in ct_xml + + def it_produces_a_package_that_round_trips_through_Presentation(self): + prs = Presentation() + buf = io.BytesIO() + prs.save_as_potx(buf) + buf.seek(0) + reopened = Presentation(buf) + assert len(reopened.slide_masters) >= 1 + assert len(reopened.slide_layouts) >= 1 + + def it_does_not_mutate_the_in_memory_presentation_content_type(self): + prs = Presentation() + before = prs.part.content_type + buf = io.BytesIO() + prs.save_as_potx(buf) + after = prs.part.content_type + assert before == after + assert after == _PML_PRESENTATION_MAIN.decode("ascii") + + def it_accepts_a_str_path(self, tmp_path): + prs = Presentation() + out = tmp_path / "template_out.potx" + prs.save_as_potx(str(out)) + with zipfile.ZipFile(str(out)) as z: + ct_xml = z.read("[Content_Types].xml") + assert _PML_TEMPLATE_MAIN in ct_xml + + def it_leaves_a_normal_save_unaffected_afterwards(self): + prs = Presentation() + potx_buf = io.BytesIO() + prs.save_as_potx(potx_buf) + pptx_buf = io.BytesIO() + prs.save(pptx_buf) + ct_xml = _content_types_xml(pptx_buf) + assert _PML_PRESENTATION_MAIN in ct_xml + assert _PML_TEMPLATE_MAIN not in ct_xml + + +class DescribeMasterShapeAuthoring: + """SF5 — author shapes directly on a SlideMaster via master.shapes. + + `MasterShapes` reparented to `_BaseGroupShapes`, gaining + add_textbox/add_picture/add_shape (issue #19 SF5, ISC-30..36). + """ + + def _png_bytes(self) -> io.BytesIO: + # ---minimal 1x1 PNG--- + import base64 + + data = base64.b64decode( + b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAen63NgAAAAASUVORK5CYII=" + ) + return io.BytesIO(data) + + def it_exposes_group_shape_add_methods_on_master_shapes(self): + prs = Presentation() + shapes = prs.slide_masters[0].shapes + assert hasattr(shapes, "add_textbox") + assert hasattr(shapes, "add_picture") + assert hasattr(shapes, "add_shape") + + def it_adds_a_textbox_to_a_master_that_survives_round_trip(self): + prs = Presentation() + master = prs.slide_masters[0] + tb = master.shapes.add_textbox(0, 0, 914400, 457200) + tb.text_frame.text = "MASTER MARKER TEXT" + buf = io.BytesIO() + prs.save(buf) + buf.seek(0) + prs2 = Presentation(buf) + texts = [s.text_frame.text for s in prs2.slide_masters[0].shapes if s.has_text_frame] + assert "MASTER MARKER TEXT" in texts + + def it_adds_an_autoshape_to_a_master(self): + from pptx.enum.shapes import MSO_SHAPE + + prs = Presentation() + master = prs.slide_masters[0] + before = len(list(master.shapes)) + master.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, 0, 0, 914400, 914400) + assert len(list(master.shapes)) == before + 1 + + def it_adds_a_picture_to_a_master(self): + prs = Presentation() + master = prs.slide_masters[0] + before = len(list(master.shapes)) + master.shapes.add_picture(self._png_bytes(), 0, 0, 914400, 914400) + assert len(list(master.shapes)) == before + 1 + + def it_increments_the_master_shape_count(self): + prs = Presentation() + master = prs.slide_masters[0] + before = len(list(master.shapes)) + master.shapes.add_textbox(0, 0, 914400, 457200) + master.shapes.add_textbox(0, 457200, 914400, 457200) + assert len(list(master.shapes)) == before + 2 + + +class DescribeCopyFromLayout: + """SF4 — SlideLayouts.copy_from(other_layout) duplicates a layout. + + Deep-copies the source layout's spTree shapes into a fresh layout + created via the SF3 add_layout machinery (issue #19 SF4, ISC-23..29). + """ + + def _layout_with_a_shape(self): + prs = Presentation() + layouts = prs.slide_masters[0].slide_layouts + src = layouts.add_layout(name="CopySource") + src.shapes.add_textbox(0, 0, 914400, 457200) + return prs, layouts, src + + def it_returns_a_new_SlideLayout(self): + from pptx.slide import SlideLayout + + prs, layouts, src = self._layout_with_a_shape() + copy = layouts.copy_from(src) + assert isinstance(copy, SlideLayout) + assert copy is not src + + def it_copies_the_source_shape_count(self): + prs, layouts, src = self._layout_with_a_shape() + src_count = len(list(src.shapes)) + copy = layouts.copy_from(src) + assert len(list(copy.shapes)) == src_count + + def it_preserves_placeholder_idx_and_type(self): + from pptx.enum.shapes import PP_PLACEHOLDER + + prs = Presentation() + layouts = prs.slide_masters[0].slide_layouts + src = layouts.add_layout(name="PHSource") + src.shapes.add_placeholder(PP_PLACEHOLDER.BODY, "horz", "full") + src_ph = [(p.element.ph_idx, p.element.ph_type) for p in src.placeholders] + copy = layouts.copy_from(src) + copy_ph = [(p.element.ph_idx, p.element.ph_type) for p in copy.placeholders] + assert sorted(map(str, copy_ph)) == sorted(map(str, src_ph)) + + def it_does_not_mutate_the_source_layout(self): + prs, layouts, src = self._layout_with_a_shape() + src_count_before = len(list(src.shapes)) + src_name_before = src.name + layouts.copy_from(src) + assert len(list(src.shapes)) == src_count_before + assert src.name == src_name_before + + def it_survives_a_save_reopen_round_trip(self): + prs, layouts, src = self._layout_with_a_shape() + copy = layouts.copy_from(src) + copy.name = "CopiedLayoutRT" + buf = io.BytesIO() + prs.save(buf) + buf.seek(0) + prs2 = Presentation(buf) + layout2 = prs2.slide_masters[0].slide_layouts.get_by_name("CopiedLayoutRT") + assert layout2 is not None + assert len(list(layout2.shapes)) == len(list(src.shapes)) + + +class DescribeLayoutPlaceholdersAdd: + """SF6 — SlideLayout.placeholders.add(idx, ph_type, ...). + + Adds a `` placeholder to a layout's shape tree, readable + afterward and surviving round-trip (issue #19 SF6, ISC-37..43). + """ + + def it_adds_a_placeholder_to_the_layout(self): + from pptx.enum.shapes import PP_PLACEHOLDER + + prs = Presentation() + layout = prs.slide_masters[0].slide_layouts.add_layout(name="PHAdd") + before = len(layout.placeholders) + layout.placeholders.add(11, PP_PLACEHOLDER.BODY) + assert len(layout.placeholders) == before + 1 + + def it_writes_the_idx_and_type(self): + from pptx.enum.shapes import PP_PLACEHOLDER + + prs = Presentation() + layout = prs.slide_masters[0].slide_layouts.add_layout(name="PHAdd2") + ph = layout.placeholders.add(12, PP_PLACEHOLDER.BODY) + assert ph.element.ph_idx == 12 + assert ph.element.ph_type == PP_PLACEHOLDER.BODY + + def it_is_readable_through_the_collection_after_add(self): + from pptx.enum.shapes import PP_PLACEHOLDER + + prs = Presentation() + layout = prs.slide_masters[0].slide_layouts.add_layout(name="PHAdd3") + layout.placeholders.add(13, PP_PLACEHOLDER.BODY) + idxs = [p.element.ph_idx for p in layout.placeholders] + assert 13 in idxs + + def it_survives_a_save_reopen_round_trip(self): + from pptx.enum.shapes import PP_PLACEHOLDER + + prs = Presentation() + layout = prs.slide_masters[0].slide_layouts.add_layout(name="PHAddRT") + layout.placeholders.add(14, PP_PLACEHOLDER.BODY) + buf = io.BytesIO() + prs.save(buf) + buf.seek(0) + prs2 = Presentation(buf) + layout2 = prs2.slide_masters[0].slide_layouts.get_by_name("PHAddRT") + assert layout2 is not None + idxs = [p.element.ph_idx for p in layout2.placeholders] + assert 14 in idxs + + def it_rejects_a_duplicate_idx_on_the_same_layout(self): + from pptx.enum.shapes import PP_PLACEHOLDER + + prs = Presentation() + layout = prs.slide_masters[0].slide_layouts.add_layout(name="PHDup") + layout.placeholders.add(15, PP_PLACEHOLDER.BODY) + with pytest.raises(ValueError): + layout.placeholders.add(15, PP_PLACEHOLDER.BODY) + + +class DescribeApplyLayout: + """SF7 — assign a slide a layout owned by a (possibly different) master. + + Re-points the slide→layout relationship via the ``Slide.slide_layout`` + setter / ``Slide.apply_layout`` method. The rel chain + slide→layout→master(theme) must stay intact and the slide's prior + layout's master must NOT be orphaned (issue #19 SF7; ISC-44..49). + """ + + def _deck_with_two_layouts(self): + prs = Presentation() + master = prs.slide_masters[0] + base_layout = prs.slide_layouts[0] + target_layout = master.slide_layouts.add_layout(name="SF7Target") + slide = prs.slides.add_slide(base_layout) + return prs, master, base_layout, target_layout, slide + + def it_repoints_the_slide_layout_via_the_setter(self): + prs, master, base_layout, target_layout, slide = self._deck_with_two_layouts() + assert slide.slide_layout.name != "SF7Target" + slide.slide_layout = target_layout + assert slide.slide_layout.name == "SF7Target" + + def it_also_exposes_an_apply_layout_method(self): + prs, master, base_layout, target_layout, slide = self._deck_with_two_layouts() + slide.apply_layout(target_layout) + assert slide.slide_layout.name == "SF7Target" + + def it_keeps_the_layout_master_chain_intact(self): + from pptx.opc.constants import RELATIONSHIP_TYPE as RT + + prs, master, base_layout, target_layout, slide = self._deck_with_two_layouts() + slide.slide_layout = target_layout + # ---slide resolves a layout, layout resolves a master, no raise--- + resolved_layout = slide.slide_layout + resolved_master = resolved_layout.slide_master + assert resolved_master is not None + # ---exactly one slide→layout rel after the repoint (no dangling)--- + layout_rels = [ + rel + for rel in slide.part.rels.values() + if not rel.is_external and rel.reltype == RT.SLIDE_LAYOUT + ] + assert len(layout_rels) == 1 + + def it_does_not_orphan_the_prior_layouts_master(self): + prs, master, base_layout, target_layout, slide = self._deck_with_two_layouts() + # ---a second slide still uses the original base layout--- + other_slide = prs.slides.add_slide(base_layout) + slide.slide_layout = target_layout + # ---the other slide's layout + master are still fully reachable--- + assert other_slide.slide_layout.name == base_layout.name + assert other_slide.slide_layout.slide_master is not None + # ---the base layout is still in the master's collection--- + names = [lay.name for lay in master.slide_layouts] + assert base_layout.name in names + + def it_survives_a_save_reopen_round_trip(self): + prs, master, base_layout, target_layout, slide = self._deck_with_two_layouts() + slide.slide_layout = target_layout + buf = io.BytesIO() + prs.save(buf) + buf.seek(0) + prs2 = Presentation(buf) + assert prs2.slides[0].slide_layout.name == "SF7Target" + # ---the round-tripped slide still resolves its master--- + assert prs2.slides[0].slide_layout.slide_master is not None + + def it_is_idempotent_when_applied_twice(self): + prs, master, base_layout, target_layout, slide = self._deck_with_two_layouts() + slide.slide_layout = target_layout + slide.slide_layout = target_layout + assert slide.slide_layout.name == "SF7Target" + + def it_rejects_a_non_layout_argument(self): + prs, master, base_layout, target_layout, slide = self._deck_with_two_layouts() + with pytest.raises(TypeError): + slide.slide_layout = "not a layout" + + +class DescribeInsertChartIntoPlaceholder: + """SF8 — insert a chart into a chart-capable placeholder. + + A CHART placeholder is replaced by a `` holding the + chart, sized/positioned from the placeholder. Non-chart placeholders + reject ``insert_chart`` cleanly. Upstream scanny/python-pptx#199 + (issue #19 SF8; ISC-50..55). + """ + + def _slide_with_chart_placeholder(self): + from pptx.enum.shapes import PP_PLACEHOLDER + + prs = Presentation() + master = prs.slide_masters[0] + layout = master.slide_layouts.add_layout(name="SF8Chart") + layout.placeholders.add( + 10, + PP_PLACEHOLDER.CHART, + left=914400, + top=914400, + width=4572000, + height=2743200, + ) + slide = prs.slides.add_slide(layout) + chart_ph = next( + p for p in slide.placeholders if p.placeholder_format.type == PP_PLACEHOLDER.CHART + ) + return prs, slide, chart_ph + + def _sample_chart_data(self): + from pptx.chart.data import CategoryChartData + + chart_data = CategoryChartData() + chart_data.categories = ["East", "West", "Midwest"] + chart_data.add_series("Q1 Sales", (19.2, 21.4, 16.7)) + return chart_data + + def it_replaces_the_placeholder_with_a_chart_graphic_frame(self): + from pptx.enum.chart import XL_CHART_TYPE + + prs, slide, chart_ph = self._slide_with_chart_placeholder() + result = chart_ph.insert_chart(XL_CHART_TYPE.COLUMN_CLUSTERED, self._sample_chart_data()) + assert result.has_chart is True + + def it_creates_a_chart_part_and_relationship(self): + from pptx.enum.chart import XL_CHART_TYPE + from pptx.opc.constants import RELATIONSHIP_TYPE as RT + + prs, slide, chart_ph = self._slide_with_chart_placeholder() + chart_rels_before = [r for r in slide.part.rels.values() if r.reltype == RT.CHART] + chart_ph.insert_chart(XL_CHART_TYPE.COLUMN_CLUSTERED, self._sample_chart_data()) + chart_rels_after = [r for r in slide.part.rels.values() if r.reltype == RT.CHART] + assert len(chart_rels_after) == len(chart_rels_before) + 1 + + def it_exposes_the_chart_on_the_returned_frame(self): + from pptx.enum.chart import XL_CHART_TYPE + + prs, slide, chart_ph = self._slide_with_chart_placeholder() + result = chart_ph.insert_chart(XL_CHART_TYPE.PIE, self._sample_chart_data()) + assert result.chart.chart_type == XL_CHART_TYPE.PIE + + def it_survives_a_save_reopen_round_trip(self): + from pptx.enum.chart import XL_CHART_TYPE + + prs, slide, chart_ph = self._slide_with_chart_placeholder() + chart_ph.insert_chart(XL_CHART_TYPE.COLUMN_CLUSTERED, self._sample_chart_data()) + buf = io.BytesIO() + prs.save(buf) + buf.seek(0) + prs2 = Presentation(buf) + slide2 = prs2.slides[0] + charts = [s for s in slide2.shapes if s.has_chart] + assert len(charts) == 1 + + def it_positions_the_frame_from_the_placeholder(self): + from pptx.enum.chart import XL_CHART_TYPE + + prs, slide, chart_ph = self._slide_with_chart_placeholder() + left, top, width, height = ( + chart_ph.left, + chart_ph.top, + chart_ph.width, + chart_ph.height, + ) + result = chart_ph.insert_chart(XL_CHART_TYPE.COLUMN_CLUSTERED, self._sample_chart_data()) + assert (result.left, result.top, result.width, result.height) == ( + left, + top, + width, + height, + ) + + def but_a_non_chart_placeholder_rejects_insert_chart(self): + from pptx.enum.chart import XL_CHART_TYPE + from pptx.enum.shapes import PP_PLACEHOLDER + + prs = Presentation() + master = prs.slide_masters[0] + layout = master.slide_layouts.add_layout(name="SF8NonChart") + layout.placeholders.add(20, PP_PLACEHOLDER.BODY, left=0, top=0, width=914400, height=457200) + slide = prs.slides.add_slide(layout) + body_ph = next( + p for p in slide.placeholders if p.placeholder_format.type == PP_PLACEHOLDER.BODY + ) + with pytest.raises(TypeError): + body_ph.insert_chart(XL_CHART_TYPE.COLUMN_CLUSTERED, self._sample_chart_data()) diff --git a/uat_issue19.py b/uat_issue19.py new file mode 100644 index 000000000..70fcaec7c --- /dev/null +++ b/uat_issue19.py @@ -0,0 +1,153 @@ +"""UAT — issue #19: Slide Masters, Layouts & .potx Templates. + +Exercises ALL nine sub-features end-to-end and emits artifacts for +maintainer visual signoff in PowerPoint/Keynote. Exits non-zero on any +structural failure. Per repo CLAUDE.md §6a this is the maintainer's +acceptance path — an agent running it green is script-QA, NOT signoff. + +Artifacts written to repo root: + uat_issue19_template.potx — SF1/SF2 (open + save_as_potx) + uat_issue19_layouts.pptx — SF3/SF4/SF5/SF6/SF7/SF8/SF9 on real slides +""" + +from __future__ import annotations + +import io +import sys +import zipfile + +from pptx import Presentation +from pptx.chart.data import CategoryChartData +from pptx.enum.chart import XL_CHART_TYPE +from pptx.enum.shapes import MSO_SHAPE, PP_PLACEHOLDER +from pptx.util import Inches, Pt + +POTX = "/Users/mhoroszowski/Projects/AI/python-pptx/uat_issue19_template.potx" +PPTX = "/Users/mhoroszowski/Projects/AI/python-pptx/uat_issue19_layouts.pptx" + +ok = True + + +def check(label: str, cond: bool) -> None: + global ok + print((" PASS " if cond else " FAIL ") + label) + ok = ok and cond + + +print("=== issue #19 UAT ===") + +# ---------- SF2: save_as_potx (and SF1 read-back) ---------- +prs = Presentation() +before_ct = prs.part.content_type +prs.save_as_potx(POTX) +with zipfile.ZipFile(POTX) as z: + ct_xml = z.read("[Content_Types].xml").decode() +check("SF2 save_as_potx writes template content-type", + "presentationml.template.main+xml" in ct_xml) +check("SF2 in-memory package content-type unmutated", + prs.part.content_type == before_ct) +# SF1: the .potx we just wrote re-opens (was a hard ValueError before the fix) +reopened = Presentation(POTX) +check("SF1 Presentation('.potx') opens without error", reopened is not None) +check("SF1 reopened .potx exposes masters", len(reopened.slide_masters) >= 1) + +# ---------- The layouts deck ---------- +prs = Presentation() +master = prs.slide_masters[0] + +# SF3: add_layout("Three Columns") — the issue's headline acceptance check +n_before = len(master.slide_layouts) +three_col = master.slide_layouts.add_layout(name="Three Columns") +check("SF3 add_layout increments layout count by 1", + len(master.slide_layouts) == n_before + 1) +check("SF3 new layout name == 'Three Columns'", three_col.name == "Three Columns") + +# SF6: programmatically add three body placeholders → an actual 3-column layout +for col, left in enumerate((Inches(0.4), Inches(4.7), Inches(9.0))): + three_col.placeholders.add( + idx=10 + col, + ph_type=PP_PLACEHOLDER.BODY, + name="Column %d" % (col + 1), + left=left, + top=Inches(1.8), + width=Inches(4.0), + height=Inches(4.8), + ) +check("SF6 three placeholders added to layout", + len(three_col.placeholders) >= 3) + +# SF5: author a shape directly on the master (visible on every slide) +wm = master.shapes.add_shape( + MSO_SHAPE.ROUNDED_RECTANGLE, Inches(0.2), Inches(0.1), Inches(3.0), Inches(0.5) +) +wm.text_frame.text = "MASTER BANNER (SF5)" +wm.text_frame.paragraphs[0].runs[0].font.size = Pt(14) +check("SF5 shape authored on master", wm is not None) + +# SF4: copy_from — duplicate the Three Columns layout +copy = master.slide_layouts.copy_from(three_col) +check("SF4 copy_from duplicates shape/placeholder count", + len(list(copy.shapes)) == len(list(three_col.shapes))) +check("SF4 copy_from leaves source untouched", + len(list(three_col.shapes)) > 0) + +# A real slide built on the new "Three Columns" layout (so it renders visibly) +slide = prs.slides.add_slide(three_col) +for i, ph in enumerate(slide.placeholders): + try: + ph.text = "Column %d content" % (i + 1) + except Exception: + pass + +# SF9: get_layout by id round-trips to the same object +idLst = master._element.get_or_add_sldLayoutIdLst() +some_id = next((e.id for e in idLst.sldLayoutId_lst if e.id is not None), None) +if some_id is not None: + check("SF9 get_layout(id) returns a layout", + master.get_layout(some_id) is not None) +check("SF9 get_layout(bad id) returns None (no raise)", + master.get_layout(987654321) is None) + +# SF7: reassign the slide to a different layout (cross-master mechanism) +slide.slide_layout = copy +check("SF7 slide_layout setter repoints layout", + slide.slide_layout.name == copy.name) + +# SF8: chart into a chart placeholder — the SF6→SF8 composition: +# build a CHART placeholder programmatically, then drop a chart into it. +chart_done = False +probe = Presentation() +pm = probe.slide_masters[0] +chart_layout = pm.slide_layouts.add_layout(name="Chart Slot") +chart_layout.placeholders.add( + idx=10, + ph_type=PP_PLACEHOLDER.CHART, + left=Inches(1.0), + top=Inches(1.2), + width=Inches(8.0), + height=Inches(4.5), +) +cslide = probe.slides.add_slide(chart_layout) +chart_ph = next( + p for p in cslide.placeholders + if p.placeholder_format.type == PP_PLACEHOLDER.CHART +) +cd = CategoryChartData() +cd.categories = ["Q1", "Q2", "Q3"] +cd.add_series("Revenue", (10, 24, 18)) +gf = chart_ph.insert_chart(XL_CHART_TYPE.COLUMN_CLUSTERED, cd) +chart_done = bool(gf.has_chart) +check("SF8 insert_chart into a CHART placeholder (SF6+SF8)", chart_done) +if chart_done: + probe.save(PPTX.replace(".pptx", "_chart.pptx")) + +prs.save(PPTX) + +# Final structural round-trip of the main deck +rt = Presentation(PPTX) +check("ALL round-trip: layouts deck reopens with 'Three Columns'", + rt.slide_masters[0].slide_layouts.get_by_name("Three Columns") is not None) + +print("ARTIFACTS:", POTX, PPTX) +print("RESULT:", "PASS" if ok else "FAIL") +sys.exit(0 if ok else 1) From 4cd420d0168d4e32fd06756caf6cb241e274ae79 Mon Sep 17 00:00:00 2001 From: Matthew Horoszowski Date: Sat, 16 May 2026 23:28:25 -0400 Subject: [PATCH 2/2] fix(masters): harden the two parallel sldLayoutId allocators (Cato audit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cato cross-vendor audit flagged defect-instance vs defect-class scoping: the id-pool fix landed only in CT_SlideLayoutIdList._next_id (the reproduced path). The two parallel allocators used by the cross-package append_from / SF7 path were correct only incidentally: - _add_sldLayoutId_to_master: max(used+[2147483647])+1 — right floor by luck, no uint32 ceiling, no exhaustion handling. - _renumber_sldLayoutIds: right floor, no ceiling guard in its loop. Both now: floor at _OOXML_LAYOUT_ID_FLOOR (explicit, not magic 2147483647), new shared _UINT32_MAX ceiling, scan-fallback on exhaustion — matching CT_SlideLayoutIdList._next_id. Closes the defect class, not just the instance the failing screenshot exposed. Trinity still green: pytest 3708, ruff clean, behave 0 failed; append_from/port/layout paths (304 tests) green. Refs #19 --- src/pptx/parts/slide.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/pptx/parts/slide.py b/src/pptx/parts/slide.py index 6477fb3de..e1cbc602e 100644 --- a/src/pptx/parts/slide.py +++ b/src/pptx/parts/slide.py @@ -330,6 +330,7 @@ def duplicate(self) -> SlidePart: _P_NS = "{http://schemas.openxmlformats.org/presentationml/2006/main}" _P14_NS = "{http://schemas.microsoft.com/office/powerpoint/2010/main}" _OOXML_LAYOUT_ID_FLOOR = 2147483648 # uint32 floor per ECMA-376 sec 19.2.1.27. +_UINT32_MAX = 4294967295 # ceiling shared by all slide-master/layout id allocators # Reltypes filtered out during slide duplication. NOTES_SLIDE is wired # explicitly by |Slides.duplicate| so the new notes-slide back-references @@ -903,6 +904,13 @@ def _renumber_sldLayoutIds(element, used_ids: set[int]) -> None: return next_id = max(used_ids | {_OOXML_LAYOUT_ID_FLOOR - 1}) + 1 for sli in sldLayoutIdLst.findall(f"{_P_NS}sldLayoutId"): + if next_id > _UINT32_MAX: + next_id = next( + (n for n in range(_OOXML_LAYOUT_ID_FLOOR, _UINT32_MAX + 1) if n not in used_ids), + None, + ) + if next_id is None: + raise ValueError("slide-layout id pool exhausted") sli.set("id", str(next_id)) used_ids.add(next_id) next_id += 1 @@ -964,7 +972,18 @@ def _add_sldLayoutId_to_master(master_part, rId: str) -> None: if raw is not None: with contextlib.suppress(ValueError): used_ids.append(int(raw)) - next_id = max(used_ids + [2147483647]) + 1 + # ---high-range allocation consistent with CT_SlideLayoutIdList._next_id: + # floor at _OOXML_LAYOUT_ID_FLOOR so the id is disjoint from the low + # p:sldId pool; ceiling-guarded at uint32 max with a scan fallback.--- + next_id = max(used_ids + [_OOXML_LAYOUT_ID_FLOOR - 1]) + 1 + if next_id > _UINT32_MAX: + seen = set(used_ids) + next_id = next( + (n for n in range(_OOXML_LAYOUT_ID_FLOOR, _UINT32_MAX + 1) if n not in seen), + None, + ) + if next_id is None: + raise ValueError("slide-layout id pool exhausted") sldLayoutId = sldLayoutIdLst._add_sldLayoutId() sldLayoutId.rId = rId sldLayoutId.set("id", str(next_id))