python-pptx2 2.13.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. pptx2/__init__.py +152 -0
  2. pptx2/_color.py +75 -0
  3. pptx2/_slide_importer.py +597 -0
  4. pptx2/_svg.py +155 -0
  5. pptx2/_template_applier.py +292 -0
  6. pptx2/_textstyle.py +187 -0
  7. pptx2/accessibility.py +365 -0
  8. pptx2/action.py +270 -0
  9. pptx2/animation.py +2237 -0
  10. pptx2/api.py +49 -0
  11. pptx2/audit.py +258 -0
  12. pptx2/chart/__init__.py +0 -0
  13. pptx2/chart/analytics.py +381 -0
  14. pptx2/chart/axis.py +543 -0
  15. pptx2/chart/category.py +200 -0
  16. pptx2/chart/chart.py +670 -0
  17. pptx2/chart/data.py +864 -0
  18. pptx2/chart/datalabel.py +406 -0
  19. pptx2/chart/legend.py +86 -0
  20. pptx2/chart/marker.py +70 -0
  21. pptx2/chart/palettes.py +129 -0
  22. pptx2/chart/plot.py +462 -0
  23. pptx2/chart/point.py +101 -0
  24. pptx2/chart/quick_layouts.py +325 -0
  25. pptx2/chart/series.py +334 -0
  26. pptx2/chart/xlsx.py +272 -0
  27. pptx2/chart/xmlwriter.py +1845 -0
  28. pptx2/compose/__init__.py +28 -0
  29. pptx2/compose/from_spec.py +1094 -0
  30. pptx2/design/__init__.py +8 -0
  31. pptx2/design/components.py +607 -0
  32. pptx2/design/figures.py +389 -0
  33. pptx2/design/layout.py +370 -0
  34. pptx2/design/recipes.py +1967 -0
  35. pptx2/design/style.py +209 -0
  36. pptx2/design/tokens.py +915 -0
  37. pptx2/diagrams.py +754 -0
  38. pptx2/dml/__init__.py +0 -0
  39. pptx2/dml/chtfmt.py +40 -0
  40. pptx2/dml/color.py +496 -0
  41. pptx2/dml/effect.py +909 -0
  42. pptx2/dml/fill.py +691 -0
  43. pptx2/dml/line.py +287 -0
  44. pptx2/dml/picture.py +212 -0
  45. pptx2/dml/three_d.py +381 -0
  46. pptx2/enum/__init__.py +0 -0
  47. pptx2/enum/action.py +71 -0
  48. pptx2/enum/animation.py +31 -0
  49. pptx2/enum/base.py +218 -0
  50. pptx2/enum/chart.py +574 -0
  51. pptx2/enum/dml.py +740 -0
  52. pptx2/enum/lang.py +685 -0
  53. pptx2/enum/presentation.py +133 -0
  54. pptx2/enum/shapes.py +1029 -0
  55. pptx2/enum/text.py +230 -0
  56. pptx2/exc.py +42 -0
  57. pptx2/formats.py +139 -0
  58. pptx2/geometry.py +420 -0
  59. pptx2/inherit.py +109 -0
  60. pptx2/lint.py +2256 -0
  61. pptx2/math.py +177 -0
  62. pptx2/media.py +197 -0
  63. pptx2/opc/__init__.py +0 -0
  64. pptx2/opc/constants.py +332 -0
  65. pptx2/opc/oxml.py +188 -0
  66. pptx2/opc/package.py +762 -0
  67. pptx2/opc/packuri.py +109 -0
  68. pptx2/opc/serialized.py +296 -0
  69. pptx2/opc/shared.py +20 -0
  70. pptx2/opc/spec.py +45 -0
  71. pptx2/oxml/__init__.py +555 -0
  72. pptx2/oxml/action.py +53 -0
  73. pptx2/oxml/chart/__init__.py +0 -0
  74. pptx2/oxml/chart/axis.py +337 -0
  75. pptx2/oxml/chart/chart.py +481 -0
  76. pptx2/oxml/chart/datalabel.py +253 -0
  77. pptx2/oxml/chart/legend.py +72 -0
  78. pptx2/oxml/chart/marker.py +61 -0
  79. pptx2/oxml/chart/plot.py +365 -0
  80. pptx2/oxml/chart/series.py +425 -0
  81. pptx2/oxml/chart/shared.py +220 -0
  82. pptx2/oxml/coreprops.py +288 -0
  83. pptx2/oxml/dml/__init__.py +0 -0
  84. pptx2/oxml/dml/color.py +135 -0
  85. pptx2/oxml/dml/effect.py +213 -0
  86. pptx2/oxml/dml/fill.py +316 -0
  87. pptx2/oxml/dml/line.py +12 -0
  88. pptx2/oxml/dml/three_d.py +110 -0
  89. pptx2/oxml/ns.py +135 -0
  90. pptx2/oxml/presentation.py +313 -0
  91. pptx2/oxml/shapes/__init__.py +19 -0
  92. pptx2/oxml/shapes/autoshape.py +467 -0
  93. pptx2/oxml/shapes/connector.py +107 -0
  94. pptx2/oxml/shapes/graphfrm.py +347 -0
  95. pptx2/oxml/shapes/groupshape.py +329 -0
  96. pptx2/oxml/shapes/picture.py +270 -0
  97. pptx2/oxml/shapes/shared.py +577 -0
  98. pptx2/oxml/simpletypes.py +1027 -0
  99. pptx2/oxml/slide.py +563 -0
  100. pptx2/oxml/table.py +650 -0
  101. pptx2/oxml/text.py +815 -0
  102. pptx2/oxml/theme.py +36 -0
  103. pptx2/oxml/xmlchemy.py +717 -0
  104. pptx2/package.py +222 -0
  105. pptx2/parts/__init__.py +0 -0
  106. pptx2/parts/chart.py +95 -0
  107. pptx2/parts/coreprops.py +167 -0
  108. pptx2/parts/diagram.py +37 -0
  109. pptx2/parts/embeddedpackage.py +93 -0
  110. pptx2/parts/image.py +275 -0
  111. pptx2/parts/media.py +37 -0
  112. pptx2/parts/presentation.py +136 -0
  113. pptx2/parts/slide.py +371 -0
  114. pptx2/presentation.py +408 -0
  115. pptx2/py.typed +0 -0
  116. pptx2/render.py +586 -0
  117. pptx2/section.py +272 -0
  118. pptx2/shapes/__init__.py +26 -0
  119. pptx2/shapes/autoshape.py +442 -0
  120. pptx2/shapes/base.py +1078 -0
  121. pptx2/shapes/connector.py +297 -0
  122. pptx2/shapes/freeform.py +337 -0
  123. pptx2/shapes/graphfrm.py +316 -0
  124. pptx2/shapes/group.py +264 -0
  125. pptx2/shapes/picture.py +422 -0
  126. pptx2/shapes/placeholder.py +468 -0
  127. pptx2/shapes/shapetree.py +2027 -0
  128. pptx2/shared.py +82 -0
  129. pptx2/skill/SKILL.md +450 -0
  130. pptx2/skill/__init__.py +78 -0
  131. pptx2/skill/__main__.py +64 -0
  132. pptx2/skill/references/animations.md +189 -0
  133. pptx2/skill/references/basics.md +421 -0
  134. pptx2/skill/references/charts.md +254 -0
  135. pptx2/skill/references/compose.md +234 -0
  136. pptx2/skill/references/design.md +366 -0
  137. pptx2/skill/references/effects.md +249 -0
  138. pptx2/skill/references/end-to-end-deck.md +231 -0
  139. pptx2/skill/references/geometry-and-arrows.md +334 -0
  140. pptx2/skill/references/lint.md +275 -0
  141. pptx2/skill/references/math.md +86 -0
  142. pptx2/skill/references/picture-effects.md +129 -0
  143. pptx2/skill/references/render.md +151 -0
  144. pptx2/skill/references/smart-art.md +75 -0
  145. pptx2/skill/references/space-aware-authoring.md +249 -0
  146. pptx2/skill/references/tables.md +244 -0
  147. pptx2/skill/references/theme.md +127 -0
  148. pptx2/skill/references/three-d.md +109 -0
  149. pptx2/skill/references/transitions.md +100 -0
  150. pptx2/slide.py +1244 -0
  151. pptx2/smart_art.py +220 -0
  152. pptx2/spec.py +633 -0
  153. pptx2/table.py +1181 -0
  154. pptx2/table_styles.py +184 -0
  155. pptx2/templates/default.pptx +0 -0
  156. pptx2/templates/docx-icon.emf +0 -0
  157. pptx2/templates/generic-icon.emf +0 -0
  158. pptx2/templates/notes.xml +23 -0
  159. pptx2/templates/notesMaster.xml +352 -0
  160. pptx2/templates/pptx-icon.emf +0 -0
  161. pptx2/templates/theme.xml +321 -0
  162. pptx2/templates/xlsx-icon.emf +0 -0
  163. pptx2/text/__init__.py +0 -0
  164. pptx2/text/fonts.py +482 -0
  165. pptx2/text/layout.py +374 -0
  166. pptx2/text/text.py +1272 -0
  167. pptx2/theme.py +721 -0
  168. pptx2/types.py +36 -0
  169. pptx2/util.py +263 -0
  170. python_pptx2-2.13.0.dist-info/METADATA +351 -0
  171. python_pptx2-2.13.0.dist-info/RECORD +175 -0
  172. python_pptx2-2.13.0.dist-info/WHEEL +5 -0
  173. python_pptx2-2.13.0.dist-info/entry_points.txt +3 -0
  174. python_pptx2-2.13.0.dist-info/licenses/LICENSE +22 -0
  175. python_pptx2-2.13.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1967 @@
