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
pptx2/diagrams.py ADDED
@@ -0,0 +1,754 @@
1
+ """Native-shape diagram recipes — pipelines, hub-and-spoke, cycles.
2
+
3
+ This module addresses the most common cause of "I built it out of 20
4
+ ``add_shape`` calls and the layout maths is wrong" diagrams produced by
5
+ LLM-driven deck generation. Each recipe takes a slide, a
6
+ :class:`~pptx2.geometry.BBox` to live inside, and a small
7
+ content spec; the recipe handles equal-column widths, mid-point
8
+ arrow routing, and inset padding so the caller only specifies the
9
+ *semantics*::
10
+
11
+ from pptx2.diagrams import horizontal_pipeline
12
+
13
+ horizontal_pipeline(
14
+ slide,
15
+ bbox,
16
+ steps=["Extract", "Classify", "Enrich", "Output"],
17
+ accent="#0B5CFF",
18
+ )
19
+
20
+ Every recipe returns a small dataclass exposing the shapes it built
21
+ (``cards``, ``arrows``, ``hub``, …) so callers can tweak individually.
22
+
23
+ The recipes deliberately use built-in :class:`~pptx2.enum.shapes.MSO_SHAPE`
24
+ geometry only. No images, no SmartArt — every output is fully native
25
+ PowerPoint shapes that PowerPoint, Keynote, and LibreOffice all render
26
+ identically.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import math
32
+ from dataclasses import dataclass, field
33
+ from typing import TYPE_CHECKING, Any, Sequence
34
+
35
+ from pptx2.enum.shapes import MSO_SHAPE
36
+ from pptx2.geometry import BBox
37
+ from pptx2.util import Emu, Inches, Pt
38
+
39
+ if TYPE_CHECKING:
40
+ from pptx2.shapes.autoshape import Shape
41
+ from pptx2.shapes.base import BaseShape
42
+ from pptx2.shapes.connector import Connector
43
+ from pptx2.slide import Slide
44
+
45
+
46
+ __all__ = [
47
+ "PipelineResult",
48
+ "HubAndSpokeResult",
49
+ "CycleResult",
50
+ "DecisionTreeResult",
51
+ "ColumnsResult",
52
+ "horizontal_pipeline",
53
+ "vertical_pipeline",
54
+ "hub_and_spoke",
55
+ "cycle",
56
+ "decision_tree",
57
+ "comparison_columns",
58
+ ]
59
+
60
+
61
+ # ----------------------------------------------------------------------------- specs
62
+
63
+
64
+ @dataclass
65
+ class _Step:
66
+ label: str
67
+ sublabel: str | None = None
68
+ fill: str | None = None
69
+ text_color: str | None = None
70
+
71
+
72
+ def _coerce_steps(steps: Sequence[Any]) -> list[_Step]:
73
+ out: list[_Step] = []
74
+ for s in steps:
75
+ if isinstance(s, str):
76
+ out.append(_Step(label=s))
77
+ elif isinstance(s, dict):
78
+ out.append(
79
+ _Step(
80
+ label=s.get("label", ""),
81
+ sublabel=s.get("sublabel"),
82
+ fill=s.get("fill"),
83
+ text_color=s.get("text_color"),
84
+ )
85
+ )
86
+ elif isinstance(s, _Step):
87
+ out.append(s)
88
+ else:
89
+ raise TypeError(
90
+ "steps must be strings, dicts, or _Step instances; got "
91
+ f"{type(s).__name__}"
92
+ )
93
+ if not out:
94
+ raise ValueError("steps must be non-empty")
95
+ return out
96
+
97
+
98
+ # ----------------------------------------------------------------------------- results
99
+
100
+
101
+ @dataclass
102
+ class PipelineResult:
103
+ cards: list[Any] = field(default_factory=list)
104
+ arrows: list[Any] = field(default_factory=list)
105
+
106
+
107
+ @dataclass
108
+ class HubAndSpokeResult:
109
+ hub: Any = None
110
+ spokes: list[Any] = field(default_factory=list)
111
+ arrows: list[Any] = field(default_factory=list)
112
+
113
+
114
+ @dataclass
115
+ class CycleResult:
116
+ cards: list[Any] = field(default_factory=list)
117
+ arrows: list[Any] = field(default_factory=list)
118
+
119
+
120
+ @dataclass
121
+ class DecisionTreeResult:
122
+ root: Any = None
123
+ branches: list[Any] = field(default_factory=list)
124
+ arrows: list[Any] = field(default_factory=list)
125
+
126
+
127
+ @dataclass
128
+ class ColumnsResult:
129
+ columns: list[Any] = field(default_factory=list)
130
+ headers: list[Any] = field(default_factory=list)
131
+
132
+
133
+ # ----------------------------------------------------------------------------- helpers
134
+
135
+
136
+ def _tag_group(slide, prefix: str, shapes: list[Any]) -> None:
137
+ """Tag every shape with a unique-on-slide ``lint_group``.
138
+
139
+ Diagram arrows intentionally overlap their target cards; without
140
+ this tag the linter / audit() would flood with ShapeCollision
141
+ warnings for the recipe's design.
142
+ """
143
+ if not shapes:
144
+ return
145
+ try:
146
+ slide.lint_group_overlaps(*shapes)
147
+ except Exception:
148
+ for s in shapes:
149
+ try:
150
+ s.lint_group = prefix
151
+ except Exception:
152
+ pass
153
+
154
+
155
+ def _fit_circular_label(
156
+ shape: Any,
157
+ *,
158
+ diameter: int,
159
+ font: str | None,
160
+ max_size_pt: float,
161
+ bold: bool = False,
162
+ italic: bool = False,
163
+ ) -> None:
164
+ """Shrink a circular node's label so it never clips the curved edge.
165
+
166
+ The headline reason this fork exists is space-awareness, so the diagram
167
+ recipes use the same ``fit_text`` pre-flight as everything else. A circle's
168
+ usable text area is narrower than its bounding box, so we inset the text
169
+ frame by a fraction of the diameter before fitting — that keeps long words
170
+ such as "Retrieval" inside the inscribed area instead of wrapping to
171
+ "Retriev al".
172
+ """
173
+ tf = shape.text_frame
174
+ tf.word_wrap = True
175
+ h_inset = Emu(int(diameter * 0.14))
176
+ v_inset = Emu(int(diameter * 0.08))
177
+ tf.margin_left = tf.margin_right = h_inset
178
+ tf.margin_top = tf.margin_bottom = v_inset
179
+ try:
180
+ tf.fit_text(
181
+ font_family=font,
182
+ max_size=max(1, int(round(max_size_pt))),
183
+ bold=bold,
184
+ italic=italic,
185
+ )
186
+ except (ValueError, OSError):
187
+ # Degrade gracefully: if even 1pt won't fit (tiny circle) or no font
188
+ # metrics are available, keep the size the caller already set.
189
+ pass
190
+
191
+
192
+ def _card(
193
+ slide,
194
+ bbox: BBox,
195
+ *,
196
+ fill: str = "#FFFFFF",
197
+ line: str | None = "#0D0D0D",
198
+ weight_pt: float = 1.0,
199
+ text: str | None = None,
200
+ text_color: str = "#0D0D0D",
201
+ font: str | None = None,
202
+ size_pt: float = 14.0,
203
+ bold: bool = False,
204
+ radius: float = 0.0,
205
+ ) -> "Shape":
206
+ shape_type = MSO_SHAPE.ROUNDED_RECTANGLE if radius > 0 else MSO_SHAPE.RECTANGLE
207
+ rect = slide.shapes.add_shape(shape_type, *bbox)
208
+ rect.fill_hex(fill)
209
+ if line is not None:
210
+ rect.line_hex(line, weight_pt=weight_pt)
211
+ else:
212
+ rect.line.fill.background()
213
+ if text:
214
+ tf = rect.text_frame
215
+ tf.word_wrap = True
216
+ from pptx2.enum.text import MSO_VERTICAL_ANCHOR, PP_PARAGRAPH_ALIGNMENT
217
+
218
+ tf.vertical_anchor = MSO_VERTICAL_ANCHOR.MIDDLE
219
+ margin = Pt(6)
220
+ tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = margin
221
+ tf.text = text
222
+ for para in tf.paragraphs:
223
+ para.alignment = PP_PARAGRAPH_ALIGNMENT.CENTER
224
+ for run in para.runs:
225
+ if font is not None:
226
+ run.font.name = font
227
+ run.font.size = Pt(float(size_pt))
228
+ run.font.bold = bool(bold)
229
+ from pptx2._color import coerce_color
230
+
231
+ run.font.color.rgb = coerce_color(text_color)
232
+ # Space-awareness: shrink the label so a long word never clips its card
233
+ # (the rectangular siblings of the already-fitted circular nodes).
234
+ try:
235
+ tf.fit_text(
236
+ font_family=font,
237
+ max_size=max(1, int(round(size_pt))),
238
+ bold=bool(bold),
239
+ )
240
+ except (ValueError, OSError):
241
+ pass
242
+ return rect
243
+
244
+
245
+ # ----------------------------------------------------------------------------- pipelines
246
+
247
+
248
+ def horizontal_pipeline(
249
+ slide,
250
+ bbox: BBox,
251
+ steps: Sequence[Any],
252
+ *,
253
+ accent: str = "#0B5CFF",
254
+ fill: str = "#FFFFFF",
255
+ text_color: str = "#0D0D0D",
256
+ font: str | None = None,
257
+ size_pt: float = 14.0,
258
+ bold_labels: bool = True,
259
+ gap: int | None = None,
260
+ arrow_inset_pt: float = 6.0,
261
+ arrow_head: str = "triangle",
262
+ card_line: str | None = "#0D0D0D",
263
+ card_radius: float = 0.0,
264
+ ) -> PipelineResult:
265
+ """Horizontal pipeline of N steps with arrows between them.
266
+
267
+ Each step renders as an evenly-sized card with a centered label.
268
+ Arrows are routed mid-edge with a small inset so the arrowhead
269
+ doesn't bleed into the next card.
270
+
271
+ ``steps`` may be a list of plain strings or ``{"label": ..., "sublabel": ...,
272
+ "fill": ..., "text_color": ...}`` dicts.
273
+ """
274
+ coerced = _coerce_steps(steps)
275
+ n = len(coerced)
276
+ if gap is None:
277
+ gap = int(Pt(12))
278
+
279
+ card_boxes = bbox.split_h([1] * n, gap=gap)
280
+ cards: list[Any] = []
281
+ for step, cb in zip(coerced, card_boxes):
282
+ label = step.label
283
+ if step.sublabel:
284
+ label = f"{label}\n{step.sublabel}"
285
+ cards.append(
286
+ _card(
287
+ slide,
288
+ cb,
289
+ fill=step.fill or fill,
290
+ line=card_line,
291
+ text=label,
292
+ text_color=step.text_color or text_color,
293
+ font=font,
294
+ size_pt=size_pt,
295
+ bold=bold_labels,
296
+ radius=card_radius,
297
+ )
298
+ )
299
+
300
+ arrows: list[Any] = []
301
+ for i in range(n - 1):
302
+ arrow = slide.shapes.add_arrow(
303
+ cards[i],
304
+ cards[i + 1],
305
+ head=arrow_head,
306
+ color=accent,
307
+ weight_pt=2.0,
308
+ inset_pt=arrow_inset_pt,
309
+ )
310
+ arrows.append(arrow)
311
+ _tag_group(slide, "pipeline", cards + arrows)
312
+ return PipelineResult(cards=cards, arrows=arrows)
313
+
314
+
315
+ def vertical_pipeline(
316
+ slide,
317
+ bbox: BBox,
318
+ steps: Sequence[Any],
319
+ **kwargs,
320
+ ) -> PipelineResult:
321
+ """Vertical pipeline — same as :func:`horizontal_pipeline` but stacked."""
322
+ coerced = _coerce_steps(steps)
323
+ n = len(coerced)
324
+ gap = kwargs.pop("gap", None)
325
+ if gap is None:
326
+ gap = int(Pt(12))
327
+ accent = kwargs.pop("accent", "#0B5CFF")
328
+ fill = kwargs.pop("fill", "#FFFFFF")
329
+ text_color = kwargs.pop("text_color", "#0D0D0D")
330
+ font = kwargs.pop("font", None)
331
+ size_pt = kwargs.pop("size_pt", 14.0)
332
+ bold_labels = kwargs.pop("bold_labels", True)
333
+ arrow_inset_pt = kwargs.pop("arrow_inset_pt", 6.0)
334
+ arrow_head = kwargs.pop("arrow_head", "triangle")
335
+ card_line = kwargs.pop("card_line", "#0D0D0D")
336
+ card_radius = kwargs.pop("card_radius", 0.0)
337
+ if kwargs:
338
+ raise TypeError(f"unexpected keyword arguments: {sorted(kwargs)}")
339
+
340
+ card_boxes = bbox.split_v([1] * n, gap=gap)
341
+ cards: list[Any] = []
342
+ for step, cb in zip(coerced, card_boxes):
343
+ label = step.label
344
+ if step.sublabel:
345
+ label = f"{label}\n{step.sublabel}"
346
+ cards.append(
347
+ _card(
348
+ slide, cb,
349
+ fill=step.fill or fill,
350
+ line=card_line,
351
+ text=label,
352
+ text_color=step.text_color or text_color,
353
+ font=font,
354
+ size_pt=size_pt,
355
+ bold=bold_labels,
356
+ radius=card_radius,
357
+ )
358
+ )
359
+ arrows: list[Any] = []
360
+ for i in range(n - 1):
361
+ arrow = slide.shapes.add_arrow(
362
+ cards[i], cards[i + 1],
363
+ head=arrow_head, color=accent, weight_pt=2.0,
364
+ inset_pt=arrow_inset_pt,
365
+ )
366
+ arrows.append(arrow)
367
+ _tag_group(slide, "vpipeline", cards + arrows)
368
+ return PipelineResult(cards=cards, arrows=arrows)
369
+
370
+
371
+ # ----------------------------------------------------------------------------- hub
372
+
373
+
374
+ def hub_and_spoke(
375
+ slide,
376
+ bbox: BBox,
377
+ *,
378
+ centre: str,
379
+ spokes: Sequence[Any],
380
+ accent: str = "#0B5CFF",
381
+ fill: str = "#FFFFFF",
382
+ hub_fill: str | None = None,
383
+ text_color: str = "#0D0D0D",
384
+ hub_text_color: str = "#FFFFFF",
385
+ font: str | None = None,
386
+ size_pt: float = 14.0,
387
+ spoke_size: float = 1.0,
388
+ hub_size: float = 1.4,
389
+ ) -> HubAndSpokeResult:
390
+ """Hub-and-spoke diagram with N spokes radially arranged.
391
+
392
+ ``centre`` is the label of the hub. ``spokes`` is an iterable of
393
+ string labels or step-dicts (see :func:`horizontal_pipeline`).
394
+ """
395
+ coerced = _coerce_steps(spokes)
396
+ n = len(coerced)
397
+
398
+ # Place the hub in the centre at ~25% of the smaller dimension.
399
+ short = min(int(bbox.width), int(bbox.height))
400
+ hub_diameter = int(short * 0.25 * hub_size)
401
+ spoke_diameter = int(short * 0.2 * spoke_size)
402
+ cx, cy = int(bbox.cx), int(bbox.cy)
403
+
404
+ hub_box = BBox(
405
+ Emu(cx - hub_diameter // 2),
406
+ Emu(cy - hub_diameter // 2),
407
+ Emu(hub_diameter),
408
+ Emu(hub_diameter),
409
+ )
410
+ from pptx2._color import coerce_color
411
+ from pptx2.enum.text import MSO_VERTICAL_ANCHOR, PP_PARAGRAPH_ALIGNMENT
412
+
413
+ hub = slide.shapes.add_shape(MSO_SHAPE.OVAL, *hub_box)
414
+ hub.fill_hex(hub_fill or accent)
415
+ hub.line.fill.background()
416
+
417
+ tf = hub.text_frame
418
+ tf.word_wrap = True
419
+ tf.vertical_anchor = MSO_VERTICAL_ANCHOR.MIDDLE
420
+ tf.text = centre
421
+ for para in tf.paragraphs:
422
+ para.alignment = PP_PARAGRAPH_ALIGNMENT.CENTER
423
+ for run in para.runs:
424
+ if font is not None:
425
+ run.font.name = font
426
+ run.font.size = Pt(float(size_pt))
427
+ run.font.bold = True
428
+ run.font.color.rgb = coerce_color(hub_text_color)
429
+ _fit_circular_label(hub, diameter=hub_diameter, font=font, max_size_pt=size_pt, bold=True)
430
+
431
+ # Place spokes evenly around the hub.
432
+ radius = (min(int(bbox.width), int(bbox.height)) - hub_diameter - spoke_diameter) // 2
433
+ spokes_built: list[Any] = []
434
+ arrows: list[Any] = []
435
+ for i, step in enumerate(coerced):
436
+ angle = 2 * math.pi * i / n - math.pi / 2 # start at top
437
+ sx = cx + int(radius * math.cos(angle))
438
+ sy = cy + int(radius * math.sin(angle))
439
+ spoke_box = BBox(
440
+ Emu(sx - spoke_diameter // 2),
441
+ Emu(sy - spoke_diameter // 2),
442
+ Emu(spoke_diameter),
443
+ Emu(spoke_diameter),
444
+ )
445
+ spoke = slide.shapes.add_shape(MSO_SHAPE.OVAL, *spoke_box)
446
+ spoke.fill_hex(step.fill or fill)
447
+ spoke.line_hex(accent, weight_pt=1.5)
448
+ tfs = spoke.text_frame
449
+ tfs.word_wrap = True
450
+ tfs.vertical_anchor = MSO_VERTICAL_ANCHOR.MIDDLE
451
+ tfs.text = step.label
452
+ for para in tfs.paragraphs:
453
+ para.alignment = PP_PARAGRAPH_ALIGNMENT.CENTER
454
+ for run in para.runs:
455
+ if font is not None:
456
+ run.font.name = font
457
+ run.font.size = Pt(float(size_pt) * 0.85)
458
+ from pptx2._color import coerce_color
459
+
460
+ run.font.color.rgb = coerce_color(step.text_color or text_color)
461
+ _fit_circular_label(
462
+ spoke, diameter=spoke_diameter, font=font, max_size_pt=size_pt * 0.85
463
+ )
464
+
465
+ spokes_built.append(spoke)
466
+ arrow = slide.shapes.add_arrow(
467
+ hub,
468
+ spoke,
469
+ head="triangle",
470
+ color=accent,
471
+ weight_pt=1.5,
472
+ inset_pt=2.0,
473
+ )
474
+ arrows.append(arrow)
475
+ _tag_group(slide, "hub-and-spoke", [hub] + spokes_built + arrows)
476
+ return HubAndSpokeResult(hub=hub, spokes=spokes_built, arrows=arrows)
477
+
478
+
479
+ # ----------------------------------------------------------------------------- cycle
480
+
481
+
482
+ def cycle(
483
+ slide,
484
+ bbox: BBox,
485
+ steps: Sequence[Any],
486
+ *,
487
+ accent: str = "#0B5CFF",
488
+ fill: str = "#FFFFFF",
489
+ text_color: str = "#0D0D0D",
490
+ font: str | None = None,
491
+ size_pt: float = 14.0,
492
+ ) -> CycleResult:
493
+ """Cyclic diagram — N cards arranged in a circle with arrows ``i → i+1``.
494
+
495
+ The last arrow loops back from card N-1 to card 0, completing the
496
+ cycle. Works best with 3–8 steps; falls back to a small circle for
497
+ larger counts.
498
+ """
499
+ coerced = _coerce_steps(steps)
500
+ n = len(coerced)
501
+ short = min(int(bbox.width), int(bbox.height))
502
+ card_diameter = int(short * 0.22)
503
+ cx, cy = int(bbox.cx), int(bbox.cy)
504
+ radius = (short - card_diameter) // 2
505
+
506
+ cards: list[Any] = []
507
+ for i, step in enumerate(coerced):
508
+ angle = 2 * math.pi * i / n - math.pi / 2
509
+ sx = cx + int(radius * math.cos(angle))
510
+ sy = cy + int(radius * math.sin(angle))
511
+ card_box = BBox(
512
+ Emu(sx - card_diameter // 2),
513
+ Emu(sy - card_diameter // 2),
514
+ Emu(card_diameter),
515
+ Emu(card_diameter),
516
+ )
517
+ card = slide.shapes.add_shape(MSO_SHAPE.OVAL, *card_box)
518
+ card.fill_hex(step.fill or fill)
519
+ card.line_hex(accent, weight_pt=1.5)
520
+ from pptx2.enum.text import MSO_VERTICAL_ANCHOR, PP_PARAGRAPH_ALIGNMENT
521
+ from pptx2._color import coerce_color
522
+
523
+ tf = card.text_frame
524
+ tf.word_wrap = True
525
+ tf.vertical_anchor = MSO_VERTICAL_ANCHOR.MIDDLE
526
+ tf.text = step.label
527
+ for para in tf.paragraphs:
528
+ para.alignment = PP_PARAGRAPH_ALIGNMENT.CENTER
529
+ for run in para.runs:
530
+ if font is not None:
531
+ run.font.name = font
532
+ run.font.size = Pt(float(size_pt))
533
+ run.font.color.rgb = coerce_color(step.text_color or text_color)
534
+ _fit_circular_label(card, diameter=card_diameter, font=font, max_size_pt=size_pt)
535
+ cards.append(card)
536
+
537
+ arrows: list[Any] = []
538
+ for i in range(n):
539
+ j = (i + 1) % n
540
+ arrow = slide.shapes.add_arrow(
541
+ cards[i], cards[j],
542
+ head="triangle",
543
+ color=accent,
544
+ weight_pt=1.5,
545
+ inset_pt=2.0,
546
+ route="curved",
547
+ )
548
+ arrows.append(arrow)
549
+ _tag_group(slide, "cycle", cards + arrows)
550
+ return CycleResult(cards=cards, arrows=arrows)
551
+
552
+
553
+ # ----------------------------------------------------------------------------- decision tree
554
+
555
+
556
+ def decision_tree(
557
+ slide,
558
+ bbox: BBox,
559
+ *,
560
+ root: str,
561
+ branches: Sequence[Any],
562
+ accent: str = "#0B5CFF",
563
+ fill: str = "#FFFFFF",
564
+ text_color: str = "#0D0D0D",
565
+ font: str | None = None,
566
+ size_pt: float = 13.0,
567
+ root_fill: str | None = None,
568
+ root_text_color: str = "#FFFFFF",
569
+ leaf_fill: str | None = None,
570
+ leaf_text_color: str | None = None,
571
+ ) -> DecisionTreeResult:
572
+ """Decision tree — root question with N branch outcomes underneath.
573
+
574
+ ``branches`` may be plain labels (each becomes a leaf) or dicts with
575
+ ``label`` and optional ``children=[…]`` for one additional level
576
+ (sufficient for most decision-tree slides).
577
+
578
+ Leaf (child) nodes inherit ``fill`` / ``text_color`` from the recipe by
579
+ default so a dark deck with light text stays legible. Pass ``leaf_fill``
580
+ / ``leaf_text_color`` to give the leaves a deliberately distinct style.
581
+ """
582
+ leaf_fill = leaf_fill if leaf_fill is not None else fill
583
+ leaf_text_color = leaf_text_color if leaf_text_color is not None else text_color
584
+ if not branches:
585
+ raise ValueError("branches must be non-empty")
586
+
587
+ # Root sits at the top, taking ~25% of the height.
588
+ root_h = int(int(bbox.height) * 0.25)
589
+ root_w = max(int(int(bbox.width) * 0.4), int(Inches(2)))
590
+ root_box = BBox(
591
+ Emu(int(bbox.cx) - root_w // 2),
592
+ bbox.top,
593
+ Emu(root_w),
594
+ Emu(root_h),
595
+ )
596
+ root_card = _card(
597
+ slide,
598
+ root_box,
599
+ fill=root_fill or accent,
600
+ line=None,
601
+ text=root,
602
+ text_color=root_text_color,
603
+ font=font,
604
+ size_pt=size_pt,
605
+ bold=True,
606
+ radius=0.05,
607
+ )
608
+
609
+ # Branches fill the lower 70% of bbox.
610
+ branch_area = BBox(
611
+ bbox.left,
612
+ Emu(int(bbox.top) + int(root_h * 1.4)),
613
+ bbox.width,
614
+ Emu(int(bbox.height) - int(root_h * 1.4)),
615
+ )
616
+ coerced = []
617
+ for b in branches:
618
+ if isinstance(b, str):
619
+ coerced.append({"label": b, "children": []})
620
+ elif isinstance(b, dict):
621
+ coerced.append({"label": b["label"], "children": b.get("children", [])})
622
+ else:
623
+ raise TypeError(
624
+ "branches items must be str or dict; got "
625
+ f"{type(b).__name__}"
626
+ )
627
+
628
+ n = len(coerced)
629
+ col_boxes = branch_area.split_h([1] * n, gap=int(Pt(8)))
630
+ built: list[Any] = []
631
+ arrows: list[Any] = []
632
+ for branch, col in zip(coerced, col_boxes):
633
+ has_children = bool(branch["children"])
634
+ if has_children:
635
+ split = col.split_v([1, 3], gap=int(Pt(8)))
636
+ label_box = split[0]
637
+ children_box = split[1]
638
+ else:
639
+ label_box = col
640
+ children_box = None
641
+ branch_card = _card(
642
+ slide, label_box, fill=fill, line=accent,
643
+ text=branch["label"], text_color=text_color,
644
+ font=font, size_pt=size_pt, bold=False, radius=0.04,
645
+ )
646
+ built.append(branch_card)
647
+ arrow = slide.shapes.add_arrow(
648
+ root_card, branch_card,
649
+ head="triangle", color=accent, weight_pt=1.5,
650
+ inset_pt=4.0,
651
+ )
652
+ arrows.append(arrow)
653
+ if children_box is not None:
654
+ child_boxes = children_box.split_v([1] * len(branch["children"]), gap=int(Pt(6)))
655
+ for c, cb in zip(branch["children"], child_boxes):
656
+ label = c if isinstance(c, str) else c.get("label", "")
657
+ child_card = _card(
658
+ slide, cb, fill=leaf_fill, line=None,
659
+ text=label, text_color=leaf_text_color,
660
+ font=font, size_pt=size_pt * 0.9,
661
+ radius=0.04,
662
+ )
663
+ built.append(child_card)
664
+ arrows.append(
665
+ slide.shapes.add_arrow(
666
+ branch_card, child_card,
667
+ head="triangle", color=accent,
668
+ weight_pt=1.0, inset_pt=3.0,
669
+ )
670
+ )
671
+ _tag_group(slide, "decision-tree", [root_card] + built + arrows)
672
+ return DecisionTreeResult(root=root_card, branches=built, arrows=arrows)
673
+
674
+
675
+ # ----------------------------------------------------------------------------- columns
676
+
677
+
678
+ def comparison_columns(
679
+ slide,
680
+ bbox: BBox,
681
+ columns: Sequence[Any],
682
+ *,
683
+ header_fill: str = "#0B5CFF",
684
+ header_text_color: str = "#FFFFFF",
685
+ body_fill: str = "#FFFFFF",
686
+ text_color: str = "#0D0D0D",
687
+ font: str | None = None,
688
+ header_size_pt: float = 16.0,
689
+ body_size_pt: float = 12.0,
690
+ gap: int | None = None,
691
+ ) -> ColumnsResult:
692
+ """N-column comparison layout.
693
+
694
+ Each column has a header card and a body card stacked vertically.
695
+ ``columns`` is a list of dicts shaped
696
+ ``{"title": str, "body": str | list[str]}``.
697
+ """
698
+ if not columns:
699
+ raise ValueError("columns must be non-empty")
700
+ if gap is None:
701
+ gap = int(Pt(12))
702
+
703
+ col_boxes = bbox.split_h([1] * len(columns), gap=gap)
704
+ header_h = int(int(bbox.height) * 0.18)
705
+ built_columns: list[Any] = []
706
+ built_headers: list[Any] = []
707
+ from pptx2.enum.text import MSO_VERTICAL_ANCHOR, PP_PARAGRAPH_ALIGNMENT
708
+ from pptx2._color import coerce_color
709
+
710
+ for spec, col in zip(columns, col_boxes):
711
+ if isinstance(spec, str):
712
+ spec = {"title": spec, "body": ""}
713
+ title = spec.get("title", "")
714
+ body = spec.get("body", "")
715
+ if isinstance(body, list):
716
+ body = "\n".join(str(item) for item in body)
717
+
718
+ header_box = BBox(col.left, col.top, col.width, Emu(header_h))
719
+ body_box = BBox(
720
+ col.left,
721
+ Emu(int(col.top) + header_h + int(Pt(6))),
722
+ col.width,
723
+ Emu(int(col.height) - header_h - int(Pt(6))),
724
+ )
725
+
726
+ header = _card(
727
+ slide, header_box, fill=header_fill, line=None,
728
+ text=title, text_color=header_text_color,
729
+ font=font, size_pt=header_size_pt, bold=True, radius=0.05,
730
+ )
731
+ body_card = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, *body_box)
732
+ body_card.fill_hex(body_fill)
733
+ body_card.line_hex(header_fill, weight_pt=1.0)
734
+ tf = body_card.text_frame
735
+ tf.word_wrap = True
736
+ tf.vertical_anchor = MSO_VERTICAL_ANCHOR.TOP
737
+ margin = Pt(8)
738
+ tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = margin
739
+ tf.text = body
740
+ for para in tf.paragraphs:
741
+ for run in para.runs:
742
+ if font is not None:
743
+ run.font.name = font
744
+ run.font.size = Pt(float(body_size_pt))
745
+ run.font.color.rgb = coerce_color(text_color)
746
+ # Shrink a long body so it doesn't overflow the column card / slide.
747
+ try:
748
+ tf.fit_text(font_family=font, max_size=max(1, int(round(body_size_pt))))
749
+ except (ValueError, OSError):
750
+ pass
751
+ built_headers.append(header)
752
+ built_columns.append(body_card)
753
+ _tag_group(slide, "columns", built_headers + built_columns)
754
+ return ColumnsResult(columns=built_columns, headers=built_headers)