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/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)