python-pptx2 2.13.0__py3-none-any.whl → 2.15.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 (41) hide show
  1. pptx2/__init__.py +4 -1
  2. pptx2/_slide_importer.py +35 -21
  3. pptx2/api.py +11 -5
  4. pptx2/chart/data.py +17 -0
  5. pptx2/chart/plot.py +19 -0
  6. pptx2/chart/xmlwriter.py +11 -2
  7. pptx2/enum/text.py +72 -0
  8. pptx2/math.py +89 -37
  9. pptx2/mathml2omml.py +1163 -0
  10. pptx2/opc/package.py +12 -11
  11. pptx2/opc/serialized.py +23 -14
  12. pptx2/oxml/__init__.py +6 -0
  13. pptx2/oxml/chart/plot.py +13 -0
  14. pptx2/oxml/customprops.py +170 -0
  15. pptx2/oxml/ns.py +3 -0
  16. pptx2/oxml/presentation.py +47 -0
  17. pptx2/oxml/simpletypes.py +12 -0
  18. pptx2/oxml/slide.py +68 -2
  19. pptx2/oxml/text.py +34 -0
  20. pptx2/package.py +14 -2
  21. pptx2/parts/customprops.py +129 -0
  22. pptx2/parts/image.py +7 -5
  23. pptx2/parts/presentation.py +21 -6
  24. pptx2/parts/slide.py +77 -6
  25. pptx2/presentation.py +15 -2
  26. pptx2/shapes/shapetree.py +48 -7
  27. pptx2/skill/SKILL.md +1 -1
  28. pptx2/skill/references/basics.md +30 -0
  29. pptx2/skill/references/charts.md +1 -0
  30. pptx2/skill/references/math.md +6 -4
  31. pptx2/skill/references/tables.md +10 -0
  32. pptx2/slide.py +104 -0
  33. pptx2/table.py +193 -1
  34. pptx2/text/text.py +137 -7
  35. pptx2/util.py +3 -5
  36. {python_pptx2-2.13.0.dist-info → python_pptx2-2.15.0.dist-info}/METADATA +3 -3
  37. {python_pptx2-2.13.0.dist-info → python_pptx2-2.15.0.dist-info}/RECORD +41 -38
  38. {python_pptx2-2.13.0.dist-info → python_pptx2-2.15.0.dist-info}/WHEEL +0 -0
  39. {python_pptx2-2.13.0.dist-info → python_pptx2-2.15.0.dist-info}/entry_points.txt +0 -0
  40. {python_pptx2-2.13.0.dist-info → python_pptx2-2.15.0.dist-info}/licenses/LICENSE +0 -0
  41. {python_pptx2-2.13.0.dist-info → python_pptx2-2.15.0.dist-info}/top_level.txt +0 -0
pptx2/__init__.py CHANGED
@@ -35,6 +35,7 @@ from pptx2.opc.constants import CONTENT_TYPE as CT
35
35
  from pptx2.opc.package import PartFactory
36
36
  from pptx2.parts.chart import ChartPart
37
37
  from pptx2.parts.coreprops import CorePropertiesPart
