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/dml/line.py ADDED
@@ -0,0 +1,287 @@
1
+ """DrawingML objects related to line formatting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pptx2.dml.color import _LazyColorFormat
6
+ from pptx2.dml.fill import FillFormat
7
+ from pptx2.enum.dml import MSO_LINE_JOIN_STYLE
8
+ from pptx2.oxml.ns import qn
9
+ from pptx2.util import Emu, lazyproperty
10
+
11
+
12
+ _JOIN_TAG = {
13
+ MSO_LINE_JOIN_STYLE.ROUND: "a:round",
14
+ MSO_LINE_JOIN_STYLE.BEVEL: "a:bevel",
15
+ MSO_LINE_JOIN_STYLE.MITER: "a:miter",
16
+ }
17
+ _JOIN_FROM_TAG = {qn(tag): join for join, tag in _JOIN_TAG.items()}
18
+
19
+
20
+ class LineEndFormat(object):
21
+ """Provides access to one end (head or tail) of a stroked line.
22
+
23
+ Wraps an ``<a:headEnd>`` or ``<a:tailEnd>`` element. The element is
24
+ created lazily; assigning |None| to a property removes the corresponding
25
+ attribute, and removing the last attribute drops the element entirely so
26
+ inheritance is not silently broken.
27
+ """
28
+
29
+ def __init__(self, line_format, end_tag):
30
+ # `end_tag` is "headEnd" or "tailEnd"
31
+ self._line_format = line_format
32
+ self._end_tag = end_tag
33
+
34
+ @property
35
+ def type(self):
36
+ """Arrowhead type as :ref:`MsoLineEndType`, or |None| if not set."""
37
+ end = self._end
38
+ if end is None:
39
+ return None
40
+ return end.type
41
+
42
+ @type.setter
43
+ def type(self, value):
44
+ self._set_attr("type", value)
45
+
46
+ @property
47
+ def width(self):
48
+ """Arrowhead width as :ref:`MsoLineEndSize`, or |None| if not set."""
49
+ end = self._end
50
+ if end is None:
51
+ return None
52
+ return end.w
53
+
54
+ @width.setter
55
+ def width(self, value):
56
+ self._set_attr("w", value)
57
+
58
+ @property
59
+ def length(self):
60
+ """Arrowhead length as :ref:`MsoLineEndSize`, or |None| if not set."""
61
+ end = self._end
62
+ if end is None:
63
+ return None
64
+ return end.len
65
+
66
+ @length.setter
67
+ def length(self, value):
68
+ self._set_attr("len", value)
69
+
70
+ @property
71
+ def _end(self):
72
+ ln = self._line_format._ln
73
+ if ln is None:
74
+ return None
75
+ return getattr(ln, self._end_tag)
76
+
77
+ def _set_attr(self, attr_name, value):
78
+ if value is None:
79
+ end = self._end
80
+ if end is None:
81
+ return
82
+ end.attrib.pop(attr_name, None)
83
+ if not end.attrib:
84
+ ln = self._line_format._ln
85
+ if ln is not None:
86
+ getattr(ln, "_remove_%s" % self._end_tag)()
87
+ return
88
+ ln = self._line_format._get_or_add_ln()
89
+ end = getattr(ln, "get_or_add_%s" % self._end_tag)()
90
+ setattr(end, attr_name, value)
91
+
92
+
93
+ class LineFormat(object):
94
+ """Provides access to line properties such as color, style, and width.
95
+
96
+ A LineFormat object is typically accessed via the ``.line`` property of
97
+ a shape such as |Shape| or |Picture|.
98
+ """
99
+
100
+ def __init__(self, parent):
101
+ super(LineFormat, self).__init__()
102
+ self._parent = parent
103
+
104
+ @lazyproperty
105
+ def color(self):
106
+ """The color settings for this line; a shortcut for ``line.fill.fore_color``.
107
+
108
+ Reads are non-mutating: when no explicit ``<a:ln>`` element exists or its
109
+ fill is not solid, accessing color properties returns the "no explicit
110
+ color" sentinel (preserving theme inheritance) instead of injecting line
111
+ and fill XML. The line element and a solid fill are only created when
112
+ ``rgb`` or ``theme_color`` is assigned.
113
+ """
114
+ return _LazyColorFormat(peek_fill=self._peek_fill, ensure_fill=lambda: self.fill)
115
+
116
+ def _peek_fill(self):
117
+ """Return |FillFormat| for the current ``<a:ln>`` element, or |None|.
118
+
119
+ Read-only: never injects an ``<a:ln>`` element if one is not already
120
+ present.
121
+ """
122
+ ln = self._ln
123
+ if ln is None:
124
+ return None
125
+ return FillFormat.from_fill_parent(ln)
126
+
127
+ @property
128
+ def dash_style(self):
129
+ """Return value indicating line style.
130
+
131
+ Returns a member of :ref:`MsoLineDashStyle` indicating line style, or
132
+ |None| if no explicit value has been set. When no explicit value has
133
+ been set, the line dash style is inherited from the style hierarchy.
134
+
135
+ Assigning |None| removes any existing explicitly-defined dash style.
136
+ """
137
+ ln = self._ln
138
+ if ln is None:
139
+ return None
140
+ return ln.prstDash_val
141
+
142
+ @dash_style.setter
143
+ def dash_style(self, dash_style):
144
+ if dash_style is None:
145
+ ln = self._ln
146
+ if ln is None:
147
+ return
148
+ ln._remove_prstDash()
149
+ ln._remove_custDash()
150
+ return
151
+ ln = self._get_or_add_ln()
152
+ ln.prstDash_val = dash_style
153
+
154
+ @lazyproperty
155
+ def fill(self):
156
+ """
157
+ |FillFormat| instance for this line, providing access to fill
158
+ properties such as foreground color.
159
+ """
160
+ ln = self._get_or_add_ln()
161
+ return FillFormat.from_fill_parent(ln)
162
+
163
+ @property
164
+ def width(self):
165
+ """
166
+ The width of the line expressed as an integer number of :ref:`English
167
+ Metric Units <EMU>`. The returned value is an instance of |Length|,
168
+ a value class having properties such as `.inches`, `.cm`, and `.pt`
169
+ for converting the value into convenient units.
170
+ """
171
+ ln = self._ln
172
+ if ln is None:
173
+ return Emu(0)
174
+ return ln.w
175
+
176
+ @width.setter
177
+ def width(self, emu):
178
+ if emu is None:
179
+ emu = 0
180
+ ln = self._get_or_add_ln()
181
+ ln.w = emu
182
+
183
+ @property
184
+ def cap(self):
185
+ """End-cap style as :ref:`MsoLineCapStyle`, or |None| if unset.
186
+
187
+ Maps to the ``cap`` attribute on ``<a:ln>``. Reads are non-mutating:
188
+ no ``<a:ln>`` element is created if one doesn't already exist.
189
+ """
190
+ ln = self._ln
191
+ if ln is None:
192
+ return None
193
+ return ln.cap
194
+
195
+ @cap.setter
196
+ def cap(self, value):
197
+ if value is None:
198
+ ln = self._ln
199
+ if ln is None:
200
+ return
201
+ ln.cap = None
202
+ return
203
+ ln = self._get_or_add_ln()
204
+ ln.cap = value
205
+
206
+ @property
207
+ def compound(self):
208
+ """Compound (multi-stroke) style as :ref:`MsoLineCompoundStyle`, or |None|.
209
+
210
+ Maps to the ``cmpd`` attribute on ``<a:ln>``.
211
+ """
212
+ ln = self._ln
213
+ if ln is None:
214
+ return None
215
+ return ln.cmpd
216
+
217
+ @compound.setter
218
+ def compound(self, value):
219
+ if value is None:
220
+ ln = self._ln
221
+ if ln is None:
222
+ return
223
+ ln.cmpd = None
224
+ return
225
+ ln = self._get_or_add_ln()
226
+ ln.cmpd = value
227
+
228
+ @property
229
+ def join(self):
230
+ """Corner-join style as :ref:`MsoLineJoinStyle`, or |None| if unset.
231
+
232
+ Returns whichever of ``<a:round/>``, ``<a:bevel/>``, or ``<a:miter/>``
233
+ is currently a child of ``<a:ln>``. Assigning |None| removes any
234
+ existing join element; assigning a member writes the matching
235
+ element (replacing any other join element if present).
236
+ """
237
+ ln = self._ln
238
+ if ln is None:
239
+ return None
240
+ join_elm = ln.eg_lineJoinProperties
241
+ if join_elm is None:
242
+ return None
243
+ return _JOIN_FROM_TAG.get(join_elm.tag)
244
+
245
+ @join.setter
246
+ def join(self, value):
247
+ if value is None:
248
+ ln = self._ln
249
+ if ln is None:
250
+ return
251
+ ln._remove_eg_lineJoinProperties()
252
+ return
253
+ if value not in _JOIN_TAG:
254
+ raise ValueError("invalid line-join style %r" % (value,))
255
+ ln = self._get_or_add_ln()
256
+ ln._remove_eg_lineJoinProperties()
257
+ # -- ZeroOrOneChoice's `_add_x` writes the child in the right slot --
258
+ getattr(ln, "_add_%s" % _JOIN_TAG[value].split(":", 1)[1])()
259
+
260
+ @lazyproperty
261
+ def head_end(self):
262
+ """:class:`LineEndFormat` for the start (head) of this line.
263
+
264
+ Provides access to the ``<a:headEnd>`` element's ``type``, ``width``,
265
+ and ``length`` attributes.
266
+ """
267
+ return LineEndFormat(self, "headEnd")
268
+
269
+ @lazyproperty
270
+ def tail_end(self):
271
+ """:class:`LineEndFormat` for the end (tail) of this line.
272
+
273
+ Provides access to the ``<a:tailEnd>`` element's ``type``, ``width``,
274
+ and ``length`` attributes.
275
+ """
276
+ return LineEndFormat(self, "tailEnd")
277
+
278
+ def _get_or_add_ln(self):
279
+ """
280
+ Return the ``<a:ln>`` element containing the line format properties
281
+ in the XML.
282
+ """
283
+ return self._parent.get_or_add_ln()
284
+
285
+ @property
286
+ def _ln(self):
287
+ return self._parent.ln
pptx2/dml/picture.py ADDED
@@ -0,0 +1,212 @@
1
+ """Picture image effects: transparency, brightness, contrast, and recolor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from pptx2.dml.color import RGBColor
9
+ from pptx2.oxml.dml.fill import CT_Blip
10
+
11
+
12
+ # Sepia duotone: dark warm brown → light cream, matching PowerPoint's built-in preset.
13
+ _SEPIA_DARK = "532400"
14
+ _SEPIA_LIGHT = "FFEFCA"
15
+
16
+
17
+ class PictureEffects:
18
+ """Provides access to image-level visual effects on a |Picture| shape.
19
+
20
+ Exposes transparency (alpha), brightness, contrast, and recolor. All reads
21
+ are non-mutating — if no explicit effect is present the property returns a
22
+ neutral sentinel value (``0.0`` for numeric adjustments, ``None`` for recolor).
23
+ Writes lazily create the required ``<a:blip>`` child elements.
24
+
25
+ Access via :attr:`pptx2.shapes.picture.Picture.effects`.
26
+ """
27
+
28
+ def __init__(self, blip: CT_Blip):
29
+ self._blip = blip
30
+
31
+ # ------------------------------------------------------------------
32
+ # transparency
33
+ # ------------------------------------------------------------------
34
+
35
+ @property
36
+ def transparency(self) -> float:
37
+ """Read/write float in ``[0.0, 1.0]``: ``0.0`` = fully opaque (default), ``1.0`` = invisible.
38
+
39
+ Maps to ``<a:alphaModFix amt="N"/>`` where ``amt`` is the *remaining* opacity
40
+ (``100000`` = fully opaque). Setting to ``0.0`` removes the element entirely,
41
+ restoring full opacity.
42
+ """
43
+ alphaModFix = self._blip.alphaModFix
44
+ if alphaModFix is None:
45
+ return 0.0
46
+ amt = alphaModFix.amt
47
+ return round(1.0 - amt, 10)
48
+
49
+ @transparency.setter
50
+ def transparency(self, value: float) -> None:
51
+ if not 0.0 <= value <= 1.0:
52
+ raise ValueError(f"transparency must be between 0.0 and 1.0, got {value!r}")
53
+ if value == 0.0:
54
+ self._blip._remove_alphaModFix() # pyright: ignore[reportPrivateUsage]
55
+ else:
56
+ self._blip.get_or_add_alphaModFix().amt = 1.0 - value # type: ignore[assignment]
57
+
58
+ # ------------------------------------------------------------------
59
+ # brightness
60
+ # ------------------------------------------------------------------
61
+
62
+ @property
63
+ def brightness(self) -> float:
64
+ """Read/write float in ``[-1.0, 1.0]``: ``0.0`` = no change (default).
65
+
66
+ Maps to the ``bright`` attribute of ``<a:lum>``. A value of ``0.4``
67
+ means +40% brightness; ``-0.25`` means 25% darker.
68
+ """
69
+ lum = self._blip.lum
70
+ return 0.0 if lum is None else lum.bright
71
+
72
+ @brightness.setter
73
+ def brightness(self, value: float) -> None:
74
+ if not -1.0 <= value <= 1.0:
75
+ raise ValueError(f"brightness must be between -1.0 and 1.0, got {value!r}")
76
+ if value == 0.0 and self._blip.lum is None:
77
+ return # -- "no change" on a picture with no `a:lum`: nothing to write
78
+ lum = self._blip.get_or_add_lum()
79
+ lum.bright = value # type: ignore[assignment]
80
+ self._drop_lum_if_neutral()
81
+
82
+ # ------------------------------------------------------------------
83
+ # contrast
84
+ # ------------------------------------------------------------------
85
+
86
+ @property
87
+ def contrast(self) -> float:
88
+ """Read/write float in ``[-1.0, 1.0]``: ``0.0`` = no change (default).
89
+
90
+ Maps to the ``contrast`` attribute of ``<a:lum>``. Shares the same
91
+ ``<a:lum>`` element as :attr:`brightness`.
92
+ """
93
+ lum = self._blip.lum
94
+ return 0.0 if lum is None else lum.contrast
95
+
96
+ @contrast.setter
97
+ def contrast(self, value: float) -> None:
98
+ if not -1.0 <= value <= 1.0:
99
+ raise ValueError(f"contrast must be between -1.0 and 1.0, got {value!r}")
100
+ if value == 0.0 and self._blip.lum is None:
101
+ return # -- "no change" on a picture with no `a:lum`: nothing to write
102
+ lum = self._blip.get_or_add_lum()
103
+ lum.contrast = value # type: ignore[assignment]
104
+ self._drop_lum_if_neutral()
105
+
106
+ def _drop_lum_if_neutral(self) -> None:
107
+ """Remove `a:lum` once both adjustments are back at their defaults.
108
+
109
+ Writing ``0.0`` drops the corresponding attribute (the schema
110
+ default), so a lum whose adjustments are both reset would otherwise
111
+ linger as a dead empty ``<a:lum/>``.
112
+ """
113
+ lum = self._blip.lum
114
+ if lum is not None and lum.get("bright") is None and lum.get("contrast") is None:
115
+ self._blip.remove(lum)
116
+
117
+ # ------------------------------------------------------------------
118
+ # recolor
119
+ # ------------------------------------------------------------------
120
+
121
+ @property
122
+ def recolor(self) -> str | None:
123
+ """Read/write recolor mode. One of ``"grayscale"``, ``"washout"``,
124
+ ``"sepia"``, ``"duotone"``, or ``None`` (no recolor).
125
+
126
+ Assigning a value removes any previously applied recolor before
127
+ applying the new one. Setting to ``None`` clears recolor entirely.
128
+ For custom duotone colors use :meth:`set_duotone`.
129
+ """
130
+ if self._blip.grayscl is not None:
131
+ return "grayscale"
132
+ biLevel = self._blip.biLevel
133
+ if biLevel is not None:
134
+ return "washout"
135
+ duotone = self._blip.duotone
136
+ if duotone is not None:
137
+ children = list(duotone)
138
+ if len(children) == 2:
139
+ from pptx2.oxml.ns import qn
140
+
141
+ vals = [c.get("val") for c in children if c.tag == qn("a:srgbClr")]
142
+ if vals == [_SEPIA_DARK, _SEPIA_LIGHT]:
143
+ return "sepia"
144
+ return "duotone"
145
+ return None
146
+
147
+ @recolor.setter
148
+ def recolor(self, value: str | None) -> None:
149
+ # Validate before mutating so an invalid value leaves the existing effect intact.
150
+ _VALID_RECOLOR = frozenset({"grayscale", "washout", "sepia", "duotone"})
151
+ if value is not None and value not in _VALID_RECOLOR:
152
+ raise ValueError(
153
+ f"recolor must be 'grayscale', 'washout', 'sepia', 'duotone', or None; got {value!r}"
154
+ )
155
+ self._clear_recolor()
156
+ if value is None:
157
+ return
158
+ if value == "grayscale":
159
+ self._blip.get_or_add_grayscl()
160
+ elif value == "washout":
161
+ self._blip.get_or_add_biLevel().thresh = 0.5 # type: ignore[assignment]
162
+ elif value == "sepia":
163
+ self.set_duotone(_SEPIA_DARK, _SEPIA_LIGHT)
164
+ elif value == "duotone":
165
+ # Default neutral duotone (dark grey → light grey).
166
+ # For custom colors use set_duotone() instead.
167
+ self.set_duotone("333333", "EBEBEB")
168
+
169
+ def set_duotone(self, dark_color: RGBColor | tuple, light_color: RGBColor | tuple) -> None:
170
+ """Apply a duotone recolor using custom dark and light colors.
171
+
172
+ Each color can be an :class:`~pptx2.dml.color.RGBColor` instance or a
173
+ 3-tuple of ``(r, g, b)`` integers. The dark color maps to shadows, the
174
+ light color to highlights.
175
+ """
176
+ self._clear_recolor()
177
+ from lxml import etree
178
+
179
+ from pptx2.oxml.ns import qn
180
+
181
+ duotone = self._blip.get_or_add_duotone()
182
+ for child in list(duotone):
183
+ duotone.remove(child)
184
+ for hex_val in (_color_to_hex(dark_color), _color_to_hex(light_color)):
185
+ clr = etree.SubElement(duotone, qn("a:srgbClr"))
186
+ clr.set("val", hex_val)
187
+
188
+ # ------------------------------------------------------------------
189
+ # internals
190
+ # ------------------------------------------------------------------
191
+
192
+ def _clear_recolor(self) -> None:
193
+ """Remove any existing recolor effects from the blip."""
194
+ self._blip._remove_grayscl() # pyright: ignore[reportPrivateUsage]
195
+ self._blip._remove_biLevel() # pyright: ignore[reportPrivateUsage]
196
+ self._blip._remove_duotone() # pyright: ignore[reportPrivateUsage]
197
+
198
+
199
+ def _color_to_hex(color: RGBColor | tuple | str) -> str:
200
+ """Return uppercase 6-char hex string for *color*.
201
+
202
+ Accepts an :class:`~pptx2.dml.color.RGBColor`, a hex string (with or
203
+ without ``#``), or a 3-tuple of ``(r, g, b)`` integers.
204
+ """
205
+ from pptx2.dml.color import RGBColor as _RGBColor
206
+
207
+ if isinstance(color, _RGBColor):
208
+ return str(color).upper()
209
+ if isinstance(color, str):
210
+ return color.upper().lstrip("#")
211
+ r, g, b = color
212
+ return f"{r:02X}{g:02X}{b:02X}"