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/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Design-system helpers layered on top of the low-level python-pptx API.
|
|
2
|
+
|
|
3
|
+
These modules are additive; they never replace the underlying shape/slide
|
|
4
|
+
APIs and never invent OOXML semantics. They exist so callers don't have to
|
|
5
|
+
hand-compute EMU geometry for common layouts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
"""Shape-level building blocks layered on top of the slide recipes.
|
|
2
|
+
|
|
3
|
+
The :mod:`pptx2.design.recipes` module covers whole-slide layouts
|
|
4
|
+
(``title_slide``, ``kpi_slide``, …); this module exposes the components
|
|
5
|
+
those recipes are built from so callers can compose mixed layouts with
|
|
6
|
+
brand-consistent components.
|
|
7
|
+
|
|
8
|
+
Two public callables today, both intentionally small:
|
|
9
|
+
|
|
10
|
+
* :func:`add_kpi_card` — a single KPI tile (label + headline value +
|
|
11
|
+
optional delta), styled from a :class:`DesignTokens` instance.
|
|
12
|
+
* :func:`add_progress_bar` — a track + fill rounded-rectangle pair
|
|
13
|
+
representing a 0..1 fraction.
|
|
14
|
+
|
|
15
|
+
Both accept an optional ``tokens`` argument that drives palette and
|
|
16
|
+
typography. When omitted, sensible defaults derived from the chart
|
|
17
|
+
palette are used. Each component is created using the existing
|
|
18
|
+
``slide.shapes.add_*`` primitives and returns a small dataclass
|
|
19
|
+
exposing the constituent shapes — callers can reach into ``.card`` /
|
|
20
|
+
``.value_box`` / ``.fill`` / ``.track`` for further per-deck tweaks
|
|
21
|
+
without re-implementing the layout.
|
|
22
|
+
|
|
23
|
+
The intentional shape stacking inside these components (label box on
|
|
24
|
+
top of card, fill bar on top of track) is tagged with
|
|
25
|
+
``shape.lint_group`` so :mod:`pptx2.lint` does not flag them as
|
|
26
|
+
overlap warnings.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from typing import TYPE_CHECKING, Any, Mapping, Optional
|
|
33
|
+
|
|
34
|
+
from pptx2.design.tokens import DesignTokens
|
|
35
|
+
from pptx2.enum.shapes import MSO_SHAPE
|
|
36
|
+
from pptx2.enum.text import MSO_ANCHOR, PP_ALIGN
|
|
37
|
+
from pptx2.util import Inches, Length, Pt
|
|
38
|
+
|
|
39
|
+
# Re-use the private helpers from recipes rather than reimplement them.
|
|
40
|
+
# Same package, same module-private convention.
|
|
41
|
+
from pptx2.design.recipes import (
|
|
42
|
+
_apply_card_styling,
|
|
43
|
+
_delta_color,
|
|
44
|
+
_fill_text_frame,
|
|
45
|
+
_palette,
|
|
46
|
+
_resolve_delta,
|
|
47
|
+
_typography,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if TYPE_CHECKING:
|
|
51
|
+
from pptx2.shapes.autoshape import Shape
|
|
52
|
+
from pptx2.slide import Slide
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
__all__ = (
|
|
56
|
+
"KpiCard",
|
|
57
|
+
"ProgressBar",
|
|
58
|
+
"Gauge",
|
|
59
|
+
"StatusPill",
|
|
60
|
+
"StatStrip",
|
|
61
|
+
"ArticleCard",
|
|
62
|
+
"add_kpi_card",
|
|
63
|
+
"add_progress_bar",
|
|
64
|
+
"add_gauge",
|
|
65
|
+
"add_status_pill",
|
|
66
|
+
"add_stat_strip",
|
|
67
|
+
"add_article_card",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class KpiCard:
|
|
73
|
+
"""Bundle of shapes produced by :func:`add_kpi_card`.
|
|
74
|
+
|
|
75
|
+
Use ``card`` for global tweaks (border colour, fill, shadow), the
|
|
76
|
+
text boxes for typography or content edits.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
card: Any
|
|
80
|
+
value_box: Any
|
|
81
|
+
label_box: Any
|
|
82
|
+
delta_box: Optional[Any] = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class ProgressBar:
|
|
87
|
+
"""Bundle of shapes produced by :func:`add_progress_bar`.
|
|
88
|
+
|
|
89
|
+
``track`` is the full-width background; ``fill`` is the
|
|
90
|
+
proportionally-sized foreground.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
track: Any
|
|
94
|
+
fill: Any
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def add_kpi_card(
|
|
98
|
+
slide: "Slide",
|
|
99
|
+
*,
|
|
100
|
+
left: Length,
|
|
101
|
+
top: Length,
|
|
102
|
+
width: Length,
|
|
103
|
+
height: Length,
|
|
104
|
+
label: str,
|
|
105
|
+
value: str,
|
|
106
|
+
delta: Optional[Mapping[str, Any]] = None,
|
|
107
|
+
tokens: Optional[DesignTokens] = None,
|
|
108
|
+
) -> KpiCard:
|
|
109
|
+
"""Add a single KPI card (label + value + optional delta) to *slide*.
|
|
110
|
+
|
|
111
|
+
`delta`, when supplied, is a mapping with the same shape consumed
|
|
112
|
+
by :func:`pptx2.design.recipes.kpi_slide` — ``{"delta": 0.27}``
|
|
113
|
+
renders as ``+27%``, ``{"delta_text": "+14 pts"}`` renders verbatim.
|
|
114
|
+
Tinted from the palette's ``positive`` / ``negative`` slot.
|
|
115
|
+
|
|
116
|
+
Returns a :class:`KpiCard` bundle so callers can reach into the
|
|
117
|
+
constituent shapes for per-deck tweaks without re-implementing
|
|
118
|
+
the layout.
|
|
119
|
+
"""
|
|
120
|
+
fill_color = _palette(tokens, ("surface", "lt2"))
|
|
121
|
+
# Border tracks the brand: fall through muted → lt1 → neutral → primary so
|
|
122
|
+
# the outline never lands on an off-palette default (a clashing pale blue
|
|
123
|
+
# on a non-blue scheme) or silently disappears when muted/lt1 are unset.
|
|
124
|
+
border_color = _palette(tokens, ("muted", "lt1", "neutral", "primary"))
|
|
125
|
+
value_color = _palette(tokens, ("primary", "neutral"))
|
|
126
|
+
label_color = _palette(tokens, ("muted",))
|
|
127
|
+
|
|
128
|
+
value_token = _typography(tokens, "heading", default_size=Pt(30), default_bold=True)
|
|
129
|
+
label_token = _typography(tokens, "body", default_size=Pt(12))
|
|
130
|
+
delta_token = _typography(tokens, "body", default_size=Pt(11), default_bold=True)
|
|
131
|
+
|
|
132
|
+
card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, left, top, width, height)
|
|
133
|
+
if fill_color is not None:
|
|
134
|
+
card.fill.solid()
|
|
135
|
+
card.fill.fore_color.rgb = fill_color
|
|
136
|
+
else:
|
|
137
|
+
card.fill.background()
|
|
138
|
+
if border_color is not None:
|
|
139
|
+
card.line.color.rgb = border_color
|
|
140
|
+
card.line.width = Pt(0.75)
|
|
141
|
+
_apply_card_styling(card, tokens)
|
|
142
|
+
card.text_frame.text = ""
|
|
143
|
+
|
|
144
|
+
# Vertical layout inside the card. Heights chosen to mirror
|
|
145
|
+
# kpi_slide's existing recipe so the visual matches when used in
|
|
146
|
+
# the same deck.
|
|
147
|
+
value_h = Inches(0.85)
|
|
148
|
+
label_h = Inches(0.4)
|
|
149
|
+
delta_h = Inches(0.35)
|
|
150
|
+
inner_top = Length(top + Inches(0.25))
|
|
151
|
+
label_top = Length(top + Inches(1.10))
|
|
152
|
+
delta_top = Length(top + Inches(1.50))
|
|
153
|
+
|
|
154
|
+
value_box = slide.shapes.add_textbox(left, inner_top, width, value_h)
|
|
155
|
+
_fill_text_frame(
|
|
156
|
+
value_box.text_frame,
|
|
157
|
+
str(value),
|
|
158
|
+
token=value_token,
|
|
159
|
+
color=value_color,
|
|
160
|
+
align=PP_ALIGN.CENTER,
|
|
161
|
+
anchor=MSO_ANCHOR.MIDDLE,
|
|
162
|
+
shrink_to_fit=True,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
label_box = slide.shapes.add_textbox(left, label_top, width, label_h)
|
|
166
|
+
_fill_text_frame(
|
|
167
|
+
label_box.text_frame,
|
|
168
|
+
str(label),
|
|
169
|
+
token=label_token,
|
|
170
|
+
color=label_color,
|
|
171
|
+
align=PP_ALIGN.CENTER,
|
|
172
|
+
anchor=MSO_ANCHOR.TOP,
|
|
173
|
+
shrink_to_fit=True,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
delta_box = None
|
|
177
|
+
if delta is not None:
|
|
178
|
+
d_text, d_sign = _resolve_delta(delta)
|
|
179
|
+
if d_text is not None:
|
|
180
|
+
delta_box = slide.shapes.add_textbox(left, delta_top, width, delta_h)
|
|
181
|
+
_fill_text_frame(
|
|
182
|
+
delta_box.text_frame,
|
|
183
|
+
d_text,
|
|
184
|
+
token=delta_token,
|
|
185
|
+
color=_delta_color(tokens, d_sign),
|
|
186
|
+
align=PP_ALIGN.CENTER,
|
|
187
|
+
anchor=MSO_ANCHOR.TOP,
|
|
188
|
+
shrink_to_fit=True,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Tag the stack so the lint pass treats them as one intentional
|
|
192
|
+
# group, not three overlapping shapes.
|
|
193
|
+
group_name = f"kpi_card@{int(left)},{int(top)}"
|
|
194
|
+
for shape in (card, value_box, label_box, delta_box):
|
|
195
|
+
if shape is None:
|
|
196
|
+
continue
|
|
197
|
+
try:
|
|
198
|
+
shape.lint_group = group_name
|
|
199
|
+
except (AttributeError, NotImplementedError):
|
|
200
|
+
pass
|
|
201
|
+
|
|
202
|
+
return KpiCard(
|
|
203
|
+
card=card, value_box=value_box, label_box=label_box, delta_box=delta_box
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def add_progress_bar(
|
|
208
|
+
slide: "Slide",
|
|
209
|
+
*,
|
|
210
|
+
left: Length,
|
|
211
|
+
top: Length,
|
|
212
|
+
width: Length,
|
|
213
|
+
height: Length,
|
|
214
|
+
fraction: float,
|
|
215
|
+
tokens: Optional[DesignTokens] = None,
|
|
216
|
+
fill_color: Any = None,
|
|
217
|
+
track_color: Any = None,
|
|
218
|
+
) -> ProgressBar:
|
|
219
|
+
"""Add a horizontal progress bar (track + fill) to *slide*.
|
|
220
|
+
|
|
221
|
+
`fraction` is clamped to ``[0.0, 1.0]``. The fill shape's width is
|
|
222
|
+
``round(fraction * width)``; when the fraction is zero the fill is
|
|
223
|
+
still emitted (with zero width) so callers can mutate it later
|
|
224
|
+
(e.g. animate the fill on click).
|
|
225
|
+
|
|
226
|
+
Colours fall back to the design tokens' ``primary`` / ``surface``
|
|
227
|
+
palette slots when ``fill_color`` / ``track_color`` are ``None``.
|
|
228
|
+
Pass any colour-like (``RGBColor``, hex string, ``(r, g, b)``)
|
|
229
|
+
to override.
|
|
230
|
+
"""
|
|
231
|
+
if not 0.0 <= float(fraction) <= 1.0:
|
|
232
|
+
# Clamp rather than raise — values from live data sources are
|
|
233
|
+
# often 99.x or 100.1 due to rounding; raising on those is
|
|
234
|
+
# hostile.
|
|
235
|
+
fraction = max(0.0, min(1.0, float(fraction)))
|
|
236
|
+
|
|
237
|
+
# Track colour: when the caller doesn't pin one, prefer a translucent
|
|
238
|
+
# ``neutral`` overlay rather than the opaque ``surface`` slot. ``surface``
|
|
239
|
+
# is nearly identical to a dark slide background, so the track (and a
|
|
240
|
+
# gauge's target tick floating on it) would vanish on dark decks; a
|
|
241
|
+
# low-alpha neutral reads against both light and dark backgrounds.
|
|
242
|
+
track_alpha: Optional[float] = None
|
|
243
|
+
if track_color is not None:
|
|
244
|
+
from pptx2._color import coerce_color
|
|
245
|
+
|
|
246
|
+
resolved_track = coerce_color(track_color)
|
|
247
|
+
else:
|
|
248
|
+
neutral = _palette(tokens, ("neutral",))
|
|
249
|
+
if neutral is not None:
|
|
250
|
+
resolved_track = neutral
|
|
251
|
+
track_alpha = 0.14
|
|
252
|
+
else:
|
|
253
|
+
resolved_track = _palette(tokens, ("surface", "lt2"))
|
|
254
|
+
resolved_fill = _coerce_or_token(fill_color, tokens, ("primary", "accent", "neutral"))
|
|
255
|
+
|
|
256
|
+
track = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, left, top, width, height)
|
|
257
|
+
if resolved_track is not None:
|
|
258
|
+
track.fill.solid()
|
|
259
|
+
track.fill.fore_color.rgb = resolved_track
|
|
260
|
+
if track_alpha is not None:
|
|
261
|
+
track.fill.fore_color.alpha = track_alpha
|
|
262
|
+
else:
|
|
263
|
+
track.fill.background()
|
|
264
|
+
track.line.fill.background()
|
|
265
|
+
track.text_frame.text = ""
|
|
266
|
+
|
|
267
|
+
fill_w = Length(int(round(int(width) * fraction)))
|
|
268
|
+
fill = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, left, top, fill_w, height)
|
|
269
|
+
if resolved_fill is not None:
|
|
270
|
+
fill.fill.solid()
|
|
271
|
+
fill.fill.fore_color.rgb = resolved_fill
|
|
272
|
+
else:
|
|
273
|
+
fill.fill.background()
|
|
274
|
+
fill.line.fill.background()
|
|
275
|
+
fill.text_frame.text = ""
|
|
276
|
+
|
|
277
|
+
group_name = f"progress_bar@{int(left)},{int(top)}"
|
|
278
|
+
for shape in (track, fill):
|
|
279
|
+
try:
|
|
280
|
+
shape.lint_group = group_name
|
|
281
|
+
except (AttributeError, NotImplementedError):
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
return ProgressBar(track=track, fill=fill)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _coerce_or_token(value, tokens, fallback_keys):
|
|
288
|
+
"""Return an RGBColor for ``value``, or read from ``tokens`` palette."""
|
|
289
|
+
if value is None:
|
|
290
|
+
return _palette(tokens, fallback_keys)
|
|
291
|
+
from pptx2._color import coerce_color
|
|
292
|
+
|
|
293
|
+
return coerce_color(value)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
# Linear gauge (fraction visualised as a slim horizontal bar with target tick)
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass
|
|
302
|
+
class Gauge:
|
|
303
|
+
"""Bundle of shapes produced by :func:`add_gauge`.
|
|
304
|
+
|
|
305
|
+
A linear gauge is shaped like a progress bar but adds a small
|
|
306
|
+
target tick — useful for "62 of 80 target". The radial variant
|
|
307
|
+
is intentionally not implemented in this module: arc rendering
|
|
308
|
+
needs a freeform path and produces visibly different geometry
|
|
309
|
+
on PowerPoint vs LibreOffice.
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
track: Any
|
|
313
|
+
fill: Any
|
|
314
|
+
target_tick: Optional[Any]
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def add_gauge(
|
|
318
|
+
slide: "Slide",
|
|
319
|
+
*,
|
|
320
|
+
left: Length,
|
|
321
|
+
top: Length,
|
|
322
|
+
width: Length,
|
|
323
|
+
height: Length,
|
|
324
|
+
fraction: float,
|
|
325
|
+
target: Optional[float] = None,
|
|
326
|
+
tokens: Optional[DesignTokens] = None,
|
|
327
|
+
fill_color: Any = None,
|
|
328
|
+
track_color: Any = None,
|
|
329
|
+
target_color: Any = None,
|
|
330
|
+
) -> Gauge:
|
|
331
|
+
"""Add a linear gauge: progress bar plus optional target tick.
|
|
332
|
+
|
|
333
|
+
`fraction` and `target` are both ``[0.0, 1.0]`` (clamped). When
|
|
334
|
+
``target`` is ``None`` no tick is drawn — the gauge degrades to a
|
|
335
|
+
lightly-styled progress bar so the same call can be used either way.
|
|
336
|
+
The tick is a thin vertical rectangle in the deck's ``negative``
|
|
337
|
+
palette slot (or red as a final fallback) so it stays legible
|
|
338
|
+
against the fill colour.
|
|
339
|
+
"""
|
|
340
|
+
bar = add_progress_bar(
|
|
341
|
+
slide,
|
|
342
|
+
left=left,
|
|
343
|
+
top=top,
|
|
344
|
+
width=width,
|
|
345
|
+
height=height,
|
|
346
|
+
fraction=fraction,
|
|
347
|
+
tokens=tokens,
|
|
348
|
+
fill_color=fill_color,
|
|
349
|
+
track_color=track_color,
|
|
350
|
+
)
|
|
351
|
+
target_tick = None
|
|
352
|
+
if target is not None:
|
|
353
|
+
t = max(0.0, min(1.0, float(target)))
|
|
354
|
+
tick_w = max(int(Inches(0.04)), 1) # ~3px on 96dpi
|
|
355
|
+
tick_x = Length(int(left) + int(round(int(width) * t)) - tick_w // 2)
|
|
356
|
+
target_tick = slide.shapes.add_shape(
|
|
357
|
+
MSO_SHAPE.RECTANGLE,
|
|
358
|
+
tick_x,
|
|
359
|
+
Length(int(top) - int(Inches(0.04))),
|
|
360
|
+
Length(tick_w),
|
|
361
|
+
Length(int(height) + int(Inches(0.08))),
|
|
362
|
+
)
|
|
363
|
+
resolved = _coerce_or_token(target_color, tokens, ("negative", "danger"))
|
|
364
|
+
if resolved is None:
|
|
365
|
+
resolved = _coerce_or_token("#DD2233", None, ())
|
|
366
|
+
target_tick.fill.solid()
|
|
367
|
+
target_tick.fill.fore_color.rgb = resolved
|
|
368
|
+
target_tick.line.fill.background()
|
|
369
|
+
# Tag with the same lint group so the linter doesn't warn.
|
|
370
|
+
try:
|
|
371
|
+
target_tick.lint_group = bar.track.lint_group
|
|
372
|
+
except (AttributeError, NotImplementedError):
|
|
373
|
+
pass
|
|
374
|
+
|
|
375
|
+
return Gauge(track=bar.track, fill=bar.fill, target_tick=target_tick)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
# Status pill — small coloured rounded rectangle with centred text
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@dataclass
|
|
384
|
+
class StatusPill:
|
|
385
|
+
pill: Any
|
|
386
|
+
label: Any
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def add_status_pill(
|
|
390
|
+
slide: "Slide",
|
|
391
|
+
*,
|
|
392
|
+
left: Length,
|
|
393
|
+
top: Length,
|
|
394
|
+
width: Length,
|
|
395
|
+
height: Length,
|
|
396
|
+
text: str,
|
|
397
|
+
accent: Any = None,
|
|
398
|
+
tokens: Optional[DesignTokens] = None,
|
|
399
|
+
text_color: Any = None,
|
|
400
|
+
) -> StatusPill:
|
|
401
|
+
"""Add a coloured pill-shape with centred label text.
|
|
402
|
+
|
|
403
|
+
`accent` controls the pill fill. When ``None``, falls back to the
|
|
404
|
+
token palette's ``accent`` slot, then ``primary``. ``text_color``
|
|
405
|
+
falls back to ``on_primary`` (or white) for contrast.
|
|
406
|
+
"""
|
|
407
|
+
fill_rgb = _coerce_or_token(accent, tokens, ("accent", "primary", "neutral"))
|
|
408
|
+
if text_color is None:
|
|
409
|
+
text_rgb = _palette(tokens, ("on_primary",))
|
|
410
|
+
if text_rgb is None:
|
|
411
|
+
from pptx2.dml.color import RGBColor
|
|
412
|
+
|
|
413
|
+
text_rgb = RGBColor(0xFF, 0xFF, 0xFF)
|
|
414
|
+
else:
|
|
415
|
+
from pptx2._color import coerce_color
|
|
416
|
+
|
|
417
|
+
text_rgb = coerce_color(text_color)
|
|
418
|
+
|
|
419
|
+
pill = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, left, top, width, height)
|
|
420
|
+
if fill_rgb is not None:
|
|
421
|
+
pill.fill.solid()
|
|
422
|
+
pill.fill.fore_color.rgb = fill_rgb
|
|
423
|
+
pill.line.fill.background()
|
|
424
|
+
pill.text_frame.text = ""
|
|
425
|
+
|
|
426
|
+
label = slide.shapes.add_textbox(left, top, width, height)
|
|
427
|
+
_fill_text_frame(
|
|
428
|
+
label.text_frame,
|
|
429
|
+
text,
|
|
430
|
+
token=_typography(tokens, "body", default_size=Pt(10), default_bold=True),
|
|
431
|
+
color=text_rgb,
|
|
432
|
+
align=PP_ALIGN.CENTER,
|
|
433
|
+
anchor=MSO_ANCHOR.MIDDLE,
|
|
434
|
+
shrink_to_fit=True,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
group_name = f"status_pill@{int(left)},{int(top)}"
|
|
438
|
+
for shape in (pill, label):
|
|
439
|
+
try:
|
|
440
|
+
shape.lint_group = group_name
|
|
441
|
+
except (AttributeError, NotImplementedError):
|
|
442
|
+
pass
|
|
443
|
+
|
|
444
|
+
return StatusPill(pill=pill, label=label)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# ---------------------------------------------------------------------------
|
|
448
|
+
# Stat strip — n KPI tiles laid out across a bounding box with a gutter
|
|
449
|
+
# ---------------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@dataclass
|
|
453
|
+
class StatStrip:
|
|
454
|
+
cards: list
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def add_stat_strip(
|
|
458
|
+
slide: "Slide",
|
|
459
|
+
*,
|
|
460
|
+
left: Length,
|
|
461
|
+
top: Length,
|
|
462
|
+
width: Length,
|
|
463
|
+
height: Length,
|
|
464
|
+
items: "list[Mapping[str, Any]]",
|
|
465
|
+
gutter: Length = Inches(0.25),
|
|
466
|
+
tokens: Optional[DesignTokens] = None,
|
|
467
|
+
) -> StatStrip:
|
|
468
|
+
"""Add ``len(items)`` KPI tiles across a strip with the given gutter.
|
|
469
|
+
|
|
470
|
+
Each item dict accepts the same fields as ``add_kpi_card``'s
|
|
471
|
+
``label`` / ``value`` / ``delta``. Cards are sized to the strip's
|
|
472
|
+
width minus the gutters and stacked left-to-right.
|
|
473
|
+
|
|
474
|
+
Returns a :class:`StatStrip` whose ``.cards`` is a list of
|
|
475
|
+
:class:`KpiCard` bundles, in the same order as `items`.
|
|
476
|
+
"""
|
|
477
|
+
if not items:
|
|
478
|
+
return StatStrip(cards=[])
|
|
479
|
+
|
|
480
|
+
n = len(items)
|
|
481
|
+
available = int(width) - (n - 1) * int(gutter)
|
|
482
|
+
card_w = Length(available // n)
|
|
483
|
+
cards = []
|
|
484
|
+
for i, kpi in enumerate(items):
|
|
485
|
+
l = Length(int(left) + i * (int(card_w) + int(gutter)))
|
|
486
|
+
delta = (
|
|
487
|
+
{"delta": kpi["delta"]}
|
|
488
|
+
if "delta" in kpi
|
|
489
|
+
else ({"delta_text": kpi["delta_text"]} if "delta_text" in kpi else None)
|
|
490
|
+
)
|
|
491
|
+
cards.append(
|
|
492
|
+
add_kpi_card(
|
|
493
|
+
slide,
|
|
494
|
+
left=l,
|
|
495
|
+
top=top,
|
|
496
|
+
width=card_w,
|
|
497
|
+
height=height,
|
|
498
|
+
label=str(kpi.get("label", "")),
|
|
499
|
+
value=str(kpi.get("value", "")),
|
|
500
|
+
delta=delta,
|
|
501
|
+
tokens=tokens,
|
|
502
|
+
)
|
|
503
|
+
)
|
|
504
|
+
return StatStrip(cards=cards)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
# ---------------------------------------------------------------------------
|
|
508
|
+
# Article card — title + blurb with optional CTA pill
|
|
509
|
+
# ---------------------------------------------------------------------------
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
@dataclass
|
|
513
|
+
class ArticleCard:
|
|
514
|
+
card: Any
|
|
515
|
+
title_box: Any
|
|
516
|
+
blurb_box: Any
|
|
517
|
+
cta: Optional[StatusPill]
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def add_article_card(
|
|
521
|
+
slide: "Slide",
|
|
522
|
+
*,
|
|
523
|
+
left: Length,
|
|
524
|
+
top: Length,
|
|
525
|
+
width: Length,
|
|
526
|
+
height: Length,
|
|
527
|
+
title: str,
|
|
528
|
+
blurb: str = "",
|
|
529
|
+
cta_text: Optional[str] = None,
|
|
530
|
+
tokens: Optional[DesignTokens] = None,
|
|
531
|
+
) -> ArticleCard:
|
|
532
|
+
"""Add a brand-styled article card (title + blurb + optional CTA).
|
|
533
|
+
|
|
534
|
+
The card uses the same surface / muted / primary palette slots as
|
|
535
|
+
the slide-level recipes for visual consistency. The CTA, when
|
|
536
|
+
supplied, is rendered as a small :class:`StatusPill` anchored at
|
|
537
|
+
the card's bottom-left.
|
|
538
|
+
"""
|
|
539
|
+
fill_color = _palette(tokens, ("surface", "lt2"))
|
|
540
|
+
border_color = _palette(tokens, ("muted", "lt1"))
|
|
541
|
+
title_color = _palette(tokens, ("primary", "neutral"))
|
|
542
|
+
blurb_color = _palette(tokens, ("muted", "neutral"))
|
|
543
|
+
|
|
544
|
+
card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, left, top, width, height)
|
|
545
|
+
if fill_color is not None:
|
|
546
|
+
card.fill.solid()
|
|
547
|
+
card.fill.fore_color.rgb = fill_color
|
|
548
|
+
else:
|
|
549
|
+
card.fill.background()
|
|
550
|
+
if border_color is not None:
|
|
551
|
+
card.line.color.rgb = border_color
|
|
552
|
+
card.line.width = Pt(0.75)
|
|
553
|
+
_apply_card_styling(card, tokens)
|
|
554
|
+
card.text_frame.text = ""
|
|
555
|
+
|
|
556
|
+
pad = Inches(0.25)
|
|
557
|
+
title_h = Inches(0.5)
|
|
558
|
+
cta_h = Inches(0.35) if cta_text else Length(0)
|
|
559
|
+
blurb_top = Length(top + pad + title_h + Inches(0.05))
|
|
560
|
+
blurb_h = Length(int(height) - int(pad) * 2 - int(title_h) - int(cta_h) - Inches(0.1))
|
|
561
|
+
|
|
562
|
+
title_box = slide.shapes.add_textbox(
|
|
563
|
+
Length(left + pad), Length(top + pad), Length(width - 2 * pad), title_h
|
|
564
|
+
)
|
|
565
|
+
_fill_text_frame(
|
|
566
|
+
title_box.text_frame,
|
|
567
|
+
title,
|
|
568
|
+
token=_typography(tokens, "heading", default_size=Pt(16), default_bold=True),
|
|
569
|
+
color=title_color,
|
|
570
|
+
align=PP_ALIGN.LEFT,
|
|
571
|
+
anchor=MSO_ANCHOR.TOP,
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
blurb_box = slide.shapes.add_textbox(
|
|
575
|
+
Length(left + pad), blurb_top, Length(width - 2 * pad), blurb_h
|
|
576
|
+
)
|
|
577
|
+
_fill_text_frame(
|
|
578
|
+
blurb_box.text_frame,
|
|
579
|
+
blurb,
|
|
580
|
+
token=_typography(tokens, "body", default_size=Pt(11)),
|
|
581
|
+
color=blurb_color,
|
|
582
|
+
align=PP_ALIGN.LEFT,
|
|
583
|
+
anchor=MSO_ANCHOR.TOP,
|
|
584
|
+
word_wrap=True,
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
cta = None
|
|
588
|
+
if cta_text:
|
|
589
|
+
cta_w = Inches(1.2)
|
|
590
|
+
cta = add_status_pill(
|
|
591
|
+
slide,
|
|
592
|
+
left=Length(left + pad),
|
|
593
|
+
top=Length(top + height - pad - cta_h),
|
|
594
|
+
width=cta_w,
|
|
595
|
+
height=cta_h,
|
|
596
|
+
text=cta_text,
|
|
597
|
+
tokens=tokens,
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
group_name = f"article_card@{int(left)},{int(top)}"
|
|
601
|
+
for shape in (card, title_box, blurb_box):
|
|
602
|
+
try:
|
|
603
|
+
shape.lint_group = group_name
|
|
604
|
+
except (AttributeError, NotImplementedError):
|
|
605
|
+
pass
|
|
606
|
+
|
|
607
|
+
return ArticleCard(card=card, title_box=title_box, blurb_box=blurb_box, cta=cta)
|