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/theme.py
ADDED
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
"""High-level theme API for python-pptx2.
|
|
2
|
+
|
|
3
|
+
Provides read/write access to a presentation's color palette and font
|
|
4
|
+
scheme as stored in the theme part (``ppt/theme/theme1.xml``).
|
|
5
|
+
|
|
6
|
+
Typical read usage::
|
|
7
|
+
|
|
8
|
+
from pptx2.enum.dml import MSO_THEME_COLOR
|
|
9
|
+
|
|
10
|
+
theme = prs.theme
|
|
11
|
+
rgb = theme.colors[MSO_THEME_COLOR.ACCENT_1] # RGBColor
|
|
12
|
+
heading_font = theme.fonts.major # e.g. "Calibri"
|
|
13
|
+
body_font = theme.fonts.minor # e.g. "Calibri"
|
|
14
|
+
|
|
15
|
+
Theme writes (Phase 7)::
|
|
16
|
+
|
|
17
|
+
theme.colors[MSO_THEME_COLOR.ACCENT_1] = RGBColor(0xFF, 0x66, 0x00)
|
|
18
|
+
theme.fonts.major = "Inter"
|
|
19
|
+
theme.fonts.minor = "Inter"
|
|
20
|
+
|
|
21
|
+
# Bulk-apply the palette + fonts of another presentation's theme
|
|
22
|
+
theme.apply(other_prs.theme)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import TYPE_CHECKING
|
|
28
|
+
|
|
29
|
+
from pptx2.dml.color import RGBColor
|
|
30
|
+
from pptx2.enum.dml import MSO_THEME_COLOR
|
|
31
|
+
from pptx2.oxml.ns import qn
|
|
32
|
+
from pptx2.oxml.xmlchemy import OxmlElement
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from pptx2.oxml.theme import CT_OfficeStyleSheet
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Theme
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Theme:
|
|
44
|
+
"""Read/write proxy for an Office theme (``<a:theme>`` element).
|
|
45
|
+
|
|
46
|
+
Exposes the color palette via :attr:`colors` and the font pair via
|
|
47
|
+
:attr:`fonts`. All reads are non-mutating; assignments to
|
|
48
|
+
:attr:`colors` slots and :attr:`fonts.major`/`.minor` modify the
|
|
49
|
+
underlying ``<a:clrScheme>``/``<a:fontScheme>`` so the changes
|
|
50
|
+
persist on save.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, theme_elm: CT_OfficeStyleSheet):
|
|
54
|
+
self._theme_elm = theme_elm
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def colors(self) -> ThemeColors:
|
|
58
|
+
"""A :class:`ThemeColors` object providing color lookups by theme slot."""
|
|
59
|
+
return ThemeColors(self._theme_elm)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def fonts(self) -> ThemeFonts:
|
|
63
|
+
"""A :class:`ThemeFonts` object exposing ``major`` and ``minor`` font names."""
|
|
64
|
+
return ThemeFonts(self._theme_elm)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def name(self) -> str:
|
|
68
|
+
"""The theme name (``<a:theme name="…">``), or an empty string if absent."""
|
|
69
|
+
return self._theme_elm.get("name", "")
|
|
70
|
+
|
|
71
|
+
@name.setter
|
|
72
|
+
def name(self, value: str) -> None:
|
|
73
|
+
self._theme_elm.set("name", value)
|
|
74
|
+
|
|
75
|
+
def apply(
|
|
76
|
+
self,
|
|
77
|
+
other: Theme,
|
|
78
|
+
*,
|
|
79
|
+
rebind_shape_colors: bool = False,
|
|
80
|
+
presentation=None,
|
|
81
|
+
) -> int:
|
|
82
|
+
"""Copy *other*'s color palette and font pair into this theme.
|
|
83
|
+
|
|
84
|
+
Iterates every :class:`MSO_THEME_COLOR` slot present on *other*
|
|
85
|
+
and writes the resolved RGB into the corresponding slot here,
|
|
86
|
+
then mirrors the major/minor font typefaces. Slots that *other*
|
|
87
|
+
cannot resolve (e.g. unsupported color types) are left
|
|
88
|
+
untouched on this theme.
|
|
89
|
+
|
|
90
|
+
When ``rebind_shape_colors=True``, every shape in *presentation*
|
|
91
|
+
whose hardcoded RGB matches a slot in the **old** (pre-swap)
|
|
92
|
+
palette is rewritten to point at that theme slot instead — so
|
|
93
|
+
re-skinning a deck no longer leaves orphan literal colors.
|
|
94
|
+
Requires *presentation* to be supplied.
|
|
95
|
+
|
|
96
|
+
Returns the number of shape-color rebinds applied (0 when
|
|
97
|
+
``rebind_shape_colors=False``).
|
|
98
|
+
"""
|
|
99
|
+
if not isinstance(other, Theme):
|
|
100
|
+
raise TypeError(f"apply() requires a Theme, got {type(other).__name__!r}")
|
|
101
|
+
|
|
102
|
+
# Snapshot the pre-swap palette so we can rebind matching shapes
|
|
103
|
+
# afterwards (rebinding by RGB only makes sense relative to the
|
|
104
|
+
# palette that was active when those RGBs were authored).
|
|
105
|
+
before_palette: dict[tuple[int, int, int], MSO_THEME_COLOR] = {}
|
|
106
|
+
if rebind_shape_colors:
|
|
107
|
+
if presentation is None:
|
|
108
|
+
raise ValueError(
|
|
109
|
+
"rebind_shape_colors=True requires presentation= to be supplied"
|
|
110
|
+
)
|
|
111
|
+
for slot in MSO_THEME_COLOR:
|
|
112
|
+
if not slot.xml_value:
|
|
113
|
+
continue
|
|
114
|
+
rgb = self.colors.get(slot)
|
|
115
|
+
if rgb is not None:
|
|
116
|
+
# First-write wins so aliases (BACKGROUND_1 vs LIGHT_1)
|
|
117
|
+
# don't shadow the canonical slot.
|
|
118
|
+
before_palette.setdefault(tuple(int(c) for c in rgb), slot)
|
|
119
|
+
|
|
120
|
+
src_colors = other.colors
|
|
121
|
+
dst_colors = self.colors
|
|
122
|
+
for slot in MSO_THEME_COLOR:
|
|
123
|
+
if not slot.xml_value:
|
|
124
|
+
continue
|
|
125
|
+
rgb = src_colors.get(slot)
|
|
126
|
+
if rgb is not None:
|
|
127
|
+
dst_colors[slot] = rgb
|
|
128
|
+
|
|
129
|
+
if other.fonts.major:
|
|
130
|
+
self.fonts.major = other.fonts.major
|
|
131
|
+
if other.fonts.minor:
|
|
132
|
+
self.fonts.minor = other.fonts.minor
|
|
133
|
+
|
|
134
|
+
if not rebind_shape_colors:
|
|
135
|
+
return 0
|
|
136
|
+
return _rebind_shape_colors(presentation, before_palette)
|
|
137
|
+
|
|
138
|
+
def to_dark_mode(self, *, min_contrast: float = 4.5) -> "Theme":
|
|
139
|
+
"""Convert this theme's palette to a dark variant **in place**.
|
|
140
|
+
|
|
141
|
+
Swaps the two background/text pairs — ``dk1``↔``lt1`` and
|
|
142
|
+
``dk2``↔``lt2`` — so the slot that previously held the (light)
|
|
143
|
+
page background now holds the (dark) one and text/background
|
|
144
|
+
invert. Accent colors are then nudged *only as needed* to keep
|
|
145
|
+
them legible against the new dark background: any accent whose
|
|
146
|
+
WCAG contrast ratio against the new ``lt1`` (the dark canvas)
|
|
147
|
+
falls below *min_contrast* is lightened in HSL space until it
|
|
148
|
+
clears the threshold (or reaches white).
|
|
149
|
+
|
|
150
|
+
The default ``min_contrast`` of 4.5 is the WCAG AA threshold for
|
|
151
|
+
normal-size text. The swap and any accent adjustments are
|
|
152
|
+
written through the existing :class:`ThemeColors` setter, so the
|
|
153
|
+
change persists on save and round-trips cleanly.
|
|
154
|
+
|
|
155
|
+
Returns *self* so calls can be chained.
|
|
156
|
+
"""
|
|
157
|
+
colors = self.colors
|
|
158
|
+
|
|
159
|
+
# 1. Swap the dark/light background-text pairs. Read both ends
|
|
160
|
+
# of each pair first so the in-place writes don't clobber a
|
|
161
|
+
# value we still need.
|
|
162
|
+
for dark_slot, light_slot in (
|
|
163
|
+
(MSO_THEME_COLOR.DARK_1, MSO_THEME_COLOR.LIGHT_1),
|
|
164
|
+
(MSO_THEME_COLOR.DARK_2, MSO_THEME_COLOR.LIGHT_2),
|
|
165
|
+
):
|
|
166
|
+
dark_rgb = colors.get(dark_slot)
|
|
167
|
+
light_rgb = colors.get(light_slot)
|
|
168
|
+
if dark_rgb is not None:
|
|
169
|
+
colors[light_slot] = dark_rgb
|
|
170
|
+
if light_rgb is not None:
|
|
171
|
+
colors[dark_slot] = light_rgb
|
|
172
|
+
|
|
173
|
+
# 2. The new page background is whatever lt1 now resolves to
|
|
174
|
+
# (lt1/bg1 is the canonical canvas slot). Raise each accent's
|
|
175
|
+
# contrast against it to at least min_contrast.
|
|
176
|
+
background = colors.get(MSO_THEME_COLOR.LIGHT_1)
|
|
177
|
+
if background is not None:
|
|
178
|
+
accent_slots = (
|
|
179
|
+
MSO_THEME_COLOR.ACCENT_1,
|
|
180
|
+
MSO_THEME_COLOR.ACCENT_2,
|
|
181
|
+
MSO_THEME_COLOR.ACCENT_3,
|
|
182
|
+
MSO_THEME_COLOR.ACCENT_4,
|
|
183
|
+
MSO_THEME_COLOR.ACCENT_5,
|
|
184
|
+
MSO_THEME_COLOR.ACCENT_6,
|
|
185
|
+
)
|
|
186
|
+
for slot in accent_slots:
|
|
187
|
+
rgb = colors.get(slot)
|
|
188
|
+
if rgb is None:
|
|
189
|
+
continue
|
|
190
|
+
fixed = _raise_contrast(rgb, background, min_contrast)
|
|
191
|
+
if fixed != rgb:
|
|
192
|
+
colors[slot] = fixed
|
|
193
|
+
|
|
194
|
+
return self
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def embed_font(
|
|
198
|
+
presentation,
|
|
199
|
+
font_path: str,
|
|
200
|
+
*,
|
|
201
|
+
typeface: str | None = None,
|
|
202
|
+
weight: str = "regular",
|
|
203
|
+
) -> str:
|
|
204
|
+
"""Embed a TrueType/OpenType font into *presentation*.
|
|
205
|
+
|
|
206
|
+
Bundles the font binary as a package part under ``/ppt/fonts/`` and
|
|
207
|
+
registers it in the presentation's ``<p:embeddedFontLst>`` so it
|
|
208
|
+
travels with the deck and is used by readers that respect embedded
|
|
209
|
+
fonts (PowerPoint 2007+).
|
|
210
|
+
|
|
211
|
+
Parameters
|
|
212
|
+
----------
|
|
213
|
+
presentation
|
|
214
|
+
The :class:`~pptx2.presentation.Presentation` to embed into.
|
|
215
|
+
font_path
|
|
216
|
+
Filesystem path to a ``.ttf`` or ``.otf`` font file.
|
|
217
|
+
typeface
|
|
218
|
+
Family name to register. If omitted, the file's stem is used
|
|
219
|
+
(e.g. ``Inter-Regular.ttf`` → ``"Inter-Regular"``).
|
|
220
|
+
weight
|
|
221
|
+
One of ``"regular"`` / ``"bold"`` / ``"italic"`` / ``"boldItalic"``.
|
|
222
|
+
|
|
223
|
+
Returns the typeface that was registered.
|
|
224
|
+
|
|
225
|
+
Notes
|
|
226
|
+
-----
|
|
227
|
+
The font is embedded *unobfuscated* using content type
|
|
228
|
+
``application/x-fontdata``. PowerPoint 2007+ accepts this form.
|
|
229
|
+
The fully-obfuscated form (per ECMA-376 §15.2.13) is on the roadmap.
|
|
230
|
+
Once an obfuscated path lands, calls written against this API will
|
|
231
|
+
not need to change.
|
|
232
|
+
"""
|
|
233
|
+
import os
|
|
234
|
+
|
|
235
|
+
from pptx2.opc.constants import CONTENT_TYPE as CT
|
|
236
|
+
from pptx2.opc.constants import RELATIONSHIP_TYPE as RT
|
|
237
|
+
from pptx2.opc.package import Part
|
|
238
|
+
|
|
239
|
+
valid_weights = ("regular", "bold", "italic", "boldItalic")
|
|
240
|
+
if weight not in valid_weights:
|
|
241
|
+
raise ValueError(
|
|
242
|
+
f"weight must be one of {valid_weights}, got {weight!r}"
|
|
243
|
+
)
|
|
244
|
+
if not os.path.isfile(font_path):
|
|
245
|
+
raise FileNotFoundError(f"font file not found: {font_path}")
|
|
246
|
+
with open(font_path, "rb") as f:
|
|
247
|
+
blob = f.read()
|
|
248
|
+
if typeface is None:
|
|
249
|
+
typeface = os.path.splitext(os.path.basename(font_path))[0]
|
|
250
|
+
|
|
251
|
+
package = presentation.part.package
|
|
252
|
+
# Existing fontdata files in /ppt/fonts/font<N>.fntdata; allocate next.
|
|
253
|
+
partname = package.next_partname("/ppt/fonts/font%d.fntdata")
|
|
254
|
+
font_part = Part(partname, CT.X_FONTDATA, package, blob)
|
|
255
|
+
|
|
256
|
+
prs_part = package.presentation_part
|
|
257
|
+
rId = prs_part.relate_to(font_part, RT.FONT)
|
|
258
|
+
|
|
259
|
+
# Inject <p:embeddedFontLst> entry into presentation.xml.
|
|
260
|
+
_add_embedded_font_entry(prs_part.presentation, typeface, weight, rId)
|
|
261
|
+
return typeface
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# Schema sequence of the weight slots inside <p:embeddedFont> (after <p:font>).
|
|
265
|
+
_EMBED_WEIGHT_ORDER = ("regular", "bold", "italic", "boldItalic")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _add_embedded_font_entry(presentation, typeface: str, weight: str, rId: str) -> None:
|
|
269
|
+
"""Add or extend a ``<p:embeddedFont>`` entry in presentation.xml.
|
|
270
|
+
|
|
271
|
+
If an entry already exists for *typeface*, the *weight* slot
|
|
272
|
+
(regular / bold / italic / boldItalic) is added to it. Otherwise a
|
|
273
|
+
new ``<p:embeddedFont>`` is appended to ``<p:embeddedFontLst>``,
|
|
274
|
+
creating the list if needed.
|
|
275
|
+
"""
|
|
276
|
+
pres_elm = presentation._element # type: ignore[attr-defined]
|
|
277
|
+
r_ns = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
|
278
|
+
|
|
279
|
+
# PowerPoint treats font embedding as *disabled* unless the presentation
|
|
280
|
+
# carries embedTrueTypeFonts="1" — without it, a user re-saving the deck
|
|
281
|
+
# in PowerPoint has the fntdata parts and font list silently stripped.
|
|
282
|
+
pres_elm.set("embedTrueTypeFonts", "1")
|
|
283
|
+
|
|
284
|
+
# Get (or create) the list in its schema-mandated position. embeddedFontLst
|
|
285
|
+
# must precede defaultTextStyle / modifyVerifier / extLst in the
|
|
286
|
+
# CT_Presentation sequence, and every default template already carries a
|
|
287
|
+
# defaultTextStyle — a bare append would place it out of order and produce a
|
|
288
|
+
# presentation.xml PowerPoint reports as broken. get_or_add_embeddedFontLst
|
|
289
|
+
# inserts before the first existing successor.
|
|
290
|
+
embedded_lst = pres_elm.get_or_add_embeddedFontLst()
|
|
291
|
+
|
|
292
|
+
# Find existing entry for this typeface.
|
|
293
|
+
existing = None
|
|
294
|
+
for ef in embedded_lst.findall(qn("p:embeddedFont")):
|
|
295
|
+
font = ef.find(qn("p:font"))
|
|
296
|
+
if font is not None and font.get("typeface") == typeface:
|
|
297
|
+
existing = ef
|
|
298
|
+
break
|
|
299
|
+
|
|
300
|
+
if existing is None:
|
|
301
|
+
ef = OxmlElement("p:embeddedFont")
|
|
302
|
+
font = OxmlElement("p:font")
|
|
303
|
+
font.set("typeface", typeface)
|
|
304
|
+
ef.append(font)
|
|
305
|
+
embedded_lst.append(ef)
|
|
306
|
+
existing = ef
|
|
307
|
+
|
|
308
|
+
# Add weight slot if absent (PowerPoint disallows duplicates).
|
|
309
|
+
slot_elm = existing.find(qn(f"p:{weight}"))
|
|
310
|
+
if slot_elm is not None:
|
|
311
|
+
slot_elm.set(f"{{{r_ns}}}id", rId)
|
|
312
|
+
else:
|
|
313
|
+
slot_elm = OxmlElement(f"p:{weight}")
|
|
314
|
+
slot_elm.set(f"{{{r_ns}}}id", rId)
|
|
315
|
+
# CT_EmbeddedFontListEntry is a fixed sequence: font, regular?, bold?,
|
|
316
|
+
# italic?, boldItalic?. Insert the new slot at its schema position
|
|
317
|
+
# rather than appending — appending out of order (e.g. italic before
|
|
318
|
+
# bold) produces a sequence violation PowerPoint may reject/repair.
|
|
319
|
+
new_idx = _EMBED_WEIGHT_ORDER.index(weight)
|
|
320
|
+
insert_before = None
|
|
321
|
+
for child in existing:
|
|
322
|
+
local = child.tag.rsplit("}", 1)[-1]
|
|
323
|
+
if local in _EMBED_WEIGHT_ORDER and _EMBED_WEIGHT_ORDER.index(local) > new_idx:
|
|
324
|
+
insert_before = child
|
|
325
|
+
break
|
|
326
|
+
if insert_before is None:
|
|
327
|
+
existing.append(slot_elm)
|
|
328
|
+
else:
|
|
329
|
+
insert_before.addprevious(slot_elm)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# Method on Theme for the user-facing API.
|
|
333
|
+
def _theme_embed_font(self, presentation, font_path, *, typeface=None, weight="regular"):
|
|
334
|
+
"""Embed *font_path* into *presentation* and register it.
|
|
335
|
+
|
|
336
|
+
Convenience method on :class:`Theme`. See :func:`embed_font` for
|
|
337
|
+
the full description.
|
|
338
|
+
"""
|
|
339
|
+
return embed_font(
|
|
340
|
+
presentation, font_path, typeface=typeface, weight=weight
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
Theme.embed_font = _theme_embed_font # type: ignore[attr-defined]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# ---------------------------------------------------------------------------
|
|
348
|
+
# ThemeColors
|
|
349
|
+
# ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
# OOXML defines bg1/bg2/tx1/tx2 as logical aliases that always resolve to
|
|
352
|
+
# lt1/lt2/dk1/dk2 respectively in the <a:clrScheme> element. The scheme
|
|
353
|
+
# never stores bg*/tx* child elements, so we must remap before the lookup.
|
|
354
|
+
_CLR_SCHEME_ALIAS: dict[str, str] = {
|
|
355
|
+
"bg1": "lt1",
|
|
356
|
+
"bg2": "lt2",
|
|
357
|
+
"tx1": "dk1",
|
|
358
|
+
"tx2": "dk2",
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class ThemeColors:
|
|
363
|
+
"""Dict-like read-only view of a theme's color scheme.
|
|
364
|
+
|
|
365
|
+
Keys are :class:`~pptx2.enum.dml.MSO_THEME_COLOR` members.
|
|
366
|
+
Values are :class:`~pptx2.util.RGBColor` instances.
|
|
367
|
+
|
|
368
|
+
Example::
|
|
369
|
+
|
|
370
|
+
from pptx2.enum.dml import MSO_THEME_COLOR
|
|
371
|
+
|
|
372
|
+
rgb = prs.theme.colors[MSO_THEME_COLOR.ACCENT_1]
|
|
373
|
+
print(rgb) # RGBColor(0x4f, 0x81, 0xbd)
|
|
374
|
+
"""
|
|
375
|
+
|
|
376
|
+
def __init__(self, theme_elm: CT_OfficeStyleSheet):
|
|
377
|
+
self._theme_elm = theme_elm
|
|
378
|
+
|
|
379
|
+
def __getitem__(self, theme_color: MSO_THEME_COLOR) -> RGBColor:
|
|
380
|
+
if not isinstance(theme_color, MSO_THEME_COLOR):
|
|
381
|
+
raise TypeError(
|
|
382
|
+
f"key must be an MSO_THEME_COLOR member, got {type(theme_color).__name__!r}"
|
|
383
|
+
)
|
|
384
|
+
rgb = self._resolve(theme_color)
|
|
385
|
+
if rgb is None:
|
|
386
|
+
raise KeyError(theme_color)
|
|
387
|
+
return rgb
|
|
388
|
+
|
|
389
|
+
def __setitem__(self, theme_color: MSO_THEME_COLOR, rgb: RGBColor) -> None:
|
|
390
|
+
"""Replace the *theme_color* slot's content with a single ``<a:srgbClr>`` *rgb*.
|
|
391
|
+
|
|
392
|
+
Only slots that map directly into ``<a:clrScheme>`` (everything
|
|
393
|
+
except ``HYPERLINK``/``FOLLOWED_HYPERLINK`` is fair game; those
|
|
394
|
+
also have first-class slots and are supported as well) can be
|
|
395
|
+
written. Aliased slots (``BACKGROUND_1``, ``BACKGROUND_2``,
|
|
396
|
+
``TEXT_1``, ``TEXT_2``) write to their canonical
|
|
397
|
+
``lt1``/``lt2``/``dk1``/``dk2`` slot.
|
|
398
|
+
|
|
399
|
+
Replaces any existing color child of the slot (``srgbClr``,
|
|
400
|
+
``sysClr``, ``schemeClr``, …) with a single ``<a:srgbClr>``,
|
|
401
|
+
which is the simplest form PowerPoint understands and what we
|
|
402
|
+
emit elsewhere in the library.
|
|
403
|
+
"""
|
|
404
|
+
if not isinstance(theme_color, MSO_THEME_COLOR):
|
|
405
|
+
raise TypeError(
|
|
406
|
+
f"key must be an MSO_THEME_COLOR member, got {type(theme_color).__name__!r}"
|
|
407
|
+
)
|
|
408
|
+
if not isinstance(rgb, RGBColor):
|
|
409
|
+
raise TypeError(
|
|
410
|
+
f"value must be an RGBColor, got {type(rgb).__name__!r}"
|
|
411
|
+
)
|
|
412
|
+
if not theme_color.xml_value:
|
|
413
|
+
raise ValueError(
|
|
414
|
+
f"{theme_color!r} has no <a:clrScheme> slot and cannot be assigned"
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
slot_name = _CLR_SCHEME_ALIAS.get(theme_color.xml_value, theme_color.xml_value)
|
|
418
|
+
clr_scheme = self._clr_scheme()
|
|
419
|
+
if clr_scheme is None:
|
|
420
|
+
raise ValueError(
|
|
421
|
+
"theme has no <a:clrScheme>; cannot write to color slot"
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
slot_elm = clr_scheme.find(qn(f"a:{slot_name}"))
|
|
425
|
+
if slot_elm is None:
|
|
426
|
+
# Slot wasn't previously declared (rare but possible); add
|
|
427
|
+
# it at the schema-defined position. CT_ColorScheme
|
|
428
|
+
# requires its children in a fixed sequence; appending at
|
|
429
|
+
# the tail would invalidate the file when later slots
|
|
430
|
+
# (e.g. hlink/folHlink) are already present.
|
|
431
|
+
slot_elm = OxmlElement(f"a:{slot_name}")
|
|
432
|
+
_insert_clr_scheme_slot(clr_scheme, slot_elm, slot_name)
|
|
433
|
+
|
|
434
|
+
# Replace the slot's color child with a fresh <a:srgbClr val="...">
|
|
435
|
+
for child in list(slot_elm):
|
|
436
|
+
slot_elm.remove(child)
|
|
437
|
+
srgb = OxmlElement("a:srgbClr")
|
|
438
|
+
srgb.set("val", "{:02X}{:02X}{:02X}".format(*rgb))
|
|
439
|
+
slot_elm.append(srgb)
|
|
440
|
+
|
|
441
|
+
def get(self, theme_color: MSO_THEME_COLOR, default: RGBColor | None = None) -> RGBColor | None:
|
|
442
|
+
"""Return the |RGBColor| for *theme_color*, or *default* if not found."""
|
|
443
|
+
if not isinstance(theme_color, MSO_THEME_COLOR):
|
|
444
|
+
return default
|
|
445
|
+
return self._resolve(theme_color) or default
|
|
446
|
+
|
|
447
|
+
def __contains__(self, theme_color: object) -> bool:
|
|
448
|
+
if not isinstance(theme_color, MSO_THEME_COLOR):
|
|
449
|
+
return False
|
|
450
|
+
return self._resolve(theme_color) is not None
|
|
451
|
+
|
|
452
|
+
def _resolve(self, theme_color: MSO_THEME_COLOR) -> RGBColor | None:
|
|
453
|
+
"""Return the resolved :class:`RGBColor` for *theme_color*, or ``None``."""
|
|
454
|
+
# Pseudo-slots like NOT_THEME_COLOR / MIXED carry an empty xml_value
|
|
455
|
+
# and have no <a:clrScheme> child — they always resolve to None.
|
|
456
|
+
if not theme_color.xml_value:
|
|
457
|
+
return None
|
|
458
|
+
|
|
459
|
+
clr_scheme = self._clr_scheme()
|
|
460
|
+
if clr_scheme is None:
|
|
461
|
+
return None
|
|
462
|
+
|
|
463
|
+
# bg1/bg2/tx1/tx2 are OOXML aliases; clrScheme only stores lt1/lt2/dk1/dk2.
|
|
464
|
+
slot_name = _CLR_SCHEME_ALIAS.get(theme_color.xml_value, theme_color.xml_value)
|
|
465
|
+
slot_elm = clr_scheme.find(qn(f"a:{slot_name}"))
|
|
466
|
+
if slot_elm is None:
|
|
467
|
+
return None
|
|
468
|
+
|
|
469
|
+
return _rgb_from_slot(slot_elm)
|
|
470
|
+
|
|
471
|
+
def _clr_scheme(self):
|
|
472
|
+
"""Return the ``<a:clrScheme>`` element, or ``None``."""
|
|
473
|
+
# BaseOxmlElement.xpath() pre-loads _nsmap so no namespaces kwarg needed
|
|
474
|
+
results = self._theme_elm.xpath("a:themeElements/a:clrScheme")
|
|
475
|
+
return results[0] if results else None
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _rgb_from_slot(slot_elm) -> RGBColor | None:
|
|
479
|
+
"""Extract an RGB value from a ``<a:dk1>``, ``<a:accent1>`` etc. element.
|
|
480
|
+
|
|
481
|
+
The slot element contains exactly one color child:
|
|
482
|
+
* ``<a:srgbClr val="RRGGBB">`` → direct hex
|
|
483
|
+
* ``<a:sysClr … lastClr="RRGGBB">`` → use *lastClr* (resolved system color)
|
|
484
|
+
* Other child types are not yet supported and return ``None``.
|
|
485
|
+
"""
|
|
486
|
+
srgb = slot_elm.find(qn("a:srgbClr"))
|
|
487
|
+
if srgb is not None:
|
|
488
|
+
val = srgb.get("val")
|
|
489
|
+
if val and len(val) == 6:
|
|
490
|
+
return RGBColor(int(val[0:2], 16), int(val[2:4], 16), int(val[4:6], 16))
|
|
491
|
+
|
|
492
|
+
sys_clr = slot_elm.find(qn("a:sysClr"))
|
|
493
|
+
if sys_clr is not None:
|
|
494
|
+
last = sys_clr.get("lastClr")
|
|
495
|
+
if last and len(last) == 6:
|
|
496
|
+
return RGBColor(int(last[0:2], 16), int(last[2:4], 16), int(last[4:6], 16))
|
|
497
|
+
|
|
498
|
+
return None
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
# ---------------------------------------------------------------------------
|
|
502
|
+
# ThemeFonts
|
|
503
|
+
# ---------------------------------------------------------------------------
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
class ThemeFonts:
|
|
507
|
+
"""Read/write view of a theme's font scheme (major and minor fonts).
|
|
508
|
+
|
|
509
|
+
Example::
|
|
510
|
+
|
|
511
|
+
print(prs.theme.fonts.major) # "Calibri"
|
|
512
|
+
prs.theme.fonts.major = "Inter" # heading font
|
|
513
|
+
prs.theme.fonts.minor = "Inter" # body font
|
|
514
|
+
"""
|
|
515
|
+
|
|
516
|
+
def __init__(self, theme_elm: CT_OfficeStyleSheet):
|
|
517
|
+
self._theme_elm = theme_elm
|
|
518
|
+
|
|
519
|
+
@property
|
|
520
|
+
def major(self) -> str | None:
|
|
521
|
+
"""The *major* (heading) Latin typeface name, or ``None`` if not set."""
|
|
522
|
+
return self._latin_typeface("majorFont")
|
|
523
|
+
|
|
524
|
+
@major.setter
|
|
525
|
+
def major(self, typeface: str) -> None:
|
|
526
|
+
self._set_latin_typeface("majorFont", typeface)
|
|
527
|
+
|
|
528
|
+
@property
|
|
529
|
+
def minor(self) -> str | None:
|
|
530
|
+
"""The *minor* (body) Latin typeface name, or ``None`` if not set."""
|
|
531
|
+
return self._latin_typeface("minorFont")
|
|
532
|
+
|
|
533
|
+
@minor.setter
|
|
534
|
+
def minor(self, typeface: str) -> None:
|
|
535
|
+
self._set_latin_typeface("minorFont", typeface)
|
|
536
|
+
|
|
537
|
+
def _latin_typeface(self, font_kind: str) -> str | None:
|
|
538
|
+
results = self._theme_elm.xpath(
|
|
539
|
+
f"a:themeElements/a:fontScheme/a:{font_kind}/a:latin/@typeface"
|
|
540
|
+
)
|
|
541
|
+
return results[0] if results else None
|
|
542
|
+
|
|
543
|
+
def _set_latin_typeface(self, font_kind: str, typeface: str) -> None:
|
|
544
|
+
if not isinstance(typeface, str) or not typeface:
|
|
545
|
+
raise TypeError("typeface must be a non-empty string")
|
|
546
|
+
|
|
547
|
+
font_scheme = self._theme_elm.find(
|
|
548
|
+
f"{qn('a:themeElements')}/{qn('a:fontScheme')}"
|
|
549
|
+
)
|
|
550
|
+
if font_scheme is None:
|
|
551
|
+
raise ValueError(
|
|
552
|
+
"theme has no <a:fontScheme>; cannot set typeface"
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
kind_elm = font_scheme.find(qn(f"a:{font_kind}"))
|
|
556
|
+
if kind_elm is None:
|
|
557
|
+
# Recovery path for a theme missing a required font collection:
|
|
558
|
+
# CT_FontCollection requires latin + ea + cs (in that order), and
|
|
559
|
+
# CT_FontScheme requires majorFont before minorFont — a bare
|
|
560
|
+
# latin-only append would itself be repair-trigger XML.
|
|
561
|
+
kind_elm = OxmlElement(f"a:{font_kind}")
|
|
562
|
+
for tag in ("a:latin", "a:ea", "a:cs"):
|
|
563
|
+
child = OxmlElement(tag)
|
|
564
|
+
child.set("typeface", "")
|
|
565
|
+
kind_elm.append(child)
|
|
566
|
+
minor = font_scheme.find(qn("a:minorFont"))
|
|
567
|
+
if font_kind == "majorFont" and minor is not None:
|
|
568
|
+
minor.addprevious(kind_elm)
|
|
569
|
+
else:
|
|
570
|
+
font_scheme.append(kind_elm)
|
|
571
|
+
|
|
572
|
+
latin = kind_elm.find(qn("a:latin"))
|
|
573
|
+
if latin is None:
|
|
574
|
+
# <a:latin> must be the first child of <a:majorFont>/<a:minorFont>
|
|
575
|
+
latin = OxmlElement("a:latin")
|
|
576
|
+
kind_elm.insert(0, latin)
|
|
577
|
+
latin.set("typeface", typeface)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
# ---------------------------------------------------------------------------
|
|
581
|
+
# OOXML schema helpers
|
|
582
|
+
# ---------------------------------------------------------------------------
|
|
583
|
+
|
|
584
|
+
# CT_ColorScheme defines its children in this exact sequence; later slots
|
|
585
|
+
# (e.g. hlink) must follow earlier ones, and <a:extLst> is allowed last.
|
|
586
|
+
_CLR_SCHEME_SLOT_ORDER: tuple[str, ...] = (
|
|
587
|
+
"dk1", "lt1", "dk2", "lt2",
|
|
588
|
+
"accent1", "accent2", "accent3", "accent4", "accent5", "accent6",
|
|
589
|
+
"hlink", "folHlink",
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _rebind_shape_colors(presentation, palette_map) -> int:
|
|
594
|
+
"""Walk every shape in *presentation* and rebind hardcoded literal RGB
|
|
595
|
+
fills/lines/text colors to a theme slot when the literal matches.
|
|
596
|
+
|
|
597
|
+
*palette_map* maps ``(r, g, b)`` ints to ``MSO_THEME_COLOR`` enum
|
|
598
|
+
members — typically a snapshot of the old palette taken just before
|
|
599
|
+
a :meth:`Theme.apply` swap.
|
|
600
|
+
|
|
601
|
+
Implemented as a direct XML rewrite: any ``<a:srgbClr val="RRGGBB">``
|
|
602
|
+
whose value matches a key in *palette_map* is replaced in-place with
|
|
603
|
+
a ``<a:schemeClr val="<slot>"/>`` referencing the theme. The shape's
|
|
604
|
+
other children (alpha, lumMod, etc.) are preserved.
|
|
605
|
+
|
|
606
|
+
Returns the number of color references rebound.
|
|
607
|
+
"""
|
|
608
|
+
if not palette_map:
|
|
609
|
+
return 0
|
|
610
|
+
|
|
611
|
+
# Build a hex-string lookup keyed by uppercase 6-char strings.
|
|
612
|
+
hex_map: dict[str, str] = {}
|
|
613
|
+
for (r, g, b), slot in palette_map.items():
|
|
614
|
+
hex_str = "{:02X}{:02X}{:02X}".format(r, g, b)
|
|
615
|
+
hex_map[hex_str] = slot.xml_value
|
|
616
|
+
|
|
617
|
+
a_srgbClr = qn("a:srgbClr")
|
|
618
|
+
a_schemeClr = qn("a:schemeClr")
|
|
619
|
+
|
|
620
|
+
rebound = 0
|
|
621
|
+
for slide in presentation.slides:
|
|
622
|
+
for srgb in slide._element.iter(a_srgbClr): # type: ignore[attr-defined]
|
|
623
|
+
val = (srgb.get("val") or "").upper()
|
|
624
|
+
if val not in hex_map:
|
|
625
|
+
continue
|
|
626
|
+
scheme = OxmlElement("a:schemeClr")
|
|
627
|
+
scheme.set("val", hex_map[val])
|
|
628
|
+
# Preserve alpha/lumMod/etc. modifier children.
|
|
629
|
+
for child in list(srgb):
|
|
630
|
+
scheme.append(child)
|
|
631
|
+
srgb.tag = a_schemeClr
|
|
632
|
+
srgb.attrib.clear()
|
|
633
|
+
srgb.set("val", hex_map[val])
|
|
634
|
+
for child in list(srgb):
|
|
635
|
+
srgb.remove(child)
|
|
636
|
+
for child in list(scheme):
|
|
637
|
+
srgb.append(child)
|
|
638
|
+
rebound += 1
|
|
639
|
+
return rebound
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
# ---------------------------------------------------------------------------
|
|
643
|
+
# Contrast helpers (WCAG)
|
|
644
|
+
#
|
|
645
|
+
# Duplicated from lint._relative_luminance / lint._contrast_ratio so this
|
|
646
|
+
# module stays self-contained (the brief asks us not to edit lint.py).
|
|
647
|
+
# ---------------------------------------------------------------------------
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _relative_luminance(rgb: RGBColor) -> float:
|
|
651
|
+
"""Return WCAG relative luminance of an ``RGBColor`` / 3-tuple."""
|
|
652
|
+
r, g, b = (int(rgb[0]) / 255.0, int(rgb[1]) / 255.0, int(rgb[2]) / 255.0)
|
|
653
|
+
|
|
654
|
+
def _ch(c: float) -> float:
|
|
655
|
+
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
|
|
656
|
+
|
|
657
|
+
return 0.2126 * _ch(r) + 0.7152 * _ch(g) + 0.0722 * _ch(b)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _contrast_ratio(rgb_a: RGBColor, rgb_b: RGBColor) -> float:
|
|
661
|
+
"""Return WCAG contrast ratio between two colors."""
|
|
662
|
+
la = _relative_luminance(rgb_a)
|
|
663
|
+
lb = _relative_luminance(rgb_b)
|
|
664
|
+
light, dark = (la, lb) if la >= lb else (lb, la)
|
|
665
|
+
return (light + 0.05) / (dark + 0.05)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _raise_contrast(rgb: RGBColor, background: RGBColor, min_contrast: float) -> RGBColor:
|
|
669
|
+
"""Lighten *rgb* in HSL space until it clears *min_contrast* on *background*.
|
|
670
|
+
|
|
671
|
+
Returns *rgb* unchanged when it already meets the threshold. The hue
|
|
672
|
+
and saturation are preserved; only lightness is raised, in small
|
|
673
|
+
steps, until the contrast target is met or the color reaches white.
|
|
674
|
+
"""
|
|
675
|
+
import colorsys
|
|
676
|
+
|
|
677
|
+
if _contrast_ratio(rgb, background) >= min_contrast:
|
|
678
|
+
return rgb
|
|
679
|
+
|
|
680
|
+
h, light, s = colorsys.rgb_to_hls(
|
|
681
|
+
rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0
|
|
682
|
+
)
|
|
683
|
+
best = rgb
|
|
684
|
+
steps = 100
|
|
685
|
+
for i in range(1, steps + 1):
|
|
686
|
+
new_l = light + (1.0 - light) * (i / steps)
|
|
687
|
+
r, g, b = colorsys.hls_to_rgb(h, new_l, s)
|
|
688
|
+
candidate = RGBColor(
|
|
689
|
+
round(r * 255), round(g * 255), round(b * 255)
|
|
690
|
+
)
|
|
691
|
+
best = candidate
|
|
692
|
+
if _contrast_ratio(candidate, background) >= min_contrast:
|
|
693
|
+
break
|
|
694
|
+
return best
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _insert_clr_scheme_slot(clr_scheme, slot_elm, slot_name: str) -> None:
|
|
698
|
+
"""Insert *slot_elm* into *clr_scheme* at the schema-defined position.
|
|
699
|
+
|
|
700
|
+
Finds the first existing child whose schema position is *after*
|
|
701
|
+
*slot_name* and inserts before it; otherwise appends at the tail
|
|
702
|
+
(but before any trailing ``<a:extLst>`` if present).
|
|
703
|
+
"""
|
|
704
|
+
try:
|
|
705
|
+
target_idx = _CLR_SCHEME_SLOT_ORDER.index(slot_name)
|
|
706
|
+
except ValueError:
|
|
707
|
+
# Unknown slot name (shouldn't happen): append at end.
|
|
708
|
+
clr_scheme.append(slot_elm)
|
|
709
|
+
return
|
|
710
|
+
|
|
711
|
+
for child in clr_scheme:
|
|
712
|
+
local = child.tag.rsplit("}", 1)[-1]
|
|
713
|
+
if local == "extLst":
|
|
714
|
+
clr_scheme.insert(list(clr_scheme).index(child), slot_elm)
|
|
715
|
+
return
|
|
716
|
+
if local in _CLR_SCHEME_SLOT_ORDER:
|
|
717
|
+
child_idx = _CLR_SCHEME_SLOT_ORDER.index(local)
|
|
718
|
+
if child_idx > target_idx:
|
|
719
|
+
clr_scheme.insert(list(clr_scheme).index(child), slot_elm)
|
|
720
|
+
return
|
|
721
|
+
clr_scheme.append(slot_elm)
|