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 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.0.0"
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)
@@ -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