1
+ """Opinionated parameterized slide recipes.
2
+
3
+ Each recipe is a small callable that produces a fully-styled slide using a
4
+ shared :class:`~pptx2.design.tokens.DesignTokens` palette/typography set.
5
+ Recipes are deliberately additive: they sit on top of the low-level shape
6
+ APIs and the :class:`~pptx2.design.style.ShapeStyle` facade, and never invent
7
+ new OOXML semantics.
8
+
9
+ The five recipes cover the slide types that account for most of a
10
+ typical pitch deck:
11
+
12
+ * :func:`title_slide` — title + subtitle hero slide.
13
+ * :func:`bullet_slide` — title + bulleted body.
14
+ * :func:`kpi_slide` — title + KPI card row.
15
+ * :func:`quote_slide` — large pull-quote with attribution.
16
+ * :func:`image_hero_slide` — full-bleed image with overlay caption.
17
+
18
+ All recipes accept an optional :class:`DesignTokens` argument; when omitted,
19
+ they fall back to PowerPoint's defaults (black text, no fill). Each
20
+ recipe also accepts an optional ``transition`` keyword that names a
21
+ :class:`~pptx2.enum.presentation.MSO_TRANSITION_TYPE` member by lowercase
22
+ name (``"fade"``, ``"morph"``, ...).
23
+
24
+ Example::
25
+
26
+ from pptx2 import Presentation
27
+ from pptx2.design.tokens import DesignTokens
28
+ from pptx2.design.recipes import title_slide, bullet_slide, kpi_slide
29
+
30
+ prs = Presentation()
31
+ tokens = DesignTokens.from_dict({
32
+ "palette": {"primary": "#3C2F80", "neutral": "#222222",
33
+ "accent": "#FF6600", "muted": "#777777"},
34
+ "typography": {
35
+ "heading": {"family": "Inter", "size": 36.0, "bold": True},
36
+ "body": {"family": "Inter", "size": 16.0},
37
+ },
38
+ })
39
+
40
+ title_slide(prs, title="Q4 Review", subtitle="April 2026",
41
+ tokens=tokens, transition="morph")
42
+ bullet_slide(prs, title="Highlights",
43
+ bullets=["Two flagships shipped.", "NPS +8 QoQ."],
44
+ tokens=tokens)
45
+ kpi_slide(prs, title="Run-rate metrics",
46
+ kpis=[{"label": "ARR", "value": "$182M", "delta": +0.27},
47
+ {"label": "NDR", "value": "131%", "delta": +0.03}],
48
+ tokens=tokens)
49
+
50
+ prs.save("review.pptx")
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import os
56
+ from typing import IO, TYPE_CHECKING, Any, Mapping, Optional, Sequence, Union
57
+
58
+ from pptx2.design.tokens import DesignTokens, TypographyToken
59
+ from pptx2.dml.color import RGBColor
60
+ from pptx2.enum.shapes import MSO_SHAPE
61
+ from pptx2.enum.text import PP_ALIGN, MSO_ANCHOR
62
+ from pptx2.util import Emu, Inches, Length, Pt
63
+
64
+ if TYPE_CHECKING:
65
+ from pptx2.presentation import Presentation
66
+ from pptx2.slide import Slide
67
+
68
+ __all__ = (
69
+ "title_slide",
70
+ "bullet_slide",
71
+ "kpi_slide",
72
+ "quote_slide",
73
+ "image_hero_slide",
74
+ "section_divider",
75
+ "chart_slide",
76
+ "table_slide",
77
+ "code_slide",
78
+ "timeline_slide",
79
+ "comparison_slide",
80
+ "figure_slide",
81
+ )
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Public recipes
86
+ # ---------------------------------------------------------------------------
87
+
88
+
89
+ def title_slide(
90
+ prs: "Presentation",
91
+ *,
92
+ title: str,
93
+ subtitle: Optional[str] = None,
94
+ tokens: Optional[DesignTokens] = None,
95
+ transition: Optional[str] = None,
96
+ ) -> "Slide":
97
+ """Append a title-hero slide to *prs* and return it.
98
+
99
+ Uses the ``Blank`` layout so the recipe owns the geometry and styling
100
+ decisions end-to-end. Title text is centered horizontally and sits in
101
+ the top half of the slide; the subtitle, if provided, sits just below.
102
+
103
+ Tokens consumed:
104
+
105
+ * **palette** — title color from ``primary`` (fallback ``neutral``);
106
+ subtitle color from ``muted`` (fallback ``neutral``).
107
+ * **typography** — ``heading`` for the title, ``body`` for the
108
+ subtitle. Both fall back to Calibri at sensible sizes when the
109
+ token isn't set.
110
+ """
111
+ slide = _add_blank(prs)
112
+ slide_w, slide_h = _slide_dims(prs)
113
+ _apply_background(slide, prs, tokens)
114
+
115
+ margin = Inches(1.0)
116
+ title_top = Length(int(slide_h * 0.38))
117
+ title_h = Inches(1.6)
118
+ title_box = slide.shapes.add_textbox(
119
+ margin, title_top, Length(slide_w - 2 * margin), title_h
120
+ )
121
+ _fill_text_frame(
122
+ title_box.text_frame,
123
+ title,
124
+ token=_typography(tokens, "heading", default_size=Pt(44), default_bold=True),
125
+ color=_palette(tokens, ("primary", "neutral")),
126
+ align=PP_ALIGN.CENTER,
127
+ anchor=MSO_ANCHOR.MIDDLE,
128
+ shrink_to_fit=True,
129
+ )
130
+
131
+ if subtitle:
132
+ sub_top = Length(title_top + title_h + Inches(0.1))
133
+ sub_h = Inches(0.8)
134
+ sub_box = slide.shapes.add_textbox(
135
+ margin, sub_top, Length(slide_w - 2 * margin), sub_h
136
+ )
137
+ _fill_text_frame(
138
+ sub_box.text_frame,
139
+ subtitle,
140
+ token=_typography(tokens, "body", default_size=Pt(20)),
141
+ color=_palette(tokens, ("muted", "neutral")),
142
+ align=PP_ALIGN.CENTER,
143
+ anchor=MSO_ANCHOR.TOP,
144
+ )
145
+
146
+ _apply_transition(slide, transition)
147
+ return slide
148
+
149
+
150
+ def bullet_slide(
151
+ prs: "Presentation",
152
+ *,
153
+ title: str,
154
+ bullets: Sequence[str],
155
+ tokens: Optional[DesignTokens] = None,
156
+ transition: Optional[str] = None,
157
+ ) -> "Slide":
158
+ """Append a title + bulleted-content slide and return it.
159
+
160
+ Tokens consumed:
161
+
162
+ * **palette** — title from ``primary`` (fallback ``neutral``);
163
+ bullet text from ``neutral``.
164
+ * **typography** — ``heading`` for the title, ``body`` for the bullet
165
+ lines.
166
+ """
167
+ slide = _add_blank(prs)
168
+ slide_w, slide_h = _slide_dims(prs)
169
+ _apply_background(slide, prs, tokens)
170
+
171
+ margin = Inches(0.6)
172
+ title_top = Inches(0.5)
173
+ title_h = Inches(1.0)
174
+ title_box = slide.shapes.add_textbox(
175
+ margin, title_top, Length(slide_w - 2 * margin), title_h
176
+ )
177
+ _fill_text_frame(
178
+ title_box.text_frame,
179
+ title,
180
+ token=_typography(tokens, "heading", default_size=Pt(32), default_bold=True),
181
+ color=_palette(tokens, ("primary", "neutral")),
182
+ align=PP_ALIGN.LEFT,
183
+ anchor=MSO_ANCHOR.TOP,
184
+ shrink_to_fit=True,
185
+ )
186
+
187
+ body_top = Length(title_top + title_h + Inches(0.2))
188
+ body_h = Length(slide_h - body_top - Inches(0.5))
189
+ body_box = slide.shapes.add_textbox(
190
+ margin, body_top, Length(slide_w - 2 * margin), body_h
191
+ )
192
+ body_token = _typography(tokens, "body", default_size=Pt(18))
193
+ body_color = _palette(tokens, ("neutral",))
194
+ tf = body_box.text_frame
195
+ tf.word_wrap = True
196
+ for i, bullet in enumerate(bullets):
197
+ para = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
198
+ para.alignment = PP_ALIGN.LEFT
199
+ run = para.add_run()
200
+ run.text = f"• {bullet}"
201
+ _apply_typography(run.font, body_token)
202
+ if body_color is not None:
203
+ run.font.color.rgb = body_color
204
+ para.space_after = Pt(6)
205
+
206
+ # Space-awareness: a long bullet list (common from LLM output) would
207
+ # otherwise overflow the body box off the bottom of the slide. Tell
208
+ # PowerPoint to shrink the text to fit the reserved region.
209
+ from pptx2.enum.text import MSO_AUTO_SIZE
210
+
211
+ tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
212
+
213
+ _apply_transition(slide, transition)
214
+ return slide
215
+
216
+
217
+ def kpi_slide(
218
+ prs: "Presentation",
219
+ *,
220
+ title: str,
221
+ kpis: Sequence[Mapping[str, Any]],
222
+ tokens: Optional[DesignTokens] = None,
223
+ transition: Optional[str] = None,
224
+ ) -> "Slide":
225
+ """Append a title + KPI card-row slide and return it.
226
+
227
+ Each KPI dict accepts:
228
+
229
+ * ``label`` — the small caption under the value.
230
+ * ``value`` — the headline number / string.
231
+ * ``delta`` *(optional)* — numeric change. Magnitude with absolute
232
+ value at most ``1.0`` is treated as a fraction (``0.27`` → ``+27%``);
233
+ anything larger is rendered as-is with one decimal
234
+ (``14.0`` → ``+14.0``). Pass a *string* to opt out of the
235
+ auto-format and render the delta verbatim (``"+14 pts"``,
236
+ ``"−$2.3M"``). Positive deltas are tinted with the palette's
237
+ ``positive`` slot, negative with ``negative`` (falling back to
238
+ green / red).
239
+ * ``delta_text`` *(optional)* — explicit string passthrough; takes
240
+ precedence over ``delta`` when both are set.
241
+
242
+ Tokens consumed:
243
+
244
+ * **palette** — title from ``primary`` (fallback ``neutral``); card
245
+ fill from ``surface`` (fallback ``lt2``); card border from
246
+ ``muted`` (fallback ``lt1``); value from ``primary`` (fallback
247
+ ``neutral``); label from ``muted``; delta from ``positive`` /
248
+ ``success`` (positive) and ``negative`` / ``danger`` (negative).
249
+ * **typography** — ``heading`` for the title and the KPI value;
250
+ ``body`` for the label and the delta line.
251
+ * **shadows** — ``card`` (optional) is applied as a soft shadow on
252
+ each KPI card.
253
+ """
254
+ slide = _add_blank(prs)
255
+ slide_w, _slide_h = _slide_dims(prs)
256
+ _apply_background(slide, prs, tokens)
257
+
258
+ margin = Inches(0.6)
259
+ title_top = Inches(0.5)
260
+ title_h = Inches(0.9)
261
+ title_box = slide.shapes.add_textbox(
262
+ margin, title_top, Length(slide_w - 2 * margin), title_h
263
+ )
264
+ _fill_text_frame(
265
+ title_box.text_frame,
266
+ title,
267
+ token=_typography(tokens, "heading", default_size=Pt(28), default_bold=True),
268
+ color=_palette(tokens, ("primary", "neutral")),
269
+ align=PP_ALIGN.LEFT,
270
+ anchor=MSO_ANCHOR.TOP,
271
+ shrink_to_fit=True,
272
+ )
273
+
274
+ n = len(kpis)
275
+ if n == 0:
276
+ _apply_transition(slide, transition)
277
+ return slide
278
+
279
+ card_h = Inches(1.9)
280
+ gap = Inches(0.25)
281
+ available = slide_w - 2 * margin - (n - 1) * gap
282
+ card_w = Length(int(available // n))
283
+ top = Length(title_top + title_h + Inches(0.4))
284
+
285
+ fill_color = _palette(tokens, ("surface", "lt2"))
286
+ border_color = _palette(tokens, ("muted", "lt1"))
287
+ value_color = _palette(tokens, ("primary", "neutral"))
288
+ label_color = _palette(tokens, ("muted",))
289
+
290
+ value_token = _typography(tokens, "heading", default_size=Pt(30), default_bold=True)
291
+ label_token = _typography(tokens, "body", default_size=Pt(12))
292
+ delta_token = _typography(tokens, "body", default_size=Pt(11), default_bold=True)
293
+
294
+ for i, kpi in enumerate(kpis):
295
+ left = Length(margin + i * (card_w + gap))
296
+
297
+ card = slide.shapes.add_shape(
298
+ MSO_SHAPE.ROUNDED_RECTANGLE, left, top, card_w, card_h
299
+ )
300
+ if fill_color is not None:
301
+ card.fill.solid()
302
+ card.fill.fore_color.rgb = fill_color
303
+ else:
304
+ card.fill.background()
305
+ if border_color is not None:
306
+ card.line.color.rgb = border_color
307
+ card.line.width = Pt(0.75)
308
+ # Drop a soft card shadow + corner radius from the tokens.
309
+ _apply_card_styling(card, tokens)
310
+ # Suppress the default text on the autoshape so it doesn't peek
311
+ # through behind our textboxes.
312
+ card.text_frame.text = ""
313
+
314
+ # Value
315
+ v_box = slide.shapes.add_textbox(
316
+ left, Length(top + Inches(0.25)), card_w, Inches(0.85)
317
+ )
318
+ _fill_text_frame(
319
+ v_box.text_frame,
320
+ str(kpi.get("value", "")),
321
+ token=value_token,
322
+ color=value_color,
323
+ align=PP_ALIGN.CENTER,
324
+ anchor=MSO_ANCHOR.MIDDLE,
325
+ shrink_to_fit=True,
326
+ )
327
+
328
+ # Label — ``shrink_to_fit=True`` keeps a long label like
329
+ # "Lines of code per slide" inside the 0.4" reserved row
330
+ # instead of wrapping to a second line that overlaps the
331
+ # delta below (IMPROVEMENTS item 11 trailing note).
332
+ l_box = slide.shapes.add_textbox(
333
+ left, Length(top + Inches(1.10)), card_w, Inches(0.4)
334
+ )
335
+ _fill_text_frame(
336
+ l_box.text_frame,
337
+ str(kpi.get("label", "")),
338
+ token=label_token,
339
+ color=label_color,
340
+ align=PP_ALIGN.CENTER,
341
+ anchor=MSO_ANCHOR.TOP,
342
+ shrink_to_fit=True,
343
+ )
344
+
345
+ d_text, d_sign = _resolve_delta(kpi)
346
+ if d_text is not None:
347
+ d_color = _delta_color(tokens, d_sign)
348
+ d_box = slide.shapes.add_textbox(
349
+ left, Length(top + Inches(1.50)), card_w, Inches(0.35)
350
+ )
351
+ _fill_text_frame(
352
+ d_box.text_frame,
353
+ d_text,
354
+ token=delta_token,
355
+ color=d_color,
356
+ align=PP_ALIGN.CENTER,
357
+ anchor=MSO_ANCHOR.TOP,
358
+ shrink_to_fit=True,
359
+ )
360
+
361
+ _apply_transition(slide, transition)
362
+ return slide
363
+
364
+
365
+ def quote_slide(
366
+ prs: "Presentation",
367
+ *,
368
+ quote: str,
369
+ attribution: Optional[str] = None,
370
+ tokens: Optional[DesignTokens] = None,
371
+ transition: Optional[str] = None,
372
+ ) -> "Slide":
373
+ """Append a centered pull-quote slide with optional attribution.
374
+
375
+ *attribution*, when supplied, is rendered with an em-dash prefix
376
+ (``— Person``). A leading dash / em-dash / en-dash on the input
377
+ is stripped so callers can pass either ``"Person"`` or ``"— Person"``
378
+ without doubling the dash.
379
+
380
+ Tokens consumed:
381
+
382
+ * **palette** — quote text from ``primary`` (fallback ``neutral``);
383
+ attribution from ``muted`` (fallback ``neutral``).
384
+ * **typography** — ``heading`` for the quote (italic by default);
385
+ ``body`` for the attribution.
386
+ """
387
+ slide = _add_blank(prs)
388
+ slide_w, slide_h = _slide_dims(prs)
389
+ _apply_background(slide, prs, tokens)
390
+
391
+ margin = Inches(1.2)
392
+ quote_h = Inches(3.5)
393
+ quote_top = Length((slide_h - quote_h) // 2)
394
+ box = slide.shapes.add_textbox(
395
+ margin, quote_top, Length(slide_w - 2 * margin), quote_h
396
+ )
397
+ _fill_text_frame(
398
+ box.text_frame,
399
+ f"“{quote}”",
400
+ token=_typography(tokens, "heading", default_size=Pt(32), default_italic=True),
401
+ color=_palette(tokens, ("primary", "neutral")),
402
+ align=PP_ALIGN.CENTER,
403
+ anchor=MSO_ANCHOR.MIDDLE,
404
+ word_wrap=True,
405
+ shrink_to_fit=True,
406
+ )
407
+
408
+ if attribution:
409
+ att_top = Length(quote_top + quote_h + Inches(0.1))
410
+ att_box = slide.shapes.add_textbox(
411
+ margin, att_top, Length(slide_w - 2 * margin), Inches(0.6)
412
+ )
413
+ # Strip any leading dash variants the caller may have already
414
+ # written (``-``, ``–`` en-dash, ``—`` em-dash) so the recipe's
415
+ # em-dash isn't doubled — the silent-doubling failure mode is
416
+ # easy to miss in PR review.
417
+ att_clean = _strip_attribution_dash(attribution)
418
+ _fill_text_frame(
419
+ att_box.text_frame,
420
+ f"— {att_clean}",
421
+ token=_typography(tokens, "body", default_size=Pt(16)),
422
+ color=_palette(tokens, ("muted", "neutral")),
423
+ align=PP_ALIGN.CENTER,
424
+ anchor=MSO_ANCHOR.TOP,
425
+ )
426
+
427
+ _apply_transition(slide, transition)
428
+ return slide
429
+
430
+
431
+ def image_hero_slide(
432
+ prs: "Presentation",
433
+ *,
434
+ title: str,
435
+ image: Union[str, IO[bytes]],
436
+ caption: Optional[str] = None,
437
+ tokens: Optional[DesignTokens] = None,
438
+ transition: Optional[str] = None,
439
+ ) -> "Slide":
440
+ """Append a full-bleed image slide with an overlaid title (and caption).
441
+
442
+ The image is added at the slide origin and stretched to the slide's
443
+ full extent. The title sits in a tinted band across the bottom third
444
+ so it remains readable regardless of the underlying image.
445
+
446
+ Tokens consumed:
447
+
448
+ * **palette** — band fill from ``primary`` (fallback ``neutral``,
449
+ then black); title and caption text from ``on_primary`` (falling
450
+ back to white / near-white).
451
+ * **typography** — ``heading`` for the title, ``body`` for the
452
+ caption.
453
+ """
454
+ slide = _add_blank(prs)
455
+ slide_w, slide_h = _slide_dims(prs)
456
+
457
+ slide.shapes.add_picture(image, Emu(0), Emu(0), slide_w, slide_h)
458
+
459
+ band_h = Inches(1.6 if caption else 1.2)
460
+ band_top = Length(slide_h - band_h)
461
+ band = slide.shapes.add_shape(
462
+ MSO_SHAPE.RECTANGLE, Emu(0), band_top, slide_w, band_h
463
+ )
464
+ band.line.fill.background()
465
+ band_color = _palette(tokens, ("primary", "neutral")) or RGBColor(0, 0, 0)
466
+ band.fill.solid()
467
+ band.fill.fore_color.rgb = band_color
468
+ band.fill.fore_color.alpha = 0.55
469
+ band.text_frame.text = ""
470
+
471
+ margin = Inches(0.6)
472
+ title_box = slide.shapes.add_textbox(
473
+ margin, Length(band_top + Inches(0.2)),
474
+ Length(slide_w - 2 * margin), Inches(0.8),
475
+ )
476
+ _fill_text_frame(
477
+ title_box.text_frame,
478
+ title,
479
+ token=_typography(tokens, "heading", default_size=Pt(28), default_bold=True),
480
+ color=_palette(tokens, ("on_primary",)) or RGBColor(0xFF, 0xFF, 0xFF),
481
+ align=PP_ALIGN.LEFT,
482
+ anchor=MSO_ANCHOR.TOP,
483
+ shrink_to_fit=True,
484
+ )
485
+
486
+ if caption:
487
+ cap_box = slide.shapes.add_textbox(
488
+ margin, Length(band_top + Inches(1.0)),
489
+ Length(slide_w - 2 * margin), Inches(0.5),
490
+ )
491
+ _fill_text_frame(
492
+ cap_box.text_frame,
493
+ caption,
494
+ token=_typography(tokens, "body", default_size=Pt(14)),
495
+ color=_palette(tokens, ("on_primary",)) or RGBColor(0xEE, 0xEE, 0xEE),
496
+ align=PP_ALIGN.LEFT,
497
+ anchor=MSO_ANCHOR.TOP,
498
+ )
499
+
500
+ _apply_transition(slide, transition)
501
+ return slide
502
+
503
+
504
+ # ---------------------------------------------------------------------------
505
+ # section_divider
506
+ # ---------------------------------------------------------------------------
507
+
508
+
509
+ def section_divider(
510
+ prs: "Presentation",
511
+ *,
512
+ title: str,
513
+ eyebrow: Optional[str] = None,
514
+ progress: Optional[tuple[int, int]] = None,
515
+ tokens: Optional[DesignTokens] = None,
516
+ transition: Optional[str] = None,
517
+ ) -> "Slide":
518
+ """Append a full-bleed section-divider slide and return it.
519
+
520
+ The slide is a coloured backdrop with a left-aligned section title,
521
+ an optional small eyebrow line above the title (e.g. ``"PART TWO"``),
522
+ and an optional progress dot pair like *3 of 7* drawn as a small row
523
+ of dots in the bottom-right corner — useful for orienting the
524
+ audience between deck sections.
525
+
526
+ *progress* is a ``(current, total)`` tuple; ``current`` is 1-indexed
527
+ so ``progress=(3, 7)`` highlights the third dot in a row of seven.
528
+
529
+ Tokens consumed:
530
+
531
+ * **palette** — backdrop fill from ``primary`` (fallback ``neutral``);
532
+ title and eyebrow text from ``on_primary`` (fallback white);
533
+ inactive progress dots from ``muted`` (fallback ``lt2``); active
534
+ progress dot from ``on_primary``.
535
+ * **typography** — ``heading`` for the title; ``body`` for the
536
+ eyebrow.
537
+ """
538
+ slide = _add_blank(prs)
539
+ slide_w, slide_h = _slide_dims(prs)
540
+
541
+ # Solid backdrop.
542
+ bg_color = _palette(tokens, ("primary", "neutral")) or RGBColor(0x22, 0x22, 0x33)
543
+ bg = slide.shapes.add_shape(
544
+ MSO_SHAPE.RECTANGLE, Emu(0), Emu(0), slide_w, slide_h
545
+ )
546
+ bg.fill.solid()
547
+ bg.fill.fore_color.rgb = bg_color
548
+ bg.line.fill.background()
549
+ bg.text_frame.text = ""
550
+
551
+ margin = Inches(0.8)
552
+ title_color = _palette(tokens, ("on_primary",)) or RGBColor(0xFF, 0xFF, 0xFF)
553
+
554
+ # Eyebrow above the title (small uppercase-ish caption).
555
+ if eyebrow:
556
+ eb_box = slide.shapes.add_textbox(
557
+ margin,
558
+ Length(slide_h // 2 - Inches(1.0)),
559
+ Length(slide_w - 2 * margin),
560
+ Inches(0.4),
561
+ )
562
+ _fill_text_frame(
563
+ eb_box.text_frame,
564
+ eyebrow,
565
+ token=_typography(tokens, "body", default_size=Pt(14), default_bold=True),
566
+ color=title_color,
567
+ align=PP_ALIGN.LEFT,
568
+ anchor=MSO_ANCHOR.TOP,
569
+ )
570
+
571
+ title_top = Length(slide_h // 2 - Inches(0.5))
572
+ title_box = slide.shapes.add_textbox(
573
+ margin, title_top, Length(slide_w - 2 * margin), Inches(2.0)
574
+ )
575
+ _fill_text_frame(
576
+ title_box.text_frame,
577
+ title,
578
+ token=_typography(tokens, "heading", default_size=Pt(48), default_bold=True),
579
+ color=title_color,
580
+ align=PP_ALIGN.LEFT,
581
+ anchor=MSO_ANCHOR.MIDDLE,
582
+ )
583
+
584
+ # Progress dots: row of `total` dots, the `current`-th highlighted.
585
+ if progress is not None:
586
+ current, total = int(progress[0]), int(progress[1])
587
+ if total < 1 or current < 1 or current > total:
588
+ raise ValueError(
589
+ f"progress must be (1≤current≤total, total≥1); got {progress!r}"
590
+ )
591
+ dot_d = Inches(0.18)
592
+ dot_gap = Inches(0.10)
593
+ row_w = total * dot_d + (total - 1) * dot_gap
594
+ row_left = Length(slide_w - margin - row_w)
595
+ row_top = Length(slide_h - margin - dot_d)
596
+ active_color = title_color
597
+ inactive_color = _palette(tokens, ("muted", "lt2")) or RGBColor(0x99, 0x99, 0xAA)
598
+ for i in range(total):
599
+ dot_left = Length(row_left + i * (dot_d + dot_gap))
600
+ dot = slide.shapes.add_shape(
601
+ MSO_SHAPE.OVAL, dot_left, row_top, dot_d, dot_d
602
+ )
603
+ dot.fill.solid()
604
+ dot.fill.fore_color.rgb = (
605
+ active_color if (i + 1) == current else inactive_color
606
+ )
607
+ dot.line.fill.background()
608
+ dot.text_frame.text = ""
609
+
610
+ _apply_transition(slide, transition)
611
+ return slide
612
+
613
+
614
+ # ---------------------------------------------------------------------------
615
+ # chart_slide
616
+ # ---------------------------------------------------------------------------
617
+
618
+
619
+ def chart_slide(
620
+ prs: "Presentation",
621
+ *,
622
+ title: str,
623
+ chart_type: str = "line",
624
+ categories: Sequence[str],
625
+ series: Sequence[Mapping[str, Any]],
626
+ chart_palette: Optional[Union[str, Sequence[Any]]] = None,
627
+ legend: bool = True,
628
+ smooth: bool = False,
629
+ data_labels: bool = False,
630
+ tokens: Optional[DesignTokens] = None,
631
+ transition: Optional[str] = None,
632
+ ) -> "Slide":
633
+ """Append a title + chart slide and return it.
634
+
635
+ *chart_type* is one of ``"line"``, ``"bar"`` (clustered horizontal
636
+ bars), ``"column"`` (clustered vertical columns), ``"pie"``,
637
+ ``"area"``, ``"line_markers"``, ``"scatter"``, or ``"doughnut"``.
638
+
639
+ *categories* is the list of x-axis labels (or pie-slice labels).
640
+ Each *series* mapping is ``{"name": str, "values": Sequence[float]}``;
641
+ pass a single-series list for pie / doughnut charts.
642
+
643
+ *chart_palette* recolours every series. Accepts:
644
+
645
+ * a named built-in (``"modern"``, ``"vibrant"``, ``"monochrome_blue"``,
646
+ …, see :func:`pptx2.chart.palettes.palette_names`),
647
+ * a list of colours (hex strings, RGBColor, 3-tuples), or
648
+ * ``None`` (default) — falls back to a palette derived from
649
+ *tokens* (``primary`` → ``accent1`` → … → ``positive`` →
650
+ ``negative`` → ``muted``) when at least one of those slots is
651
+ set, and otherwise leaves PowerPoint's default chart_style in
652
+ place.
653
+
654
+ *legend* toggles the chart legend (default ``True``). *smooth*
655
+ smooths the line for line charts (no-op on non-line charts).
656
+ *data_labels* turns on series-level data labels.
657
+
658
+ Tokens consumed:
659
+
660
+ * **palette** — title from ``primary`` (fallback ``neutral``).
661
+ Series colours are derived from the same palette unless an
662
+ explicit *chart_palette* is supplied.
663
+ * **typography** — ``heading`` for the title.
664
+ """
665
+ from pptx2.chart.data import CategoryChartData
666
+ from pptx2.enum.chart import XL_CHART_TYPE
667
+
668
+ chart_map = {
669
+ "line": XL_CHART_TYPE.LINE,
670
+ "line_markers": XL_CHART_TYPE.LINE_MARKERS,
671
+ "bar": XL_CHART_TYPE.BAR_CLUSTERED,
672
+ "column": XL_CHART_TYPE.COLUMN_CLUSTERED,
673
+ "pie": XL_CHART_TYPE.PIE,
674
+ "doughnut": XL_CHART_TYPE.DOUGHNUT,
675
+ "area": XL_CHART_TYPE.AREA,
676
+ }
677
+ if chart_type not in chart_map:
678
+ raise ValueError(
679
+ f"Unknown chart_type {chart_type!r}; "
680
+ f"choose from {sorted(chart_map)}"
681
+ )
682
+
683
+ slide = _add_blank(prs)
684
+ slide_w, slide_h = _slide_dims(prs)
685
+ _apply_background(slide, prs, tokens)
686
+
687
+ margin = Inches(0.6)
688
+ title_top = Inches(0.5)
689
+ title_h = Inches(0.9)
690
+ title_box = slide.shapes.add_textbox(
691
+ margin, title_top, Length(slide_w - 2 * margin), title_h
692
+ )
693
+ _fill_text_frame(
694
+ title_box.text_frame,
695
+ title,
696
+ token=_typography(tokens, "heading", default_size=Pt(28), default_bold=True),
697
+ color=_palette(tokens, ("primary", "neutral")),
698
+ align=PP_ALIGN.LEFT,
699
+ anchor=MSO_ANCHOR.TOP,
700
+ shrink_to_fit=True,
701
+ )
702
+
703
+ chart_data = CategoryChartData()
704
+ chart_data.categories = list(categories)
705
+ for s in series:
706
+ chart_data.add_series(str(s.get("name", "")), [float(v) for v in s.get("values", ())])
707
+
708
+ chart_top = Length(title_top + title_h + Inches(0.2))
709
+ chart_h = Length(slide_h - chart_top - Inches(0.5))
710
+ gframe = slide.shapes.add_chart(
711
+ chart_map[chart_type],
712
+ margin,
713
+ chart_top,
714
+ Length(slide_w - 2 * margin),
715
+ chart_h,
716
+ chart_data,
717
+ )
718
+ chart = gframe.chart
719
+
720
+ # Apply palette (explicit > token-derived > leave default).
721
+ resolved_palette = chart_palette
722
+ if resolved_palette is None:
723
+ resolved_palette = _token_chart_palette(tokens)
724
+ if resolved_palette is not None:
725
+ try:
726
+ if chart_type in ("pie", "doughnut"):
727
+ chart.color_by_category(resolved_palette)
728
+ else:
729
+ chart.apply_palette(resolved_palette)
730
+ except Exception:
731
+ # A misconfigured palette shouldn't fail the whole recipe;
732
+ # the chart still renders with PowerPoint defaults.
733
+ pass
734
+
735
+ chart.has_legend = bool(legend)
736
+
737
+ if smooth and chart_type in ("line", "line_markers"):
738
+ for s in chart.series:
739
+ try:
740
+ s.smooth = True
741
+ except Exception:
742
+ pass
743
+
744
+ if data_labels:
745
+ for plot in chart.plots:
746
+ try:
747
+ plot.has_data_labels = True
748
+ except Exception:
749
+ pass
750
+
751
+ _apply_transition(slide, transition)
752
+ return slide
753
+
754
+
755
+ # ---------------------------------------------------------------------------
756
+ # table_slide
757
+ # ---------------------------------------------------------------------------
758
+
759
+
760
+ def table_slide(
761
+ prs: "Presentation",
762
+ *,
763
+ title: str,
764
+ columns: Sequence[str],
765
+ rows: Sequence[Sequence[Any]],
766
+ banded: bool = True,
767
+ widths: Optional[Sequence[Union[float, Length]]] = None,
768
+ aligns: Optional[Sequence[str]] = None,
769
+ totals: Optional[Mapping[str, Any]] = None,
770
+ tokens: Optional[DesignTokens] = None,
771
+ transition: Optional[str] = None,
772
+ ) -> "Slide":
773
+ """Append a title + data-table slide and return it.
774
+
775
+ *columns* are header strings; each entry of *rows* is a sequence
776
+ with one value per column. Values are coerced to ``str`` for
777
+ display.
778
+
779
+ *banded* (default ``True``) tints alternating data rows with the
780
+ palette's ``surface`` slot to improve scanning.
781
+
782
+ *widths* assigns column widths. Accepts a sequence of either
783
+ fractions summing to ~1.0 (``[0.5, 0.25, 0.25]``) or absolute
784
+ :class:`~pptx2.util.Length` values. Unspecified columns split
785
+ the remaining width evenly.
786
+
787
+ *aligns* assigns horizontal alignment per column. Accepts
788
+ ``"left"`` / ``"center"`` / ``"right"``; defaults to ``"left"``
789
+ for every column. Useful for right-aligning numeric columns.
790
+
791
+ *totals* adds a footer row that visually separates from the data.
792
+ Mapping shape: ``{"label": "Total", "values": [n1, n2, ...]}`` or
793
+ ``{"row": [c1, c2, c3, ...]}`` for a fully-explicit row. The
794
+ footer is bold and uses ``primary`` palette text on a subtle band.
795
+
796
+ Tokens consumed:
797
+
798
+ * **palette** — title and header text from ``primary`` (fallback
799
+ ``neutral``); header band fill from ``primary``; banded rows from
800
+ ``surface`` (fallback ``lt2``); body text from ``neutral``;
801
+ totals-row band from ``surface`` and totals text from ``primary``.
802
+ * **typography** — ``heading`` for the title; ``body`` for the
803
+ header (bold) and cell text.
804
+ """
805
+ if not columns:
806
+ raise ValueError("table_slide requires at least one column")
807
+
808
+ slide = _add_blank(prs)
809
+ slide_w, slide_h = _slide_dims(prs)
810
+ _apply_background(slide, prs, tokens)
811
+
812
+ margin = Inches(0.6)
813
+ title_top = Inches(0.5)
814
+ title_h = Inches(0.9)
815
+ title_box = slide.shapes.add_textbox(
816
+ margin, title_top, Length(slide_w - 2 * margin), title_h
817
+ )
818
+ _fill_text_frame(
819
+ title_box.text_frame,
820
+ title,
821
+ token=_typography(tokens, "heading", default_size=Pt(28), default_bold=True),
822
+ color=_palette(tokens, ("primary", "neutral")),
823
+ align=PP_ALIGN.LEFT,
824
+ anchor=MSO_ANCHOR.TOP,
825
+ shrink_to_fit=True,
826
+ )
827
+
828
+ totals_row = _coerce_totals_row(totals, len(columns)) if totals else None
829
+ n_rows = len(rows) + 1 + (1 if totals_row is not None else 0)
830
+ n_cols = len(columns)
831
+ table_top = Length(title_top + title_h + Inches(0.2))
832
+ table_h = Length(slide_h - table_top - Inches(0.5))
833
+ table_w = Length(slide_w - 2 * margin)
834
+
835
+ gframe = slide.shapes.add_table(
836
+ n_rows, n_cols, margin, table_top, table_w, table_h
837
+ )
838
+ table = gframe.table
839
+
840
+ if widths is not None:
841
+ _apply_column_widths(table, widths, table_w, n_cols)
842
+
843
+ align_map = _coerce_aligns(aligns, n_cols)
844
+
845
+ header_token = _typography(tokens, "body", default_size=Pt(14), default_bold=True)
846
+ cell_token = _typography(tokens, "body", default_size=Pt(13))
847
+ totals_token = _typography(tokens, "body", default_size=Pt(13), default_bold=True)
848
+ header_fill = _palette(tokens, ("primary", "neutral")) or RGBColor(0x33, 0x33, 0x33)
849
+ header_text = _palette(tokens, ("on_primary",)) or RGBColor(0xFF, 0xFF, 0xFF)
850
+ band_fill = _palette(tokens, ("surface", "lt2")) or RGBColor(0xF4, 0xF4, 0xF8)
851
+ body_text = _palette(tokens, ("neutral",)) or RGBColor(0x22, 0x22, 0x22)
852
+ totals_text = _palette(tokens, ("primary", "neutral")) or RGBColor(0x22, 0x22, 0x22)
853
+
854
+ # Header
855
+ for c, name in enumerate(columns):
856
+ cell = table.cell(0, c)
857
+ cell.fill.solid()
858
+ cell.fill.fore_color.rgb = header_fill
859
+ _fill_text_frame(
860
+ cell.text_frame,
861
+ str(name),
862
+ token=header_token,
863
+ color=header_text,
864
+ align=align_map[c],
865
+ anchor=MSO_ANCHOR.MIDDLE,
866
+ )
867
+
868
+ # Body
869
+ for r, row in enumerate(rows):
870
+ for c in range(n_cols):
871
+ cell = table.cell(r + 1, c)
872
+ if banded and (r % 2 == 0):
873
+ cell.fill.solid()
874
+ cell.fill.fore_color.rgb = band_fill
875
+ else:
876
+ cell.fill.background()
877
+ value = row[c] if c < len(row) else ""
878
+ _fill_text_frame(
879
+ cell.text_frame,
880
+ str(value),
881
+ token=cell_token,
882
+ color=body_text,
883
+ align=align_map[c],
884
+ anchor=MSO_ANCHOR.MIDDLE,
885
+ )
886
+
887
+ # Totals row (footer): tinted band + bold text in the primary palette.
888
+ if totals_row is not None:
889
+ footer_idx = n_rows - 1
890
+ for c in range(n_cols):
891
+ cell = table.cell(footer_idx, c)
892
+ cell.fill.solid()
893
+ cell.fill.fore_color.rgb = band_fill
894
+ _fill_text_frame(
895
+ cell.text_frame,
896
+ str(totals_row[c]),
897
+ token=totals_token,
898
+ color=totals_text,
899
+ align=align_map[c],
900
+ anchor=MSO_ANCHOR.MIDDLE,
901
+ )
902
+
903
+ _apply_transition(slide, transition)
904
+ return slide
905
+
906
+
907
+ # ---------------------------------------------------------------------------
908
+ # code_slide
909
+ # ---------------------------------------------------------------------------
910
+
911
+
912
+ def code_slide(
913
+ prs: "Presentation",
914
+ *,
915
+ title: str,
916
+ code: str,
917
+ language: Optional[str] = None,
918
+ tokens: Optional[DesignTokens] = None,
919
+ transition: Optional[str] = None,
920
+ ) -> "Slide":
921
+ """Append a title + monospace code-block slide and return it.
922
+
923
+ When *language* is supplied **and** Pygments is installed, the code
924
+ block is syntax-highlighted using Pygments' ``terminal`` lexer
925
+ output (translated to per-token RGB runs). Without Pygments — or
926
+ without a *language* — the code is rendered as a plain monospace
927
+ block on the surface fill.
928
+
929
+ Tokens consumed:
930
+
931
+ * **palette** — title from ``primary`` (fallback ``neutral``); code
932
+ panel fill from ``surface`` (fallback ``lt2``); plain code text
933
+ from ``neutral``; panel border from ``muted`` (fallback ``lt1``).
934
+ * **typography** — ``heading`` for the title; ``body`` is **not**
935
+ used for the code text (which is locked to a monospace family —
936
+ Cascadia Code → Consolas → Menlo → monospace).
937
+ """
938
+ slide = _add_blank(prs)
939
+ slide_w, slide_h = _slide_dims(prs)
940
+ _apply_background(slide, prs, tokens)
941
+
942
+ margin = Inches(0.6)
943
+ title_top = Inches(0.5)
944
+ title_h = Inches(0.9)
945
+ title_box = slide.shapes.add_textbox(
946
+ margin, title_top, Length(slide_w - 2 * margin), title_h
947
+ )
948
+ _fill_text_frame(
949
+ title_box.text_frame,
950
+ title,
951
+ token=_typography(tokens, "heading", default_size=Pt(28), default_bold=True),
952
+ color=_palette(tokens, ("primary", "neutral")),
953
+ align=PP_ALIGN.LEFT,
954
+ anchor=MSO_ANCHOR.TOP,
955
+ shrink_to_fit=True,
956
+ )
957
+
958
+ panel_top = Length(title_top + title_h + Inches(0.2))
959
+ panel_h = Length(slide_h - panel_top - Inches(0.5))
960
+ panel_w = Length(slide_w - 2 * margin)
961
+ panel = slide.shapes.add_shape(
962
+ MSO_SHAPE.ROUNDED_RECTANGLE, margin, panel_top, panel_w, panel_h
963
+ )
964
+ panel.fill.solid()
965
+ panel.fill.fore_color.rgb = (
966
+ _palette(tokens, ("surface", "lt2")) or RGBColor(0x14, 0x14, 0x18)
967
+ )
968
+ border = _palette(tokens, ("muted", "lt1"))
969
+ if border is not None:
970
+ panel.line.color.rgb = border
971
+ panel.line.width = Pt(0.75)
972
+ else:
973
+ panel.line.fill.background()
974
+ _apply_card_styling(panel, tokens)
975
+ panel.text_frame.text = ""
976
+
977
+ pad = Inches(0.25)
978
+ code_box = slide.shapes.add_textbox(
979
+ Length(margin + pad),
980
+ Length(panel_top + pad),
981
+ Length(panel_w - 2 * pad),
982
+ Length(panel_h - 2 * pad),
983
+ )
984
+ tf = code_box.text_frame
985
+ tf.word_wrap = True
986
+ tf.vertical_anchor = MSO_ANCHOR.TOP
987
+
988
+ fallback_color = _palette(tokens, ("neutral",)) or RGBColor(0x22, 0x22, 0x22)
989
+ mono_size = Pt(14)
990
+ mono_family = "Cascadia Code, Consolas, Menlo, monospace"
991
+
992
+ # Try to highlight via Pygments; on any failure (missing dep,
993
+ # unknown lexer) fall back to plain monospace text — the slide
994
+ # renders correctly either way.
995
+ highlighted = _pygments_highlight(code, language) if language else None
996
+
997
+ para = tf.paragraphs[0]
998
+ para.text = ""
999
+ para.alignment = PP_ALIGN.LEFT
1000
+ if highlighted is None:
1001
+ for line_idx, line in enumerate(code.splitlines() or [""]):
1002
+ p = para if line_idx == 0 else tf.add_paragraph()
1003
+ p.alignment = PP_ALIGN.LEFT
1004
+ run = p.add_run()
1005
+ run.text = line
1006
+ run.font.name = mono_family
1007
+ run.font.size = mono_size
1008
+ run.font.color.rgb = fallback_color
1009
+ else:
1010
+ for line_idx, line_tokens in enumerate(highlighted):
1011
+ p = para if line_idx == 0 else tf.add_paragraph()
1012
+ p.alignment = PP_ALIGN.LEFT
1013
+ for text, rgb in line_tokens:
1014
+ if not text:
1015
+ continue
1016
+ run = p.add_run()
1017
+ run.text = text
1018
+ run.font.name = mono_family
1019
+ run.font.size = mono_size
1020
+ run.font.color.rgb = rgb if rgb is not None else fallback_color
1021
+
1022
+ _apply_transition(slide, transition)
1023
+ return slide
1024
+
1025
+
1026
+ # ---------------------------------------------------------------------------
1027
+ # timeline_slide
1028
+ # ---------------------------------------------------------------------------
1029
+
1030
+
1031
+ def timeline_slide(
1032
+ prs: "Presentation",
1033
+ *,
1034
+ title: str,
1035
+ milestones: Sequence[Mapping[str, Any]],
1036
+ tokens: Optional[DesignTokens] = None,
1037
+ transition: Optional[str] = None,
1038
+ ) -> "Slide":
1039
+ """Append a horizontal-timeline slide with evenly-spaced milestones.
1040
+
1041
+ Each milestone dict accepts ``date``, ``label``, and an optional
1042
+ ``done`` flag (default ``False``). Completed milestones get the
1043
+ ``positive`` palette tint; pending ones use ``muted``.
1044
+
1045
+ Tokens consumed:
1046
+
1047
+ * **palette** — title from ``primary`` (fallback ``neutral``);
1048
+ timeline rail from ``muted`` (fallback ``lt1``); pending markers
1049
+ from ``muted``; completed markers from ``positive`` (fallback
1050
+ ``success``); date / label text from ``neutral``.
1051
+ * **typography** — ``heading`` for the title; ``body`` for dates
1052
+ and labels.
1053
+ """
1054
+ if not milestones:
1055
+ raise ValueError("timeline_slide requires at least one milestone")
1056
+
1057
+ slide = _add_blank(prs)
1058
+ slide_w, slide_h = _slide_dims(prs)
1059
+ _apply_background(slide, prs, tokens)
1060
+
1061
+ margin = Inches(0.6)
1062
+ title_top = Inches(0.5)
1063
+ title_h = Inches(0.9)
1064
+ title_box = slide.shapes.add_textbox(
1065
+ margin, title_top, Length(slide_w - 2 * margin), title_h
1066
+ )
1067
+ _fill_text_frame(
1068
+ title_box.text_frame,
1069
+ title,
1070
+ token=_typography(tokens, "heading", default_size=Pt(28), default_bold=True),
1071
+ color=_palette(tokens, ("primary", "neutral")),
1072
+ align=PP_ALIGN.LEFT,
1073
+ anchor=MSO_ANCHOR.TOP,
1074
+ shrink_to_fit=True,
1075
+ )
1076
+
1077
+ # Rail across the middle.
1078
+ rail_y = Length(slide_h // 2)
1079
+ rail_h = Pt(2)
1080
+ rail_left = Length(margin + Inches(0.5))
1081
+ rail_right = Length(slide_w - margin - Inches(0.5))
1082
+ rail = slide.shapes.add_shape(
1083
+ MSO_SHAPE.RECTANGLE, rail_left, Length(rail_y - rail_h // 2),
1084
+ Length(rail_right - rail_left), rail_h,
1085
+ )
1086
+ rail_color = _palette(tokens, ("muted", "lt1")) or RGBColor(0xBB, 0xBB, 0xBB)
1087
+ rail.fill.solid()
1088
+ rail.fill.fore_color.rgb = rail_color
1089
+ rail.line.fill.background()
1090
+ rail.text_frame.text = ""
1091
+
1092
+ n = len(milestones)
1093
+ span = rail_right - rail_left
1094
+ # Equally space milestones along the rail; n=1 sits dead center.
1095
+ if n == 1:
1096
+ positions = [Length(rail_left + span // 2)]
1097
+ else:
1098
+ positions = [
1099
+ Length(rail_left + int(i * span / (n - 1))) for i in range(n)
1100
+ ]
1101
+
1102
+ dot_d = Inches(0.28)
1103
+ # Cap the label width to the per-milestone spacing so adjacent labels don't
1104
+ # overlap once milestones get close together (many milestones on one rail).
1105
+ label_w = Inches(2.4)
1106
+ if n > 1:
1107
+ label_w = Length(min(int(label_w), int(span // (n - 1))))
1108
+ body_token = _typography(tokens, "body", default_size=Pt(12))
1109
+ date_token = _typography(tokens, "body", default_size=Pt(11), default_bold=True)
1110
+ body_color = _palette(tokens, ("neutral",)) or RGBColor(0x22, 0x22, 0x22)
1111
+ pending_color = rail_color
1112
+ done_color = (
1113
+ _palette(tokens, ("positive", "success"))
1114
+ or RGBColor(0x00, 0x8A, 0x3C)
1115
+ )
1116
+
1117
+ for i, ms in enumerate(milestones):
1118
+ cx = positions[i]
1119
+ # Milestone dot.
1120
+ dot = slide.shapes.add_shape(
1121
+ MSO_SHAPE.OVAL,
1122
+ Length(cx - dot_d // 2),
1123
+ Length(rail_y - dot_d // 2),
1124
+ dot_d,
1125
+ dot_d,
1126
+ )
1127
+ dot.fill.solid()
1128
+ dot.fill.fore_color.rgb = (
1129
+ done_color if ms.get("done") else pending_color
1130
+ )
1131
+ dot.line.fill.background()
1132
+ dot.text_frame.text = ""
1133
+
1134
+ date_text = str(ms.get("date", ""))
1135
+ label_text = str(ms.get("label", ""))
1136
+ # Alternate above / below the rail so labels don't fight for the
1137
+ # same vertical space when milestones are close together.
1138
+ above = (i % 2 == 0)
1139
+ if above:
1140
+ date_top = Length(rail_y - dot_d // 2 - Inches(0.6))
1141
+ label_top = Length(date_top + Inches(0.25))
1142
+ else:
1143
+ date_top = Length(rail_y + dot_d // 2 + Inches(0.15))
1144
+ label_top = Length(date_top + Inches(0.3))
1145
+
1146
+ if date_text:
1147
+ db = slide.shapes.add_textbox(
1148
+ Length(cx - label_w // 2), date_top, label_w, Inches(0.3)
1149
+ )
1150
+ _fill_text_frame(
1151
+ db.text_frame, date_text,
1152
+ token=date_token, color=body_color,
1153
+ align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.TOP,
1154
+ shrink_to_fit=True,
1155
+ )
1156
+ if label_text:
1157
+ lb = slide.shapes.add_textbox(
1158
+ Length(cx - label_w // 2), label_top, label_w, Inches(0.7)
1159
+ )
1160
+ _fill_text_frame(
1161
+ lb.text_frame, label_text,
1162
+ token=body_token, color=body_color,
1163
+ align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.TOP,
1164
+ shrink_to_fit=True,
1165
+ )
1166
+
1167
+ _apply_transition(slide, transition)
1168
+ return slide
1169
+
1170
+
1171
+ # ---------------------------------------------------------------------------
1172
+ # comparison_slide
1173
+ # ---------------------------------------------------------------------------
1174
+
1175
+
1176
+ def comparison_slide(
1177
+ prs: "Presentation",
1178
+ *,
1179
+ title: str,
1180
+ left_heading: str,
1181
+ right_heading: str,
1182
+ rows: Sequence[Mapping[str, Any]],
1183
+ tokens: Optional[DesignTokens] = None,
1184
+ transition: Optional[str] = None,
1185
+ ) -> "Slide":
1186
+ """Append a two-column comparison slide with matched left/right rows.
1187
+
1188
+ Each *rows* mapping is ``{"left": str, "right": str}`` (a label
1189
+ column is *not* drawn — the comparison is intended for prose
1190
+ bullets). Rows are evenly spaced down the slide so the left and
1191
+ right entries always line up.
1192
+
1193
+ Tokens consumed:
1194
+
1195
+ * **palette** — title from ``primary`` (fallback ``neutral``);
1196
+ column heading band from ``primary``; heading text from
1197
+ ``on_primary``; row text from ``neutral``; even-row tint from
1198
+ ``surface`` (fallback ``lt2``).
1199
+ * **typography** — ``heading`` for the title and column headings;
1200
+ ``body`` for the row text.
1201
+ """
1202
+ slide = _add_blank(prs)
1203
+ slide_w, slide_h = _slide_dims(prs)
1204
+ _apply_background(slide, prs, tokens)
1205
+
1206
+ margin = Inches(0.6)
1207
+ title_top = Inches(0.5)
1208
+ title_h = Inches(0.9)
1209
+ title_box = slide.shapes.add_textbox(
1210
+ margin, title_top, Length(slide_w - 2 * margin), title_h
1211
+ )
1212
+ _fill_text_frame(
1213
+ title_box.text_frame,
1214
+ title,
1215
+ token=_typography(tokens, "heading", default_size=Pt(28), default_bold=True),
1216
+ color=_palette(tokens, ("primary", "neutral")),
1217
+ align=PP_ALIGN.LEFT,
1218
+ anchor=MSO_ANCHOR.TOP,
1219
+ shrink_to_fit=True,
1220
+ )
1221
+
1222
+ gap = Inches(0.3)
1223
+ col_w = Length((slide_w - 2 * margin - gap) // 2)
1224
+ col_top = Length(title_top + title_h + Inches(0.2))
1225
+ col_h = Length(slide_h - col_top - Inches(0.5))
1226
+
1227
+ head_h = Inches(0.6)
1228
+ head_color = _palette(tokens, ("primary", "neutral")) or RGBColor(0x33, 0x33, 0x33)
1229
+ head_text_color = _palette(tokens, ("on_primary",)) or RGBColor(0xFF, 0xFF, 0xFF)
1230
+ row_text_color = _palette(tokens, ("neutral",)) or RGBColor(0x22, 0x22, 0x22)
1231
+ band_color = _palette(tokens, ("surface", "lt2")) or RGBColor(0xF4, 0xF4, 0xF8)
1232
+ head_token = _typography(tokens, "heading", default_size=Pt(18), default_bold=True)
1233
+ row_token = _typography(tokens, "body", default_size=Pt(14))
1234
+
1235
+ columns = [
1236
+ (margin, left_heading, "left"),
1237
+ (Length(margin + col_w + gap), right_heading, "right"),
1238
+ ]
1239
+
1240
+ n_rows = max(1, len(rows))
1241
+ row_h = Length((col_h - head_h) // n_rows)
1242
+
1243
+ for col_left, heading, key in columns:
1244
+ # Heading band.
1245
+ band = slide.shapes.add_shape(
1246
+ MSO_SHAPE.RECTANGLE, col_left, col_top, col_w, head_h
1247
+ )
1248
+ band.fill.solid()
1249
+ band.fill.fore_color.rgb = head_color
1250
+ band.line.fill.background()
1251
+ _fill_text_frame(
1252
+ band.text_frame,
1253
+ heading,
1254
+ token=head_token,
1255
+ color=head_text_color,
1256
+ align=PP_ALIGN.LEFT,
1257
+ anchor=MSO_ANCHOR.MIDDLE,
1258
+ )
1259
+ # Rows.
1260
+ for i, row in enumerate(rows):
1261
+ r_top = Length(col_top + head_h + i * row_h)
1262
+ if i % 2 == 0:
1263
+ tile = slide.shapes.add_shape(
1264
+ MSO_SHAPE.RECTANGLE, col_left, r_top, col_w, row_h
1265
+ )
1266
+ tile.fill.solid()
1267
+ tile.fill.fore_color.rgb = band_color
1268
+ tile.line.fill.background()
1269
+ tile.text_frame.text = ""
1270
+ text = str(row.get(key, ""))
1271
+ tb = slide.shapes.add_textbox(
1272
+ Length(col_left + Inches(0.2)),
1273
+ Length(r_top + Inches(0.05)),
1274
+ Length(col_w - Inches(0.4)),
1275
+ Length(row_h - Inches(0.1)),
1276
+ )
1277
+ _fill_text_frame(
1278
+ tb.text_frame,
1279
+ text,
1280
+ token=row_token,
1281
+ color=row_text_color,
1282
+ align=PP_ALIGN.LEFT,
1283
+ anchor=MSO_ANCHOR.MIDDLE,
1284
+ shrink_to_fit=True,
1285
+ )
1286
+
1287
+ _apply_transition(slide, transition)
1288
+ return slide
1289
+
1290
+
1291
+ # ---------------------------------------------------------------------------
1292
+ # figure_slide — embed Plotly / Matplotlib / SVG / HTML / image figures.
1293
+ # ---------------------------------------------------------------------------
1294
+
1295
+
1296
+ def figure_slide(
1297
+ prs: "Presentation",
1298
+ *,
1299
+ title: str,
1300
+ figure: Any,
1301
+ caption: Optional[str] = None,
1302
+ figure_format: str = "auto",
1303
+ tokens: Optional[DesignTokens] = None,
1304
+ transition: Optional[str] = None,
1305
+ ) -> "Slide":
1306
+ """Append a title + embedded-figure slide and return it.
1307
+
1308
+ *figure* is dispatched by type:
1309
+
1310
+ * a Plotly ``Figure`` (or anything with ``.to_image``) → rendered
1311
+ via :func:`pptx2.design.figures.add_plotly_figure`.
1312
+ * a Matplotlib ``Figure`` (or anything with ``.savefig``) →
1313
+ :func:`add_matplotlib_figure`.
1314
+ * a string starting with ``"<svg"`` (after lstrip) or ``bytes``
1315
+ whose head matches the SVG sniff → :func:`add_svg_figure`.
1316
+ * a string starting with ``"<"`` (any other tag) → treated as an
1317
+ HTML snippet and rendered via :func:`add_html_figure` (needs
1318
+ Playwright).
1319
+ * a path to a file → routed to ``add_picture`` for raster types
1320
+ (.png/.jpg/.jpeg/.bmp/.tif/.gif) or ``add_svg_picture`` for
1321
+ ``.svg``.
1322
+
1323
+ *figure_format* (``"auto"`` / ``"svg"`` / ``"png"``) is forwarded
1324
+ to the Plotly / Matplotlib adapter; ignored for SVG / HTML / image
1325
+ inputs.
1326
+
1327
+ *caption* is rendered in the bottom-right corner if supplied.
1328
+
1329
+ Tokens consumed:
1330
+
1331
+ * **palette** — title from ``primary`` (fallback ``neutral``);
1332
+ caption from ``muted`` (fallback ``neutral``).
1333
+ * **typography** — ``heading`` for the title; ``body`` for the
1334
+ caption.
1335
+ """
1336
+ slide = _add_blank(prs)
1337
+ slide_w, slide_h = _slide_dims(prs)
1338
+ _apply_background(slide, prs, tokens)
1339
+
1340
+ margin = Inches(0.6)
1341
+ title_top = Inches(0.5)
1342
+ title_h = Inches(0.9)
1343
+ title_box = slide.shapes.add_textbox(
1344
+ margin, title_top, Length(slide_w - 2 * margin), title_h
1345
+ )
1346
+ _fill_text_frame(
1347
+ title_box.text_frame,
1348
+ title,
1349
+ token=_typography(tokens, "heading", default_size=Pt(28), default_bold=True),
1350
+ color=_palette(tokens, ("primary", "neutral")),
1351
+ align=PP_ALIGN.LEFT,
1352
+ anchor=MSO_ANCHOR.TOP,
1353
+ shrink_to_fit=True,
1354
+ )
1355
+
1356
+ fig_top = Length(title_top + title_h + Inches(0.2))
1357
+ cap_h = Inches(0.4) if caption else Length(0)
1358
+ fig_h = Length(slide_h - fig_top - Inches(0.5) - cap_h)
1359
+ fig_w = Length(slide_w - 2 * margin)
1360
+
1361
+ _embed_figure(slide, figure, margin, fig_top, fig_w, fig_h, figure_format)
1362
+
1363
+ if caption:
1364
+ cap_top = Length(slide_h - Inches(0.5) - cap_h)
1365
+ cap_box = slide.shapes.add_textbox(
1366
+ margin, cap_top, fig_w, cap_h
1367
+ )
1368
+ _fill_text_frame(
1369
+ cap_box.text_frame,
1370
+ caption,
1371
+ token=_typography(tokens, "body", default_size=Pt(12), default_italic=True),
1372
+ color=_palette(tokens, ("muted", "neutral")),
1373
+ align=PP_ALIGN.RIGHT,
1374
+ anchor=MSO_ANCHOR.TOP,
1375
+ )
1376
+
1377
+ _apply_transition(slide, transition)
1378
+ return slide
1379
+
1380
+
1381
+ _RASTER_EXTS = frozenset({".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tif", ".tiff", ".webp"})
1382
+
1383
+
1384
+ def _embed_figure(
1385
+ slide: "Slide",
1386
+ figure: Any,
1387
+ left: Length,
1388
+ top: Length,
1389
+ width: Length,
1390
+ height: Length,
1391
+ figure_format: str,
1392
+ ) -> None:
1393
+ """Dispatch *figure* by type to the matching figures-module adapter."""
1394
+ from pptx2.design import figures as _figures
1395
+
1396
+ # Plotly figure: detected by duck-typed .to_image().
1397
+ if hasattr(figure, "to_image") and callable(figure.to_image):
1398
+ _figures.add_plotly_figure(
1399
+ slide, figure, left, top, width, height, format=figure_format
1400
+ )
1401
+ return
1402
+
1403
+ # Matplotlib figure: detected by duck-typed .savefig().
1404
+ if hasattr(figure, "savefig") and callable(figure.savefig):
1405
+ _figures.add_matplotlib_figure(
1406
+ slide, figure, left, top, width, height, format=figure_format
1407
+ )
1408
+ return
1409
+
1410
+ # File path: dispatch by extension.
1411
+ if isinstance(figure, (str, os.PathLike)) and not _is_markup_string(figure):
1412
+ ext = os.path.splitext(str(figure))[1].lower()
1413
+ if ext == ".svg":
1414
+ _figures.add_svg_figure(slide, figure, left, top, width, height)
1415
+ return
1416
+ if ext in _RASTER_EXTS:
1417
+ slide.shapes.add_picture(str(figure), left, top, width, height)
1418
+ return
1419
+ # Unknown extension — best-effort raster.
1420
+ slide.shapes.add_picture(str(figure), left, top, width, height)
1421
+ return
1422
+
1423
+ # Bytes / strings: SVG sniff vs HTML.
1424
+ if isinstance(figure, (bytes, bytearray, str)):
1425
+ head = figure if isinstance(figure, (bytes, bytearray)) else figure.encode("utf-8", "replace")
1426
+ head = bytes(head[:512]).lstrip()
1427
+ if head.startswith(b"<?xml") or b"<svg" in head[:200]:
1428
+ _figures.add_svg_figure(slide, figure, left, top, width, height)
1429
+ return
1430
+ if head.startswith(b"<"):
1431
+ _figures.add_html_figure(slide, figure, left, top, width, height)
1432
+ return
1433
+
1434
+ raise TypeError(
1435
+ f"figure_slide can't dispatch a figure of type {type(figure).__name__!r}; "
1436
+ "pass a Plotly Figure, a Matplotlib Figure, an SVG / HTML "
1437
+ "string, an image path, or raw bytes."
1438
+ )
1439
+
1440
+
1441
+ def _is_markup_string(value: Any) -> bool:
1442
+ """Return True when *value* looks like inline SVG / HTML rather than a path.
1443
+
1444
+ Markup detection has to come *before* the path-separator check because
1445
+ inline SVG routinely contains namespace URLs (``xmlns="http://..."``)
1446
+ whose ``/`` characters would otherwise mis-route the figure to
1447
+ :meth:`add_picture` and raise :class:`FileNotFoundError`.
1448
+
1449
+ Recognised markup forms:
1450
+
1451
+ * ``<?xml`` declarations
1452
+ * ``<!DOCTYPE`` declarations
1453
+ * ``<!--`` comments
1454
+ * ``<svg``, ``<html``, or any other ``<tagname`` opening tag
1455
+ * ``</tagname>`` closing tags
1456
+
1457
+ Anything else starting with ``<`` (e.g. an exotic filename) falls
1458
+ through to the path heuristic.
1459
+ """
1460
+ if not isinstance(value, str):
1461
+ return False
1462
+ s = value.lstrip()
1463
+ if not s.startswith("<"):
1464
+ return False
1465
+ # XML declarations, doctypes, comments.
1466
+ if s.startswith(("<?xml", "<!DOCTYPE", "<!--", "<svg", "<html")):
1467
+ return True
1468
+ # ``<tagname`` (opening) or ``</tagname>`` (closing) — both are
1469
+ # markup; differentiate from a stray ``<`` in a filename by
1470
+ # requiring an ASCII letter after the optional ``/``.
1471
+ rest = s[2:] if s.startswith("</") else s[1:]
1472
+ if rest and rest[0].isalpha():
1473
+ return True
1474
+ # Truly unrecognised ``<…``: treat as a path (rare on real input,
1475
+ # but preserves the historical behaviour for the corner case of
1476
+ # weird filenames that happen to start with ``<``).
1477
+ return False
1478
+
1479
+
1480
+ # ---------------------------------------------------------------------------
1481
+ # Internal helpers
1482
+ # ---------------------------------------------------------------------------
1483
+
1484
+
1485
+ def _add_blank(prs: "Presentation") -> "Slide":
1486
+ layouts = prs.slide_layouts
1487
+ blank = layouts.get_by_name("Blank")
1488
+ if blank is None:
1489
+ blank = layouts[-1]
1490
+ return prs.slides.add_slide(blank)
1491
+
1492
+
1493
+ def _apply_background(
1494
+ slide: "Slide", prs: "Presentation", tokens: Optional[DesignTokens]
1495
+ ) -> None:
1496
+ """Lay down a full-bleed ``palette["background"]`` rectangle behind a recipe.
1497
+
1498
+ No-op unless the token set defines a ``background`` palette slot, so the
1499
+ default (master-inherited white) is preserved for callers who don't opt
1500
+ in. When set, a deck that mixes hand-built slides (which honour the token
1501
+ background) with recipe slides stays visually consistent instead of
1502
+ showing two different "whites". The rectangle is added first so it sits
1503
+ behind every other shape the recipe places.
1504
+ """
1505
+ if tokens is None:
1506
+ return
1507
+ bg = tokens.palette.get("background")
1508
+ if bg is None:
1509
+ return
1510
+ slide_w, slide_h = _slide_dims(prs)
1511
+ rect = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Emu(0), Emu(0), slide_w, slide_h)
1512
+ rect.fill.solid()
1513
+ rect.fill.fore_color.rgb = bg
1514
+ rect.line.fill.background()
1515
+ rect.text_frame.text = ""
1516
+
1517
+
1518
+ def _slide_dims(prs: "Presentation") -> tuple[Length, Length]:
1519
+ width = prs.slide_width or Inches(13.333)
1520
+ height = prs.slide_height or Inches(7.5)
1521
+ return width, height
1522
+
1523
+
1524
+ def _palette(
1525
+ tokens: Optional[DesignTokens], names: Sequence[str]
1526
+ ) -> Optional[RGBColor]:
1527
+ if tokens is None:
1528
+ return None
1529
+ for name in names:
1530
+ rgb = tokens.palette.get(name)
1531
+ if rgb is not None:
1532
+ return rgb
1533
+ return None
1534
+
1535
+
1536
+ def _typography(
1537
+ tokens: Optional[DesignTokens],
1538
+ name: str,
1539
+ *,
1540
+ default_size: Length,
1541
+ default_bold: Optional[bool] = None,
1542
+ default_italic: Optional[bool] = None,
1543
+ ) -> TypographyToken:
1544
+ """Return the named token from *tokens* or a sensible default."""
1545
+ if tokens is not None:
1546
+ existing = tokens.typography.get(name)
1547
+ if existing is not None:
1548
+ # Backfill any unset fields from the recipe defaults so callers
1549
+ # don't have to repeat them.
1550
+ return TypographyToken(
1551
+ family=existing.family,
1552
+ size=existing.size if existing.size is not None else default_size,
1553
+ bold=existing.bold if existing.bold is not None else default_bold,
1554
+ italic=existing.italic if existing.italic is not None else default_italic,
1555
+ color=existing.color,
1556
+ )
1557
+ return TypographyToken(
1558
+ family="Calibri",
1559
+ size=default_size,
1560
+ bold=default_bold,
1561
+ italic=default_italic,
1562
+ )
1563
+
1564
+
1565
+ def _apply_typography(font: Any, token: TypographyToken) -> None:
1566
+ font.name = token.family
1567
+ if token.size is not None:
1568
+ font.size = token.size
1569
+ if token.bold is not None:
1570
+ font.bold = token.bold
1571
+ if token.italic is not None:
1572
+ font.italic = token.italic
1573
+ if token.color is not None:
1574
+ font.color.rgb = token.color
1575
+
1576
+
1577
+ def _fill_text_frame(
1578
+ text_frame: Any,
1579
+ text: str,
1580
+ *,
1581
+ token: TypographyToken,
1582
+ color: Optional[RGBColor],
1583
+ align: PP_ALIGN,
1584
+ anchor: MSO_ANCHOR,
1585
+ word_wrap: bool = True,
1586
+ shrink_to_fit: bool = False,
1587
+ ) -> None:
1588
+ text_frame.word_wrap = word_wrap
1589
+ text_frame.vertical_anchor = anchor
1590
+ if shrink_to_fit:
1591
+ # Tell PowerPoint to scale the text down when it doesn't fit
1592
+ # the reserved frame. Used by the recipe titles whose
1593
+ # text-region height is fixed by the recipe geometry — without
1594
+ # this, a long title wraps to a second line that overlaps the
1595
+ # body region below (see IMPROVEMENTS item 11).
1596
+ from pptx2.enum.text import MSO_AUTO_SIZE
1597
+
1598
+ text_frame.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
1599
+ para = text_frame.paragraphs[0]
1600
+ para.alignment = align
1601
+ # Clear any default text and add a fresh run we can style.
1602
+ para.text = ""
1603
+ run = para.add_run()
1604
+ run.text = text
1605
+ _apply_typography(run.font, token)
1606
+ if color is not None:
1607
+ run.font.color.rgb = color
1608
+
1609
+
1610
+ def _apply_transition(slide: "Slide", transition: Optional[str]) -> None:
1611
+ if not transition:
1612
+ return
1613
+ from pptx2.enum.presentation import MSO_TRANSITION_TYPE
1614
+
1615
+ key = transition.upper().replace("-", "_")
1616
+ member = getattr(MSO_TRANSITION_TYPE, key, None)
1617
+ if member is None:
1618
+ raise ValueError(
1619
+ f"Unknown transition {transition!r}; "
1620
+ f"see pptx2.enum.presentation.MSO_TRANSITION_TYPE for valid values"
1621
+ )
1622
+ slide.transition.kind = member
1623
+
1624
+
1625
+ def _delta_color(tokens: Optional[DesignTokens], sign: int) -> RGBColor:
1626
+ """Return the palette color tinting a delta with ``sign`` (+1 / -1 / 0).
1627
+
1628
+ ``sign == 0`` and ``sign > 0`` both use the positive slot; only
1629
+ strictly-negative deltas use the negative slot.
1630
+ """
1631
+ if sign >= 0:
1632
+ return _palette(tokens, ("positive", "success")) or RGBColor(0x00, 0x8A, 0x3C)
1633
+ return _palette(tokens, ("negative", "danger")) or RGBColor(0xCC, 0x00, 0x00)
1634
+
1635
+
1636
+ # Magnitude at or below which a numeric delta is treated as a fraction
1637
+ # (``0.27`` → ``+27%``). Outside this range we render the raw number
1638
+ # with one decimal so callers who already pass percentages — ``14.0`` —
1639
+ # don't get them silently multiplied by 100.
1640
+ _DELTA_FRACTION_LIMIT = 1.0
1641
+
1642
+
1643
+ def _resolve_delta(kpi: Mapping[str, Any]) -> tuple[Optional[str], int]:
1644
+ """Resolve the formatted delta string and sign from a KPI dict.
1645
+
1646
+ Returns ``(text, sign)`` where *text* is ``None`` when no delta was
1647
+ supplied. The auto-detect rules:
1648
+
1649
+ * ``delta_text="…"`` — explicit string passthrough wins outright.
1650
+ * ``delta="…"`` (string) — used verbatim; sign is inferred from a
1651
+ leading ``-`` / ``−`` if present.
1652
+ * ``delta`` (numeric) with ``|delta| <= 1.0`` — formatted as a
1653
+ signed percentage.
1654
+ * ``delta`` (numeric) with ``|delta| > 1.0`` — formatted as a
1655
+ signed number with one decimal place.
1656
+ """
1657
+ explicit = kpi.get("delta_text")
1658
+ if explicit is not None:
1659
+ s = str(explicit)
1660
+ sign = -1 if s.lstrip().startswith(("-", "−", "–")) else 1
1661
+ return s, sign
1662
+
1663
+ delta = kpi.get("delta")
1664
+ if delta is None:
1665
+ return None, 0
1666
+ if isinstance(delta, str):
1667
+ sign = -1 if delta.lstrip().startswith(("-", "−", "–")) else 1
1668
+ return delta, sign
1669
+
1670
+ d_val = float(delta)
1671
+ sign = -1 if d_val < 0 else 1
1672
+ sign_glyph = "+" if d_val >= 0 else "−" # proper minus glyph
1673
+ if abs(d_val) <= _DELTA_FRACTION_LIMIT:
1674
+ text = f"{sign_glyph}{abs(d_val):.0%}"
1675
+ else:
1676
+ text = f"{sign_glyph}{abs(d_val):.1f}"
1677
+ return text, sign
1678
+
1679
+
1680
+ def _pygments_highlight(
1681
+ code: str, language: Optional[str]
1682
+ ) -> Optional[list[list[tuple[str, Optional[RGBColor]]]]]:
1683
+ """Return per-line lists of ``(text, RGBColor)`` runs, or ``None``.
1684
+
1685
+ ``None`` covers every soft-failure path: Pygments missing, lexer
1686
+ unknown, formatter error. Callers fall back to plain text in that
1687
+ case so the slide still renders.
1688
+ """
1689
+ if not language:
1690
+ return None
1691
+ try:
1692
+ from pygments import lex # type: ignore[import-not-found]
1693
+ from pygments.lexers import get_lexer_by_name # type: ignore[import-not-found]
1694
+ from pygments.token import Token # type: ignore[import-not-found]
1695
+ except ImportError:
1696
+ return None
1697
+ try:
1698
+ lexer = get_lexer_by_name(language)
1699
+ except Exception:
1700
+ return None
1701
+
1702
+ # A small token-class → hex map. Pygments has built-in styles but
1703
+ # they're tuned for HTML output; using a hand-picked map keeps the
1704
+ # slide colors in a single, reviewable place.
1705
+ #
1706
+ # Operator/Punctuation/Text are mapped to the sentinel ``None`` so
1707
+ # they fall through to ``fallback_color`` at render time. Hard-coding
1708
+ # ``#D4D4D4`` (a light grey) here would render as nearly-invisible
1709
+ # punctuation on light token themes — and ``.`` in member access
1710
+ # (``optimiser.zero_grad``) is its own Pygments token, so the
1711
+ # consequence was code reading as ``optimiser zero_grad`` on slides
1712
+ # that used a light surface. Routing these through ``fallback_color``
1713
+ # keeps them legible on whichever theme the caller is using.
1714
+ color_map: dict[Any, Optional[str]] = {
1715
+ Token.Keyword: "#C586C0",
1716
+ Token.Keyword.Constant: "#569CD6",
1717
+ Token.Keyword.Namespace: "#C586C0",
1718
+ Token.Name.Function: "#DCDCAA",
1719
+ Token.Name.Class: "#4EC9B0",
1720
+ Token.Name.Builtin: "#4EC9B0",
1721
+ Token.Name.Decorator: "#DCDCAA",
1722
+ Token.String: "#CE9178",
1723
+ Token.String.Doc: "#608B4E",
1724
+ Token.Number: "#B5CEA8",
1725
+ Token.Comment: "#6A9955",
1726
+ Token.Comment.Single: "#6A9955",
1727
+ Token.Comment.Multiline: "#6A9955",
1728
+ Token.Operator: None,
1729
+ Token.Punctuation: None,
1730
+ Token.Text: None,
1731
+ }
1732
+
1733
+ def _color_for(tok_type: Any) -> Optional[RGBColor]:
1734
+ # Pygments token types are hierarchical: walk up to the nearest
1735
+ # mapped ancestor so e.g. ``Token.String.Single`` reuses the
1736
+ # ``Token.String`` color. A mapped entry of ``None`` means
1737
+ # "use the fallback colour" — explicit no-color rather than
1738
+ # falling further up the hierarchy.
1739
+ t = tok_type
1740
+ while t is not None:
1741
+ if t in color_map:
1742
+ hex_str = color_map[t]
1743
+ if hex_str is None:
1744
+ return None
1745
+ hex_str = hex_str.lstrip("#")
1746
+ return RGBColor(
1747
+ int(hex_str[0:2], 16),
1748
+ int(hex_str[2:4], 16),
1749
+ int(hex_str[4:6], 16),
1750
+ )
1751
+ t = getattr(t, "parent", None)
1752
+ return None
1753
+
1754
+ try:
1755
+ tokens = list(lex(code, lexer))
1756
+ except Exception:
1757
+ return None
1758
+
1759
+ lines: list[list[tuple[str, Optional[RGBColor]]]] = [[]]
1760
+ for tok_type, value in tokens:
1761
+ rgb = _color_for(tok_type)
1762
+ # Pygments emits newlines as their own runs / inside strings;
1763
+ # split here so we honour them as paragraph breaks rather than
1764
+ # rendering literal newline characters in a single-line run.
1765
+ chunks = value.split("\n")
1766
+ for j, chunk in enumerate(chunks):
1767
+ if chunk:
1768
+ lines[-1].append((chunk, rgb))
1769
+ if j < len(chunks) - 1:
1770
+ lines.append([])
1771
+ return lines
1772
+
1773
+
1774
+ _ALIGN_MAP: dict[str, PP_ALIGN] = {
1775
+ "left": PP_ALIGN.LEFT,
1776
+ "center": PP_ALIGN.CENTER,
1777
+ "right": PP_ALIGN.RIGHT,
1778
+ }
1779
+
1780
+
1781
+ def _coerce_aligns(
1782
+ aligns: Optional[Sequence[str]], n_cols: int
1783
+ ) -> list[PP_ALIGN]:
1784
+ if aligns is None:
1785
+ return [PP_ALIGN.LEFT] * n_cols
1786
+ out: list[PP_ALIGN] = []
1787
+ for i in range(n_cols):
1788
+ if i >= len(aligns):
1789
+ out.append(PP_ALIGN.LEFT)
1790
+ continue
1791
+ a = aligns[i]
1792
+ if isinstance(a, PP_ALIGN):
1793
+ out.append(a)
1794
+ continue
1795
+ key = str(a).lower()
1796
+ if key not in _ALIGN_MAP:
1797
+ raise ValueError(
1798
+ f"align[{i}] must be one of {sorted(_ALIGN_MAP)}, got {a!r}"
1799
+ )
1800
+ out.append(_ALIGN_MAP[key])
1801
+ return out
1802
+
1803
+
1804
+ def _apply_column_widths(
1805
+ table: Any,
1806
+ widths: Sequence[Union[float, Length]],
1807
+ table_w: Length,
1808
+ n_cols: int,
1809
+ ) -> None:
1810
+ """Set table column widths from a sequence of fractions or Lengths.
1811
+
1812
+ Supports two input modes:
1813
+
1814
+ * **Fractions**: values < 5 are treated as fractions of *table_w*;
1815
+ they should sum to ~1.0 (we normalise on a defensive basis so a
1816
+ slightly-off list still lays out reasonably).
1817
+ * **Absolute lengths**: any :class:`Length` (or value ≥ 5) is taken
1818
+ verbatim.
1819
+
1820
+ Columns past ``len(widths)`` keep their default share of the
1821
+ remaining width.
1822
+ """
1823
+ total_table_w = int(table_w)
1824
+ spec_lengths: list[int] = []
1825
+ spec_is_fraction = False
1826
+ for w in widths[:n_cols]:
1827
+ if isinstance(w, Length):
1828
+ spec_lengths.append(int(w))
1829
+ elif isinstance(w, (int, float)) and float(w) < 5:
1830
+ spec_is_fraction = True
1831
+ spec_lengths.append(int(round(float(w) * total_table_w)))
1832
+ else:
1833
+ spec_lengths.append(int(w))
1834
+
1835
+ # Normalise fractions defensively if they don't sum to ~1.
1836
+ if spec_is_fraction:
1837
+ total_spec = sum(spec_lengths)
1838
+ if total_spec > 0 and abs(total_spec - total_table_w) > total_table_w * 0.01:
1839
+ scale = total_table_w / total_spec
1840
+ spec_lengths = [int(round(v * scale)) for v in spec_lengths]
1841
+
1842
+ used = sum(spec_lengths)
1843
+ remaining = max(0, total_table_w - used)
1844
+ unspecified = n_cols - len(spec_lengths)
1845
+ fill = (remaining // unspecified) if unspecified > 0 else 0
1846
+
1847
+ for i in range(n_cols):
1848
+ col = table.columns[i]
1849
+ if i < len(spec_lengths):
1850
+ col.width = Emu(spec_lengths[i])
1851
+ else:
1852
+ col.width = Emu(fill)
1853
+
1854
+
1855
+ def _coerce_totals_row(
1856
+ totals: Mapping[str, Any], n_cols: int
1857
+ ) -> list[Any]:
1858
+ """Resolve a totals-row spec into a per-column list of cell values."""
1859
+ if "row" in totals:
1860
+ row = list(totals["row"])
1861
+ if len(row) != n_cols:
1862
+ raise ValueError(
1863
+ f"totals.row must have {n_cols} entries, got {len(row)}"
1864
+ )
1865
+ return row
1866
+ label = totals.get("label", "Total")
1867
+ values = list(totals.get("values", []))
1868
+ # Right-pad values with empty strings, place label in column 0,
1869
+ # values fill from the right. This matches how spreadsheet
1870
+ # totals usually read.
1871
+ out: list[Any] = [""] * n_cols
1872
+ out[0] = label
1873
+ if values:
1874
+ # Place values in the *last* len(values) columns.
1875
+ start = max(1, n_cols - len(values))
1876
+ for i, v in enumerate(values):
1877
+ if start + i < n_cols:
1878
+ out[start + i] = v
1879
+ return out
1880
+
1881
+
1882
+ _TOKEN_CHART_SLOTS: tuple[str, ...] = (
1883
+ "primary",
1884
+ "accent1", "accent2", "accent3", "accent4", "accent5", "accent6",
1885
+ "secondary", "tertiary",
1886
+ "positive", "negative",
1887
+ "muted", "neutral",
1888
+ )
1889
+
1890
+
1891
+ def _token_chart_palette(
1892
+ tokens: Optional[DesignTokens],
1893
+ ) -> Optional[list[Any]]:
1894
+ """Build an ordered chart palette from a token set, or ``None``.
1895
+
1896
+ Pulls every chart-suitable slot in priority order (primary first,
1897
+ then accent1..6, then positive / negative, then muted / neutral as
1898
+ filler) and de-duplicates by RGB. Returns ``None`` when fewer than
1899
+ two distinct colours are available — a one-colour palette would
1900
+ just paint every series the same hue, which is worse than
1901
+ PowerPoint's default theme colours.
1902
+ """
1903
+ if tokens is None:
1904
+ return None
1905
+ seen: set[tuple[int, int, int]] = set()
1906
+ out: list[Any] = []
1907
+ for slot in _TOKEN_CHART_SLOTS:
1908
+ rgb = tokens.palette.get(slot)
1909
+ if rgb is None:
1910
+ continue
1911
+ key = (int(rgb[0]), int(rgb[1]), int(rgb[2]))
1912
+ if key in seen:
1913
+ continue
1914
+ seen.add(key)
1915
+ out.append(rgb)
1916
+ return out if len(out) >= 2 else None
1917
+
1918
+
1919
+ def _apply_card_styling(shape: Any, tokens: Optional[DesignTokens]) -> None:
1920
+ """Apply the ``shadows.card`` and ``radii.md`` tokens to *shape*.
1921
+
1922
+ A no-op for token sets that don't define those slots, so it's safe
1923
+ to call unconditionally from recipes. When ``radii.md`` is present
1924
+ *and* the shape is a rounded rectangle, the adjustment value is
1925
+ nudged to roughly match the requested corner radius (the OOXML
1926
+ ``adj`` is a fraction of the smaller bbox edge, so we clamp to
1927
+ ``[0, 0.5]`` to avoid overlapping curves on small cards).
1928
+ """
1929
+ if tokens is None:
1930
+ return
1931
+ shadow = tokens.shadows.get("card")
1932
+ if shadow is not None:
1933
+ try:
1934
+ shape.style.shadow = shadow
1935
+ except Exception:
1936
+ # Some shape types (graphic frames, group shapes) don't
1937
+ # carry a shadow facade. Silently skip rather than fail
1938
+ # the whole recipe — the caller can layer one on by hand.
1939
+ pass
1940
+ md = tokens.radii.get("md")
1941
+ if md is not None:
1942
+ try:
1943
+ # ``ROUNDED_RECTANGLE`` exposes a single adjustment whose
1944
+ # value is a fraction (0..50000 maps to 0..0.5 of the
1945
+ # shorter edge). Translating ``radii.md`` directly is
1946
+ # approximate but visually consistent across card sizes.
1947
+ adj_list = shape.adjustments
1948
+ if len(adj_list) >= 1:
1949
+ short_edge = min(int(shape.width or 1), int(shape.height or 1))
1950
+ if short_edge > 0:
1951
+ frac = max(0.0, min(0.5, float(md) / float(short_edge)))
1952
+ adj_list[0] = frac
1953
+ except Exception:
1954
+ pass
1955
+
1956
+
1957
+ def _strip_attribution_dash(attribution: str) -> str:
1958
+ """Remove a leading dash variant + whitespace from *attribution*.
1959
+
1960
+ Handles ``-``, ``–`` (en-dash), and ``—`` (em-dash) so callers can
1961
+ pass either ``"Person"`` or ``"— Person"`` without producing
1962
+ ``"— — Person"`` in the rendered slide.
1963
+ """
1964
+ s = attribution.lstrip()
1965
+ while s and s[0] in ("-", "–", "—"):
1966
+ s = s[1:].lstrip()
1967
+ return s