python-pptx2 2.13.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. pptx2/__init__.py +152 -0
  2. pptx2/_color.py +75 -0
  3. pptx2/_slide_importer.py +597 -0
  4. pptx2/_svg.py +155 -0
  5. pptx2/_template_applier.py +292 -0
  6. pptx2/_textstyle.py +187 -0
  7. pptx2/accessibility.py +365 -0
  8. pptx2/action.py +270 -0
  9. pptx2/animation.py +2237 -0
  10. pptx2/api.py +49 -0
  11. pptx2/audit.py +258 -0
  12. pptx2/chart/__init__.py +0 -0
  13. pptx2/chart/analytics.py +381 -0
  14. pptx2/chart/axis.py +543 -0
  15. pptx2/chart/category.py +200 -0
  16. pptx2/chart/chart.py +670 -0
  17. pptx2/chart/data.py +864 -0
  18. pptx2/chart/datalabel.py +406 -0
  19. pptx2/chart/legend.py +86 -0
  20. pptx2/chart/marker.py +70 -0
  21. pptx2/chart/palettes.py +129 -0
  22. pptx2/chart/plot.py +462 -0
  23. pptx2/chart/point.py +101 -0
  24. pptx2/chart/quick_layouts.py +325 -0
  25. pptx2/chart/series.py +334 -0
  26. pptx2/chart/xlsx.py +272 -0
  27. pptx2/chart/xmlwriter.py +1845 -0
  28. pptx2/compose/__init__.py +28 -0
  29. pptx2/compose/from_spec.py +1094 -0
  30. pptx2/design/__init__.py +8 -0
  31. pptx2/design/components.py +607 -0
  32. pptx2/design/figures.py +389 -0
  33. pptx2/design/layout.py +370 -0
  34. pptx2/design/recipes.py +1967 -0
  35. pptx2/design/style.py +209 -0
  36. pptx2/design/tokens.py +915 -0
  37. pptx2/diagrams.py +754 -0
  38. pptx2/dml/__init__.py +0 -0
  39. pptx2/dml/chtfmt.py +40 -0
  40. pptx2/dml/color.py +496 -0
  41. pptx2/dml/effect.py +909 -0
  42. pptx2/dml/fill.py +691 -0
  43. pptx2/dml/line.py +287 -0
  44. pptx2/dml/picture.py +212 -0
  45. pptx2/dml/three_d.py +381 -0
  46. pptx2/enum/__init__.py +0 -0
  47. pptx2/enum/action.py +71 -0
  48. pptx2/enum/animation.py +31 -0
  49. pptx2/enum/base.py +218 -0
  50. pptx2/enum/chart.py +574 -0
  51. pptx2/enum/dml.py +740 -0
  52. pptx2/enum/lang.py +685 -0
  53. pptx2/enum/presentation.py +133 -0
  54. pptx2/enum/shapes.py +1029 -0
  55. pptx2/enum/text.py +230 -0
  56. pptx2/exc.py +42 -0
  57. pptx2/formats.py +139 -0
  58. pptx2/geometry.py +420 -0
  59. pptx2/inherit.py +109 -0
  60. pptx2/lint.py +2256 -0
  61. pptx2/math.py +177 -0
  62. pptx2/media.py +197 -0
  63. pptx2/opc/__init__.py +0 -0
  64. pptx2/opc/constants.py +332 -0
  65. pptx2/opc/oxml.py +188 -0
  66. pptx2/opc/package.py +762 -0
  67. pptx2/opc/packuri.py +109 -0
  68. pptx2/opc/serialized.py +296 -0
  69. pptx2/opc/shared.py +20 -0
  70. pptx2/opc/spec.py +45 -0
  71. pptx2/oxml/__init__.py +555 -0
  72. pptx2/oxml/action.py +53 -0
  73. pptx2/oxml/chart/__init__.py +0 -0
  74. pptx2/oxml/chart/axis.py +337 -0
  75. pptx2/oxml/chart/chart.py +481 -0
  76. pptx2/oxml/chart/datalabel.py +253 -0
  77. pptx2/oxml/chart/legend.py +72 -0
  78. pptx2/oxml/chart/marker.py +61 -0
  79. pptx2/oxml/chart/plot.py +365 -0
  80. pptx2/oxml/chart/series.py +425 -0
  81. pptx2/oxml/chart/shared.py +220 -0
  82. pptx2/oxml/coreprops.py +288 -0
  83. pptx2/oxml/dml/__init__.py +0 -0
  84. pptx2/oxml/dml/color.py +135 -0
  85. pptx2/oxml/dml/effect.py +213 -0
  86. pptx2/oxml/dml/fill.py +316 -0
  87. pptx2/oxml/dml/line.py +12 -0
  88. pptx2/oxml/dml/three_d.py +110 -0
  89. pptx2/oxml/ns.py +135 -0
  90. pptx2/oxml/presentation.py +313 -0
  91. pptx2/oxml/shapes/__init__.py +19 -0
  92. pptx2/oxml/shapes/autoshape.py +467 -0
  93. pptx2/oxml/shapes/connector.py +107 -0
  94. pptx2/oxml/shapes/graphfrm.py +347 -0
  95. pptx2/oxml/shapes/groupshape.py +329 -0
  96. pptx2/oxml/shapes/picture.py +270 -0
  97. pptx2/oxml/shapes/shared.py +577 -0
  98. pptx2/oxml/simpletypes.py +1027 -0
  99. pptx2/oxml/slide.py +563 -0
  100. pptx2/oxml/table.py +650 -0
  101. pptx2/oxml/text.py +815 -0
  102. pptx2/oxml/theme.py +36 -0
  103. pptx2/oxml/xmlchemy.py +717 -0
  104. pptx2/package.py +222 -0
  105. pptx2/parts/__init__.py +0 -0
  106. pptx2/parts/chart.py +95 -0
  107. pptx2/parts/coreprops.py +167 -0
  108. pptx2/parts/diagram.py +37 -0
  109. pptx2/parts/embeddedpackage.py +93 -0
  110. pptx2/parts/image.py +275 -0
  111. pptx2/parts/media.py +37 -0
  112. pptx2/parts/presentation.py +136 -0
  113. pptx2/parts/slide.py +371 -0
  114. pptx2/presentation.py +408 -0
  115. pptx2/py.typed +0 -0
  116. pptx2/render.py +586 -0
  117. pptx2/section.py +272 -0
  118. pptx2/shapes/__init__.py +26 -0
  119. pptx2/shapes/autoshape.py +442 -0
  120. pptx2/shapes/base.py +1078 -0
  121. pptx2/shapes/connector.py +297 -0
  122. pptx2/shapes/freeform.py +337 -0
  123. pptx2/shapes/graphfrm.py +316 -0
  124. pptx2/shapes/group.py +264 -0
  125. pptx2/shapes/picture.py +422 -0
  126. pptx2/shapes/placeholder.py +468 -0
  127. pptx2/shapes/shapetree.py +2027 -0
  128. pptx2/shared.py +82 -0
  129. pptx2/skill/SKILL.md +450 -0
  130. pptx2/skill/__init__.py +78 -0
  131. pptx2/skill/__main__.py +64 -0
  132. pptx2/skill/references/animations.md +189 -0
  133. pptx2/skill/references/basics.md +421 -0
  134. pptx2/skill/references/charts.md +254 -0
  135. pptx2/skill/references/compose.md +234 -0
  136. pptx2/skill/references/design.md +366 -0
  137. pptx2/skill/references/effects.md +249 -0
  138. pptx2/skill/references/end-to-end-deck.md +231 -0
  139. pptx2/skill/references/geometry-and-arrows.md +334 -0
  140. pptx2/skill/references/lint.md +275 -0
  141. pptx2/skill/references/math.md +86 -0
  142. pptx2/skill/references/picture-effects.md +129 -0
  143. pptx2/skill/references/render.md +151 -0
  144. pptx2/skill/references/smart-art.md +75 -0
  145. pptx2/skill/references/space-aware-authoring.md +249 -0
  146. pptx2/skill/references/tables.md +244 -0
  147. pptx2/skill/references/theme.md +127 -0
  148. pptx2/skill/references/three-d.md +109 -0
  149. pptx2/skill/references/transitions.md +100 -0
  150. pptx2/slide.py +1244 -0
  151. pptx2/smart_art.py +220 -0
  152. pptx2/spec.py +633 -0
  153. pptx2/table.py +1181 -0
  154. pptx2/table_styles.py +184 -0
  155. pptx2/templates/default.pptx +0 -0
  156. pptx2/templates/docx-icon.emf +0 -0
  157. pptx2/templates/generic-icon.emf +0 -0
  158. pptx2/templates/notes.xml +23 -0
  159. pptx2/templates/notesMaster.xml +352 -0
  160. pptx2/templates/pptx-icon.emf +0 -0
  161. pptx2/templates/theme.xml +321 -0
  162. pptx2/templates/xlsx-icon.emf +0 -0
  163. pptx2/text/__init__.py +0 -0
  164. pptx2/text/fonts.py +482 -0
  165. pptx2/text/layout.py +374 -0
  166. pptx2/text/text.py +1272 -0
  167. pptx2/theme.py +721 -0
  168. pptx2/types.py +36 -0
  169. pptx2/util.py +263 -0
  170. python_pptx2-2.13.0.dist-info/METADATA +351 -0
  171. python_pptx2-2.13.0.dist-info/RECORD +175 -0
  172. python_pptx2-2.13.0.dist-info/WHEEL +5 -0
  173. python_pptx2-2.13.0.dist-info/entry_points.txt +3 -0
  174. python_pptx2-2.13.0.dist-info/licenses/LICENSE +22 -0
  175. python_pptx2-2.13.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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)