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.
- pptx2/__init__.py +152 -0
- pptx2/_color.py +75 -0
- pptx2/_slide_importer.py +597 -0
- pptx2/_svg.py +155 -0
- pptx2/_template_applier.py +292 -0
- pptx2/_textstyle.py +187 -0
- pptx2/accessibility.py +365 -0
- pptx2/action.py +270 -0
- pptx2/animation.py +2237 -0
- pptx2/api.py +49 -0
- pptx2/audit.py +258 -0
- pptx2/chart/__init__.py +0 -0
- pptx2/chart/analytics.py +381 -0
- pptx2/chart/axis.py +543 -0
- pptx2/chart/category.py +200 -0
- pptx2/chart/chart.py +670 -0
- pptx2/chart/data.py +864 -0
- pptx2/chart/datalabel.py +406 -0
- pptx2/chart/legend.py +86 -0
- pptx2/chart/marker.py +70 -0
- pptx2/chart/palettes.py +129 -0
- pptx2/chart/plot.py +462 -0
- pptx2/chart/point.py +101 -0
- pptx2/chart/quick_layouts.py +325 -0
- pptx2/chart/series.py +334 -0
- pptx2/chart/xlsx.py +272 -0
- pptx2/chart/xmlwriter.py +1845 -0
- pptx2/compose/__init__.py +28 -0
- pptx2/compose/from_spec.py +1094 -0
- pptx2/design/__init__.py +8 -0
- pptx2/design/components.py +607 -0
- pptx2/design/figures.py +389 -0
- pptx2/design/layout.py +370 -0
- pptx2/design/recipes.py +1967 -0
- pptx2/design/style.py +209 -0
- pptx2/design/tokens.py +915 -0
- pptx2/diagrams.py +754 -0
- pptx2/dml/__init__.py +0 -0
- pptx2/dml/chtfmt.py +40 -0
- pptx2/dml/color.py +496 -0
- pptx2/dml/effect.py +909 -0
- pptx2/dml/fill.py +691 -0
- pptx2/dml/line.py +287 -0
- pptx2/dml/picture.py +212 -0
- pptx2/dml/three_d.py +381 -0
- pptx2/enum/__init__.py +0 -0
- pptx2/enum/action.py +71 -0
- pptx2/enum/animation.py +31 -0
- pptx2/enum/base.py +218 -0
- pptx2/enum/chart.py +574 -0
- pptx2/enum/dml.py +740 -0
- pptx2/enum/lang.py +685 -0
- pptx2/enum/presentation.py +133 -0
- pptx2/enum/shapes.py +1029 -0
- pptx2/enum/text.py +230 -0
- pptx2/exc.py +42 -0
- pptx2/formats.py +139 -0
- pptx2/geometry.py +420 -0
- pptx2/inherit.py +109 -0
- pptx2/lint.py +2256 -0
- pptx2/math.py +177 -0
- pptx2/media.py +197 -0
- pptx2/opc/__init__.py +0 -0
- pptx2/opc/constants.py +332 -0
- pptx2/opc/oxml.py +188 -0
- pptx2/opc/package.py +762 -0
- pptx2/opc/packuri.py +109 -0
- pptx2/opc/serialized.py +296 -0
- pptx2/opc/shared.py +20 -0
- pptx2/opc/spec.py +45 -0
- pptx2/oxml/__init__.py +555 -0
- pptx2/oxml/action.py +53 -0
- pptx2/oxml/chart/__init__.py +0 -0
- pptx2/oxml/chart/axis.py +337 -0
- pptx2/oxml/chart/chart.py +481 -0
- pptx2/oxml/chart/datalabel.py +253 -0
- pptx2/oxml/chart/legend.py +72 -0
- pptx2/oxml/chart/marker.py +61 -0
- pptx2/oxml/chart/plot.py +365 -0
- pptx2/oxml/chart/series.py +425 -0
- pptx2/oxml/chart/shared.py +220 -0
- pptx2/oxml/coreprops.py +288 -0
- pptx2/oxml/dml/__init__.py +0 -0
- pptx2/oxml/dml/color.py +135 -0
- pptx2/oxml/dml/effect.py +213 -0
- pptx2/oxml/dml/fill.py +316 -0
- pptx2/oxml/dml/line.py +12 -0
- pptx2/oxml/dml/three_d.py +110 -0
- pptx2/oxml/ns.py +135 -0
- pptx2/oxml/presentation.py +313 -0
- pptx2/oxml/shapes/__init__.py +19 -0
- pptx2/oxml/shapes/autoshape.py +467 -0
- pptx2/oxml/shapes/connector.py +107 -0
- pptx2/oxml/shapes/graphfrm.py +347 -0
- pptx2/oxml/shapes/groupshape.py +329 -0
- pptx2/oxml/shapes/picture.py +270 -0
- pptx2/oxml/shapes/shared.py +577 -0
- pptx2/oxml/simpletypes.py +1027 -0
- pptx2/oxml/slide.py +563 -0
- pptx2/oxml/table.py +650 -0
- pptx2/oxml/text.py +815 -0
- pptx2/oxml/theme.py +36 -0
- pptx2/oxml/xmlchemy.py +717 -0
- pptx2/package.py +222 -0
- pptx2/parts/__init__.py +0 -0
- pptx2/parts/chart.py +95 -0
- pptx2/parts/coreprops.py +167 -0
- pptx2/parts/diagram.py +37 -0
- pptx2/parts/embeddedpackage.py +93 -0
- pptx2/parts/image.py +275 -0
- pptx2/parts/media.py +37 -0
- pptx2/parts/presentation.py +136 -0
- pptx2/parts/slide.py +371 -0
- pptx2/presentation.py +408 -0
- pptx2/py.typed +0 -0
- pptx2/render.py +586 -0
- pptx2/section.py +272 -0
- pptx2/shapes/__init__.py +26 -0
- pptx2/shapes/autoshape.py +442 -0
- pptx2/shapes/base.py +1078 -0
- pptx2/shapes/connector.py +297 -0
- pptx2/shapes/freeform.py +337 -0
- pptx2/shapes/graphfrm.py +316 -0
- pptx2/shapes/group.py +264 -0
- pptx2/shapes/picture.py +422 -0
- pptx2/shapes/placeholder.py +468 -0
- pptx2/shapes/shapetree.py +2027 -0
- pptx2/shared.py +82 -0
- pptx2/skill/SKILL.md +450 -0
- pptx2/skill/__init__.py +78 -0
- pptx2/skill/__main__.py +64 -0
- pptx2/skill/references/animations.md +189 -0
- pptx2/skill/references/basics.md +421 -0
- pptx2/skill/references/charts.md +254 -0
- pptx2/skill/references/compose.md +234 -0
- pptx2/skill/references/design.md +366 -0
- pptx2/skill/references/effects.md +249 -0
- pptx2/skill/references/end-to-end-deck.md +231 -0
- pptx2/skill/references/geometry-and-arrows.md +334 -0
- pptx2/skill/references/lint.md +275 -0
- pptx2/skill/references/math.md +86 -0
- pptx2/skill/references/picture-effects.md +129 -0
- pptx2/skill/references/render.md +151 -0
- pptx2/skill/references/smart-art.md +75 -0
- pptx2/skill/references/space-aware-authoring.md +249 -0
- pptx2/skill/references/tables.md +244 -0
- pptx2/skill/references/theme.md +127 -0
- pptx2/skill/references/three-d.md +109 -0
- pptx2/skill/references/transitions.md +100 -0
- pptx2/slide.py +1244 -0
- pptx2/smart_art.py +220 -0
- pptx2/spec.py +633 -0
- pptx2/table.py +1181 -0
- pptx2/table_styles.py +184 -0
- pptx2/templates/default.pptx +0 -0
- pptx2/templates/docx-icon.emf +0 -0
- pptx2/templates/generic-icon.emf +0 -0
- pptx2/templates/notes.xml +23 -0
- pptx2/templates/notesMaster.xml +352 -0
- pptx2/templates/pptx-icon.emf +0 -0
- pptx2/templates/theme.xml +321 -0
- pptx2/templates/xlsx-icon.emf +0 -0
- pptx2/text/__init__.py +0 -0
- pptx2/text/fonts.py +482 -0
- pptx2/text/layout.py +374 -0
- pptx2/text/text.py +1272 -0
- pptx2/theme.py +721 -0
- pptx2/types.py +36 -0
- pptx2/util.py +263 -0
- python_pptx2-2.13.0.dist-info/METADATA +351 -0
- python_pptx2-2.13.0.dist-info/RECORD +175 -0
- python_pptx2-2.13.0.dist-info/WHEEL +5 -0
- python_pptx2-2.13.0.dist-info/entry_points.txt +3 -0
- python_pptx2-2.13.0.dist-info/licenses/LICENSE +22 -0
- 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}")
|