python-pptx2 3.0.0__py3-none-any.whl → 3.1.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 +19 -1
- pptx2/design/blocks.py +433 -0
- pptx2/design/palettes.py +217 -0
- pptx2/presentation.py +12 -0
- pptx2/render.py +99 -0
- pptx2/skill/SKILL.md +108 -19
- pptx2/skill/references/design.md +35 -0
- pptx2/skill/references/render.md +20 -0
- pptx2/skill/references/slide-design.md +495 -0
- {python_pptx2-3.0.0.dist-info → python_pptx2-3.1.0.dist-info}/METADATA +1 -1
- {python_pptx2-3.0.0.dist-info → python_pptx2-3.1.0.dist-info}/RECORD +15 -12
- {python_pptx2-3.0.0.dist-info → python_pptx2-3.1.0.dist-info}/WHEEL +0 -0
- {python_pptx2-3.0.0.dist-info → python_pptx2-3.1.0.dist-info}/entry_points.txt +0 -0
- {python_pptx2-3.0.0.dist-info → python_pptx2-3.1.0.dist-info}/licenses/LICENSE +0 -0
- {python_pptx2-3.0.0.dist-info → python_pptx2-3.1.0.dist-info}/top_level.txt +0 -0
pptx2/__init__.py
CHANGED
|
@@ -9,6 +9,14 @@ import pptx2.exc as exceptions
|
|
|
9
9
|
from pptx2.api import Presentation
|
|
10
10
|
from pptx2.audit import AuditReport, audit
|
|
11
11
|
from pptx2.geometry import BBox
|
|
12
|
+
from pptx2.design.blocks import (
|
|
13
|
+
Card,
|
|
14
|
+
FittedPicture,
|
|
15
|
+
add_bullets,
|
|
16
|
+
add_card,
|
|
17
|
+
add_picture_fit,
|
|
18
|
+
)
|
|
19
|
+
from pptx2.design.palettes import PALETTES, Palette, palette
|
|
12
20
|
from pptx2.design.components import (
|
|
13
21
|
ArticleCard,
|
|
14
22
|
Gauge,
|
|
@@ -57,7 +65,7 @@ from pptx2.parts.slide import (
|
|
|
57
65
|
if TYPE_CHECKING:
|
|
58
66
|
from pptx2.opc.package import Part
|
|
59
67
|
|
|
60
|
-
__version__ = "3.
|
|
68
|
+
__version__ = "3.1.0"
|
|
61
69
|
|
|
62
70
|
sys.modules["pptx2.exceptions"] = exceptions
|
|
63
71
|
del sys
|
|
@@ -91,6 +99,16 @@ __all__ = [
|
|
|
91
99
|
"StatusPill",
|
|
92
100
|
"StatStrip",
|
|
93
101
|
"ArticleCard",
|
|
102
|
+
# Everyday slide blocks (hex-driven; no token setup required).
|
|
103
|
+
"add_card",
|
|
104
|
+
"add_bullets",
|
|
105
|
+
"add_picture_fit",
|
|
106
|
+
"Card",
|
|
107
|
+
"FittedPicture",
|
|
108
|
+
# Curated colour sets.
|
|
109
|
+
"Palette",
|
|
110
|
+
"PALETTES",
|
|
111
|
+
"palette",
|
|
94
112
|
]
|
|
95
113
|
|
|
96
114
|
content_type_to_part_class_map: dict[str, type[Part]] = {
|
pptx2/design/blocks.py
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""Slide building blocks: the handful of pieces most content slides are made of.
|
|
2
|
+
|
|
3
|
+
Where :mod:`pptx2.design.recipes` builds whole slides and
|
|
4
|
+
:mod:`pptx2.design.components` builds token-driven dashboard widgets, this
|
|
5
|
+
module covers the everyday vocabulary of a teaching or explanatory deck,
|
|
6
|
+
driven by plain hex colours and points so a script needs no token setup:
|
|
7
|
+
|
|
8
|
+
* :func:`add_card` — one calm surface with a padded title and body. The
|
|
9
|
+
card *is* the emphasis: a tinted fill or a hairline outline, generous
|
|
10
|
+
padding, text that fits. No decorative stripes, badges or icons.
|
|
11
|
+
* :func:`add_bullets` — real PowerPoint bullets (``a:buChar`` /
|
|
12
|
+
``a:buAutoNum``) with a hanging indent and breathing room between
|
|
13
|
+
items, shrunk to fit the box when the list runs long.
|
|
14
|
+
* :func:`add_picture_fit` — a picture placed *inside* a box, either
|
|
15
|
+
letter-boxed (``mode="contain"``) or cropped to fill (``mode="cover"``),
|
|
16
|
+
centred, with an optional caption underneath.
|
|
17
|
+
|
|
18
|
+
Every block tags the shapes it stacks with ``lint_group`` so the linter
|
|
19
|
+
treats a card and the text on it as one deliberate cluster, and every
|
|
20
|
+
block returns the shapes it made so callers can keep styling.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import contextlib
|
|
26
|
+
import os
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import IO, TYPE_CHECKING, Any, Optional, Sequence, Union
|
|
29
|
+
|
|
30
|
+
from pptx2.enum.shapes import MSO_SHAPE
|
|
31
|
+
from pptx2.geometry import BBox
|
|
32
|
+
from pptx2.util import Emu, Pt
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from pptx2.shapes.autoshape import Shape
|
|
36
|
+
from pptx2.slide import Slide
|
|
37
|
+
|
|
38
|
+
__all__ = (
|
|
39
|
+
"Card",
|
|
40
|
+
"FittedPicture",
|
|
41
|
+
"add_card",
|
|
42
|
+
"add_bullets",
|
|
43
|
+
"add_picture_fit",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _as_bbox(bbox_or_positional: Sequence[Any]) -> BBox:
|
|
48
|
+
if len(bbox_or_positional) == 1 and isinstance(bbox_or_positional[0], BBox):
|
|
49
|
+
return bbox_or_positional[0]
|
|
50
|
+
if len(bbox_or_positional) == 4:
|
|
51
|
+
left, top, width, height = bbox_or_positional
|
|
52
|
+
return BBox(Emu(int(left)), Emu(int(top)), Emu(int(width)), Emu(int(height)))
|
|
53
|
+
raise TypeError(
|
|
54
|
+
"pass either a BBox or (left, top, width, height); got %d positional arg(s)"
|
|
55
|
+
% len(bbox_or_positional)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _tag(shapes: Sequence[Any], group: str) -> None:
|
|
60
|
+
for shape in shapes:
|
|
61
|
+
if shape is None:
|
|
62
|
+
continue
|
|
63
|
+
with contextlib.suppress(AttributeError, NotImplementedError):
|
|
64
|
+
shape.lint_group = group
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _fit(tf: Any, *, font: Optional[str], max_pt: float, min_pt: float, bold: bool) -> None:
|
|
68
|
+
"""Shrink *tf* to fit its shape, never below *min_pt*; fall back to autofit."""
|
|
69
|
+
from pptx2.enum.text import MSO_AUTO_SIZE
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
applied = tf.fit_text(font_family=font, max_size=max(1, int(round(max_pt))), bold=bold)
|
|
73
|
+
except (ValueError, OSError):
|
|
74
|
+
applied = None
|
|
75
|
+
if applied is not None and applied < min_pt:
|
|
76
|
+
for para in tf.paragraphs:
|
|
77
|
+
for run in para.runs:
|
|
78
|
+
run.font.size = Pt(min_pt)
|
|
79
|
+
tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
|
|
80
|
+
elif applied is None:
|
|
81
|
+
tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ----------------------------------------------------------------------------- bullets
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def add_bullets(
|
|
88
|
+
slide: "Slide",
|
|
89
|
+
*bbox_or_positional,
|
|
90
|
+
items: Sequence[str],
|
|
91
|
+
size_pt: float = 18.0,
|
|
92
|
+
color: Any = "#0F172A",
|
|
93
|
+
font: Optional[str] = None,
|
|
94
|
+
bold: bool = False,
|
|
95
|
+
numbered: bool = False,
|
|
96
|
+
bullet: str = "•",
|
|
97
|
+
gap_pt: float = 8.0,
|
|
98
|
+
line_spacing: float = 1.1,
|
|
99
|
+
align: str = "left",
|
|
100
|
+
anchor: str = "top",
|
|
101
|
+
margin_pt: float = 0.0,
|
|
102
|
+
min_size_pt: float = 12.0,
|
|
103
|
+
) -> "Shape":
|
|
104
|
+
"""Add a bulleted (or numbered) list that fits its box.
|
|
105
|
+
|
|
106
|
+
``items`` become one paragraph each, carrying a real PowerPoint bullet
|
|
107
|
+
with a hanging indent — so wrapped lines align under the first word,
|
|
108
|
+
not under the bullet — and ``gap_pt`` of space after every item.
|
|
109
|
+
Text is measured and shrunk to fit the box, never below
|
|
110
|
+
``min_size_pt``; if even that overflows, PowerPoint's shrink-on-render
|
|
111
|
+
is switched on as a safety net.
|
|
112
|
+
|
|
113
|
+
Returns the textbox :class:`Shape`.
|
|
114
|
+
"""
|
|
115
|
+
from pptx2._color import coerce_color
|
|
116
|
+
from pptx2._textstyle import coerce_align, coerce_anchor
|
|
117
|
+
|
|
118
|
+
bb = _as_bbox(bbox_or_positional)
|
|
119
|
+
items = [str(item) for item in items]
|
|
120
|
+
if not items:
|
|
121
|
+
raise ValueError("items must be non-empty")
|
|
122
|
+
|
|
123
|
+
box = slide.shapes.add_textbox(*bb)
|
|
124
|
+
tf = box.text_frame
|
|
125
|
+
tf.word_wrap = True
|
|
126
|
+
tf.vertical_anchor = coerce_anchor(anchor)
|
|
127
|
+
if margin_pt:
|
|
128
|
+
m = Pt(margin_pt)
|
|
129
|
+
tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = m
|
|
130
|
+
rgb = coerce_color(color)
|
|
131
|
+
align_value = coerce_align(align)
|
|
132
|
+
|
|
133
|
+
for i, item in enumerate(items):
|
|
134
|
+
para = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
|
135
|
+
para.alignment = align_value
|
|
136
|
+
para.line_spacing = float(line_spacing)
|
|
137
|
+
if i < len(items) - 1:
|
|
138
|
+
para.space_after = Pt(gap_pt)
|
|
139
|
+
run = para.add_run()
|
|
140
|
+
run.text = item
|
|
141
|
+
if font is not None:
|
|
142
|
+
run.font.name = font
|
|
143
|
+
run.font.size = Pt(size_pt)
|
|
144
|
+
run.font.bold = bool(bold)
|
|
145
|
+
run.font.color.rgb = rgb
|
|
146
|
+
# Hanging list: marker at the left edge, every wrapped line aligned
|
|
147
|
+
# under the first word. The indent scales with the type size so a
|
|
148
|
+
# two-digit number still clears the text.
|
|
149
|
+
hang = Pt(size_pt * 1.4)
|
|
150
|
+
if numbered:
|
|
151
|
+
para.bullet.set_numbered(
|
|
152
|
+
"arabicPeriod", start_at=1, left_margin=hang, hanging_indent=hang
|
|
153
|
+
)
|
|
154
|
+
if i > 0:
|
|
155
|
+
# Only the first item pins the start number; the rest
|
|
156
|
+
# continue the sequence (an explicit startAt on every
|
|
157
|
+
# paragraph would restart each one at 1).
|
|
158
|
+
para._p.pPr.buAutoNum.startAt = None
|
|
159
|
+
else:
|
|
160
|
+
para.bullet.set_character(bullet, left_margin=hang, hanging_indent=hang)
|
|
161
|
+
|
|
162
|
+
_fit(tf, font=font, max_pt=size_pt, min_pt=min_size_pt, bold=bold)
|
|
163
|
+
return box
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ----------------------------------------------------------------------------- card
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass
|
|
170
|
+
class Card:
|
|
171
|
+
"""Shapes produced by :func:`add_card`."""
|
|
172
|
+
|
|
173
|
+
card: Any
|
|
174
|
+
title_box: Optional[Any]
|
|
175
|
+
body_box: Optional[Any]
|
|
176
|
+
inner: BBox
|
|
177
|
+
"""The padded content area, for placing extra shapes inside the card."""
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def shapes(self) -> list:
|
|
181
|
+
return [s for s in (self.card, self.title_box, self.body_box) if s is not None]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def add_card(
|
|
185
|
+
slide: "Slide",
|
|
186
|
+
*bbox_or_positional,
|
|
187
|
+
title: Optional[str] = None,
|
|
188
|
+
body: Union[str, Sequence[str], None] = None,
|
|
189
|
+
fill: Any = "#F1F5F9",
|
|
190
|
+
line: Any = None,
|
|
191
|
+
line_pt: float = 1.0,
|
|
192
|
+
radius_pt: float = 12.0,
|
|
193
|
+
pad_pt: float = 20.0,
|
|
194
|
+
title_size_pt: float = 20.0,
|
|
195
|
+
body_size_pt: float = 16.0,
|
|
196
|
+
title_color: Any = "#0F172A",
|
|
197
|
+
body_color: Any = "#334155",
|
|
198
|
+
font: Optional[str] = None,
|
|
199
|
+
align: str = "left",
|
|
200
|
+
anchor: str = "top",
|
|
201
|
+
title_gap_pt: float = 6.0,
|
|
202
|
+
body_min_size_pt: float = 12.0,
|
|
203
|
+
numbered: bool = False,
|
|
204
|
+
) -> Card:
|
|
205
|
+
"""Add a card: one surface, padded title and body, nothing else.
|
|
206
|
+
|
|
207
|
+
The surface is a rounded rectangle with a flat ``fill`` (and no theme
|
|
208
|
+
shadow). Give it *either* a tinted fill *or* a ``line`` outline — a
|
|
209
|
+
tint reads as a surface on its own, an outline reads as a frame; both
|
|
210
|
+
at once compete. Text sits inside ``pad_pt`` of padding on every side.
|
|
211
|
+
|
|
212
|
+
``body`` may be a string (one paragraph, wrapped) or a sequence of
|
|
213
|
+
strings (rendered through :func:`add_bullets`, numbered when
|
|
214
|
+
``numbered=True``). Body text is fitted to the remaining height and
|
|
215
|
+
never shrinks below ``body_min_size_pt``.
|
|
216
|
+
|
|
217
|
+
Returns a :class:`Card` exposing ``card``, ``title_box``, ``body_box``
|
|
218
|
+
and ``inner`` (the padded content box) so a picture, equation or
|
|
219
|
+
diagram can be dropped inside the same card.
|
|
220
|
+
"""
|
|
221
|
+
from pptx2._color import coerce_color
|
|
222
|
+
from pptx2._textstyle import coerce_align, coerce_anchor
|
|
223
|
+
|
|
224
|
+
bb = _as_bbox(bbox_or_positional)
|
|
225
|
+
pad = Pt(pad_pt)
|
|
226
|
+
inner = bb.inset(all=pad)
|
|
227
|
+
|
|
228
|
+
surface = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, *bb)
|
|
229
|
+
surface.fill.solid()
|
|
230
|
+
surface.fill.fore_color.rgb = coerce_color(fill)
|
|
231
|
+
if line is not None:
|
|
232
|
+
surface.line.color.rgb = coerce_color(line)
|
|
233
|
+
surface.line.width = Pt(line_pt)
|
|
234
|
+
else:
|
|
235
|
+
surface.line.fill.background()
|
|
236
|
+
surface.shadow.clear()
|
|
237
|
+
short_edge = min(int(bb.width), int(bb.height))
|
|
238
|
+
surface.corner_radius = Emu(min(int(Pt(radius_pt)), short_edge // 2))
|
|
239
|
+
surface.text_frame.text = ""
|
|
240
|
+
|
|
241
|
+
title_box = None
|
|
242
|
+
body_box = None
|
|
243
|
+
cursor_top = int(inner.top)
|
|
244
|
+
remaining = int(inner.height)
|
|
245
|
+
|
|
246
|
+
if title:
|
|
247
|
+
title_h = int(Pt(title_size_pt * 1.45))
|
|
248
|
+
title_h = min(title_h, remaining)
|
|
249
|
+
title_box = slide.shapes.add_textbox(inner.left, Emu(cursor_top), inner.width, Emu(title_h))
|
|
250
|
+
tf = title_box.text_frame
|
|
251
|
+
tf.word_wrap = True
|
|
252
|
+
tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = Emu(0)
|
|
253
|
+
tf.vertical_anchor = coerce_anchor("top")
|
|
254
|
+
para = tf.paragraphs[0]
|
|
255
|
+
para.alignment = coerce_align(align)
|
|
256
|
+
run = para.add_run()
|
|
257
|
+
run.text = title
|
|
258
|
+
if font is not None:
|
|
259
|
+
run.font.name = font
|
|
260
|
+
run.font.size = Pt(title_size_pt)
|
|
261
|
+
run.font.bold = True
|
|
262
|
+
run.font.color.rgb = coerce_color(title_color)
|
|
263
|
+
_fit(tf, font=font, max_pt=title_size_pt, min_pt=max(12.0, title_size_pt * 0.7), bold=True)
|
|
264
|
+
cursor_top += title_h + int(Pt(title_gap_pt))
|
|
265
|
+
remaining = int(inner.bottom) - cursor_top
|
|
266
|
+
|
|
267
|
+
if body is not None and remaining > int(Pt(body_size_pt)):
|
|
268
|
+
body_bb = BBox(inner.left, Emu(cursor_top), inner.width, Emu(remaining))
|
|
269
|
+
if isinstance(body, str):
|
|
270
|
+
body_box = slide.shapes.add_textbox(*body_bb)
|
|
271
|
+
tf = body_box.text_frame
|
|
272
|
+
tf.word_wrap = True
|
|
273
|
+
tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = Emu(0)
|
|
274
|
+
tf.vertical_anchor = coerce_anchor(anchor if title is None else "top")
|
|
275
|
+
para = tf.paragraphs[0]
|
|
276
|
+
para.alignment = coerce_align(align)
|
|
277
|
+
para.line_spacing = 1.1
|
|
278
|
+
run = para.add_run()
|
|
279
|
+
run.text = body
|
|
280
|
+
if font is not None:
|
|
281
|
+
run.font.name = font
|
|
282
|
+
run.font.size = Pt(body_size_pt)
|
|
283
|
+
run.font.color.rgb = coerce_color(body_color)
|
|
284
|
+
_fit(tf, font=font, max_pt=body_size_pt, min_pt=body_min_size_pt, bold=False)
|
|
285
|
+
else:
|
|
286
|
+
body_box = add_bullets(
|
|
287
|
+
slide,
|
|
288
|
+
body_bb,
|
|
289
|
+
items=list(body),
|
|
290
|
+
size_pt=body_size_pt,
|
|
291
|
+
color=body_color,
|
|
292
|
+
font=font,
|
|
293
|
+
numbered=numbered,
|
|
294
|
+
align=align,
|
|
295
|
+
anchor=anchor if title is None else "top",
|
|
296
|
+
min_size_pt=body_min_size_pt,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
card = Card(card=surface, title_box=title_box, body_box=body_box, inner=inner)
|
|
300
|
+
_tag(card.shapes, f"card@{int(bb.left)},{int(bb.top)}")
|
|
301
|
+
return card
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ----------------------------------------------------------------------------- picture
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@dataclass
|
|
308
|
+
class FittedPicture:
|
|
309
|
+
"""Shapes produced by :func:`add_picture_fit`."""
|
|
310
|
+
|
|
311
|
+
picture: Any
|
|
312
|
+
caption_box: Optional[Any]
|
|
313
|
+
frame: BBox
|
|
314
|
+
"""The box the picture actually occupies (after contain/cover)."""
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _image_size(image: Union[str, "os.PathLike[str]", IO[bytes]]) -> tuple[int, int]:
|
|
318
|
+
from PIL import Image as _PIL
|
|
319
|
+
|
|
320
|
+
if hasattr(image, "read"):
|
|
321
|
+
pos = image.tell() # type: ignore[union-attr]
|
|
322
|
+
with _PIL.open(image) as im: # type: ignore[arg-type]
|
|
323
|
+
size = im.size
|
|
324
|
+
image.seek(pos) # type: ignore[union-attr]
|
|
325
|
+
return size
|
|
326
|
+
with _PIL.open(image) as im: # type: ignore[arg-type]
|
|
327
|
+
return im.size
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def add_picture_fit(
|
|
331
|
+
slide: "Slide",
|
|
332
|
+
image: Union[str, "os.PathLike[str]", IO[bytes]],
|
|
333
|
+
*bbox_or_positional,
|
|
334
|
+
mode: str = "contain",
|
|
335
|
+
align: str = "center",
|
|
336
|
+
caption: Optional[str] = None,
|
|
337
|
+
caption_size_pt: float = 12.0,
|
|
338
|
+
caption_color: Any = "#64748B",
|
|
339
|
+
caption_gap_pt: float = 6.0,
|
|
340
|
+
caption_height_pt: float = 22.0,
|
|
341
|
+
font: Optional[str] = None,
|
|
342
|
+
) -> FittedPicture:
|
|
343
|
+
"""Place *image* inside a box without distorting it.
|
|
344
|
+
|
|
345
|
+
``mode="contain"`` scales the picture to fit entirely inside the box
|
|
346
|
+
(letter-boxed, positioned by ``align``: ``"center"``, ``"left"``,
|
|
347
|
+
``"right"``, ``"top"``, ``"bottom"`` or corner pairs like
|
|
348
|
+
``"top-left"``). ``mode="cover"`` fills the whole box and crops the
|
|
349
|
+
overflow symmetrically — the right choice for photographic
|
|
350
|
+
backgrounds and edge-to-edge hero images.
|
|
351
|
+
|
|
352
|
+
When ``caption`` is given, ``caption_height_pt`` is reserved at the
|
|
353
|
+
bottom of the box and the picture is fitted above it.
|
|
354
|
+
|
|
355
|
+
Returns a :class:`FittedPicture` with the picture, the caption box
|
|
356
|
+
(or ``None``) and the box the picture finally occupies.
|
|
357
|
+
"""
|
|
358
|
+
bb = _as_bbox(bbox_or_positional)
|
|
359
|
+
if mode not in ("contain", "cover"):
|
|
360
|
+
raise ValueError("mode must be 'contain' or 'cover', got %r" % (mode,))
|
|
361
|
+
|
|
362
|
+
caption_box = None
|
|
363
|
+
pic_area = bb
|
|
364
|
+
if caption:
|
|
365
|
+
cap_h = int(Pt(caption_height_pt))
|
|
366
|
+
gap = int(Pt(caption_gap_pt))
|
|
367
|
+
pic_area = BBox(bb.left, bb.top, bb.width, Emu(max(1, int(bb.height) - cap_h - gap)))
|
|
368
|
+
|
|
369
|
+
iw, ih = _image_size(image)
|
|
370
|
+
box_w, box_h = int(pic_area.width), int(pic_area.height)
|
|
371
|
+
img_ratio = iw / float(ih)
|
|
372
|
+
box_ratio = box_w / float(box_h)
|
|
373
|
+
|
|
374
|
+
if mode == "contain":
|
|
375
|
+
if img_ratio >= box_ratio:
|
|
376
|
+
w = box_w
|
|
377
|
+
h = int(round(box_w / img_ratio))
|
|
378
|
+
else:
|
|
379
|
+
h = box_h
|
|
380
|
+
w = int(round(box_h * img_ratio))
|
|
381
|
+
a = align.lower()
|
|
382
|
+
if "left" in a:
|
|
383
|
+
left = int(pic_area.left)
|
|
384
|
+
elif "right" in a:
|
|
385
|
+
left = int(pic_area.right) - w
|
|
386
|
+
else:
|
|
387
|
+
left = int(pic_area.left) + (box_w - w) // 2
|
|
388
|
+
if "top" in a:
|
|
389
|
+
top = int(pic_area.top)
|
|
390
|
+
elif "bottom" in a:
|
|
391
|
+
top = int(pic_area.bottom) - h
|
|
392
|
+
else:
|
|
393
|
+
top = int(pic_area.top) + (box_h - h) // 2
|
|
394
|
+
frame = BBox(Emu(left), Emu(top), Emu(w), Emu(h))
|
|
395
|
+
picture = slide.shapes.add_picture(image, frame.left, frame.top, frame.width, frame.height)
|
|
396
|
+
else:
|
|
397
|
+
frame = pic_area
|
|
398
|
+
picture = slide.shapes.add_picture(image, frame.left, frame.top, frame.width, frame.height)
|
|
399
|
+
if img_ratio > box_ratio:
|
|
400
|
+
# Image is wider than the box: trim left/right.
|
|
401
|
+
keep = box_ratio / img_ratio
|
|
402
|
+
trim = (1.0 - keep) / 2.0
|
|
403
|
+
picture.crop_left = trim
|
|
404
|
+
picture.crop_right = trim
|
|
405
|
+
elif img_ratio < box_ratio:
|
|
406
|
+
keep = img_ratio / box_ratio
|
|
407
|
+
trim = (1.0 - keep) / 2.0
|
|
408
|
+
picture.crop_top = trim
|
|
409
|
+
picture.crop_bottom = trim
|
|
410
|
+
|
|
411
|
+
if caption:
|
|
412
|
+
cap_top = int(frame.bottom) + int(Pt(caption_gap_pt))
|
|
413
|
+
cap_h = int(bb.bottom) - cap_top
|
|
414
|
+
if cap_h > 0:
|
|
415
|
+
a = align.lower()
|
|
416
|
+
caption_align = "left" if "left" in a else "right" if "right" in a else "center"
|
|
417
|
+
caption_box = slide.shapes.add_text(
|
|
418
|
+
BBox(bb.left, Emu(cap_top), bb.width, Emu(cap_h)),
|
|
419
|
+
text=caption,
|
|
420
|
+
font=font,
|
|
421
|
+
size_pt=caption_size_pt,
|
|
422
|
+
italic=True,
|
|
423
|
+
color=caption_color,
|
|
424
|
+
align=caption_align,
|
|
425
|
+
anchor="top",
|
|
426
|
+
margin_pt=0,
|
|
427
|
+
)
|
|
428
|
+
from pptx2.enum.text import MSO_AUTO_SIZE
|
|
429
|
+
|
|
430
|
+
caption_box.text_frame.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
|
|
431
|
+
|
|
432
|
+
_tag([picture, caption_box], f"picture@{int(bb.left)},{int(bb.top)}")
|
|
433
|
+
return FittedPicture(picture=picture, caption_box=caption_box, frame=frame)
|
pptx2/design/palettes.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Curated slide palettes.
|
|
2
|
+
|
|
3
|
+
A deck reads as designed when every colour on it comes from one small,
|
|
4
|
+
deliberate set: a paper colour for the background, an ink for text, one
|
|
5
|
+
accent that carries emphasis, a soft tint of that accent for surfaces,
|
|
6
|
+
and a muted tone for captions and rules. :class:`Palette` names those
|
|
7
|
+
roles so a script can ask for ``P.accent`` instead of remembering a hex
|
|
8
|
+
string, and :data:`PALETTES` ships a handful of combinations that are
|
|
9
|
+
known to sit well together and pass contrast checks for projected text.
|
|
10
|
+
|
|
11
|
+
Every value is a ``"#RRGGBB"`` string, so it can be passed straight to
|
|
12
|
+
``add_text(color=...)``, ``shape.fill_hex(...)``, ``cell.format(fill=...)``
|
|
13
|
+
and the rest of the hex-accepting surface.
|
|
14
|
+
|
|
15
|
+
Typical use::
|
|
16
|
+
|
|
17
|
+
from pptx2.design.palettes import PALETTES
|
|
18
|
+
|
|
19
|
+
P = PALETTES["slate"]
|
|
20
|
+
slide.background.fill.solid()
|
|
21
|
+
slide.background.fill.fore_color.rgb = P.paper
|
|
22
|
+
slide.shapes.add_text(bb, text="Title", size_pt=36, bold=True, color=P.ink)
|
|
23
|
+
card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, *cell).fill_hex(P.surface)
|
|
24
|
+
|
|
25
|
+
``P.dark()`` flips the same hues onto a dark background (``paper`` becomes
|
|
26
|
+
the ink colour and vice-versa) for title and section slides, so a deck can
|
|
27
|
+
alternate light content slides with dark feature slides without picking
|
|
28
|
+
new colours.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from dataclasses import dataclass, replace
|
|
34
|
+
from typing import Dict, Iterator
|
|
35
|
+
|
|
36
|
+
__all__ = ["Palette", "PALETTES", "palette"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Palette:
|
|
41
|
+
"""One coherent colour set for a deck.
|
|
42
|
+
|
|
43
|
+
Roles:
|
|
44
|
+
|
|
45
|
+
* ``paper`` — slide background.
|
|
46
|
+
* ``surface`` — card / panel fill that sits *on* the paper; a step
|
|
47
|
+
away from ``paper`` so a card reads as a surface without a border.
|
|
48
|
+
* ``line`` — hairline rules, table grid, card outline when one is
|
|
49
|
+
wanted.
|
|
50
|
+
* ``ink`` — headings and body text.
|
|
51
|
+
* ``muted`` — captions, sources, secondary labels.
|
|
52
|
+
* ``accent`` — the one emphasis colour: key numbers, the active step
|
|
53
|
+
of a process, a highlighted word.
|
|
54
|
+
* ``accent_soft`` — a light tint of ``accent`` for a callout fill or a
|
|
55
|
+
highlighted row; text on it stays ``ink``.
|
|
56
|
+
* ``accent_ink`` — text colour that is readable *on* ``accent``
|
|
57
|
+
(white on a dark accent, ink on a light one).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
name: str
|
|
61
|
+
paper: str
|
|
62
|
+
surface: str
|
|
63
|
+
line: str
|
|
64
|
+
ink: str
|
|
65
|
+
muted: str
|
|
66
|
+
accent: str
|
|
67
|
+
accent_soft: str
|
|
68
|
+
accent_ink: str = "#FFFFFF"
|
|
69
|
+
|
|
70
|
+
def dark(self) -> "Palette":
|
|
71
|
+
"""The same hues arranged for a dark slide (title, section, closing).
|
|
72
|
+
|
|
73
|
+
``paper`` and ``ink`` swap; ``surface`` becomes a slightly lifted
|
|
74
|
+
dark panel; ``muted`` lightens so captions stay legible; the
|
|
75
|
+
accent is kept because a saturated accent reads well on both.
|
|
76
|
+
"""
|
|
77
|
+
return replace(
|
|
78
|
+
self,
|
|
79
|
+
name=f"{self.name}-dark",
|
|
80
|
+
paper=self.ink,
|
|
81
|
+
surface=_mix(self.ink, self.paper, 0.10),
|
|
82
|
+
line=_mix(self.ink, self.paper, 0.25),
|
|
83
|
+
ink=self.paper,
|
|
84
|
+
muted=_mix(self.paper, self.ink, 0.35),
|
|
85
|
+
accent_soft=_mix(self.ink, self.accent, 0.35),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def __iter__(self) -> Iterator[str]:
|
|
89
|
+
yield from (
|
|
90
|
+
self.paper,
|
|
91
|
+
self.surface,
|
|
92
|
+
self.line,
|
|
93
|
+
self.ink,
|
|
94
|
+
self.muted,
|
|
95
|
+
self.accent,
|
|
96
|
+
self.accent_soft,
|
|
97
|
+
self.accent_ink,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _hex_to_rgb(value: str) -> tuple[int, int, int]:
|
|
102
|
+
v = value.lstrip("#")
|
|
103
|
+
if len(v) != 6:
|
|
104
|
+
raise ValueError(f"expected #RRGGBB, got {value!r}")
|
|
105
|
+
return int(v[0:2], 16), int(v[2:4], 16), int(v[4:6], 16)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _rgb_to_hex(rgb: tuple[int, int, int]) -> str:
|
|
109
|
+
return "#%02X%02X%02X" % rgb
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _mix(a: str, b: str, t: float) -> str:
|
|
113
|
+
"""Linear blend of two hex colours; ``t=0`` → *a*, ``t=1`` → *b*."""
|
|
114
|
+
ra, ga, ba = _hex_to_rgb(a)
|
|
115
|
+
rb, gb, bb = _hex_to_rgb(b)
|
|
116
|
+
t = max(0.0, min(1.0, float(t)))
|
|
117
|
+
return _rgb_to_hex(
|
|
118
|
+
(
|
|
119
|
+
int(round(ra + (rb - ra) * t)),
|
|
120
|
+
int(round(ga + (gb - ga) * t)),
|
|
121
|
+
int(round(ba + (bb - ba) * t)),
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
PALETTES: Dict[str, Palette] = {
|
|
127
|
+
# Calm blue-grey. The safe default for any subject.
|
|
128
|
+
"slate": Palette(
|
|
129
|
+
name="slate",
|
|
130
|
+
paper="#FFFFFF",
|
|
131
|
+
surface="#F1F5F9",
|
|
132
|
+
line="#CBD5E1",
|
|
133
|
+
ink="#0F172A",
|
|
134
|
+
muted="#64748B",
|
|
135
|
+
accent="#2563EB",
|
|
136
|
+
accent_soft="#DBEAFE",
|
|
137
|
+
),
|
|
138
|
+
# Warm paper with a deep teal accent. Humanities, languages, civics.
|
|
139
|
+
"linen": Palette(
|
|
140
|
+
name="linen",
|
|
141
|
+
paper="#FBF8F3",
|
|
142
|
+
surface="#F2ECE2",
|
|
143
|
+
line="#D9CFC0",
|
|
144
|
+
ink="#1F2933",
|
|
145
|
+
muted="#7B6F63",
|
|
146
|
+
accent="#0F766E",
|
|
147
|
+
accent_soft="#CCEDE8",
|
|
148
|
+
),
|
|
149
|
+
# Green on near-white. Biology, geography, sustainability.
|
|
150
|
+
"forest": Palette(
|
|
151
|
+
name="forest",
|
|
152
|
+
paper="#FFFFFF",
|
|
153
|
+
surface="#F0F5F1",
|
|
154
|
+
line="#C8D6CB",
|
|
155
|
+
ink="#14261B",
|
|
156
|
+
muted="#5F7365",
|
|
157
|
+
accent="#15803D",
|
|
158
|
+
accent_soft="#DCFCE7",
|
|
159
|
+
),
|
|
160
|
+
# Plum accent on cool paper. Literature, arts, history.
|
|
161
|
+
"plum": Palette(
|
|
162
|
+
name="plum",
|
|
163
|
+
paper="#FFFFFF",
|
|
164
|
+
surface="#F5F1F8",
|
|
165
|
+
line="#D9CFE3",
|
|
166
|
+
ink="#231A2B",
|
|
167
|
+
muted="#6F6478",
|
|
168
|
+
accent="#7E22CE",
|
|
169
|
+
accent_soft="#EDE0F7",
|
|
170
|
+
),
|
|
171
|
+
# Warm orange accent. Physics, technology, energy — anything that should feel active.
|
|
172
|
+
"ember": Palette(
|
|
173
|
+
name="ember",
|
|
174
|
+
paper="#FFFFFF",
|
|
175
|
+
surface="#FBF3EC",
|
|
176
|
+
line="#E7D4C4",
|
|
177
|
+
ink="#1C1917",
|
|
178
|
+
muted="#78716C",
|
|
179
|
+
accent="#C2410C",
|
|
180
|
+
accent_soft="#FFEDD5",
|
|
181
|
+
),
|
|
182
|
+
# Deep navy accent on white. Mathematics, economics, formal topics.
|
|
183
|
+
"navy": Palette(
|
|
184
|
+
name="navy",
|
|
185
|
+
paper="#FFFFFF",
|
|
186
|
+
surface="#EEF2F7",
|
|
187
|
+
line="#C9D3E0",
|
|
188
|
+
ink="#0B1B33",
|
|
189
|
+
muted="#5B6B82",
|
|
190
|
+
accent="#1E3A8A",
|
|
191
|
+
accent_soft="#DCE4F5",
|
|
192
|
+
),
|
|
193
|
+
# Dark deck: charcoal paper with a mint accent. Use as the base palette
|
|
194
|
+
# when the whole deck should be dark, or take PALETTES["slate"].dark()
|
|
195
|
+
# for a single dark slide inside a light deck.
|
|
196
|
+
"graphite": Palette(
|
|
197
|
+
name="graphite",
|
|
198
|
+
paper="#111827",
|
|
199
|
+
surface="#1F2937",
|
|
200
|
+
line="#374151",
|
|
201
|
+
ink="#F9FAFB",
|
|
202
|
+
muted="#9CA3AF",
|
|
203
|
+
accent="#34D399",
|
|
204
|
+
accent_soft="#1F3D34",
|
|
205
|
+
accent_ink="#062017",
|
|
206
|
+
),
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def palette(name: str) -> Palette:
|
|
211
|
+
"""Return the curated palette called *name* (``KeyError`` lists the options)."""
|
|
212
|
+
try:
|
|
213
|
+
return PALETTES[name]
|
|
214
|
+
except KeyError:
|
|
215
|
+
raise KeyError(
|
|
216
|
+
f"unknown palette {name!r}; choose one of {', '.join(sorted(PALETTES))}"
|
|
217
|
+
) from None
|