Skip to content

Commit fed7e45

Browse files
authored
Merge pull request #54 from MHoroszowski/feature/masters-layouts-potx
Slide Masters, Layouts & .potx Templates — issue #19 epic
2 parents 0bd67aa + 4cd420d commit fed7e45

15 files changed

Lines changed: 1631 additions & 30 deletions

features/environment.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,7 @@
1414

1515
import os
1616

17-
scratch_dir = os.path.abspath(
18-
os.path.join(os.path.split(__file__)[0], '_scratch')
19-
)
17+
scratch_dir = os.path.abspath(os.path.join(os.path.split(__file__)[0], "_scratch"))
2018

2119

2220
def before_all(context):

features/sld-add-layout.feature

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
Feature: Add a slide layout to a slide master
2+
In order to build presentation templates programmatically
3+
As a developer using python-pptx
4+
I need to create new slide layouts on a slide master (issue #19 SF3)
5+
6+
7+
Scenario: SlideLayouts.add_layout() with no name
8+
Given a default presentation
9+
When I call slide_layouts.add_layout()
10+
Then the slide-master layout count increased by exactly 1
11+
And the new layout has a non-empty name
12+
13+
14+
Scenario: SlideLayouts.add_layout(name) sets the name
15+
Given a default presentation
16+
When I call slide_layouts.add_layout("Acceptance Layout")
17+
Then slide_layouts.get_by_name("Acceptance Layout") is the new layout
18+
19+
20+
Scenario: A presentation survives reopen after add_layout
21+
Given a default presentation
22+
When I call slide_layouts.add_layout("Persisted Layout")
23+
And I save and reopen the presentation
24+
Then the reopened presentation has a layout named "Persisted Layout"
25+
26+
27+
Scenario: A new layout is usable as the basis for a slide
28+
Given a default presentation
29+
When I call slide_layouts.add_layout("Slide Basis")
30+
And I add a slide based on the new layout
31+
Then the slide count increased by exactly 1
32+
33+
34+
Scenario: Presentation.save_as_potx writes a template content-type (SF2)
35+
Given a default presentation
36+
When I save the presentation as a potx
37+
Then the saved potx declares the template content-type
38+
And the in-memory presentation content-type is unchanged
39+
And the saved potx reopens as a valid presentation
40+
41+
42+
Scenario: Authoring a textbox directly on a slide master (SF5)
43+
Given a default presentation
44+
When I add a textbox to the slide master
45+
And I save and reopen the presentation
46+
Then the reopened slide master has the master textbox text
47+
48+
49+
Scenario: Duplicating a layout with copy_from (SF4)
50+
Given a default presentation
51+
When I call slide_layouts.add_layout("Copy Origin")
52+
And I add a textbox to the new layout
53+
And I copy the new layout with copy_from
54+
Then the copied layout has the same shape count as its source
55+
And the source layout is unchanged after copy_from
56+
57+
58+
Scenario: Applying a different layout to a slide (SF7)
59+
Given a default presentation
60+
When I add a slide on the default layout
61+
And I call slide_layouts.add_layout("Reassigned Layout")
62+
And I apply the new layout to that slide
63+
And I save and reopen the presentation
64+
Then the reopened slide uses the layout named "Reassigned Layout"
65+
And the reopened slide still resolves its slide master
66+
67+
68+
Scenario: Inserting a chart into a chart placeholder (SF8)
69+
Given a default presentation
70+
When I add a layout with a chart placeholder
71+
And I add a slide on that chart-placeholder layout
72+
And I insert a chart into the slide's chart placeholder
73+
And I save and reopen the presentation
74+
Then the reopened slide has exactly one chart

features/steps/add_layout.py

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
"""Gherkin step implementations for issue #19 SF3 — SlideLayouts.add_layout()."""
2+
3+
from __future__ import annotations
4+
5+
import io
6+
7+
from behave import given, then, when
8+
9+
from pptx import Presentation
10+
11+
# given ===================================================
12+
13+
14+
@given("a default presentation")
15+
def given_a_default_presentation(context):
16+
context.prs = Presentation()
17+
master = context.prs.slide_masters[0]
18+
context.slide_layouts = master.slide_layouts
19+
context.layout_count_before = len(context.slide_layouts)
20+
context.slide_count_before = len(context.prs.slides)
21+
22+
23+
# when ====================================================
24+
25+
26+
@when("I call slide_layouts.add_layout()")
27+
def when_I_call_add_layout_no_name(context):
28+
context.new_layout = context.slide_layouts.add_layout()
29+
30+
31+
@when('I call slide_layouts.add_layout("{name}")')
32+
def when_I_call_add_layout_with_name(context, name):
33+
context.new_layout = context.slide_layouts.add_layout(name)
34+
35+
36+
@when("I save and reopen the presentation")
37+
def when_I_save_and_reopen(context):
38+
buf = io.BytesIO()
39+
context.prs.save(buf)
40+
buf.seek(0)
41+
context.reopened = Presentation(buf)
42+
43+
44+
@when("I add a slide based on the new layout")
45+
def when_I_add_a_slide_based_on_the_new_layout(context):
46+
context.prs.slides.add_slide(context.new_layout)
47+
48+
49+
# then ====================================================
50+
51+
52+
@then("the slide-master layout count increased by exactly 1")
53+
def then_layout_count_increased_by_1(context):
54+
assert len(context.slide_layouts) == context.layout_count_before + 1
55+
56+
57+
@then("the new layout has a non-empty name")
58+
def then_new_layout_has_non_empty_name(context):
59+
assert context.new_layout.name not in (None, "")
60+
61+
62+
@then('slide_layouts.get_by_name("{name}") is the new layout')
63+
def then_get_by_name_returns_new_layout(context, name):
64+
assert context.slide_layouts.get_by_name(name) is not None
65+
assert context.slide_layouts.get_by_name(name).name == context.new_layout.name
66+
67+
68+
@then('the reopened presentation has a layout named "{name}"')
69+
def then_reopened_has_layout_named(context, name):
70+
layouts = context.reopened.slide_masters[0].slide_layouts
71+
assert layouts.get_by_name(name) is not None
72+
73+
74+
@then("the slide count increased by exactly 1")
75+
def then_slide_count_increased_by_1(context):
76+
assert len(context.prs.slides) == context.slide_count_before + 1
77+
78+
79+
# SF2 — save_as_potx ======================================
80+
81+
_TEMPLATE_CT = b"application/vnd.openxmlformats-officedocument.presentationml.template.main+xml"
82+
_PRESENTATION_CT = (
83+
b"application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"
84+
)
85+
86+
87+
@when("I save the presentation as a potx")
88+
def when_I_save_as_potx(context):
89+
context.ct_before = context.prs.part.content_type
90+
context.potx_buf = io.BytesIO()
91+
context.prs.save_as_potx(context.potx_buf)
92+
93+
94+
@then("the saved potx declares the template content-type")
95+
def then_potx_declares_template_ct(context):
96+
import zipfile
97+
98+
context.potx_buf.seek(0)
99+
with zipfile.ZipFile(context.potx_buf) as z:
100+
ct_xml = z.read("[Content_Types].xml")
101+
assert _TEMPLATE_CT in ct_xml
102+
assert _PRESENTATION_CT not in ct_xml
103+
104+
105+
@then("the in-memory presentation content-type is unchanged")
106+
def then_in_memory_ct_unchanged(context):
107+
assert context.prs.part.content_type == context.ct_before
108+
assert context.prs.part.content_type == _PRESENTATION_CT.decode("ascii")
109+
110+
111+
@then("the saved potx reopens as a valid presentation")
112+
def then_potx_reopens_valid(context):
113+
context.potx_buf.seek(0)
114+
reopened = Presentation(context.potx_buf)
115+
assert len(reopened.slide_masters) >= 1
116+
117+
118+
# SF5 — master shape authoring ============================
119+
120+
121+
@when("I add a textbox to the slide master")
122+
def when_I_add_textbox_to_master(context):
123+
master = context.prs.slide_masters[0]
124+
tb = master.shapes.add_textbox(0, 0, 914400, 457200)
125+
tb.text_frame.text = "ACCEPTANCE MASTER TEXT"
126+
127+
128+
@then("the reopened slide master has the master textbox text")
129+
def then_reopened_master_has_textbox_text(context):
130+
master = context.reopened.slide_masters[0]
131+
texts = [s.text_frame.text for s in master.shapes if s.has_text_frame]
132+
assert "ACCEPTANCE MASTER TEXT" in texts
133+
134+
135+
# SF4 — copy_from =========================================
136+
137+
138+
@when("I add a textbox to the new layout")
139+
def when_I_add_textbox_to_new_layout(context):
140+
context.new_layout.shapes.add_textbox(0, 0, 914400, 457200)
141+
142+
143+
@when("I copy the new layout with copy_from")
144+
def when_I_copy_layout_with_copy_from(context):
145+
context.source_shape_count = len(list(context.new_layout.shapes))
146+
context.copied_layout = context.slide_layouts.copy_from(context.new_layout)
147+
148+
149+
@then("the copied layout has the same shape count as its source")
150+
def then_copied_layout_same_shape_count(context):
151+
assert len(list(context.copied_layout.shapes)) == context.source_shape_count
152+
153+
154+
@then("the source layout is unchanged after copy_from")
155+
def then_source_layout_unchanged(context):
156+
assert len(list(context.new_layout.shapes)) == context.source_shape_count
157+
158+
159+
# SF7 — cross-master / apply layout =======================
160+
161+
162+
@when("I add a slide on the default layout")
163+
def when_I_add_slide_on_default_layout(context):
164+
context.sf7_slide = context.prs.slides.add_slide(context.prs.slide_layouts[0])
165+
166+
167+
@when("I apply the new layout to that slide")
168+
def when_I_apply_new_layout_to_slide(context):
169+
context.sf7_slide.slide_layout = context.new_layout
170+
171+
172+
@then('the reopened slide uses the layout named "{name}"')
173+
def then_reopened_slide_uses_layout(context, name):
174+
assert context.reopened.slides[0].slide_layout.name == name
175+
176+
177+
@then("the reopened slide still resolves its slide master")
178+
def then_reopened_slide_resolves_master(context):
179+
assert context.reopened.slides[0].slide_layout.slide_master is not None
180+
181+
182+
# SF8 — chart into placeholder ============================
183+
184+
185+
@when("I add a layout with a chart placeholder")
186+
def when_I_add_layout_with_chart_placeholder(context):
187+
from pptx.enum.shapes import PP_PLACEHOLDER
188+
189+
master = context.prs.slide_masters[0]
190+
context.chart_layout = master.slide_layouts.add_layout("Chart PH Layout")
191+
context.chart_layout.placeholders.add(
192+
10,
193+
PP_PLACEHOLDER.CHART,
194+
left=914400,
195+
top=914400,
196+
width=4572000,
197+
height=2743200,
198+
)
199+
200+
201+
@when("I add a slide on that chart-placeholder layout")
202+
def when_I_add_slide_on_chart_layout(context):
203+
context.chart_slide = context.prs.slides.add_slide(context.chart_layout)
204+
205+
206+
@when("I insert a chart into the slide's chart placeholder")
207+
def when_I_insert_chart_into_placeholder(context):
208+
from pptx.chart.data import CategoryChartData
209+
from pptx.enum.chart import XL_CHART_TYPE
210+
from pptx.enum.shapes import PP_PLACEHOLDER
211+
212+
chart_ph = next(
213+
p
214+
for p in context.chart_slide.placeholders
215+
if p.placeholder_format.type == PP_PLACEHOLDER.CHART
216+
)
217+
chart_data = CategoryChartData()
218+
chart_data.categories = ["A", "B", "C"]
219+
chart_data.add_series("S1", (1.0, 2.0, 3.0))
220+
chart_ph.insert_chart(XL_CHART_TYPE.COLUMN_CLUSTERED, chart_data)
221+
222+
223+
@then("the reopened slide has exactly one chart")
224+
def then_reopened_slide_has_one_chart(context):
225+
slide = context.reopened.slides[0]
226+
charts = [s for s in slide.shapes if s.has_chart]
227+
assert len(charts) == 1

features/steps/slides.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -267,9 +267,7 @@ def when_target_append_from_source_all(context):
267267

268268
@when("I call target.append_from(source, slide_indexes=[0, 1])")
269269
def when_target_append_from_source_indexes_0_1(context):
270-
context.new_slides = context.target_pres.append_from(
271-
context.source_pres, slide_indexes=[0, 1]
272-
)
270+
context.new_slides = context.target_pres.append_from(context.source_pres, slide_indexes=[0, 1])
273271

274272

275273
@when("I call target.append_from(source, slide_indexes=[])")
@@ -287,8 +285,9 @@ def then_target_grew_by_source_slide_count(context):
287285
@then("target's master count grew by 1")
288286
def then_target_master_count_grew_by_1(context):
289287
actual = sum(1 for _ in context.target_pres.slide_masters)
290-
assert actual == context.target_masters_before + 1, (
291-
"expected %d masters, got %d" % (context.target_masters_before + 1, actual)
288+
assert actual == context.target_masters_before + 1, "expected %d masters, got %d" % (
289+
context.target_masters_before + 1,
290+
actual,
292291
)
293292

294293

@@ -345,8 +344,7 @@ def then_len_section_slides_is_n(context, n):
345344
def then_section_still_contains_moved_slide(context):
346345
section_slide_ids = [s.slide_id for s in context.section.slides]
347346
assert context.tracked_slide_id in section_slide_ids, (
348-
"expected slide_id %r in section.slides %r"
349-
% (context.tracked_slide_id, section_slide_ids)
347+
"expected slide_id %r in section.slides %r" % (context.tracked_slide_id, section_slide_ids)
350348
)
351349

352350

features/steps/tbl_merge.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,7 @@ def when_merge_cells(context, r1, r2, c1, c2):
3030

3131

3232
@when(
33-
"I call table.merge_cells with range({r_start:d},{r_stop:d}) "
34-
"and range({c_start:d},{c_stop:d})"
33+
"I call table.merge_cells with range({r_start:d},{r_stop:d}) and range({c_start:d},{c_stop:d})"
3534
)
3635
def when_merge_cells_with_range(context, r_start, r_stop, c_start, c_stop):
3736
context.table_.merge_cells(range(r_start, r_stop), range(c_start, c_stop))

src/pptx/api.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@ def _default_pptx_path() -> str:
5353

5454

5555
def _is_pptx_package(prs_part: PresentationPart):
56-
"""Return |True| if *prs_part* is a valid main document part, |False| otherwise."""
57-
valid_content_types = (CT.PML_PRESENTATION_MAIN, CT.PML_PRES_MACRO_MAIN)
56+
"""Return |True| if *prs_part* is a valid main document part, |False| otherwise.
57+
58+
The allowlist includes ``PML_TEMPLATE_MAIN`` so ``.potx`` template packages
59+
open as ordinary presentations (issue #19 / scanny/python-pptx#1070,
60+
#1095). A ``.potx`` differs from a ``.pptx`` only in this content-type;
61+
every downstream part graph is identical.
62+
"""
63+
valid_content_types = (
64+
CT.PML_PRESENTATION_MAIN,
65+
CT.PML_PRES_MACRO_MAIN,
66+
CT.PML_TEMPLATE_MAIN,
67+
)
5868
return prs_part.content_type in valid_content_types

0 commit comments

Comments
 (0)