python-pptx2 2.13.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pptx2/__init__.py +152 -0
- pptx2/_color.py +75 -0
- pptx2/_slide_importer.py +597 -0
- pptx2/_svg.py +155 -0
- pptx2/_template_applier.py +292 -0
- pptx2/_textstyle.py +187 -0
- pptx2/accessibility.py +365 -0
- pptx2/action.py +270 -0
- pptx2/animation.py +2237 -0
- pptx2/api.py +49 -0
- pptx2/audit.py +258 -0
- pptx2/chart/__init__.py +0 -0
- pptx2/chart/analytics.py +381 -0
- pptx2/chart/axis.py +543 -0
- pptx2/chart/category.py +200 -0
- pptx2/chart/chart.py +670 -0
- pptx2/chart/data.py +864 -0
- pptx2/chart/datalabel.py +406 -0
- pptx2/chart/legend.py +86 -0
- pptx2/chart/marker.py +70 -0
- pptx2/chart/palettes.py +129 -0
- pptx2/chart/plot.py +462 -0
- pptx2/chart/point.py +101 -0
- pptx2/chart/quick_layouts.py +325 -0
- pptx2/chart/series.py +334 -0
- pptx2/chart/xlsx.py +272 -0
- pptx2/chart/xmlwriter.py +1845 -0
- pptx2/compose/__init__.py +28 -0
- pptx2/compose/from_spec.py +1094 -0
- pptx2/design/__init__.py +8 -0
- pptx2/design/components.py +607 -0
- pptx2/design/figures.py +389 -0
- pptx2/design/layout.py +370 -0
- pptx2/design/recipes.py +1967 -0
- pptx2/design/style.py +209 -0
- pptx2/design/tokens.py +915 -0
- pptx2/diagrams.py +754 -0
- pptx2/dml/__init__.py +0 -0
- pptx2/dml/chtfmt.py +40 -0
- pptx2/dml/color.py +496 -0
- pptx2/dml/effect.py +909 -0
- pptx2/dml/fill.py +691 -0
- pptx2/dml/line.py +287 -0
- pptx2/dml/picture.py +212 -0
- pptx2/dml/three_d.py +381 -0
- pptx2/enum/__init__.py +0 -0
- pptx2/enum/action.py +71 -0
- pptx2/enum/animation.py +31 -0
- pptx2/enum/base.py +218 -0
- pptx2/enum/chart.py +574 -0
- pptx2/enum/dml.py +740 -0
- pptx2/enum/lang.py +685 -0
- pptx2/enum/presentation.py +133 -0
- pptx2/enum/shapes.py +1029 -0
- pptx2/enum/text.py +230 -0
- pptx2/exc.py +42 -0
- pptx2/formats.py +139 -0
- pptx2/geometry.py +420 -0
- pptx2/inherit.py +109 -0
- pptx2/lint.py +2256 -0
- pptx2/math.py +177 -0
- pptx2/media.py +197 -0
- pptx2/opc/__init__.py +0 -0
- pptx2/opc/constants.py +332 -0
- pptx2/opc/oxml.py +188 -0
- pptx2/opc/package.py +762 -0
- pptx2/opc/packuri.py +109 -0
- pptx2/opc/serialized.py +296 -0
- pptx2/opc/shared.py +20 -0
- pptx2/opc/spec.py +45 -0
- pptx2/oxml/__init__.py +555 -0
- pptx2/oxml/action.py +53 -0
- pptx2/oxml/chart/__init__.py +0 -0
- pptx2/oxml/chart/axis.py +337 -0
- pptx2/oxml/chart/chart.py +481 -0
- pptx2/oxml/chart/datalabel.py +253 -0
- pptx2/oxml/chart/legend.py +72 -0
- pptx2/oxml/chart/marker.py +61 -0
- pptx2/oxml/chart/plot.py +365 -0
- pptx2/oxml/chart/series.py +425 -0
- pptx2/oxml/chart/shared.py +220 -0
- pptx2/oxml/coreprops.py +288 -0
- pptx2/oxml/dml/__init__.py +0 -0
- pptx2/oxml/dml/color.py +135 -0
- pptx2/oxml/dml/effect.py +213 -0
- pptx2/oxml/dml/fill.py +316 -0
- pptx2/oxml/dml/line.py +12 -0
- pptx2/oxml/dml/three_d.py +110 -0
- pptx2/oxml/ns.py +135 -0
- pptx2/oxml/presentation.py +313 -0
- pptx2/oxml/shapes/__init__.py +19 -0
- pptx2/oxml/shapes/autoshape.py +467 -0
- pptx2/oxml/shapes/connector.py +107 -0
- pptx2/oxml/shapes/graphfrm.py +347 -0
- pptx2/oxml/shapes/groupshape.py +329 -0
- pptx2/oxml/shapes/picture.py +270 -0
- pptx2/oxml/shapes/shared.py +577 -0
- pptx2/oxml/simpletypes.py +1027 -0
- pptx2/oxml/slide.py +563 -0
- pptx2/oxml/table.py +650 -0
- pptx2/oxml/text.py +815 -0
- pptx2/oxml/theme.py +36 -0
- pptx2/oxml/xmlchemy.py +717 -0
- pptx2/package.py +222 -0
- pptx2/parts/__init__.py +0 -0
- pptx2/parts/chart.py +95 -0
- pptx2/parts/coreprops.py +167 -0
- pptx2/parts/diagram.py +37 -0
- pptx2/parts/embeddedpackage.py +93 -0
- pptx2/parts/image.py +275 -0
- pptx2/parts/media.py +37 -0
- pptx2/parts/presentation.py +136 -0
- pptx2/parts/slide.py +371 -0
- pptx2/presentation.py +408 -0
- pptx2/py.typed +0 -0
- pptx2/render.py +586 -0
- pptx2/section.py +272 -0
- pptx2/shapes/__init__.py +26 -0
- pptx2/shapes/autoshape.py +442 -0
- pptx2/shapes/base.py +1078 -0
- pptx2/shapes/connector.py +297 -0
- pptx2/shapes/freeform.py +337 -0
- pptx2/shapes/graphfrm.py +316 -0
- pptx2/shapes/group.py +264 -0
- pptx2/shapes/picture.py +422 -0
- pptx2/shapes/placeholder.py +468 -0
- pptx2/shapes/shapetree.py +2027 -0
- pptx2/shared.py +82 -0
- pptx2/skill/SKILL.md +450 -0
- pptx2/skill/__init__.py +78 -0
- pptx2/skill/__main__.py +64 -0
- pptx2/skill/references/animations.md +189 -0
- pptx2/skill/references/basics.md +421 -0
- pptx2/skill/references/charts.md +254 -0
- pptx2/skill/references/compose.md +234 -0
- pptx2/skill/references/design.md +366 -0
- pptx2/skill/references/effects.md +249 -0
- pptx2/skill/references/end-to-end-deck.md +231 -0
- pptx2/skill/references/geometry-and-arrows.md +334 -0
- pptx2/skill/references/lint.md +275 -0
- pptx2/skill/references/math.md +86 -0
- pptx2/skill/references/picture-effects.md +129 -0
- pptx2/skill/references/render.md +151 -0
- pptx2/skill/references/smart-art.md +75 -0
- pptx2/skill/references/space-aware-authoring.md +249 -0
- pptx2/skill/references/tables.md +244 -0
- pptx2/skill/references/theme.md +127 -0
- pptx2/skill/references/three-d.md +109 -0
- pptx2/skill/references/transitions.md +100 -0
- pptx2/slide.py +1244 -0
- pptx2/smart_art.py +220 -0
- pptx2/spec.py +633 -0
- pptx2/table.py +1181 -0
- pptx2/table_styles.py +184 -0
- pptx2/templates/default.pptx +0 -0
- pptx2/templates/docx-icon.emf +0 -0
- pptx2/templates/generic-icon.emf +0 -0
- pptx2/templates/notes.xml +23 -0
- pptx2/templates/notesMaster.xml +352 -0
- pptx2/templates/pptx-icon.emf +0 -0
- pptx2/templates/theme.xml +321 -0
- pptx2/templates/xlsx-icon.emf +0 -0
- pptx2/text/__init__.py +0 -0
- pptx2/text/fonts.py +482 -0
- pptx2/text/layout.py +374 -0
- pptx2/text/text.py +1272 -0
- pptx2/theme.py +721 -0
- pptx2/types.py +36 -0
- pptx2/util.py +263 -0
- python_pptx2-2.13.0.dist-info/METADATA +351 -0
- python_pptx2-2.13.0.dist-info/RECORD +175 -0
- python_pptx2-2.13.0.dist-info/WHEEL +5 -0
- python_pptx2-2.13.0.dist-info/entry_points.txt +3 -0
- python_pptx2-2.13.0.dist-info/licenses/LICENSE +22 -0
- python_pptx2-2.13.0.dist-info/top_level.txt +1 -0
pptx2/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
|
+
}
|