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/design/tokens.py ADDED
@@ -0,0 +1,915 @@
1
+ """Design tokens — palette, typography, radii, shadows, spacings.
2
+
3
+ A :class:`DesignTokens` object is an opinionated, source-agnostic
4
+ container for the design decisions that recur across a deck. It is the
5
+ foundation of the "design system layer" described in Phase 9 of the
6
+ roadmap; recipes and the :attr:`shape.style` facade resolve their inputs
7
+ through tokens rather than naming raw EMU/RGB values inline.
8
+
9
+ Tokens can be built three ways:
10
+
11
+ * :meth:`DesignTokens.from_dict` — a plain Python dict (the canonical form).
12
+ * :meth:`DesignTokens.from_yaml` — a YAML brand file (requires ``pyyaml``).
13
+ * :meth:`DesignTokens.from_pptx` — extract palette + fonts from an
14
+ existing ``.pptx`` / ``.potx`` file's theme.
15
+
16
+ Example::
17
+
18
+ from pptx2.design.tokens import DesignTokens
19
+ from pptx2.dml.color import RGBColor
20
+ from pptx2.util import Pt
21
+
22
+ tokens = DesignTokens.from_dict({
23
+ "palette": {
24
+ "primary": RGBColor(0x3C, 0x2F, 0x80),
25
+ "secondary": "#FF6600",
26
+ "neutral": (0x33, 0x33, 0x33),
27
+ },
28
+ "typography": {
29
+ "heading": {"family": "Inter", "size": Pt(36)},
30
+ "body": {"family": "Inter", "size": Pt(14)},
31
+ },
32
+ "radii": {"sm": Pt(4), "md": Pt(8), "lg": Pt(16)},
33
+ "spacings": {"xs": Pt(4), "sm": Pt(8), "md": Pt(16), "lg": Pt(32)},
34
+ "shadows": {
35
+ "card": {"blur_radius": Pt(8), "distance": Pt(2),
36
+ "direction": 90, "color": RGBColor(0, 0, 0),
37
+ "alpha": 0.25},
38
+ },
39
+ })
40
+
41
+ print(tokens.palette["primary"]) # RGBColor(0x3C, 0x2F, 0x80)
42
+ print(tokens.typography["body"].family) # "Inter"
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ from dataclasses import dataclass, field
48
+ from typing import TYPE_CHECKING, Any, Mapping, MutableMapping, Optional, Union
49
+
50
+ from pptx2.dml.color import RGBColor
51
+ from pptx2.util import Emu, Length, Pt
52
+
53
+ if TYPE_CHECKING:
54
+ from pptx2.theme import Theme
55
+
56
+
57
+ ColorSpec = Union[RGBColor, str, tuple]
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Sub-token value objects
62
+ # ---------------------------------------------------------------------------
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class TypographyToken:
67
+ """A typography token: font family, size, weight, optional color.
68
+
69
+ Only :attr:`family` is required; the other fields fall back to
70
+ PowerPoint defaults when unset.
71
+ """
72
+
73
+ family: str
74
+ size: Optional[Length] = None
75
+ bold: Optional[bool] = None
76
+ italic: Optional[bool] = None
77
+ color: Optional[RGBColor] = None
78
+
79
+ @classmethod
80
+ def from_value(cls, value: Any) -> "TypographyToken":
81
+ """Coerce a dict / string / existing token into a :class:`TypographyToken`.
82
+
83
+ A bare string is interpreted as a font family with no other
84
+ attributes set; a mapping is unpacked through :meth:`__init__`.
85
+ """
86
+ if isinstance(value, cls):
87
+ return value
88
+ if isinstance(value, str):
89
+ return cls(family=value)
90
+ if isinstance(value, Mapping):
91
+ family = value.get("family")
92
+ if not isinstance(family, str) or not family:
93
+ raise ValueError(
94
+ "typography token requires a non-empty 'family' string"
95
+ )
96
+ size = value.get("size")
97
+ if size is not None:
98
+ size = _coerce_length(size)
99
+ color = value.get("color")
100
+ if color is not None:
101
+ color = _coerce_color(color)
102
+ return cls(
103
+ family=family,
104
+ size=size,
105
+ bold=value.get("bold"),
106
+ italic=value.get("italic"),
107
+ color=color,
108
+ )
109
+ raise TypeError(
110
+ f"cannot build TypographyToken from {type(value).__name__}"
111
+ )
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class ShadowToken:
116
+ """A shadow token, mirroring :class:`pptx2.dml.effect.ShadowFormat`."""
117
+
118
+ blur_radius: Optional[Length] = None
119
+ distance: Optional[Length] = None
120
+ direction: Optional[float] = None
121
+ color: Optional[RGBColor] = None
122
+ alpha: Optional[float] = None
123
+
124
+ @classmethod
125
+ def from_value(cls, value: Any) -> "ShadowToken":
126
+ if isinstance(value, cls):
127
+ return value
128
+ if not isinstance(value, Mapping):
129
+ raise TypeError(
130
+ f"cannot build ShadowToken from {type(value).__name__}"
131
+ )
132
+ blur = value.get("blur_radius")
133
+ if blur is not None:
134
+ blur = _coerce_length(blur)
135
+ distance = value.get("distance")
136
+ if distance is not None:
137
+ distance = _coerce_length(distance)
138
+ direction = value.get("direction")
139
+ if direction is not None:
140
+ direction = float(direction)
141
+ alpha = value.get("alpha")
142
+ if alpha is not None:
143
+ alpha = float(alpha)
144
+ if not 0.0 <= alpha <= 1.0:
145
+ raise ValueError("shadow alpha must be in [0.0, 1.0]")
146
+ color = value.get("color")
147
+ if color is not None:
148
+ color = _coerce_color(color)
149
+ return cls(
150
+ blur_radius=blur,
151
+ distance=distance,
152
+ direction=direction,
153
+ color=color,
154
+ alpha=alpha,
155
+ )
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # DesignTokens
160
+ # ---------------------------------------------------------------------------
161
+
162
+
163
+ @dataclass
164
+ class DesignTokens:
165
+ """A bag of design tokens — palette, typography, radii, shadows, spacings.
166
+
167
+ Tokens are mutable so callers can layer overrides on top of a loaded
168
+ base set::
169
+
170
+ tokens = DesignTokens.from_pptx("brand.pptx")
171
+ tokens.palette["primary"] = RGBColor(0xFF, 0x00, 0x00)
172
+ """
173
+
174
+ palette: MutableMapping[str, RGBColor] = field(default_factory=dict)
175
+ typography: MutableMapping[str, TypographyToken] = field(default_factory=dict)
176
+ radii: MutableMapping[str, Length] = field(default_factory=dict)
177
+ shadows: MutableMapping[str, ShadowToken] = field(default_factory=dict)
178
+ spacings: MutableMapping[str, Length] = field(default_factory=dict)
179
+
180
+ # ------------------------------------------------------------------
181
+ # Constructors
182
+ # ------------------------------------------------------------------
183
+
184
+ @classmethod
185
+ def from_dict(cls, spec: Mapping[str, Any]) -> "DesignTokens":
186
+ """Build a token set from a plain dict.
187
+
188
+ Unknown top-level keys are ignored so a single brand-spec file
189
+ can carry extra application-specific data alongside the design
190
+ tokens.
191
+ """
192
+ palette = {
193
+ name: _coerce_color(value)
194
+ for name, value in (spec.get("palette") or {}).items()
195
+ }
196
+ typography = {
197
+ name: TypographyToken.from_value(value)
198
+ for name, value in (spec.get("typography") or {}).items()
199
+ }
200
+ radii = {
201
+ name: _coerce_length(value)
202
+ for name, value in (spec.get("radii") or {}).items()
203
+ }
204
+ spacings = {
205
+ name: _coerce_length(value)
206
+ for name, value in (spec.get("spacings") or {}).items()
207
+ }
208
+ shadows = {
209
+ name: ShadowToken.from_value(value)
210
+ for name, value in (spec.get("shadows") or {}).items()
211
+ }
212
+ return cls(
213
+ palette=palette,
214
+ typography=typography,
215
+ radii=radii,
216
+ shadows=shadows,
217
+ spacings=spacings,
218
+ )
219
+
220
+ @classmethod
221
+ def from_preset(cls, name: str) -> "DesignTokens":
222
+ """Load a built-in token preset by *name*.
223
+
224
+ The presets are intentionally small and opinionated — they
225
+ cover the styles most decks reach for first so callers don't
226
+ have to invent a palette from scratch. Available presets:
227
+
228
+ * ``"modern_light"`` — clean, neutral background with a single
229
+ accent. Good default for product / engineering decks.
230
+ * ``"modern_dark"`` — same shape as modern_light, dark canvas.
231
+ * ``"corporate_navy"`` — navy + warm accent, banded surfaces;
232
+ reads as conservative / formal.
233
+ * ``"vibrant"`` — saturated palette for marketing / launch
234
+ decks.
235
+
236
+ Each preset populates ``palette``, ``typography``, ``radii``,
237
+ ``shadows``, and ``spacings``. Callers can layer overrides on
238
+ top with ``DesignTokens.from_preset("modern_light").merge(...)``
239
+ or simply mutate the returned instance — the dataclass fields
240
+ are mutable.
241
+ """
242
+ spec = _PRESETS.get(name)
243
+ if spec is None:
244
+ raise ValueError(
245
+ f"Unknown preset {name!r}; choose from {sorted(_PRESETS)}"
246
+ )
247
+ return cls.from_dict(spec)
248
+
249
+ @classmethod
250
+ def from_seed(
251
+ cls, seed: ColorSpec, harmony: str = "complementary"
252
+ ) -> "DesignTokens":
253
+ """Generate a full palette from a single *seed* color.
254
+
255
+ Uses HSL color-wheel harmony math to derive a coherent set of
256
+ brand colors from one seed, so brand onboarding needs only a
257
+ single hex value. The output is **deterministic** — the same
258
+ seed and harmony always yield the same palette.
259
+
260
+ *seed* accepts the same forms as every color input in this
261
+ module (``RGBColor`` / ``"#RRGGBB"`` / ``(r, g, b)``).
262
+
263
+ *harmony* selects how the secondary/accent hues relate to the
264
+ seed on the color wheel:
265
+
266
+ * ``"complementary"`` — secondary at +180°, accent at +150°.
267
+ * ``"analogous"`` — secondary at +30°, accent at -30°.
268
+ * ``"triadic"`` — secondary at +120°, accent at +240°.
269
+ * ``"monochromatic"`` — same hue throughout; secondary/accent
270
+ differ only in lightness.
271
+
272
+ The returned :class:`DesignTokens` populates the same ``palette``
273
+ keys the built-in presets use (``primary``, ``secondary``,
274
+ ``accent``, ``neutral``, ``muted``, ``surface``, ``background``,
275
+ ``text``, ``on_primary``, ``lt1``, ``lt2``, ``positive``,
276
+ ``negative``, ``success``, ``danger``), so it drops into recipes
277
+ that expect a preset. Typography, radii, spacings, and shadows
278
+ are left empty for the caller to layer in.
279
+ """
280
+ import colorsys
281
+
282
+ valid = ("complementary", "analogous", "triadic", "monochromatic")
283
+ if harmony not in valid:
284
+ raise ValueError(
285
+ f"unknown harmony {harmony!r}; choose from {list(valid)}"
286
+ )
287
+
288
+ seed_rgb = _coerce_color(seed)
289
+ h, light, s = colorsys.rgb_to_hls(
290
+ seed_rgb[0] / 255.0, seed_rgb[1] / 255.0, seed_rgb[2] / 255.0
291
+ )
292
+
293
+ def from_hsl(hue: float, lightness: float, sat: float) -> RGBColor:
294
+ r, g, b = colorsys.hls_to_rgb(
295
+ hue % 1.0, _clamp01(lightness), _clamp01(sat)
296
+ )
297
+ return RGBColor(round(r * 255), round(g * 255), round(b * 255))
298
+
299
+ def shift(deg: float, lightness: float, sat: float) -> RGBColor:
300
+ return from_hsl(h + deg / 360.0, lightness, sat)
301
+
302
+ if harmony == "complementary":
303
+ sec_off, acc_off = 180.0, 150.0
304
+ elif harmony == "analogous":
305
+ sec_off, acc_off = 30.0, -30.0
306
+ elif harmony == "triadic":
307
+ sec_off, acc_off = 120.0, 240.0
308
+ else: # monochromatic
309
+ sec_off, acc_off = 0.0, 0.0
310
+
311
+ primary = seed_rgb
312
+ if harmony == "monochromatic":
313
+ secondary = from_hsl(h, light + 0.18, s)
314
+ accent = from_hsl(h, light - 0.18, s)
315
+ else:
316
+ secondary = shift(sec_off, light, s)
317
+ accent = shift(acc_off, light, s)
318
+
319
+ # Neutral / muted: desaturate the seed hue toward grey.
320
+ neutral = from_hsl(h, 0.16, s * 0.18)
321
+ muted = from_hsl(h, 0.55, s * 0.25)
322
+ # Surfaces: a near-white tint and a slightly darker band.
323
+ surface = from_hsl(h, 0.97, s * 0.10)
324
+ lt1 = RGBColor(0xFF, 0xFF, 0xFF)
325
+ lt2 = from_hsl(h, 0.92, s * 0.15)
326
+ background = lt1
327
+ text = neutral
328
+
329
+ # Foreground over the primary: white over a dark seed, near-black
330
+ # over a light one.
331
+ on_primary = (
332
+ RGBColor(0xFF, 0xFF, 0xFF)
333
+ if _luminance(primary) < 0.5
334
+ else RGBColor(0x10, 0x10, 0x10)
335
+ )
336
+
337
+ # Semantic colors are hue-anchored (green / red) so meaning stays
338
+ # legible regardless of brand hue.
339
+ positive = from_hsl(120.0 / 360.0, 0.40, 0.55)
340
+ negative = from_hsl(0.0, 0.48, 0.65)
341
+
342
+ palette: dict[str, RGBColor] = {
343
+ "primary": primary,
344
+ "secondary": secondary,
345
+ "accent": accent,
346
+ "neutral": neutral,
347
+ "muted": muted,
348
+ "surface": surface,
349
+ "background": background,
350
+ "text": text,
351
+ "on_primary": on_primary,
352
+ "lt1": lt1,
353
+ "lt2": lt2,
354
+ "positive": positive,
355
+ "negative": negative,
356
+ "success": positive,
357
+ "danger": negative,
358
+ }
359
+ return cls(palette=palette)
360
+
361
+ def validate_color_blindness(self, kind: str) -> list[tuple[str, str]]:
362
+ """Return palette color pairs likely confusable under *kind*.
363
+
364
+ Simulates one of the common color-vision deficiencies and reports
365
+ every pair of *named* palette colors that the deficiency
366
+ *collapses together* — distinguishable to typical vision, but
367
+ whose sRGB separation drops to under half (and small in absolute
368
+ terms) once the simulation is applied.
369
+
370
+ *kind* is one of ``"deuteranopia"``, ``"protanopia"``, or
371
+ ``"tritanopia"``. The returned list contains ``(name_a, name_b)``
372
+ tuples (each pair once, alphabetically ordered) — an empty list
373
+ means no confusable pairs were found. A heuristic aid, not a
374
+ clinical tool.
375
+ """
376
+ valid = ("deuteranopia", "protanopia", "tritanopia")
377
+ if kind not in valid:
378
+ raise ValueError(
379
+ f"unknown kind {kind!r}; choose from {list(valid)}"
380
+ )
381
+
382
+ names = sorted(self.palette)
383
+ confusable: list[tuple[str, str]] = []
384
+ for i, a in enumerate(names):
385
+ for b in names[i + 1 :]:
386
+ ra, rb = self.palette[a], self.palette[b]
387
+ normal = _rgb_distance(ra, rb)
388
+ # Skip pairs already near-identical to normal vision —
389
+ # they're "confusable" by being the same color, not the CVD.
390
+ if normal < 24.0:
391
+ continue
392
+ sim = _rgb_distance(
393
+ _simulate_cvd(ra, kind), _simulate_cvd(rb, kind)
394
+ )
395
+ # Flag the pair when the deficiency *collapses* their
396
+ # separation: the simulated distance drops to under half
397
+ # the normal-vision distance and is itself small in
398
+ # absolute terms. Both conditions matter — a large
399
+ # well-separated pair that merely shrinks proportionally
400
+ # is still distinguishable.
401
+ if sim <= 0.5 * normal and sim < 120.0:
402
+ confusable.append((a, b))
403
+ return confusable
404
+
405
+ @classmethod
406
+ def from_yaml(cls, path: str) -> "DesignTokens":
407
+ """Load a token set from a YAML brand file.
408
+
409
+ Requires ``pyyaml``; raises :class:`ImportError` with a clear
410
+ installation hint when the dependency is missing.
411
+ """
412
+ try:
413
+ import yaml # type: ignore[import-not-found]
414
+ except ImportError as exc: # pragma: no cover - import guard
415
+ raise ImportError(
416
+ "DesignTokens.from_yaml requires pyyaml; install with "
417
+ "`pip install pyyaml`"
418
+ ) from exc
419
+ with open(path, "r", encoding="utf-8") as f:
420
+ spec = yaml.safe_load(f) or {}
421
+ if not isinstance(spec, Mapping):
422
+ raise ValueError(
423
+ f"YAML at {path!r} did not parse to a mapping"
424
+ )
425
+ return cls.from_dict(spec)
426
+
427
+ @classmethod
428
+ def from_pptx(cls, path_or_prs: Any) -> "DesignTokens":
429
+ """Extract palette and typography tokens from a deck's theme.
430
+
431
+ *path_or_prs* may be a path to a ``.pptx`` / ``.potx`` file or
432
+ an already-opened :class:`pptx2.presentation.Presentation`. The
433
+ slots populated are::
434
+
435
+ palette: accent1..accent6, dk1, dk2, lt1, lt2, hyperlink,
436
+ followed_hyperlink (under their canonical names)
437
+ typography: 'heading' (theme major font),
438
+ 'body' (theme minor font)
439
+
440
+ Radii, spacings, and shadows are not encoded in the OOXML theme;
441
+ callers should layer those in via :meth:`from_dict` overrides.
442
+ """
443
+ from pptx2.api import Presentation
444
+ from pptx2.enum.dml import MSO_THEME_COLOR
445
+
446
+ if isinstance(path_or_prs, str):
447
+ prs = Presentation(path_or_prs)
448
+ else:
449
+ prs = path_or_prs
450
+
451
+ theme: "Theme" = prs.theme
452
+ slot_names = {
453
+ MSO_THEME_COLOR.ACCENT_1: "accent1",
454
+ MSO_THEME_COLOR.ACCENT_2: "accent2",
455
+ MSO_THEME_COLOR.ACCENT_3: "accent3",
456
+ MSO_THEME_COLOR.ACCENT_4: "accent4",
457
+ MSO_THEME_COLOR.ACCENT_5: "accent5",
458
+ MSO_THEME_COLOR.ACCENT_6: "accent6",
459
+ MSO_THEME_COLOR.DARK_1: "dk1",
460
+ MSO_THEME_COLOR.DARK_2: "dk2",
461
+ MSO_THEME_COLOR.LIGHT_1: "lt1",
462
+ MSO_THEME_COLOR.LIGHT_2: "lt2",
463
+ MSO_THEME_COLOR.HYPERLINK: "hyperlink",
464
+ MSO_THEME_COLOR.FOLLOWED_HYPERLINK: "followed_hyperlink",
465
+ }
466
+ palette: dict[str, RGBColor] = {}
467
+ for slot, name in slot_names.items():
468
+ try:
469
+ rgb = theme.colors[slot]
470
+ except (KeyError, AttributeError):
471
+ continue
472
+ if rgb is not None:
473
+ palette[name] = rgb
474
+
475
+ typography: dict[str, TypographyToken] = {}
476
+ major = theme.fonts.major
477
+ minor = theme.fonts.minor
478
+ if major:
479
+ typography["heading"] = TypographyToken(family=major)
480
+ if minor:
481
+ typography["body"] = TypographyToken(family=minor)
482
+
483
+ return cls(palette=palette, typography=typography)
484
+
485
+ # ------------------------------------------------------------------
486
+ # Convenience
487
+ # ------------------------------------------------------------------
488
+
489
+ def with_overrides(
490
+ self, overrides: Mapping[str, Any]
491
+ ) -> "DesignTokens":
492
+ """Return a new :class:`DesignTokens` with *overrides* layered on.
493
+
494
+ Two equivalent input shapes are accepted:
495
+
496
+ * **Dotted-path keys** — flat mapping where each key is a
497
+ dotted path through the token tree::
498
+
499
+ tokens.with_overrides({
500
+ "palette.primary": "#FF6600",
501
+ "typography.heading.size": Pt(40),
502
+ "radii.md": Pt(12),
503
+ })
504
+
505
+ * **Nested dicts** — the same structure expressed as nested
506
+ mappings (matches the README's earlier examples)::
507
+
508
+ tokens.with_overrides({
509
+ "palette": {"primary": "#FF6600"},
510
+ "typography": {"heading": {"size": Pt(40)}},
511
+ "radii": {"md": Pt(12)},
512
+ })
513
+
514
+ The leading segment is the token category (``palette`` /
515
+ ``typography`` / ``radii`` / ``shadows`` / ``spacings``), the
516
+ next segment is the slot name, and any further segments
517
+ navigate into a typography or shadow token (e.g.
518
+ ``typography.heading.size`` updates only the ``size`` field of
519
+ the existing heading token).
520
+
521
+ Useful for per-call recipe overrides::
522
+
523
+ kpi_slide(prs, ..., tokens=tokens.with_overrides({
524
+ "palette.primary": "#FF6600",
525
+ }))
526
+
527
+ without forking the base token set.
528
+ """
529
+ overrides = _flatten_overrides(overrides)
530
+ # Deep-copy at the dict level so callers don't accidentally
531
+ # mutate the base. Token dataclasses themselves are frozen.
532
+ palette = dict(self.palette)
533
+ typography = dict(self.typography)
534
+ radii = dict(self.radii)
535
+ shadows = dict(self.shadows)
536
+ spacings = dict(self.spacings)
537
+
538
+ bins: dict[str, MutableMapping[str, Any]] = {
539
+ "palette": palette,
540
+ "typography": typography,
541
+ "radii": radii,
542
+ "shadows": shadows,
543
+ "spacings": spacings,
544
+ }
545
+
546
+ for key, value in overrides.items():
547
+ parts = key.split(".")
548
+ if len(parts) < 2:
549
+ raise ValueError(
550
+ f"override key {key!r} must be dotted, e.g. "
551
+ "'palette.primary' or 'typography.heading.size'"
552
+ )
553
+ category = parts[0]
554
+ target = bins.get(category)
555
+ if target is None:
556
+ raise ValueError(
557
+ f"unknown override category {category!r}; choose "
558
+ f"from {sorted(bins)}"
559
+ )
560
+ if len(parts) == 2:
561
+ slot = parts[1]
562
+ if category == "palette":
563
+ target[slot] = _coerce_color(value)
564
+ elif category in ("radii", "spacings"):
565
+ target[slot] = _coerce_length(value)
566
+ elif category == "typography":
567
+ target[slot] = TypographyToken.from_value(value)
568
+ elif category == "shadows":
569
+ target[slot] = ShadowToken.from_value(value)
570
+ else:
571
+ # Sub-field override — merge into an existing token.
572
+ slot = parts[1]
573
+ field_name = parts[2]
574
+ existing = target.get(slot)
575
+ if category == "typography":
576
+ base = (
577
+ existing
578
+ if isinstance(existing, TypographyToken)
579
+ else TypographyToken(family="Calibri")
580
+ )
581
+ target[slot] = _typography_with_field(base, field_name, value)
582
+ elif category == "shadows":
583
+ base = (
584
+ existing if isinstance(existing, ShadowToken) else ShadowToken()
585
+ )
586
+ target[slot] = _shadow_with_field(base, field_name, value)
587
+ else:
588
+ raise ValueError(
589
+ f"sub-field override {key!r} only supported on "
590
+ "typography and shadows"
591
+ )
592
+
593
+ return DesignTokens(
594
+ palette=palette,
595
+ typography=typography,
596
+ radii=radii,
597
+ shadows=shadows,
598
+ spacings=spacings,
599
+ )
600
+
601
+ def merge(self, other: "DesignTokens") -> "DesignTokens":
602
+ """Return a new :class:`DesignTokens` with *other*'s values layered over self.
603
+
604
+ Each named slot in *other* overrides this token set's value for
605
+ the same name; slots that *other* doesn't define are kept.
606
+ """
607
+ return DesignTokens(
608
+ palette={**self.palette, **other.palette},
609
+ typography={**self.typography, **other.typography},
610
+ radii={**self.radii, **other.radii},
611
+ shadows={**self.shadows, **other.shadows},
612
+ spacings={**self.spacings, **other.spacings},
613
+ )
614
+
615
+
616
+ # ---------------------------------------------------------------------------
617
+ # Coercion helpers
618
+ # ---------------------------------------------------------------------------
619
+
620
+
621
+ def _flatten_overrides(
622
+ overrides: Mapping[str, Any], _prefix: str = ""
623
+ ) -> dict[str, Any]:
624
+ """Flatten a nested-dict override spec into dotted-key form.
625
+
626
+ Dotted-key inputs round-trip unchanged. Nested dicts are walked
627
+ recursively, joining segments with ``"."``. Mixing both styles in
628
+ the same input is allowed — e.g. ``{"palette.primary": ...,
629
+ "typography": {"heading": {"size": ...}}}``.
630
+
631
+ Stops descending at non-mapping values; in particular, dataclasses
632
+ like :class:`TypographyToken` and :class:`ShadowToken` are passed
633
+ through whole rather than walked, so callers can supply a token
634
+ instance for a slot.
635
+ """
636
+ flat: dict[str, Any] = {}
637
+ for key, value in overrides.items():
638
+ full = f"{_prefix}.{key}" if _prefix else key
639
+ if isinstance(value, Mapping) and not isinstance(value, RGBColor):
640
+ # Mappings whose values are themselves token instances
641
+ # (TypographyToken, ShadowToken) shouldn't be walked.
642
+ if not _is_token_value(value):
643
+ flat.update(_flatten_overrides(value, full))
644
+ continue
645
+ flat[full] = value
646
+ return flat
647
+
648
+
649
+ def _is_token_value(value: Any) -> bool:
650
+ """True if *value* is a typography/shadow/length token shouldn't be flattened."""
651
+ return isinstance(value, (TypographyToken, ShadowToken, Length, RGBColor))
652
+
653
+
654
+ def _clamp01(x: float) -> float:
655
+ """Clamp *x* into the inclusive [0.0, 1.0] range."""
656
+ return 0.0 if x < 0.0 else 1.0 if x > 1.0 else x
657
+
658
+
659
+ def _luminance(rgb: RGBColor) -> float:
660
+ """Perceived relative luminance (0..1) of *rgb* — quick Rec.709 weighting."""
661
+ return (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) / 255.0
662
+
663
+
664
+ def _rgb_distance(a: RGBColor, b: RGBColor) -> float:
665
+ """Euclidean distance between two colors in sRGB space (0..~441)."""
666
+ return (
667
+ (int(a[0]) - int(b[0])) ** 2
668
+ + (int(a[1]) - int(b[1])) ** 2
669
+ + (int(a[2]) - int(b[2])) ** 2
670
+ ) ** 0.5
671
+
672
+
673
+ # Linearized-RGB confusion-line matrices (Brettel/Viénot-style approximations,
674
+ # applied in plain sRGB for a fast heuristic). Each maps an RGB triple to the
675
+ # color a person with that deficiency is estimated to perceive.
676
+ _CVD_MATRICES: dict[str, tuple[tuple[float, float, float], ...]] = {
677
+ "protanopia": (
678
+ (0.567, 0.433, 0.000),
679
+ (0.558, 0.442, 0.000),
680
+ (0.000, 0.242, 0.758),
681
+ ),
682
+ "deuteranopia": (
683
+ (0.625, 0.375, 0.000),
684
+ (0.700, 0.300, 0.000),
685
+ (0.000, 0.300, 0.700),
686
+ ),
687
+ "tritanopia": (
688
+ (0.950, 0.050, 0.000),
689
+ (0.000, 0.433, 0.567),
690
+ (0.000, 0.475, 0.525),
691
+ ),
692
+ }
693
+
694
+
695
+ def _simulate_cvd(rgb: RGBColor, kind: str) -> RGBColor:
696
+ """Return *rgb* as estimated to be perceived under deficiency *kind*."""
697
+ m = _CVD_MATRICES[kind]
698
+ r, g, b = int(rgb[0]), int(rgb[1]), int(rgb[2])
699
+
700
+ def chan(row: tuple[float, float, float]) -> int:
701
+ v = row[0] * r + row[1] * g + row[2] * b
702
+ return int(max(0, min(255, round(v))))
703
+
704
+ return RGBColor(chan(m[0]), chan(m[1]), chan(m[2]))
705
+
706
+
707
+ def _coerce_color(value: Any) -> RGBColor:
708
+ if isinstance(value, RGBColor):
709
+ return value
710
+ if isinstance(value, str):
711
+ s = value.lstrip("#")
712
+ if len(s) != 6:
713
+ raise ValueError(
714
+ f"hex color string must be 6 hex digits, got {value!r}"
715
+ )
716
+ return RGBColor(int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16))
717
+ if isinstance(value, tuple) and len(value) == 3:
718
+ return RGBColor(int(value[0]), int(value[1]), int(value[2]))
719
+ raise TypeError(
720
+ f"cannot coerce {value!r} to RGBColor; "
721
+ "expected RGBColor, hex string, or 3-tuple"
722
+ )
723
+
724
+
725
+ def _coerce_length(value: Any) -> Length:
726
+ if isinstance(value, Length):
727
+ return value
728
+ if isinstance(value, int):
729
+ return Emu(value)
730
+ if isinstance(value, float):
731
+ # Treat bare floats as points — the most common authoring unit.
732
+ return Pt(value)
733
+ raise TypeError(
734
+ f"cannot coerce {value!r} to Length; "
735
+ "expected Length, int (EMU), or float (points)"
736
+ )
737
+
738
+
739
+ def _typography_with_field(
740
+ base: TypographyToken, field_name: str, value: Any
741
+ ) -> TypographyToken:
742
+ """Return a copy of *base* with ``field_name`` set to *value*."""
743
+ fields = {
744
+ "family": base.family,
745
+ "size": base.size,
746
+ "bold": base.bold,
747
+ "italic": base.italic,
748
+ "color": base.color,
749
+ }
750
+ if field_name not in fields:
751
+ raise ValueError(
752
+ f"unknown typography field {field_name!r}; choose from "
753
+ f"{sorted(fields)}"
754
+ )
755
+ if field_name == "size" and value is not None:
756
+ value = _coerce_length(value)
757
+ elif field_name == "color" and value is not None:
758
+ value = _coerce_color(value)
759
+ fields[field_name] = value
760
+ return TypographyToken(**fields)
761
+
762
+
763
+ def _shadow_with_field(
764
+ base: ShadowToken, field_name: str, value: Any
765
+ ) -> ShadowToken:
766
+ """Return a copy of *base* with ``field_name`` set to *value*."""
767
+ fields = {
768
+ "blur_radius": base.blur_radius,
769
+ "distance": base.distance,
770
+ "direction": base.direction,
771
+ "color": base.color,
772
+ "alpha": base.alpha,
773
+ }
774
+ if field_name not in fields:
775
+ raise ValueError(
776
+ f"unknown shadow field {field_name!r}; choose from "
777
+ f"{sorted(fields)}"
778
+ )
779
+ if field_name in ("blur_radius", "distance") and value is not None:
780
+ value = _coerce_length(value)
781
+ elif field_name == "color" and value is not None:
782
+ value = _coerce_color(value)
783
+ elif field_name in ("direction", "alpha") and value is not None:
784
+ value = float(value)
785
+ fields[field_name] = value
786
+ return ShadowToken(**fields)
787
+
788
+
789
+ # ---------------------------------------------------------------------------
790
+ # Built-in presets — small, opinionated palettes that cover the most-common
791
+ # deck styles so callers don't have to invent a brand from scratch.
792
+ # ---------------------------------------------------------------------------
793
+
794
+ _PRESETS: Mapping[str, Mapping[str, Any]] = {
795
+ "modern_light": {
796
+ "palette": {
797
+ "primary": "#3B5BDB",
798
+ "neutral": "#1F2933",
799
+ "muted": "#7B8794",
800
+ "surface": "#F5F7FA",
801
+ "on_primary": "#FFFFFF",
802
+ "lt1": "#FFFFFF",
803
+ "lt2": "#E4E7EB",
804
+ "positive": "#0CA678",
805
+ "negative": "#E03131",
806
+ "success": "#0CA678",
807
+ "danger": "#E03131",
808
+ },
809
+ "typography": {
810
+ "heading": {"family": "Inter", "size": Pt(32), "bold": True},
811
+ "body": {"family": "Inter", "size": Pt(16)},
812
+ },
813
+ "radii": {"sm": Pt(4), "md": Pt(8), "lg": Pt(16)},
814
+ "spacings": {"xs": Pt(4), "sm": Pt(8), "md": Pt(16), "lg": Pt(32)},
815
+ "shadows": {
816
+ "card": {
817
+ "blur_radius": Pt(8),
818
+ "distance": Pt(2),
819
+ "direction": 90.0,
820
+ "color": "#000000",
821
+ "alpha": 0.18,
822
+ },
823
+ },
824
+ },
825
+ "modern_dark": {
826
+ "palette": {
827
+ "primary": "#7C5CFF",
828
+ "neutral": "#E4E7EB",
829
+ "muted": "#7B8794",
830
+ "surface": "#1F2933",
831
+ "on_primary": "#0B0F19",
832
+ "lt1": "#323F4B",
833
+ "lt2": "#3E4C59",
834
+ "positive": "#3DD68C",
835
+ "negative": "#FF6B6B",
836
+ "success": "#3DD68C",
837
+ "danger": "#FF6B6B",
838
+ },
839
+ "typography": {
840
+ "heading": {"family": "Inter", "size": Pt(32), "bold": True},
841
+ "body": {"family": "Inter", "size": Pt(16)},
842
+ },
843
+ "radii": {"sm": Pt(4), "md": Pt(8), "lg": Pt(16)},
844
+ "spacings": {"xs": Pt(4), "sm": Pt(8), "md": Pt(16), "lg": Pt(32)},
845
+ "shadows": {
846
+ "card": {
847
+ "blur_radius": Pt(12),
848
+ "distance": Pt(3),
849
+ "direction": 90.0,
850
+ "color": "#000000",
851
+ "alpha": 0.45,
852
+ },
853
+ },
854
+ },
855
+ "corporate_navy": {
856
+ "palette": {
857
+ "primary": "#0B2545",
858
+ "neutral": "#13315C",
859
+ "muted": "#8DA9C4",
860
+ "surface": "#EEF4ED",
861
+ "on_primary": "#FFFFFF",
862
+ "lt1": "#FFFFFF",
863
+ "lt2": "#D6DDE0",
864
+ "positive": "#247B7B",
865
+ "negative": "#A23B3B",
866
+ "success": "#247B7B",
867
+ "danger": "#A23B3B",
868
+ },
869
+ "typography": {
870
+ "heading": {"family": "Source Serif Pro", "size": Pt(34), "bold": True},
871
+ "body": {"family": "Source Sans Pro", "size": Pt(16)},
872
+ },
873
+ "radii": {"sm": Pt(2), "md": Pt(4), "lg": Pt(8)},
874
+ "spacings": {"xs": Pt(4), "sm": Pt(8), "md": Pt(16), "lg": Pt(32)},
875
+ "shadows": {
876
+ "card": {
877
+ "blur_radius": Pt(6),
878
+ "distance": Pt(1),
879
+ "direction": 90.0,
880
+ "color": "#000000",
881
+ "alpha": 0.12,
882
+ },
883
+ },
884
+ },
885
+ "vibrant": {
886
+ "palette": {
887
+ "primary": "#FF3366",
888
+ "neutral": "#22223B",
889
+ "muted": "#9A8C98",
890
+ "surface": "#FFF8F0",
891
+ "on_primary": "#FFFFFF",
892
+ "lt1": "#FFFFFF",
893
+ "lt2": "#FFE5D9",
894
+ "positive": "#06D6A0",
895
+ "negative": "#EF233C",
896
+ "success": "#06D6A0",
897
+ "danger": "#EF233C",
898
+ },
899
+ "typography": {
900
+ "heading": {"family": "Poppins", "size": Pt(36), "bold": True},
901
+ "body": {"family": "Poppins", "size": Pt(16)},
902
+ },
903
+ "radii": {"sm": Pt(6), "md": Pt(12), "lg": Pt(24)},
904
+ "spacings": {"xs": Pt(4), "sm": Pt(8), "md": Pt(16), "lg": Pt(32)},
905
+ "shadows": {
906
+ "card": {
907
+ "blur_radius": Pt(14),
908
+ "distance": Pt(4),
909
+ "direction": 90.0,
910
+ "color": "#000000",
911
+ "alpha": 0.20,
912
+ },
913
+ },
914
+ },
915
+ }