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/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)
|