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..e1cbc602e 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
@@ -311,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
@@ -342,6 +362,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`.
@@ -848,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
@@ -909,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))
@@ -1022,6 +1096,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 +1117,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 +1147,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)