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,1094 @@
1
+ """JSON / YAML-driven presentation authoring — the ``from_spec`` entry point.
2
+
3
+ This module exposes :func:`from_spec` (dict input) and :func:`from_yaml`
4
+ (YAML file input), both returning a fully-populated
5
+ :class:`~pptx2.api.Presentation`.
6
+
7
+ The spec dispatches to the styled :mod:`pptx2.design.recipes` library by
8
+ default, so layout names like ``"kpi"`` / ``"chart"`` / ``"timeline"``
9
+ produce token-driven recipe slides instead of bare placeholder text.
10
+ The original placeholder-based aliases (``"title"``, ``"bullets"``,
11
+ ``"two_column"``, …) are still available — they're useful when the
12
+ spec is meant to populate an existing branded template.
13
+
14
+ Example::
15
+
16
+ from pptx2.compose import from_spec
17
+
18
+ prs = from_spec({
19
+ "tokens": {"preset": "modern_light"},
20
+ "vars": {"company": "ACME"},
21
+ "slides": [
22
+ {
23
+ "layout": "title",
24
+ "title": "{{company}} Q4 Review",
25
+ "subtitle": "April 2026",
26
+ "transition": "morph",
27
+ },
28
+ {
29
+ "layout": "kpi", # routes to recipes.kpi_slide
30
+ "title": "Run-rate metrics",
31
+ "kpis": [
32
+ {"label": "ARR", "value": "$182M", "delta": 0.27},
33
+ {"label": "NDR", "value": "131%", "delta": 0.03},
34
+ ],
35
+ },
36
+ {
37
+ "layout": "chart",
38
+ "title": "Revenue by quarter",
39
+ "chart_type": "line",
40
+ "categories": ["Q1", "Q2", "Q3"],
41
+ "series": [{"name": "ARR", "values": [82, 110, 132]}],
42
+ },
43
+ ],
44
+ "lint": "raise",
45
+ })
46
+
47
+ YAML usage::
48
+
49
+ from pptx2.compose import from_yaml
50
+ prs = from_yaml("deck.yml", vars={"company": "ACME"})
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import re
56
+ from typing import Any, Iterable, Mapping, Optional
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Built-in layout aliases — map friendly names to the PowerPoint blank
60
+ # template's named layouts (from SlideLayouts collection).
61
+ # ---------------------------------------------------------------------------
62
+
63
+ _LAYOUT_ALIASES: dict[str, str] = {
64
+ "title": "Title Slide",
65
+ "bullets": "Title and Content",
66
+ "section": "Section Header",
67
+ "two_column": "Two Content",
68
+ # The bare ``comparison`` alias is intentionally absent here — that
69
+ # name now exclusively routes to the ``comparison_slide`` recipe.
70
+ # Use ``comparison_layout`` to opt in to the placeholder-based
71
+ # layout from the underlying template.
72
+ "comparison_layout": "Comparison",
73
+ "title_only": "Title Only",
74
+ "blank": "Blank",
75
+ "caption": "Content with Caption",
76
+ "picture": "Picture with Caption",
77
+ "kpi_grid": "Title Only", # rendered via shapes on top of Title Only
78
+ }
79
+
80
+ # Lowercase transition name → MSO_TRANSITION_TYPE member name.
81
+ _TRANSITION_NAMES: dict[str, str] = {
82
+ "none": "NONE",
83
+ "fade": "FADE",
84
+ "push": "PUSH",
85
+ "wipe": "WIPE",
86
+ "split": "SPLIT",
87
+ "random_bar": "RANDOM_BAR",
88
+ "circle": "CIRCLE",
89
+ "dissolve": "DISSOLVE",
90
+ "checker": "CHECKER",
91
+ "diamond": "DIAMOND",
92
+ "plus": "PLUS",
93
+ "wedge": "WEDGE",
94
+ "zoom": "ZOOM",
95
+ "newsflash": "NEWSFLASH",
96
+ "cover": "COVER",
97
+ "strips": "STRIPS",
98
+ "cut": "CUT",
99
+ "blinds": "BLINDS",
100
+ "pull": "PULL",
101
+ "random": "RANDOM",
102
+ "wheel": "WHEEL",
103
+ "morph": "MORPH",
104
+ "fly_through": "FLY_THROUGH",
105
+ "vortex": "VORTEX",
106
+ "switch": "SWITCH",
107
+ "gallery": "GALLERY",
108
+ "conveyor": "CONVEYOR",
109
+ }
110
+
111
+
112
+ def from_spec(
113
+ spec: dict[str, Any],
114
+ *,
115
+ vars: Optional[Mapping[str, Any]] = None,
116
+ ) -> Any:
117
+ """Return a :class:`~pptx2.api.Presentation` built from the plain-dict *spec*.
118
+
119
+ *spec* keys:
120
+
121
+ ``slides`` *(required)*
122
+ A list of slide-spec dicts. Each slide dict must have a
123
+ ``layout`` key. Recipe layouts (``title_recipe``, ``bullets_recipe``,
124
+ ``kpi``, ``chart``, ``table``, ``code``, ``timeline``,
125
+ ``comparison``, ``quote``, ``image_hero``, ``section_divider``)
126
+ run the matching :mod:`pptx2.design.recipes` function;
127
+ legacy layouts (``title``, ``bullets``, ``two_column``, …)
128
+ populate the standard placeholder layouts.
129
+
130
+ A slide dict may also carry a ``shapes`` list of extra shapes
131
+ drawn on top of whatever the layout produced — see
132
+ :func:`_add_spec_shapes` for the full shape-entry surface,
133
+ including the ``lint_group`` / ``allow_overlap_with`` /
134
+ ``layer`` / ``layer_above`` fields that declare an overlap as
135
+ intentional at generation time.
136
+
137
+ ``tokens`` *(optional)*
138
+ Either a preset name (``{"preset": "modern_light"}``), a path
139
+ to a YAML file (``{"yaml": "brand.yml"}``), an inline token
140
+ dict, a mix of preset + ``overrides`` for per-deck tweaks, or
141
+ an already-built :class:`~pptx2.design.tokens.DesignTokens`
142
+ instance.
143
+
144
+ ``slide_size`` *(optional)*
145
+ Resize the slides to the given dimensions. Accepts shorthand
146
+ strings (``"16:9"``, ``"widescreen"``, ``"4:3"``, ``"a4"``)
147
+ or an explicit ``(width, height)`` pair / dict
148
+ (``{"width": 13.333, "height": 7.5}``). Numbers are
149
+ interpreted as inches. See :func:`_apply_slide_size` for the
150
+ resolver implementation and ``_SLIDE_SIZE_PRESETS`` for the
151
+ full list of named aspect ratios.
152
+
153
+ ``vars`` *(optional)*
154
+ Variable bag for ``{{name}}`` interpolation in any string field
155
+ of the spec. Spec-level ``vars`` are layered under any *vars*
156
+ argument passed to :func:`from_spec` (the kwarg wins).
157
+
158
+ ``lint`` *(optional)*
159
+ ``"off"`` (default), ``"warn"``, or ``"raise"``.
160
+
161
+ ``template`` *(optional)*
162
+ Path to a ``.pptx`` or ``.potx`` file to use as the base template
163
+ instead of the default blank template.
164
+
165
+ Raises:
166
+ :class:`~pptx2.exc.LintError` when ``lint == "raise"`` and the linter
167
+ finds errors.
168
+ :class:`ValueError` for unrecognised keys or invalid values.
169
+ """
170
+ if not isinstance(spec, dict):
171
+ raise TypeError(f"spec must be a dict, got {type(spec).__name__!r}")
172
+
173
+ _validate_spec_keys(spec)
174
+
175
+ # Resolve interpolation variables: kwarg overrides spec-level.
176
+ merged_vars: dict[str, Any] = {}
177
+ spec_vars = spec.get("vars")
178
+ if spec_vars is not None:
179
+ if not isinstance(spec_vars, Mapping):
180
+ raise ValueError("spec 'vars' must be a mapping")
181
+ merged_vars.update(spec_vars)
182
+ if vars is not None:
183
+ merged_vars.update(vars)
184
+
185
+ # Always interpolate — even with no vars, a stray ``{{name}}`` in
186
+ # the spec should raise rather than silently rendering as the
187
+ # literal placeholder.
188
+ spec = _interpolate(spec, merged_vars)
189
+
190
+ from pptx2 import Presentation
191
+
192
+ template = spec.get("template")
193
+ prs = Presentation(template) if template else Presentation()
194
+
195
+ # ``theme`` is treated as a friendly alias for ``tokens`` when
196
+ # ``tokens`` is absent. Pre-IMPROVEMENTS-#8 the key was in
197
+ # ``_VALID_TOP_KEYS`` and silently ignored, so docs that read
198
+ # ``{"theme": {...}}`` produced an unstyled deck without error.
199
+ token_spec = spec.get("tokens")
200
+ if token_spec is None:
201
+ token_spec = spec.get("theme")
202
+ tokens = _resolve_tokens(token_spec)
203
+
204
+ slide_size = spec.get("slide_size")
205
+ if slide_size is not None:
206
+ _apply_slide_size(prs, slide_size)
207
+
208
+ slide_specs = spec.get("slides", [])
209
+ # Shape names are collected up-front so a bad ``allow_overlap_with``
210
+ # reference can say *why* it failed — unknown everywhere vs. defined
211
+ # on a different slide (see ``_apply_overlap_allowances``).
212
+ deck_shape_names = _collect_shape_names(slide_specs)
213
+ for slide_index, slide_spec in enumerate(slide_specs):
214
+ _add_slide(
215
+ prs,
216
+ slide_spec,
217
+ tokens,
218
+ slide_index=slide_index,
219
+ deck_shape_names=deck_shape_names,
220
+ )
221
+
222
+ lint_mode = spec.get("lint", "off")
223
+ if lint_mode != "off":
224
+ _run_lint(prs, lint_mode)
225
+
226
+ return prs
227
+
228
+
229
+ def from_yaml(
230
+ path: str,
231
+ *,
232
+ vars: Optional[Mapping[str, Any]] = None,
233
+ ) -> Any:
234
+ """Load a deck spec from *path* (YAML file) and run :func:`from_spec`.
235
+
236
+ Requires ``pyyaml`` (``pip install pyyaml``). The YAML file must
237
+ parse to a top-level mapping; the same keys :func:`from_spec`
238
+ accepts are valid here. Variable interpolation (*vars*) is
239
+ threaded through unchanged so YAML decks parameterise cleanly::
240
+
241
+ prs = from_yaml("deck.yml", vars={"company": "ACME", "quarter": "Q4"})
242
+ """
243
+ try:
244
+ import yaml # type: ignore[import-not-found]
245
+ except ImportError as exc: # pragma: no cover - import guard
246
+ raise ImportError(
247
+ "from_yaml requires pyyaml; install with `pip install pyyaml`"
248
+ ) from exc
249
+ with open(path, "r", encoding="utf-8") as f:
250
+ spec = yaml.safe_load(f) or {}
251
+ if not isinstance(spec, dict):
252
+ raise ValueError(f"YAML at {path!r} did not parse to a mapping")
253
+ return from_spec(spec, vars=vars)
254
+
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # Internal helpers
258
+ # ---------------------------------------------------------------------------
259
+
260
+ _VALID_TOP_KEYS = frozenset(
261
+ {"slides", "lint", "template", "theme", "tokens", "vars", "slide_size"}
262
+ )
263
+ _VALID_LINT_VALUES = frozenset({"off", "warn", "raise"})
264
+
265
+ # Recipe layout name → (recipe callable, mandatory spec keys). Listed
266
+ # inline rather than imported lazily so the dispatcher fails closed if
267
+ # a recipe gets renamed.
268
+ _RECIPE_LAYOUTS: dict[str, tuple[str, frozenset[str]]] = {
269
+ "title_recipe": ("title_slide", frozenset({"title"})),
270
+ "bullets_recipe": ("bullet_slide", frozenset({"title", "bullets"})),
271
+ "kpi": ("kpi_slide", frozenset({"title", "kpis"})),
272
+ "quote": ("quote_slide", frozenset({"quote"})),
273
+ "image_hero": ("image_hero_slide", frozenset({"title", "image"})),
274
+ "section_divider": ("section_divider", frozenset({"title"})),
275
+ "chart": ("chart_slide", frozenset({"title", "categories", "series"})),
276
+ "table": ("table_slide", frozenset({"title", "columns", "rows"})),
277
+ "code": ("code_slide", frozenset({"title", "code"})),
278
+ "timeline": ("timeline_slide", frozenset({"title", "milestones"})),
279
+ "comparison": ("comparison_slide", frozenset({"title", "left_heading", "right_heading", "rows"})),
280
+ "figure": ("figure_slide", frozenset({"title", "figure"})),
281
+ }
282
+
283
+
284
+ def _did_you_mean(word: str, candidates: Iterable[str]) -> str:
285
+ """Return a ``" (did you mean 'x'?)"`` suffix for the closest candidate.
286
+
287
+ Returns an empty string when nothing is close enough. Used to make typo'd
288
+ spec keys / values recoverable in a single follow-up — particularly for an
289
+ LLM authoring a spec, which can act on the suggestion without a round-trip.
290
+ """
291
+ import difflib
292
+
293
+ matches = difflib.get_close_matches(word, list(candidates), n=1, cutoff=0.6)
294
+ return f" (did you mean {matches[0]!r}?)" if matches else ""
295
+
296
+
297
+ def _format_unknown(unknown: Iterable[str], candidates: Iterable[str]) -> str:
298
+ """Render unknown keys/values, each annotated with its closest candidate."""
299
+ candidates = list(candidates)
300
+ return "; ".join(f"{u!r}{_did_you_mean(u, candidates)}" for u in sorted(unknown))
301
+
302
+
303
+ def _validate_spec_keys(spec: dict[str, Any]) -> None:
304
+ unknown = set(spec) - _VALID_TOP_KEYS
305
+ if unknown:
306
+ raise ValueError(
307
+ f"Unknown spec keys: {_format_unknown(unknown, _VALID_TOP_KEYS)}. "
308
+ f"Valid keys: {sorted(_VALID_TOP_KEYS)}"
309
+ )
310
+ lint = spec.get("lint", "off")
311
+ if lint not in _VALID_LINT_VALUES:
312
+ raise ValueError(f"lint must be one of {sorted(_VALID_LINT_VALUES)!r}, got {lint!r}")
313
+ if not isinstance(spec.get("slides", []), list):
314
+ raise ValueError("'slides' must be a list")
315
+
316
+
317
+ def _resolve_layout(prs: Any, layout_name: str) -> Any:
318
+ """Return the SlideLayout for *layout_name*.
319
+
320
+ First tries the built-in alias table, then an exact case-insensitive
321
+ match against the presentation's own layout names (so custom
322
+ templates work). An unrecognized name raises :class:`ValueError`
323
+ rather than silently substituting the Blank layout — a silent
324
+ fallback reads as "my styled layout just didn't apply" and is the
325
+ same fail-closed-on-typos contract the spec-key validation uses. Use
326
+ ``"blank"`` explicitly for a deliberately blank slide.
327
+ """
328
+ canonical = _LAYOUT_ALIASES.get(layout_name.lower())
329
+ if canonical:
330
+ layout = prs.slide_layouts.get_by_name(canonical)
331
+ if layout is not None:
332
+ return layout
333
+
334
+ # Try exact match in the presentation's layouts (supports custom templates)
335
+ for sl in prs.slide_layouts:
336
+ if sl.name.lower() == layout_name.lower():
337
+ return sl
338
+
339
+ candidates = sorted(
340
+ set(_LAYOUT_ALIASES)
341
+ | set(_RECIPE_LAYOUTS)
342
+ | set(_LEGACY_TO_RECIPE)
343
+ | {sl.name.lower() for sl in prs.slide_layouts}
344
+ )
345
+ raise ValueError(
346
+ f"Unknown layout {layout_name!r}{_did_you_mean(layout_name.lower(), candidates)}. "
347
+ f"Valid layouts: {candidates}"
348
+ )
349
+
350
+
351
+ def _add_slide(
352
+ prs: Any,
353
+ slide_spec: dict[str, Any],
354
+ tokens: Any = None,
355
+ *,
356
+ slide_index: int = 0,
357
+ deck_shape_names: Optional[Mapping[str, list[int]]] = None,
358
+ ) -> Any:
359
+ """Add a single slide to *prs* according to *slide_spec*.
360
+
361
+ When the layout name matches a styled recipe (``kpi``, ``chart``,
362
+ …), dispatch through :mod:`pptx2.design.recipes`; otherwise
363
+ fall back to the placeholder-based legacy path so existing decks
364
+ keep working.
365
+
366
+ When *tokens* is provided, legacy alias names (``"title"`` /
367
+ ``"bullets"``) are silently upgraded to their token-aware recipe
368
+ counterparts (``"title_recipe"`` / ``"bullets_recipe"``). Before
369
+ this change the placeholder-based legacy path was taken and the
370
+ user's tokens were silently ignored, producing a default-styled
371
+ slide while ``lint`` and ``save`` succeeded. See IMPROVEMENTS
372
+ item 9.
373
+
374
+ A ``shapes`` list on the slide spec is applied last, on top of
375
+ whatever the layout produced — see :func:`_add_spec_shapes`.
376
+ *slide_index* and *deck_shape_names* only feed error messages and
377
+ cross-slide reference detection there.
378
+ """
379
+ layout_name = (slide_spec.get("layout") or "blank").lower()
380
+
381
+ if tokens is not None:
382
+ upgrade = _LEGACY_TO_RECIPE.get(layout_name)
383
+ if upgrade is not None:
384
+ layout_name = upgrade
385
+
386
+ if layout_name in _RECIPE_LAYOUTS:
387
+ slide = _add_recipe_slide(prs, slide_spec, layout_name, tokens)
388
+ else:
389
+ layout = _resolve_layout(prs, layout_name)
390
+ slide = prs.slides.add_slide(layout)
391
+
392
+ _set_title(slide, slide_spec.get("title"))
393
+ _set_subtitle_or_body(slide, slide_spec, layout_name)
394
+ _set_transition(slide, slide_spec.get("transition"))
395
+
396
+ if "shapes" in slide_spec:
397
+ _add_spec_shapes(
398
+ slide,
399
+ slide_spec["shapes"],
400
+ slide_index=slide_index,
401
+ deck_shape_names=deck_shape_names or {},
402
+ )
403
+
404
+ return slide
405
+
406
+
407
+ # Legacy placeholder-based layout name → recipe layout name. Only
408
+ # applied when ``tokens`` is provided to :func:`from_spec`; without
409
+ # tokens the legacy path is what the caller wants.
410
+ _LEGACY_TO_RECIPE: dict[str, str] = {
411
+ "title": "title_recipe",
412
+ "bullets": "bullets_recipe",
413
+ }
414
+
415
+
416
+ # Slide-spec keys handled by the dispatcher itself and therefore never
417
+ # forwarded to a recipe (which would reject them as unknown kwargs).
418
+ _RECIPE_NEVER_KWARGS = frozenset({"layout", "shapes"})
419
+
420
+
421
+ def _add_recipe_slide(
422
+ prs: Any, slide_spec: dict[str, Any], layout_name: str, tokens: Any
423
+ ) -> Any:
424
+ """Dispatch to the recipe matching *layout_name*.
425
+
426
+ Validates required keys, then forwards *every other* spec key as a
427
+ keyword argument to the recipe. The ``tokens`` and ``transition``
428
+ arguments are threaded through automatically: spec-level tokens
429
+ win when a slide-level ``tokens`` field isn't set.
430
+
431
+ Unknown kwargs (keys the recipe's signature doesn't accept) raise
432
+ :class:`ValueError` rather than being silently dropped. This
433
+ prevents subtle typos like ``subtitlz: ...`` from quietly producing
434
+ a slide without the intended subtitle.
435
+ """
436
+ import inspect
437
+
438
+ from pptx2.design import recipes as _recipes
439
+
440
+ recipe_name, required = _RECIPE_LAYOUTS[layout_name]
441
+ recipe = getattr(_recipes, recipe_name)
442
+
443
+ missing = [k for k in required if k not in slide_spec]
444
+ if missing:
445
+ raise ValueError(
446
+ f"layout {layout_name!r} requires keys "
447
+ f"{sorted(required)}; missing {sorted(missing)}"
448
+ )
449
+
450
+ kwargs = {
451
+ k: v for k, v in slide_spec.items()
452
+ if k not in _RECIPE_NEVER_KWARGS
453
+ }
454
+ # Spec-level tokens flow through unless the slide opts out / overrides.
455
+ kwargs.setdefault("tokens", tokens)
456
+
457
+ # Fail closed on typos: any kwarg the recipe doesn't accept is an
458
+ # error. Recipes accept ``tokens`` and ``transition`` consistently,
459
+ # so this catches misspelled content keys (``subtitlz``, ``millestones``)
460
+ # that previously silently no-op'd.
461
+ sig = inspect.signature(recipe)
462
+ accepts_var_kw = any(
463
+ p.kind is inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
464
+ )
465
+ if not accepts_var_kw:
466
+ accepted = set(sig.parameters)
467
+ unknown = sorted(set(kwargs) - accepted)
468
+ if unknown:
469
+ accepted_public = sorted(accepted - {"prs"})
470
+ raise ValueError(
471
+ f"layout {layout_name!r}: unknown spec keys "
472
+ f"{_format_unknown(unknown, accepted_public)}. "
473
+ f"Accepted: {accepted_public}."
474
+ )
475
+
476
+ return recipe(prs, **kwargs)
477
+
478
+
479
+ def _set_title(slide: Any, title: str | None) -> None:
480
+ if title is None:
481
+ return
482
+ try:
483
+ slide.shapes.title.text = title
484
+ except AttributeError:
485
+ pass # layout has no title placeholder
486
+
487
+
488
+ def _set_subtitle_or_body(slide: Any, spec: dict[str, Any], layout_name: str) -> None:
489
+ """Populate the secondary placeholder or add shapes based on layout type."""
490
+ if layout_name == "title":
491
+ subtitle = spec.get("subtitle")
492
+ if subtitle:
493
+ _set_placeholder_idx(slide, 1, subtitle)
494
+
495
+ elif layout_name == "bullets":
496
+ bullets = spec.get("bullets", [])
497
+ if bullets:
498
+ _set_placeholder_idx(slide, 1, "\n".join(str(b) for b in bullets))
499
+
500
+ elif layout_name == "section":
501
+ subtitle = spec.get("subtitle") or spec.get("text")
502
+ if subtitle:
503
+ _set_placeholder_idx(slide, 1, subtitle)
504
+
505
+ elif layout_name == "kpi_grid":
506
+ kpis = spec.get("kpis", [])
507
+ if kpis:
508
+ _add_kpi_shapes(slide, kpis)
509
+
510
+ elif layout_name in ("two_column", "comparison_layout"):
511
+ # Note: ``comparison`` (bare) routes to the recipe earlier in the
512
+ # dispatcher and never reaches this branch. ``comparison_layout``
513
+ # is the placeholder-based opt-in that does.
514
+ left = spec.get("left") or spec.get("content_left")
515
+ right = spec.get("right") or spec.get("content_right")
516
+ if left:
517
+ _set_placeholder_idx(slide, 1, left)
518
+ if right:
519
+ _set_placeholder_idx(slide, 2, right)
520
+
521
+
522
+ def _set_placeholder_idx(slide: Any, idx: int, text: str) -> None:
523
+ """Set text on the placeholder with the given idx, if it exists."""
524
+ for ph in slide.placeholders:
525
+ if ph.placeholder_format.idx == idx:
526
+ ph.text = text
527
+ return
528
+
529
+
530
+ def _set_transition(slide: Any, transition: str | None) -> None:
531
+ if not transition:
532
+ return
533
+ key = transition.lower().replace("-", "_")
534
+ member_name = _TRANSITION_NAMES.get(key)
535
+ if member_name is None:
536
+ raise ValueError(
537
+ f"Unknown transition {transition!r}{_did_you_mean(key, _TRANSITION_NAMES)}. "
538
+ f"Valid values: {sorted(_TRANSITION_NAMES)}"
539
+ )
540
+ from pptx2.enum.presentation import MSO_TRANSITION_TYPE
541
+
542
+ slide.transition.kind = getattr(MSO_TRANSITION_TYPE, member_name)
543
+
544
+
545
+ # ---------------------------------------------------------------------------
546
+ # Per-slide ``shapes`` entries — extra shapes plus lint-intent declarations
547
+ # ---------------------------------------------------------------------------
548
+
549
+ # Keys accepted on a single ``shapes`` entry. Fail-closed like every
550
+ # other key set in this module: an unrecognised key raises rather than
551
+ # being silently dropped, so ``lint_groupp`` doesn't quietly leave the
552
+ # overlap undeclared.
553
+ _VALID_SHAPE_KEYS = frozenset(
554
+ {
555
+ "name",
556
+ "shape",
557
+ "text",
558
+ "left",
559
+ "top",
560
+ "width",
561
+ "height",
562
+ "lint_group",
563
+ "layer",
564
+ "layer_above",
565
+ "allow_overlap_with",
566
+ }
567
+ )
568
+
569
+ # Geometry is mandatory — a shape entry with no box has nothing to draw.
570
+ _REQUIRED_SHAPE_KEYS = ("left", "top", "width", "height")
571
+
572
+ # Value of the ``shape`` key that means "plain text box" rather than an
573
+ # ``MSO_SHAPE`` autoshape. Also the default when ``shape`` is omitted.
574
+ _TEXTBOX_SHAPE_NAME = "textbox"
575
+
576
+
577
+ def _collect_shape_names(slide_specs: Any) -> dict[str, list[int]]:
578
+ """Map every ``shapes`` entry name in the deck to the slides defining it.
579
+
580
+ Collected before any slide is built so an ``allow_overlap_with``
581
+ reference to a shape on a *different* slide can be reported as
582
+ exactly that, rather than as a plain "unknown shape" — including
583
+ when the other slide comes later in the spec.
584
+ """
585
+ names: dict[str, list[int]] = {}
586
+ if not isinstance(slide_specs, list):
587
+ return names
588
+ for slide_index, slide_spec in enumerate(slide_specs):
589
+ if not isinstance(slide_spec, Mapping):
590
+ continue
591
+ entries = slide_spec.get("shapes")
592
+ if not isinstance(entries, list):
593
+ continue
594
+ for entry in entries:
595
+ if not isinstance(entry, Mapping):
596
+ continue
597
+ name = entry.get("name")
598
+ if isinstance(name, str) and name.strip():
599
+ names.setdefault(name, []).append(slide_index)
600
+ return names
601
+
602
+
603
+ def _add_spec_shapes(
604
+ slide: Any,
605
+ shape_specs: Any,
606
+ *,
607
+ slide_index: int,
608
+ deck_shape_names: Mapping[str, list[int]],
609
+ ) -> None:
610
+ """Add the slide spec's ``shapes`` entries to *slide*.
611
+
612
+ A shape entry is a mapping with a geometry box and, optionally, a
613
+ name, a shape type, text, and the linter's three intent
614
+ declarations::
615
+
616
+ {
617
+ "layout": "blank",
618
+ "shapes": [
619
+ {"name": "card", "shape": "rounded_rectangle",
620
+ "left": 1, "top": 1, "width": 4, "height": 2,
621
+ "layer": "card"},
622
+ {"name": "badge", "shape": "oval",
623
+ "left": 4.4, "top": 0.7, "width": 1.2, "height": 0.8,
624
+ "layer_above": "card",
625
+ "allow_overlap_with": "card"},
626
+ ],
627
+ }
628
+
629
+ Entry keys:
630
+
631
+ ``left`` / ``top`` / ``width`` / ``height`` *(required)*
632
+ Numbers are inches (matching ``slide_size``); pass a
633
+ :class:`~pptx2.util.Length` to opt out.
634
+
635
+ ``name`` *(optional)*
636
+ The shape's name, which doubles as the spec-level handle
637
+ ``allow_overlap_with`` resolves against. Names must be unique
638
+ within a slide.
639
+
640
+ ``shape`` *(optional)*
641
+ An ``MSO_SHAPE`` member name, case- and separator-insensitive
642
+ (``"rounded_rectangle"``, ``"Rounded Rectangle"``). Defaults to
643
+ ``"textbox"``.
644
+
645
+ ``text`` *(optional)*
646
+ Text for the shape's text frame.
647
+
648
+ ``lint_group`` / ``layer`` / ``layer_above`` *(optional)*
649
+ Passed straight through to the matching
650
+ :class:`~pptx2.shapes.base.BaseShape` property, which
651
+ validates them.
652
+
653
+ ``allow_overlap_with`` *(optional)*
654
+ A shape name, or a list of them, naming other shapes **on the
655
+ same slide**. Resolved to real shape ids after every shape on
656
+ the slide exists, so forward references work.
657
+ """
658
+ where_slide = f"slides[{slide_index}]"
659
+ if not isinstance(shape_specs, list):
660
+ raise ValueError(
661
+ f"{where_slide}: 'shapes' must be a list of shape entries; got "
662
+ f"{type(shape_specs).__name__!r}"
663
+ )
664
+
665
+ built: list[tuple[Mapping[str, Any], Any]] = []
666
+ by_name: dict[str, Any] = {}
667
+ for pos, entry in enumerate(shape_specs):
668
+ where = f"{where_slide}.shapes[{pos}]"
669
+ shape = _add_spec_shape(slide, entry, where=where)
670
+ name = entry.get("name")
671
+ if name is not None:
672
+ if name in by_name:
673
+ raise ValueError(
674
+ f"{where}: duplicate shape name {name!r} on {where_slide}. "
675
+ "Shape names must be unique within a slide so "
676
+ "'allow_overlap_with' can resolve them."
677
+ )
678
+ by_name[name] = shape
679
+ built.append((entry, shape))
680
+
681
+ # Second pass: every shape on the slide now exists (and has an id),
682
+ # so a reference may point forward as well as back.
683
+ for pos, (entry, shape) in enumerate(built):
684
+ _apply_overlap_allowances(
685
+ shape,
686
+ entry,
687
+ by_name,
688
+ where=f"{where_slide}.shapes[{pos}]",
689
+ slide_index=slide_index,
690
+ deck_shape_names=deck_shape_names,
691
+ )
692
+
693
+
694
+ def _add_spec_shape(slide: Any, entry: Any, *, where: str) -> Any:
695
+ """Create one shape from a ``shapes`` entry and return it."""
696
+ if not isinstance(entry, Mapping):
697
+ raise ValueError(
698
+ f"{where}: each 'shapes' entry must be a mapping; got "
699
+ f"{type(entry).__name__!r}"
700
+ )
701
+
702
+ unknown = set(entry) - _VALID_SHAPE_KEYS
703
+ if unknown:
704
+ raise ValueError(
705
+ f"{where}: unknown shape keys "
706
+ f"{_format_unknown(unknown, _VALID_SHAPE_KEYS)}. "
707
+ f"Valid keys: {sorted(_VALID_SHAPE_KEYS)}"
708
+ )
709
+
710
+ name = entry.get("name")
711
+ if name is not None and (not isinstance(name, str) or not name.strip()):
712
+ raise ValueError(
713
+ f"{where}: shape 'name' must be a non-empty string; got {name!r}"
714
+ )
715
+
716
+ missing = [k for k in _REQUIRED_SHAPE_KEYS if k not in entry]
717
+ if missing:
718
+ raise ValueError(
719
+ f"{where}: shape entries require keys "
720
+ f"{list(_REQUIRED_SHAPE_KEYS)}; missing {missing}"
721
+ )
722
+ box = tuple(
723
+ _coerce_length(entry[k], f"{where}: {k!r}") for k in _REQUIRED_SHAPE_KEYS
724
+ )
725
+
726
+ kind = entry.get("shape", _TEXTBOX_SHAPE_NAME)
727
+ if not isinstance(kind, str):
728
+ raise ValueError(
729
+ f"{where}: 'shape' must be a string naming an MSO_SHAPE member or "
730
+ f"{_TEXTBOX_SHAPE_NAME!r}; got {type(kind).__name__!r}"
731
+ )
732
+ if kind.strip().lower() == _TEXTBOX_SHAPE_NAME:
733
+ shape = slide.shapes.add_textbox(*box)
734
+ else:
735
+ shape = slide.shapes.add_shape(_resolve_autoshape(kind, where=where), *box)
736
+
737
+ if name is not None:
738
+ shape.name = name
739
+
740
+ text = entry.get("text")
741
+ if text is not None:
742
+ shape.text_frame.text = str(text)
743
+
744
+ # The three lint-intent scalars are plain pass-throughs: the shape
745
+ # properties own their validation, so a bad value fails the same way
746
+ # (and with the same exception type) whether it came from Python or
747
+ # from a spec. Only the location is added, so a rejected value in a
748
+ # 40-slide spec is findable.
749
+ for field in ("lint_group", "layer", "layer_above"):
750
+ if field not in entry:
751
+ continue
752
+ try:
753
+ setattr(shape, field, entry[field])
754
+ except (TypeError, ValueError) as exc:
755
+ raise type(exc)(f"{where}: {field!r}: {exc}") from exc
756
+
757
+ return shape
758
+
759
+
760
+ def _resolve_autoshape(kind: str, *, where: str) -> Any:
761
+ """Return the ``MSO_SHAPE`` member named by *kind*.
762
+
763
+ Accepts the member name in any case and with spaces or hyphens
764
+ standing in for underscores, so ``"rounded rectangle"`` and
765
+ ``"ROUNDED_RECTANGLE"`` both land on the same member.
766
+ """
767
+ from pptx2.enum.shapes import MSO_SHAPE
768
+
769
+ member = kind.strip().upper().replace(" ", "_").replace("-", "_")
770
+ try:
771
+ return getattr(MSO_SHAPE, member)
772
+ except AttributeError:
773
+ pass
774
+ candidates = [m.name.lower() for m in MSO_SHAPE] + [_TEXTBOX_SHAPE_NAME]
775
+ raise ValueError(
776
+ f"{where}: unknown shape type {kind!r}"
777
+ f"{_did_you_mean(member.lower(), candidates)}. Valid values are "
778
+ f"{_TEXTBOX_SHAPE_NAME!r} or any MSO_SHAPE member name, e.g. "
779
+ "'rectangle', 'rounded_rectangle', 'oval'."
780
+ )
781
+
782
+
783
+ def _apply_overlap_allowances(
784
+ shape: Any,
785
+ entry: Mapping[str, Any],
786
+ by_name: Mapping[str, Any],
787
+ *,
788
+ where: str,
789
+ slide_index: int,
790
+ deck_shape_names: Mapping[str, list[int]],
791
+ ) -> None:
792
+ """Resolve an entry's ``allow_overlap_with`` names and apply them.
793
+
794
+ Shape ids are assigned by the library at creation time, so a spec
795
+ names its peers instead; resolution happens here, once every shape
796
+ on the slide exists. Ids are only unique within a slide, so a
797
+ reference to a shape on another slide is rejected rather than
798
+ silently ignored.
799
+ """
800
+ raw = entry.get("allow_overlap_with")
801
+ if raw is None:
802
+ return
803
+ if isinstance(raw, str):
804
+ refs: list[Any] = [raw]
805
+ elif isinstance(raw, (list, tuple)):
806
+ refs = list(raw)
807
+ else:
808
+ raise ValueError(
809
+ f"{where}: 'allow_overlap_with' must be a shape name or a list of "
810
+ f"shape names; got {type(raw).__name__!r}"
811
+ )
812
+
813
+ own_name = entry.get("name")
814
+ targets = []
815
+ for ref in refs:
816
+ if not isinstance(ref, str) or not ref.strip():
817
+ raise ValueError(
818
+ f"{where}: 'allow_overlap_with' entries must be non-empty "
819
+ f"shape names; got {ref!r}"
820
+ )
821
+ if ref == own_name:
822
+ raise ValueError(
823
+ f"{where}: 'allow_overlap_with' names this shape itself "
824
+ f"({ref!r}); an allowance always describes a pair of shapes."
825
+ )
826
+ target = by_name.get(ref)
827
+ if target is None:
828
+ elsewhere = sorted(set(deck_shape_names.get(ref, ())) - {slide_index})
829
+ if elsewhere:
830
+ raise ValueError(
831
+ f"{where}: 'allow_overlap_with' names shape {ref!r}, which "
832
+ f"is defined on slides {elsewhere} — an overlap allowance "
833
+ "is keyed on shape id, and shape ids are only unique "
834
+ "within a slide, so it can only name a shape on "
835
+ f"slides[{slide_index}]."
836
+ )
837
+ raise ValueError(
838
+ f"{where}: 'allow_overlap_with' names unknown shape {ref!r}"
839
+ f"{_did_you_mean(ref, by_name)} on slides[{slide_index}]. "
840
+ f"Named shapes on that slide: {sorted(by_name)}"
841
+ )
842
+ targets.append(target)
843
+
844
+ if targets:
845
+ shape.allow_overlap_with(*targets)
846
+
847
+
848
+ def _add_kpi_shapes(slide: Any, kpis: list[dict[str, Any]]) -> None:
849
+ """Add KPI card shapes to *slide* — label, value, and optional delta."""
850
+ from pptx2.enum.text import PP_ALIGN
851
+ from pptx2.util import Inches, Pt
852
+ from pptx2.dml.color import RGBColor
853
+
854
+ prs_part = slide.part.package.presentation_part
855
+ slide_w = prs_part.presentation.slide_width or Inches(10)
856
+
857
+ n = len(kpis)
858
+ if n == 0:
859
+ return
860
+
861
+ card_w = Inches(2.2)
862
+ card_h = Inches(1.8)
863
+ gap = Inches(0.2)
864
+ total_w = n * card_w + (n - 1) * gap
865
+ start_x = (slide_w - total_w) // 2
866
+ top = Inches(2.5)
867
+
868
+ for i, kpi in enumerate(kpis):
869
+ left = start_x + i * (card_w + gap)
870
+ label = str(kpi.get("label", ""))
871
+ value = str(kpi.get("value", ""))
872
+ delta = kpi.get("delta")
873
+
874
+ # Value textbox (large, centered)
875
+ tf_value = slide.shapes.add_textbox(left, top, card_w, Inches(1.0))
876
+ tf = tf_value.text_frame
877
+ tf.word_wrap = False
878
+ p = tf.paragraphs[0]
879
+ run = p.add_run()
880
+ run.text = value
881
+ run.font.size = Pt(32)
882
+ run.font.bold = True
883
+ p.alignment = PP_ALIGN.CENTER
884
+
885
+ # Label textbox
886
+ label_top = top + Inches(1.0)
887
+ tf_label = slide.shapes.add_textbox(left, label_top, card_w, Inches(0.4))
888
+ tf2 = tf_label.text_frame
889
+ p2 = tf2.paragraphs[0]
890
+ run2 = p2.add_run()
891
+ run2.text = label
892
+ run2.font.size = Pt(12)
893
+ run2.font.color.rgb = RGBColor(0x60, 0x60, 0x60)
894
+ p2.alignment = PP_ALIGN.CENTER
895
+
896
+ # Delta textbox (optional)
897
+ if delta is not None:
898
+ delta_top = label_top + Inches(0.4)
899
+ sign = "+" if float(delta) >= 0 else ""
900
+ delta_str = f"{sign}{float(delta):.0%}"
901
+ tf_delta = slide.shapes.add_textbox(left, delta_top, card_w, Inches(0.3))
902
+ tf3 = tf_delta.text_frame
903
+ p3 = tf3.paragraphs[0]
904
+ run3 = p3.add_run()
905
+ run3.text = delta_str
906
+ run3.font.size = Pt(11)
907
+ run3.font.color.rgb = (
908
+ RGBColor(0x00, 0x8A, 0x00) if float(delta) >= 0 else RGBColor(0xCC, 0x00, 0x00)
909
+ )
910
+ p3.alignment = PP_ALIGN.CENTER
911
+
912
+
913
+ def _resolve_tokens(spec: Any) -> Any:
914
+ """Build a :class:`DesignTokens` from a token spec, or ``None``.
915
+
916
+ Accepts:
917
+
918
+ * ``None`` — return ``None``.
919
+ * A :class:`~pptx2.design.tokens.DesignTokens` instance — returned as-is so
920
+ callers can reuse a built token bag between imperative recipes and
921
+ :func:`from_spec` without round-tripping through ``.to_dict()`` (no such
922
+ method exists today). See IMPROVEMENTS item 8.
923
+ * ``{"preset": "modern_light", "overrides": {...}}`` — load preset
924
+ and optionally layer overrides.
925
+ * ``{"yaml": "brand.yml"}`` — load from a YAML file.
926
+ * Any other mapping — treated as an inline ``DesignTokens.from_dict``
927
+ payload.
928
+ """
929
+ if spec is None:
930
+ return None
931
+ from pptx2.design.tokens import DesignTokens
932
+
933
+ if isinstance(spec, DesignTokens):
934
+ return spec
935
+ if not isinstance(spec, Mapping):
936
+ raise ValueError(
937
+ f"'tokens' must be a mapping or DesignTokens instance; got "
938
+ f"{type(spec).__name__!r}"
939
+ )
940
+ if "preset" in spec:
941
+ tokens = DesignTokens.from_preset(spec["preset"])
942
+ overrides = spec.get("overrides")
943
+ if overrides:
944
+ tokens = tokens.with_overrides(overrides)
945
+ return tokens
946
+ if "yaml" in spec:
947
+ return DesignTokens.from_yaml(spec["yaml"])
948
+ return DesignTokens.from_dict(spec)
949
+
950
+
951
+ # ---------------------------------------------------------------------------
952
+ # Slide size resolver
953
+ # ---------------------------------------------------------------------------
954
+
955
+ # Named aspect-ratio shorthands → (width_in, height_in) in inches.
956
+ # Matches PowerPoint's built-in "Page Setup" presets.
957
+ _SLIDE_SIZE_PRESETS: dict[str, tuple[float, float]] = {
958
+ "16:9": (13.333, 7.5),
959
+ "widescreen": (13.333, 7.5),
960
+ "4:3": (10.0, 7.5),
961
+ "standard": (10.0, 7.5),
962
+ "16:10": (13.333, 8.333),
963
+ "a4": (11.69, 8.27),
964
+ "letter": (11.0, 8.5),
965
+ }
966
+
967
+
968
+ def _coerce_length(value: Any, what: str) -> Any:
969
+ """Return *value* as a :class:`~pptx2.util.Length`.
970
+
971
+ Bare numbers are inches — the convention the rest of the spec uses
972
+ (``slide_size``, shape geometry) — so a spec never has to name a raw
973
+ EMU integer. *what* names the offending field in the error message.
974
+ """
975
+ from pptx2.util import Inches, Length
976
+
977
+ if isinstance(value, Length):
978
+ return value
979
+ # ``bool`` is a subclass of ``int``, so the ``isinstance(value,
980
+ # (int, float))`` branch below would silently accept
981
+ # ``slide_size=(True, False)`` as a 1" × 0" canvas. Reject it
982
+ # explicitly — matches the boolean-rejection rule that
983
+ # ``pptx2.util._coerce_emu`` applies for shape coordinates.
984
+ if isinstance(value, bool):
985
+ raise ValueError(
986
+ f"{what} must be a number (inches) or Length; got bool: {value!r}"
987
+ )
988
+ if isinstance(value, (int, float)):
989
+ return Inches(float(value))
990
+ raise ValueError(
991
+ f"{what} must be a number (inches) or Length; "
992
+ f"got {type(value).__name__!r}: {value!r}"
993
+ )
994
+
995
+
996
+ def _apply_slide_size(prs: Any, slide_size: Any) -> None:
997
+ """Set ``prs.slide_width`` / ``prs.slide_height`` from a spec value.
998
+
999
+ Accepts a named shorthand string, an ``(width, height)`` 2-tuple of
1000
+ inches, or a ``{"width": w, "height": h}`` mapping (inches or
1001
+ :class:`~pptx2.util.Length`). Numbers are interpreted as inches;
1002
+ pass an explicit ``Length`` to opt out. See IMPROVEMENTS item 10.
1003
+ """
1004
+ from pptx2.util import Inches
1005
+
1006
+ def _to_emu(value: Any) -> Any:
1007
+ return _coerce_length(value, "slide_size dimension")
1008
+
1009
+ if isinstance(slide_size, str):
1010
+ key = slide_size.lower()
1011
+ preset = _SLIDE_SIZE_PRESETS.get(key)
1012
+ if preset is None:
1013
+ raise ValueError(
1014
+ f"Unknown slide_size {slide_size!r}"
1015
+ f"{_did_you_mean(key, _SLIDE_SIZE_PRESETS)}. Valid shorthands: "
1016
+ f"{sorted(_SLIDE_SIZE_PRESETS)}, or pass (width, height)."
1017
+ )
1018
+ width_in, height_in = preset
1019
+ prs.slide_width = Inches(width_in)
1020
+ prs.slide_height = Inches(height_in)
1021
+ return
1022
+ if isinstance(slide_size, Mapping):
1023
+ if "width" not in slide_size or "height" not in slide_size:
1024
+ raise ValueError(
1025
+ "slide_size mapping must have 'width' and 'height' keys"
1026
+ )
1027
+ prs.slide_width = _to_emu(slide_size["width"])
1028
+ prs.slide_height = _to_emu(slide_size["height"])
1029
+ return
1030
+ if isinstance(slide_size, (list, tuple)) and len(slide_size) == 2:
1031
+ prs.slide_width = _to_emu(slide_size[0])
1032
+ prs.slide_height = _to_emu(slide_size[1])
1033
+ return
1034
+ raise ValueError(
1035
+ f"slide_size must be a string preset, (w, h) pair, or "
1036
+ f"{{'width', 'height'}} mapping; got {type(slide_size).__name__!r}"
1037
+ )
1038
+
1039
+
1040
+ _INTERP_RE = re.compile(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}")
1041
+
1042
+
1043
+ def _interpolate(value: Any, vars_: Mapping[str, Any]) -> Any:
1044
+ """Recursively substitute ``{{name}}`` markers in any string within *value*.
1045
+
1046
+ Walks dicts, lists, tuples, and strings. ``{{name}}`` resolves to
1047
+ ``vars_['name']``; ``{{a.b.c}}`` walks dotted paths through nested
1048
+ mappings. Unknown names raise :class:`KeyError` so a typo doesn't
1049
+ silently render as the literal placeholder.
1050
+ """
1051
+ if isinstance(value, str):
1052
+ def _sub(match: "re.Match[str]") -> str:
1053
+ key = match.group(1)
1054
+ parts = key.split(".")
1055
+ cur: Any = vars_
1056
+ for p in parts:
1057
+ if isinstance(cur, Mapping) and p in cur:
1058
+ cur = cur[p]
1059
+ else:
1060
+ raise KeyError(
1061
+ f"interpolation variable {key!r} not found "
1062
+ f"in vars={list(vars_)!r}"
1063
+ )
1064
+ return str(cur)
1065
+ return _INTERP_RE.sub(_sub, value)
1066
+ if isinstance(value, dict):
1067
+ return {k: _interpolate(v, vars_) for k, v in value.items()}
1068
+ if isinstance(value, list):
1069
+ return [_interpolate(v, vars_) for v in value]
1070
+ if isinstance(value, tuple):
1071
+ return tuple(_interpolate(v, vars_) for v in value)
1072
+ return value
1073
+
1074
+
1075
+ def _run_lint(prs: Any, mode: str) -> None:
1076
+ """Run the deck-level linter according to *mode* (``"warn"`` or ``"raise"``)."""
1077
+ import logging
1078
+
1079
+ from pptx2.exc import LintError
1080
+
1081
+ logger = logging.getLogger(__name__)
1082
+ all_issues = []
1083
+ for slide in prs.slides:
1084
+ report = slide.lint()
1085
+ all_issues.extend(report.issues)
1086
+
1087
+ errors = [i for i in all_issues if getattr(i, "severity", "warning") == "error"]
1088
+
1089
+ if mode == "warn":
1090
+ for issue in all_issues:
1091
+ logger.warning("pptx lint: %s", issue)
1092
+ elif mode == "raise" and errors:
1093
+ msgs = "; ".join(str(i) for i in errors)
1094
+ raise LintError(f"Lint errors in generated presentation: {msgs}")