38
+ from pptx2.parts.customprops import CustomPropertiesPart
38
39
  from pptx2.parts.diagram import (
39
40
  DiagramColorsPart,
40
41
  DiagramDataPart,
@@ -56,7 +57,7 @@ from pptx2.parts.slide import (
56
57
  if TYPE_CHECKING:
57
58
  from pptx2.opc.package import Part
58
59
 
59
- __version__ = "2.13.0"
60
+ __version__ = "2.15.0"
60
61
 
61
62
  sys.modules["pptx2.exceptions"] = exceptions
62
63
  del sys
@@ -98,6 +99,7 @@ content_type_to_part_class_map: dict[str, type[Part]] = {
98
99
  CT.PML_TEMPLATE_MAIN: PresentationPart,
99
100
  CT.PML_SLIDESHOW_MAIN: PresentationPart,
100
101
  CT.OPC_CORE_PROPERTIES: CorePropertiesPart,
102
+ CT.OFC_CUSTOM_PROPERTIES: CustomPropertiesPart,
101
103
  CT.PML_NOTES_MASTER: NotesMasterPart,
102
104
  CT.PML_NOTES_SLIDE: NotesSlidePart,
103
105
  CT.PML_SLIDE: SlidePart,
@@ -136,6 +138,7 @@ PartFactory.part_type_for.update(content_type_to_part_class_map)
136
138
  del (
137
139
  ChartPart,
138
140
  CorePropertiesPart,
141
+ CustomPropertiesPart,
139
142
  DiagramColorsPart,
140
143
  DiagramDataPart,
141
144
  DiagramLayoutPart,
pptx2/_slide_importer.py CHANGED
@@ -93,6 +93,37 @@ def import_slide(
93
93
  return importer.run()
94
94
 
95
95
 
96
+ def next_layout_hierarchy_id(prs_part: PresentationPart) -> int:
97
+ """Return a fresh unique id for a `p:sldMasterId` / `p:sldLayoutId` entry.
98
+
99
+ These share one id space starting at 2147483648 (ST_SlideMasterId /
100
+ ST_SlideLayoutId minimum); duplicates across the presentation are a
101
+ repair trigger, so scan every master's layout-id list plus the
102
+ presentation's master-id list for the current maximum. Used both when
103
+ cloning masters (import_slide) and when adding or cloning slide layouts.
104
+ """
105
+ used = [2147483647]
106
+ prs_element = prs_part._element # pyright: ignore[reportPrivateUsage]
107
+ used += [int(v) for v in prs_element.xpath("p:sldMasterIdLst/p:sldMasterId/@id")]
108
+ for dst_master_part in _iter_master_parts(prs_part):
109
+ used += [
110
+ int(v)
111
+ for v in dst_master_part._element.xpath( # pyright: ignore[reportPrivateUsage]
112
+ "p:sldLayoutIdLst/p:sldLayoutId/@id"
113
+ )
114
+ ]
115
+ return max(used) + 1
116
+
117
+
118
+ def _iter_master_parts(prs_part: PresentationPart):
119
+ """Yield each SlideMasterPart referenced from *prs_part*'s `p:sldMasterIdLst`."""
120
+ prs_element = prs_part._element # pyright: ignore[reportPrivateUsage]
121
+ if prs_element.sldMasterIdLst is None:
122
+ return
123
+ for entry in prs_element.sldMasterIdLst.sldMasterId_lst:
124
+ yield prs_part.related_part(entry.rId)
125
+
126
+
96
127
  # ---------------------------------------------------------------------------
97
128
  # Internal implementation
98
129
  # ---------------------------------------------------------------------------
@@ -169,13 +200,9 @@ class _SlideImporter:
169
200
  return dst_master_part
170
201
  return None
171
202
 
172
- def _iter_dst_masters(self): # type: ignore[return]
203
+ def _iter_dst_masters(self):
173
204
  """Yield each SlideMasterPart already in the destination presentation."""
174
- prs_element = self._dst_prs_part._element # pyright: ignore[reportPrivateUsage]
175
- if prs_element.sldMasterIdLst is None:
176
- return
177
- for entry in prs_element.sldMasterIdLst.sldMasterId_lst:
178
- yield self._dst_prs_part.related_part(entry.rId)
205
+ yield from _iter_master_parts(self._dst_prs_part)
179
206
 
180
207
  def _find_or_clone_layout_in_master(
181
208
  self, src_layout_part: SlideLayoutPart, dst_master_part: SlideMasterPart
@@ -325,22 +352,9 @@ class _SlideImporter:
325
352
  def _next_hierarchy_id(self) -> int:
326
353
  """Return a fresh unique id for a `p:sldMasterId` / `p:sldLayoutId` entry.
327
354
 
328
- These share one id space starting at 2147483648 (ST_SlideMasterId /
329
- ST_SlideLayoutId minimum); duplicates across the presentation are a
330
- repair trigger, so scan every master's layout-id list plus the
331
- presentation's master-id list for the current maximum.
355
+ See :func:`next_layout_hierarchy_id`.
332
356
  """
333
- used = [2147483647]
334
- prs_element = self._dst_prs_part._element # pyright: ignore[reportPrivateUsage]
335
- used += [int(v) for v in prs_element.xpath("p:sldMasterIdLst/p:sldMasterId/@id")]
336
- for dst_master_part in self._iter_dst_masters():
337
- used += [
338
- int(v)
339
- for v in dst_master_part._element.xpath( # pyright: ignore[reportPrivateUsage]
340
- "p:sldLayoutIdLst/p:sldLayoutId/@id"
341
- )
342
- ]
343
- return max(used) + 1
357
+ return next_layout_hierarchy_id(self._dst_prs_part)
344
358
 
345
359
  # ------------------------------------------------------------------
346
360
  # Slide copy
pptx2/api.py CHANGED
@@ -18,12 +18,14 @@ if TYPE_CHECKING:
18
18
  from pptx2.parts.presentation import PresentationPart
19
19
 
20
20
 
21
- def Presentation(pptx: str | IO[bytes] | None = None) -> presentation.Presentation:
21
+ def Presentation(
22
+ pptx: str | os.PathLike[str] | IO[bytes] | None = None,
23
+ ) -> presentation.Presentation:
22
24
  """
23
25
  Return a |Presentation| object loaded from *pptx*, where *pptx* can be
24
- either a path to a ``.pptx`` file (a string) or a file-like object. If
25
- *pptx* is missing or ``None``, the built-in default presentation
26
- "template" is loaded.
26
+ either a path to a ``.pptx`` file (a string or ``pathlib.Path``) or a
27
+ file-like object. If *pptx* is missing or ``None``, the built-in default
28
+ presentation "template" is loaded.
27
29
  """
28
30
  if pptx is None:
29
31
  pptx = _default_pptx_path()
@@ -45,5 +47,9 @@ def _default_pptx_path() -> str:
45
47
 
46
48
  def _is_pptx_package(prs_part: PresentationPart):
47
49
  """Return |True| if *prs_part* is a valid main document part, |False| otherwise."""
48
- valid_content_types = (CT.PML_PRESENTATION_MAIN, CT.PML_PRES_MACRO_MAIN)
50
+ valid_content_types = (
51
+ CT.PML_PRESENTATION_MAIN,
52
+ CT.PML_PRES_MACRO_MAIN,
53
+ CT.PML_TEMPLATE_MAIN,
54
+ )
49
55
  return prs_part.content_type in valid_content_types
pptx2/chart/data.py CHANGED
@@ -12,6 +12,7 @@ from pptx2.chart.xlsx import (
12
12
  XyWorkbookWriter,
13
13
  )
14
14
  from pptx2.chart.xmlwriter import ChartXmlWriter
15
+ from pptx2.oxml.simpletypes import ST_FirstSliceAng
15
16
  from pptx2.util import lazyproperty
16
17
 
17
18
 
@@ -268,6 +269,8 @@ class CategoryChartData(_BaseChartData):
268
269
  The corresponding X value is inferred by its position in the sequence.
269
270
  """
270
271
 
272
+ _first_slice_angle = 0
273
+
271
274
  def add_category(self, label):
272
275
  """
273
276
  Return a newly created |data.Category| object having *label* and
@@ -323,6 +326,20 @@ class CategoryChartData(_BaseChartData):
323
326
  """
324
327
  return self._workbook_writer.categories_ref
325
328
 
329
+ @property
330
+ def first_slice_angle(self):
331
+ """
332
+ Read/write int in range 0..359 specifying the angle in degrees of
333
+ the first slice for a doughnut chart created from this chart data,
334
+ clockwise from 12 o'clock. The default is 0, the PowerPoint default.
335
+ """
336
+ return self._first_slice_angle
337
+
338
+ @first_slice_angle.setter
339
+ def first_slice_angle(self, value):
340
+ ST_FirstSliceAng.validate(value)
341
+ self._first_slice_angle = int(value)
342
+
326
343
  def values_ref(self, series):
327
344
  """
328
345
  The Excel worksheet reference to the values for *series* (not
pptx2/chart/plot.py CHANGED
@@ -220,6 +220,25 @@ class DoughnutPlot(_BasePlot):
220
220
  holeSize = self._element.get_or_add_holeSize()
221
221
  holeSize.val = value
222
222
 
223
+ @property
224
+ def first_slice_angle(self):
225
+ """
226
+ Read/write int in range 0..359 specifying the angle in degrees of
227
+ the first slice of this doughnut, clockwise from 12 o'clock. The
228
+ PowerPoint default for a new doughnut chart is 0, which is what this
229
+ property reports when no explicit ``<c:firstSliceAng>`` element is
230
+ present.
231
+ """
232
+ firstSliceAng = self._element.firstSliceAng
233
+ if firstSliceAng is None:
234
+ return 0
235
+ return firstSliceAng.val
236
+
237
+ @first_slice_angle.setter
238
+ def first_slice_angle(self, value):
239
+ firstSliceAng = self._element.get_or_add_firstSliceAng()
240
+ firstSliceAng.val = value
241
+
223
242
 
224
243
  class LinePlot(_BasePlot):
225
244
  """
pptx2/chart/xmlwriter.py CHANGED
@@ -669,7 +669,7 @@ class _DoughnutChartXmlWriter(_BaseChartXmlWriter):
669
669
  ' <c:showBubbleSize val="0"/>\n'
670
670
  ' <c:showLeaderLines val="1"/>\n'
671
671
  " </c:dLbls>\n"
672
- ' <c:firstSliceAng val="0"/>\n'
672
+ ' <c:firstSliceAng val="{firstSliceAng}"/>\n'
673
673
  ' <c:holeSize val="50"/>\n'
674
674
  " </c:doughnutChart>\n"
675
675
  " </c:plotArea>\n"
@@ -693,7 +693,7 @@ class _DoughnutChartXmlWriter(_BaseChartXmlWriter):
693
693
  " </a:p>\n"
694
694
  " </c:txPr>\n"
695
695
  "</c:chartSpace>\n"
696
- ).format(**{"ser_xml": self._ser_xml})
696
+ ).format(**{"ser_xml": self._ser_xml, "firstSliceAng": self._firstSliceAng})
697
697
 
698
698
  @property
699
699
  def _explosion_xml(self):
@@ -701,6 +701,15 @@ class _DoughnutChartXmlWriter(_BaseChartXmlWriter):
701
701
  return ' <c:explosion val="25"/>\n'
702
702
  return ""
703
703
 
704
+ @property
705
+ def _firstSliceAng(self):
706
+ """
707
+ The integer angle in degrees for the ``<c:firstSliceAng>`` element,
708
+ taken from the chart data object when it specifies one and 0
709
+ (the PowerPoint default) otherwise.
710
+ """
711
+ return getattr(self._chart_data, "first_slice_angle", 0)
712
+
704
713
  @property
705
714
  def _ser_xml(self):
706
715
  xml = ""
pptx2/enum/text.py CHANGED
@@ -228,3 +228,75 @@ class PP_PARAGRAPH_ALIGNMENT(BaseXmlEnum):
228
228
 
229
229
 
230
230
  PP_ALIGN = PP_PARAGRAPH_ALIGNMENT
231
+
232
+
233
+ class MSO_TEXT_FIELD_TYPE(BaseXmlEnum):
234
+ """Specifies the kind of value a text field (`a:fld`) displays.
235
+
236
+ Used with :meth:`.Paragraph.add_field` to add a run-like field element to a paragraph.
237
+ The field's displayed text (the actual slide number, the current date, ...) is computed by
238
+ PowerPoint when the slide is rendered; the `a:t` child element holds only the "cached"
239
+ placeholder text readers that do not compute fields will show.
240
+
241
+ Example::
242
+
243
+ from pptx2.enum.text import MSO_TEXT_FIELD_TYPE
244
+
245
+ paragraph.add_field(MSO_TEXT_FIELD_TYPE.SLIDE_NUMBER)
246
+
247
+ The XML tokens are the ST_TextFieldType values DrawingML defines ("slidenum", "datetime",
248
+ "datetime1" through "datetime13") plus "datetimeFigureOut", the token PowerPoint writes for
249
+ an automatically updated date.
250
+ """
251
+
252
+ SLIDE_NUMBER = (1, "slidenum", "The number of the slide the field appears on.")
253
+ """The number of the slide the field appears on."""
254
+
255
+ DATETIME = (2, "datetime", "The date and time in the containing document's default format.")
256
+ """The date and time in the containing document's default format."""
257
+
258
+ DATETIME_1 = (3, "datetime1", "Predefined date/time format 1 (locale-dependent rendering).")
259
+ """Predefined date/time format 1 (locale-dependent rendering)."""
260
+
261
+ DATETIME_2 = (4, "datetime2", "Predefined date/time format 2 (locale-dependent rendering).")
262
+ """Predefined date/time format 2 (locale-dependent rendering)."""
263
+
264
+ DATETIME_3 = (5, "datetime3", "Predefined date/time format 3 (locale-dependent rendering).")
265
+ """Predefined date/time format 3 (locale-dependent rendering)."""
266
+
267
+ DATETIME_4 = (6, "datetime4", "Predefined date/time format 4 (locale-dependent rendering).")
268
+ """Predefined date/time format 4 (locale-dependent rendering)."""
269
+
270
+ DATETIME_5 = (7, "datetime5", "Predefined date/time format 5 (locale-dependent rendering).")
271
+ """Predefined date/time format 5 (locale-dependent rendering)."""
272
+
273
+ DATETIME_6 = (8, "datetime6", "Predefined date/time format 6 (locale-dependent rendering).")
274
+ """Predefined date/time format 6 (locale-dependent rendering)."""
275
+
276
+ DATETIME_7 = (9, "datetime7", "Predefined date/time format 7 (locale-dependent rendering).")
277
+ """Predefined date/time format 7 (locale-dependent rendering)."""
278
+
279
+ DATETIME_8 = (10, "datetime8", "Predefined date/time format 8 (locale-dependent rendering).")
280
+ """Predefined date/time format 8 (locale-dependent rendering)."""
281
+
282
+ DATETIME_9 = (11, "datetime9", "Predefined date/time format 9 (locale-dependent rendering).")
283
+ """Predefined date/time format 9 (locale-dependent rendering)."""
284
+
285
+ DATETIME_10 = (12, "datetime10", "Predefined date/time format 10 (locale-dependent rendering).")
286
+ """Predefined date/time format 10 (locale-dependent rendering)."""
287
+
288
+ DATETIME_11 = (13, "datetime11", "Predefined date/time format 11 (locale-dependent rendering).")
289
+ """Predefined date/time format 11 (locale-dependent rendering)."""
290
+
291
+ DATETIME_12 = (14, "datetime12", "Predefined date/time format 12 (locale-dependent rendering).")
292
+ """Predefined date/time format 12 (locale-dependent rendering)."""
293
+
294
+ DATETIME_13 = (15, "datetime13", "Predefined date/time format 13 (locale-dependent rendering).")
295
+ """Predefined date/time format 13 (locale-dependent rendering)."""
296
+
297
+ DATETIME_FIGURE_OUT = (
298
+ 16,
299
+ "datetimeFigureOut",
300
+ "The current date, in the format PowerPoint figures out automatically.",
301
+ )
302
+ """The current date, in the format PowerPoint figures out automatically."""
pptx2/math.py CHANGED
@@ -2,10 +2,11 @@
2
2
 
3
3
  PowerPoint stores editable equations as Office Math (OMML) wrapped in an
4
4
  ``a14:m`` marker inside a DrawingML paragraph. This module does not compile
5
- LaTeX itself: it calls ``latex2mathml`` then ``mathml2omml`` and wraps the
6
- result so PowerPoint will open it.
5
+ LaTeX itself: ``latex2mathml`` turns the fragment into MathML and the
6
+ bundled ``pptx2.mathml2omml`` port (from mathml2omml-plus / ECMA-376 §7.1)
7
+ turns that into Office Math.
7
8
 
8
- Install the converters with::
9
+ Install the LaTeX front-end with::
9
10
 
10
11
  pip install "python-pptx2[math]"
11
12
  """
@@ -17,6 +18,7 @@ from typing import TYPE_CHECKING, Any
17
18
 
18
19
  from lxml import etree
19
20
 
21
+ from pptx2.mathml2omml import convert as mathml_to_omml
20
22
  from pptx2.oxml import parse_xml
21
23
  from pptx2.oxml.ns import nsuri, qn
22
24
 
@@ -26,6 +28,8 @@ if TYPE_CHECKING:
26
28
 
27
29
  A14_NS = nsuri("a14")
28
30
  M_NS = nsuri("m")
31
+ W_NS = nsuri("w")
32
+ MC_NS = nsuri("mc")
29
33
 
30
34
  _WRAPPER_PATTERNS = (
31
35
  re.compile(r"^\s*\$\$(.*)\$\$\s*$", re.DOTALL),
@@ -47,9 +51,15 @@ _DISPLAY_JC = {
47
51
  "justify": "centerGroup",
48
52
  }
49
53
 
54
+ _SLD_OPEN_RE = re.compile(
55
+ r"^(<\?xml[^>]*\?>\s*)?(<p:sld\b)([^>]*)(>)",
56
+ re.DOTALL,
57
+ )
58
+ _IGNORABLE_RE = re.compile(r'\smc:Ignorable="([^"]*)"')
59
+
50
60
 
51
61
  class MathBackendUnavailable(ImportError):
52
- """Raised when the optional LaTeX → OMML converters are not installed."""
62
+ """Raised when the optional LaTeX → MathML converter is not installed."""
53
63
 
54
64
 
55
65
  def strip_math_delimiters(source: str) -> str:
@@ -72,13 +82,13 @@ def latex_to_omml(latex: str) -> str:
72
82
  if not fragment:
73
83
  raise ValueError("latex must be a non-empty math fragment")
74
84
 
75
- converter, mathml2omml = _import_math_backends()
85
+ converter = _import_latex2mathml()
76
86
  try:
77
87
  mathml = converter.convert(fragment)
78
88
  except Exception as exc:
79
89
  raise ValueError(f"LaTeX could not be converted to MathML: {exc}") from exc
80
90
  try:
81
- omml = mathml2omml.convert(mathml)
91
+ omml = mathml_to_omml(mathml)
82
92
  except Exception as exc:
83
93
  raise ValueError(f"MathML could not be converted to Office Math: {exc}") from exc
84
94
  if not isinstance(omml, str) or not omml.strip():
@@ -94,7 +104,10 @@ def office_math_element(
94
104
  ) -> CT_OfficeMathMarker:
95
105
  """Return an ``a14:m`` element ready to append to a DrawingML paragraph."""
96
106
  payload = _omml_payload(latex_to_omml(latex), display=display, align=align)
97
- xml = f'<a14:m xmlns:a14="{A14_NS}" xmlns:m="{M_NS}">{payload}</a14:m>'
107
+ xml = (
108
+ f'<a14:m xmlns:a14="{A14_NS}" xmlns:m="{M_NS}" xmlns:w="{W_NS}">'
109
+ f"{payload}</a14:m>"
110
+ )
98
111
  return parse_xml(xml)
99
112
 
100
113
 
@@ -105,7 +118,7 @@ def style_office_math(
105
118
  color: Any = None,
106
119
  font: str | None = None,
107
120
  ) -> None:
108
- """Write run-level size / color / typeface onto each ``m:r`` inside *marker*."""
121
+ """Write size / color / typeface onto every ``m:r`` and ``m:ctrlPr``."""
109
122
  if size_pt is None and color is None and font is None:
110
123
  return
111
124
 
@@ -113,32 +126,70 @@ def style_office_math(
113
126
 
114
127
  sz = None if size_pt is None else str(int(round(float(size_pt) * 100)))
115
128
  rgb = None if color is None else str(coerce_color(color))
129
+ r_pr_xml = _drawing_rpr(sz, rgb, font)
116
130
 
117
131
  for run in marker.iter(qn("m:r")):
118
- rPr = run.find(qn("a:rPr"))
119
- if rPr is None:
120
- rPr = etree.Element(qn("a:rPr"))
121
- text = run.find(qn("m:t"))
122
- if text is not None:
123
- text.addprevious(rPr)
124
- else:
125
- run.append(rPr)
126
- if sz is not None:
127
- rPr.set("sz", sz)
128
- if font is not None:
129
- latin = rPr.find(qn("a:latin"))
130
- if latin is None:
131
- latin = etree.SubElement(rPr, qn("a:latin"))
132
- latin.set("typeface", font)
133
- if rgb is not None:
134
- solid = rPr.find(qn("a:solidFill"))
135
- if solid is None:
136
- solid = etree.Element(qn("a:solidFill"))
137
- rPr.insert(0, solid)
138
- srgb = solid.find(qn("a:srgbClr"))
139
- if srgb is None:
140
- srgb = etree.SubElement(solid, qn("a:srgbClr"))
141
- srgb.set("val", rgb)
132
+ _stamp_arpr(run, r_pr_xml, before=qn("m:t"))
133
+ for ctrl in marker.iter(qn("m:ctrlPr")):
134
+ _stamp_arpr(ctrl, r_pr_xml, before=None)
135
+
136
+
137
+ def prepare_slide_xml_for_math(xml: bytes) -> bytes:
138
+ """Ensure the ``p:sld`` root declares math host namespaces.
139
+
140
+ PowerPoint requires ``xmlns:m``, ``xmlns:a14``, ``xmlns:mc`` and
141
+ ``mc:Ignorable="a14"`` on the slide (MS-PPTX §2.2.8). Bare ``m:oMath``
142
+ without ``a14:m`` is stripped on open.
143
+ """
144
+ match = _SLD_OPEN_RE.match(xml.decode("utf-8"))
145
+ if match is None:
146
+ return xml
147
+ decl, start, attrs, close = match.group(1) or "", match.group(2), match.group(3), match.group(4)
148
+ for prefix, uri in (
149
+ ("xmlns:m", M_NS),
150
+ ("xmlns:a14", A14_NS),
151
+ ("xmlns:mc", MC_NS),
152
+ ("xmlns:w", W_NS),
153
+ ):
154
+ if f'{prefix}="' not in attrs:
155
+ attrs += f' {prefix}="{uri}"'
156
+ ignorable = _IGNORABLE_RE.search(attrs)
157
+ if ignorable is None:
158
+ attrs += ' mc:Ignorable="a14"'
159
+ else:
160
+ tokens = ignorable.group(1).split()
161
+ if "a14" not in tokens:
162
+ tokens.append("a14")
163
+ attrs = _IGNORABLE_RE.sub(f' mc:Ignorable="{" ".join(tokens)}"', attrs, count=1)
164
+ rewritten = decl + start + attrs + close + xml.decode("utf-8")[match.end() :]
165
+ return rewritten.encode("utf-8")
166
+
167
+
168
+ def _drawing_rpr(sz: str | None, rgb: str | None, font: str | None) -> etree._Element:
169
+ attrs = {"dirty": "0"}
170
+ if sz is not None:
171
+ attrs["sz"] = sz
172
+ r_pr = etree.Element(qn("a:rPr"), attrs)
173
+ if rgb is not None:
174
+ solid = etree.SubElement(r_pr, qn("a:solidFill"))
175
+ etree.SubElement(solid, qn("a:srgbClr")).set("val", rgb)
176
+ if font is not None:
177
+ etree.SubElement(r_pr, qn("a:latin")).set("typeface", font)
178
+ return r_pr
179
+
180
+
181
+ def _stamp_arpr(parent: Any, template: etree._Element, before: str | None) -> None:
182
+ existing = parent.find(qn("a:rPr"))
183
+ stamped = etree.fromstring(etree.tostring(template))
184
+ if existing is not None:
185
+ parent.replace(existing, stamped)
186
+ return
187
+ if before is not None:
188
+ sibling = parent.find(before)
189
+ if sibling is not None:
190
+ sibling.addprevious(stamped)
191
+ return
192
+ parent.append(stamped)
142
193
 
143
194
 
144
195
  def _omml_payload(omml: str, *, display: bool, align: str | None) -> str:
@@ -156,22 +207,23 @@ def _omml_payload(omml: str, *, display: bool, align: str | None) -> str:
156
207
  )
157
208
 
158
209
 
159
- def _import_math_backends():
210
+ def _import_latex2mathml():
160
211
  try:
161
212
  import latex2mathml.converter as latex2mathml_converter
162
- import mathml2omml
163
213
  except ImportError as exc:
164
214
  raise MathBackendUnavailable(
165
- "Native PowerPoint equations require the latex2mathml and "
166
- "mathml2omml packages. Install with: pip install 'python-pptx2[math]'"
215
+ "Native PowerPoint equations require latex2mathml. "
216
+ "Install with: pip install 'python-pptx2[math]'"
167
217
  ) from exc
168
- return latex2mathml_converter, mathml2omml
218
+ return latex2mathml_converter
169
219
 
170
220
 
171
221
  __all__ = (
172
222
  "MathBackendUnavailable",
173
223
  "latex_to_omml",
224
+ "mathml_to_omml",
174
225
  "office_math_element",
226
+ "prepare_slide_xml_for_math",
175
227
  "strip_math_delimiters",
176
228
  "style_office_math",
177
229
  )