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