diff --git a/.gitignore b/.gitignore
index c043a21c1..092759652 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,5 @@ _scratch/
/spec/gen_spec/spec*.db
tags
/tests/debug.py
+.DS_Store
+temp
diff --git a/docs/api/math.rst b/docs/api/math.rst
new file mode 100644
index 000000000..0d20c2f01
--- /dev/null
+++ b/docs/api/math.rst
@@ -0,0 +1,83 @@
+Math
+====
+
+The following classes provide access to mathematical equations and OMML (Office Math Markup Language) functionality.
+
+Math objects
+-------------
+
+|Math| objects provide access to the mathematical content of a math shape.
+
+.. autoclass:: pptx.math.Math()
+ :members:
+ :inherited-members:
+
+
+MathShape objects
+------------------
+
+|MathShape| objects represent mathematical equations on a slide.
+
+.. autoclass:: pptx.shapes.math.MathShape()
+ :members:
+ :inherited-members:
+
+
+MathPart objects
+----------------
+
+|MathPart| objects handle the packaging and storage of OMML content.
+
+.. autoclass:: pptx.parts.math.MathPart()
+ :members:
+ :inherited-members:
+
+
+OMML Element Classes
+--------------------
+
+The following classes provide low-level access to OMML XML elements:
+
+.. autoclass:: pptx.oxml.math.CT_OMath()
+ :members:
+ :inherited-members:
+
+.. autoclass:: pptx.oxml.math.CT_R()
+ :members:
+ :inherited-members:
+
+.. autoclass:: pptx.oxml.math.CT_T()
+ :members:
+ :inherited-members:
+
+.. autoclass:: pptx.oxml.math.CT_F()
+ :members:
+ :inherited-members:
+
+.. autoclass:: pptx.oxml.math.CT_Num()
+ :members:
+ :inherited-members:
+
+.. autoclass:: pptx.oxml.math.CT_Den()
+ :members:
+ :inherited-members:
+
+.. autoclass:: pptx.oxml.math.CT_SSup()
+ :members:
+ :inherited-members:
+
+.. autoclass:: pptx.oxml.math.CT_SSub()
+ :members:
+ :inherited-members:
+
+.. autoclass:: pptx.oxml.math.CT_E()
+ :members:
+ :inherited-members:
+
+.. autoclass:: pptx.oxml.math.CT_Rad()
+ :members:
+ :inherited-members:
+
+.. autoclass:: pptx.oxml.math.CT_Nary()
+ :members:
+ :inherited-members:
diff --git a/docs/index.rst b/docs/index.rst
index 79ad6c369..4d1bcab48 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -64,6 +64,7 @@ User Guide
user/text
user/charts
user/table
+ user/math
user/notes
user/use-cases
user/concepts
@@ -96,6 +97,7 @@ API Documentation
api/chart-data
api/chart
api/text
+ api/math
api/action
api/dml
api/image
diff --git a/docs/user/math.rst b/docs/user/math.rst
new file mode 100644
index 000000000..2da530775
--- /dev/null
+++ b/docs/user/math.rst
@@ -0,0 +1,372 @@
+Working with Math Equations
+==========================
+
+python-pptx supports adding mathematical equations to slides using OMML (Office Math Markup Language). This allows you to include complex mathematical formulas, fractions, superscripts, radicals, and more in your PowerPoint presentations.
+
+.. warning::
+ **Important:** This OMML feature is a stopgap implementation that provides direct access to PowerPoint's native math format. Future versions may include LaTeX-to-OMML conversion for more convenient equation input. For now, you'll need to work directly with OMML XML or use external tools to convert LaTeX to OMML.
+
+.. note::
+ **Critical Design Note:** PowerPoint stores OMML content at the **slide level**, not in individual shapes. This is a fundamental requirement for proper equation rendering.
+
+.. note::
+ OMML is the native PowerPoint math format. If you need LaTeX support, you'll need to convert LaTeX to OMML first using external tools.
+
+Adding Math Equations
+---------------------
+
+Basic math equation
+~~~~~~~~~~~~~~~~~~~~
+
+To add a simple math equation to a slide::
+
+ from pptx import Presentation
+ from pptx.oxml.xmlchemy import OxmlElement
+
+ prs = Presentation()
+ slide_layout = prs.slide_layouts[0]
+ slide = prs.slides.add_slide(slide_layout)
+
+ # Create OMML structure at slide level
+ oMathPara = OxmlElement("m:oMathPara")
+ oMath = OxmlElement("m:oMath")
+
+ # Add simple text run
+ r = OxmlElement("m:r")
+ t = OxmlElement("m:t")
+ t.content = "E = mc²"
+ r.append(t)
+ oMath.append(r)
+
+ # Add to slide
+ oMathPara.append(oMath)
+ slide._element.append(oMathPara)
+
+ prs.save("math_equation.pptx")
+
+Complex equations
+~~~~~~~~~~~~~~~~~~
+
+For more complex equations like fractions, superscripts, and radicals::
+
+ # Create a fraction: x/2
+ oMathPara = OxmlElement("m:oMathPara")
+ oMath = OxmlElement("m:oMath")
+
+ f = OxmlElement("m:f")
+ num = OxmlElement("m:num")
+ r_num = OxmlElement("m:r")
+ t_num = OxmlElement("m:t")
+ t_num.content = "x"
+ r_num.append(t_num)
+ num.append(r_num)
+
+ den = OxmlElement("m:den")
+ r_den = OxmlElement("m:r")
+ t_den = OxmlElement("m:t")
+ t_den.content = "2"
+ r_den.append(t_den)
+ den.append(r_den)
+
+ f.append(num)
+ f.append(den)
+ oMath.append(f)
+
+ oMathPara.append(oMath)
+ slide._element.append(oMathPara)
+
+Unicode math characters
+~~~~~~~~~~~~~~~~~~~~~~~
+
+PowerPoint expects Unicode math characters for proper rendering::
+
+ # Use Unicode math italic characters
+ t.content = "𝑎" # Mathematical italic small a
+ t.content = "𝑏" # Mathematical italic small b
+ t.content = "𝑐" # Mathematical italic small c
+
+Common Unicode math characters:
+- 𝑎, 𝑏, 𝑐, 𝑑, 𝑒, 𝑓, 𝑔, , 𝑖, 𝑗, 𝑘, 𝑙, 𝑚, 𝑛, 𝑜, 𝑝, 𝑞, 𝑟, 𝑠, 𝑡, 𝑢, 𝑣, 𝑤, 𝑥, 𝑦, 𝑧, 𝑨, 𝑩
+- 𝑨, 𝑩, 𝑪, 𝑫, 𝑬, 𝑮, 𝑰, 𝑱, 𝑲, 𝑳, 𝑴, 𝑵, 𝑶, 𝑷, 𝑸, 𝑹, 𝑺, 𝑻, 𝑼, 𝑽, 𝑾, 𝑿
+
+OMML Structure Requirements
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+For proper PowerPoint rendering, OMML must follow this structure:
+
+1. **Slide-level storage**: Add to `slide._element`, not shapes
+2. **oMathPara wrapper**: Required container for equation grouping
+3. **Proper namespace**: `http://schemas.openxmlformats.org/officeDocument/2006/math`
+4. **Unicode characters**: Use mathematical Unicode characters
+5. **Centered alignment**: Use `m:oMathParaPr` with `m:jc val="centerGroup"`
+
+Working with existing equations
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+To retrieve OMML content from existing presentations::
+
+ # Find all OMML elements in a slide
+ slide_xml = serialize_for_reading(slide._element)
+ omml_matches = re.findall(r']*>.*?', slide_xml, re.DOTALL)
+
+ for omml in omml_matches:
+ print(f"Found equation: {omml}")
+
+Limitations
+~~~~~~~~~~~
+
+- **No LaTeX support**: Direct OMML only
+- **Manual positioning**: Equations positioned at slide level
+- **Unicode required**: Regular ASCII letters don't render as math
+- **Complex structure**: Requires understanding OMML XML format
+
+Radicals (Square Roots)
+~~~~~~~~~~~~~~~~~~~~~~~
+
+For square roots::
+
+ omml_xml = """
+
+
+
+
+
+ x
+
+
+
+ """
+
+For nth roots (like cube root)::
+
+ omml_xml = """
+
+
+
+
+ 3
+
+
+ x
+
+
+
+ """
+
+N-ary Operators (Summation, Integration)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+For summation::
+
+ omml_xml = """
+
+
+
+
+
+
+
+ i=1
+
+
+ n
+
+
+ i
+
+
+
+ """
+
+For integration::
+
+ omml_xml = """
+
+
+
+
+
+
+
+ 0
+
+
+ ∞
+
+
+ e^{-x^2} dx
+
+
+
+ """
+
+Complex Example: Quadratic Formula
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+A complete quadratic formula::
+
+ omml_xml = """
+
+ x =
+
+
+ -b
+
+
+
+
+ b
+
+ 2
+
+ - 4ac
+
+
+
+
+ 2a
+
+
+
+ """
+
+Working with Existing Equations
+-------------------------------
+
+Getting OMML XML
+~~~~~~~~~~~~~~~~
+
+To retrieve the OMML XML from an existing math shape::
+
+ omml_xml = math_shape.math.get_omml()
+ print(omml_xml)
+
+Replacing Equation Content
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+To replace the content of an existing math equation::
+
+ new_omml = """
+
+ y = mx + b
+
+ """
+
+ math_shape.math.set_omml(new_omml)
+
+Finding Math Shapes
+~~~~~~~~~~~~~~~~~~~
+
+To find all math shapes on a slide::
+
+ math_shapes = [shape for shape in slide.shapes if hasattr(shape, 'math')]
+
+ for math_shape in math_shapes:
+ print(f"Found math equation: {math_shape.math.get_omml()}")
+
+Multiple Equations Example
+~~~~~~~~~~~~~~~~~~~~~~~~
+
+Creating a slide with multiple related equations::
+
+ from pptx import Presentation
+ from pptx.util import Inches
+
+ prs = Presentation()
+ slide = prs.slides.add_slide(prs.slide_layouts[0])
+
+ # Add title
+ title = slide.shapes.title
+ title.text = "Pythagorean Theorem"
+
+ # Add first equation: a² + b²
+ eq1_xml = """
+
+ a
+
+ 2
+
+ +
+ b
+
+ 2
+
+
+ """
+
+ math1 = slide.shapes.add_math_equation()
+ math1.math.add_omml(eq1_xml)
+ math1.left = Inches(1)
+ math1.top = Inches(2)
+
+ # Add second equation: = c²
+ eq2_xml = """
+
+ =
+ c
+
+ 2
+
+
+ """
+
+ math2 = slide.shapes.add_math_equation()
+ math2.math.add_omml(eq2_xml)
+ math2.left = Inches(4)
+ math2.top = Inches(2)
+
+ prs.save('pythagorean_theorem.pptx')
+
+OMML Reference
+---------------
+
+Common OMML Elements
+~~~~~~~~~~~~~~~~~~~
+
+Here are the most commonly used OMML elements:
+
+- ``m:oMath`` - Root element for a math equation
+- ``m:r`` - Text run (contains text)
+- ``m:t`` - Text content
+- ``m:f`` - Fraction
+- ``m:num`` - Numerator
+- ``m:den`` - Denominator
+- ``m:sSup`` - Superscript
+- ``m:sSub`` - Subscript
+- ``m:e`` - Expression (base of superscript/subscript)
+- ``m:rad`` - Radical (square root)
+- ``m:radPr`` - Radical properties
+- ``m:deg`` - Degree (for nth roots)
+- ``m:nary`` - N-ary operator (summation, integral)
+- ``m:naryPr`` - N-ary operator properties
+- ``m:chr`` - Character for n-ary operators
+- ``m:sub`` - Subscript for n-ary operators
+- ``m:sup`` - Superscript for n-ary operators
+
+Namespace
+~~~~~~~~~~
+
+All OMML elements must use the math namespace::
+
+ xmlns:m="http://purl.oclc.org/ooxml/officeDocument/math"
+
+Tips and Best Practices
+------------------------
+
+1. **Use proper OMML structure** - Always validate your OMML XML against the Office Open XML specification
+2. **Test in PowerPoint first** - Create complex equations in PowerPoint, save as .pptx, then extract the OMML for use in python-pptx
+3. **Keep equations readable** - Use proper indentation in your OMML XML strings for maintainability
+4. **Handle special characters** - Use proper OMML elements instead of Unicode math characters
+5. **Consider file size** - Complex equations with many nested structures can increase file size
+
+Future Enhancements
+------------------
+
+Future versions of python-pptx may include:
+
+- **LaTeX to OMML conversion** - Automatic conversion from LaTeX syntax to OMML
+- **Equation templates** - Pre-built templates for common mathematical formulas
+- **MathML support** - Import/export support for MathML format
+- **Visual equation builder** - Programmatic building blocks for complex equations
+
+For now, the direct OMML approach provides reliable access to PowerPoint's full mathematical capabilities.
diff --git a/features/math-omml.feature b/features/math-omml.feature
new file mode 100644
index 000000000..683413fac
--- /dev/null
+++ b/features/math-omml.feature
@@ -0,0 +1,78 @@
+Feature: OMML (Office Math Markup Language) Support
+ As a presentation developer
+ I want to add mathematical equations to slides using OMML
+ So that I can include complex mathematical formulas in PowerPoint presentations
+
+ Scenario: Add basic math equation to slide
+ Given a presentation with one slide
+ When I add a math equation "x = 2"
+ Then the slide should contain one math shape
+ And the math shape should contain the equation "x = 2"
+
+ Scenario: Add fraction equation to slide
+ Given a presentation with one slide
+ When I add a math equation with fraction "x2"
+ Then the slide should contain one math shape
+ And the math shape should contain a fraction with numerator "x" and denominator "2"
+
+ Scenario: Add superscript equation to slide
+ Given a presentation with one slide
+ When I add a math equation with superscript "x2"
+ Then the slide should contain one math shape
+ And the math shape should contain "x²"
+
+ Scenario: Add radical equation to slide
+ Given a presentation with one slide
+ When I add a math equation with radical "x"
+ Then the slide should contain one math shape
+ And the math shape should contain a square root of "x"
+
+ Scenario: Add n-ary operator equation to slide
+ Given a presentation with one slide
+ When I add a math equation with summation "i=1ni"
+ Then the slide should contain one math shape
+ And the math shape should contain a summation from i=1 to n of i
+
+ Scenario: Position and size math equation
+ Given a presentation with one slide
+ When I add a math equation "E = mc²"
+ And I position the math shape at left 100000, top 100000
+ And I size the math shape to width 200000, height 100000
+ Then the math shape left should be 100000
+ And the math shape top should be 100000
+ And the math shape width should be 200000
+ And the math shape height should be 100000
+
+ Scenario: Add multiple math equations to slide
+ Given a presentation with one slide
+ When I add a first math equation "a² + b²"
+ And I add a second math equation "= c²"
+ Then the slide should contain two math shapes
+ And the first math shape should contain "a² + b²"
+ And the second math shape should contain "= c²"
+
+ Scenario: Get OMML XML from existing math equation
+ Given a presentation with one slide containing a math equation
+ When I get the OMML XML from the math shape
+ Then the OMML XML should be valid math markup
+ And the OMML XML should contain the original equation content
+
+ Scenario: Replace OMML content in existing math equation
+ Given a presentation with one slide containing a math equation
+ When I replace the OMML content with "y = mx + b"
+ Then the math shape should contain the equation "y = mx + b"
+
+ Scenario: Math shape properties
+ Given a presentation with one slide containing a math shape
+ Then the math shape should have default properties
+ And the math shape should support rotation
+ And the math shape should support shadow effects
+ And the math shape should be included in slide shapes collection
+
+ Scenario: Save and reload presentation with math equations
+ Given a presentation with math equations
+ When I save the presentation to a file
+ And I reload the presentation from the file
+ Then the presentation should contain the same math equations
+ And the math equations should have the same content
+ And the math equations should have the same positions and sizes
diff --git a/features/steps/math.py b/features/steps/math.py
new file mode 100644
index 000000000..7a162b293
--- /dev/null
+++ b/features/steps/math.py
@@ -0,0 +1,320 @@
+"""Gherkin step implementations for OMML math-related features."""
+
+from __future__ import annotations
+
+from behave import given, then, when
+from helpers import test_pptx
+
+from pptx import Presentation
+from pptx.util import Emu
+
+# given ====================================================
+
+
+@given("a presentation with one slide")
+def given_a_presentation_with_one_slide(context):
+ prs = Presentation()
+ slide_layout = prs.slide_layouts[0] # Use first available layout
+ slide = prs.slides.add_slide(slide_layout)
+ context.presentation = prs
+ context.slide = slide
+
+
+@given("a presentation with one slide containing a math equation")
+def given_a_presentation_with_math_equation(context):
+ prs = Presentation()
+ slide_layout = prs.slide_layouts[0]
+ slide = prs.slides.add_slide(slide_layout)
+
+ # Add a simple math equation
+ omml_xml = "x = 2"
+ math_shape = slide.shapes.add_math_equation()
+ math_shape.math.add_omml(omml_xml)
+
+ context.presentation = prs
+ context.slide = slide
+ context.math_shape = math_shape
+
+
+@given("a presentation with one slide containing a math shape")
+def given_a_presentation_with_math_shape(context):
+ prs = Presentation()
+ slide_layout = prs.slide_layouts[0]
+ slide = prs.slides.add_slide(slide_layout)
+
+ omml_xml = "E = mc²"
+ math_shape = slide.shapes.add_math_equation()
+ math_shape.math.add_omml(omml_xml)
+
+ context.presentation = prs
+ context.slide = slide
+ context.math_shape = math_shape
+
+
+@given("a presentation with math equations")
+def given_a_presentation_with_math_equations(context):
+ prs = Presentation()
+ slide_layout = prs.slide_layouts[0]
+ slide = prs.slides.add_slide(slide_layout)
+
+ # Add multiple math equations
+ equations = [
+ "a² + b²",
+ "= c²"
+ ]
+
+ for omml_xml in equations:
+ math_shape = slide.shapes.add_math_equation()
+ math_shape.math.add_omml(omml_xml)
+
+ context.presentation = prs
+ context.slide = slide
+
+
+# when =====================================================
+
+
+@when('I add a math equation "{omml_xml}"')
+def when_i_add_math_equation(context, omml_xml):
+ math_shape = context.slide.shapes.add_math_equation()
+ math_shape.math.add_omml(omml_xml)
+ context.math_shape = math_shape
+
+
+@when('I add a math equation with fraction "{omml_xml}"')
+def when_i_add_math_equation_with_fraction(context, omml_xml):
+ math_shape = context.slide.shapes.add_math_equation()
+ math_shape.math.add_omml(omml_xml)
+ context.math_shape = math_shape
+
+
+@when('I add a math equation with superscript "{omml_xml}"')
+def when_i_add_math_equation_with_superscript(context, omml_xml):
+ math_shape = context.slide.shapes.add_math_equation()
+ math_shape.math.add_omml(omml_xml)
+ context.math_shape = math_shape
+
+
+@when('I add a math equation with radical "{omml_xml}"')
+def when_i_add_math_equation_with_radical(context, omml_xml):
+ math_shape = context.slide.shapes.add_math_equation()
+ math_shape.math.add_omml(omml_xml)
+ context.math_shape = math_shape
+
+
+@when('I add a math equation with summation "{omml_xml}"')
+def when_i_add_math_equation_with_summation(context, omml_xml):
+ math_shape = context.slide.shapes.add_math_equation()
+ math_shape.math.add_omml(omml_xml)
+ context.math_shape = math_shape
+
+
+@when("I position the math shape at left {left:d}, top {top:d}")
+def when_i_position_math_shape(context, left, top):
+ context.math_shape.left = Emu(left)
+ context.math_shape.top = Emu(top)
+
+
+@when("I size the math shape to width {width:d}, height {height:d}")
+def when_i_size_math_shape(context, width, height):
+ context.math_shape.width = Emu(width)
+ context.math_shape.height = Emu(height)
+
+
+@when('I add a first math equation "{omml_xml}"')
+def when_i_add_first_math_equation(context, omml_xml):
+ math_shape = context.slide.shapes.add_math_equation()
+ math_shape.math.add_omml(omml_xml)
+ context.first_math_shape = math_shape
+
+
+@when('I add a second math equation "{omml_xml}"')
+def when_i_add_second_math_equation(context, omml_xml):
+ math_shape = context.slide.shapes.add_math_equation()
+ math_shape.math.add_omml(omml_xml)
+ context.second_math_shape = math_shape
+
+
+@when("I get the OMML XML from the math shape")
+def when_i_get_omml_xml(context):
+ context.omml_xml = context.math_shape.math.get_omml()
+
+
+@when('I replace the OMML content with "{omml_xml}"')
+def when_i_replace_omml_content(context, omml_xml):
+ context.math_shape.math.set_omml(omml_xml)
+
+
+@when("I save the presentation to a file")
+def when_i_save_presentation(context):
+ import tempfile
+ import os
+
+ temp_file = tempfile.NamedTemporaryFile(suffix='.pptx', delete=False)
+ temp_file.close()
+
+ context.presentation.save(temp_file.name)
+ context.saved_file_path = temp_file.name
+
+
+@when("I reload the presentation from the file")
+def when_i_reload_presentation(context):
+ context.presentation = Presentation(context.saved_file_path)
+ context.slide = context.presentation.slides[0]
+
+
+# then =====================================================
+
+
+@then("the slide should contain one math shape")
+def then_slide_should_contain_one_math_shape(context):
+ math_shapes = [s for s in context.slide.shapes if hasattr(s, 'math')]
+ assert len(math_shapes) == 1, f"Expected 1 math shape, found {len(math_shapes)}"
+
+
+@then('the math shape should contain the equation "{expected_equation}"')
+def then_math_shape_should_contain_equation(context, expected_equation):
+ omml_xml = context.math_shape.math.get_omml()
+ assert expected_equation in omml_xml, f"Expected '{expected_equation}' in OMML: {omml_xml}"
+
+
+@then("the math shape should contain a fraction with numerator \"{numerator}\" and denominator \"{denominator}\"")
+def then_math_shape_should_contain_fraction(context, numerator, denominator):
+ omml_xml = context.math_shape.math.get_omml()
+ assert "" in omml_xml, "No fraction element found"
+ assert f"{numerator}" in omml_xml, f"Numerator '{numerator}' not found"
+ assert f"{denominator}" in omml_xml, f"Denominator '{denominator}' not found"
+
+
+@then('the math shape should contain "{expected_content}"')
+def then_math_shape_should_contain_content(context, expected_content):
+ omml_xml = context.math_shape.math.get_omml()
+ # Convert superscript notation for comparison
+ if "²" in expected_content:
+ assert "" in omml_xml, "No superscript element found"
+ assert "2" in omml_xml, "Superscript '2' not found"
+ else:
+ assert expected_content in omml_xml, f"Expected '{expected_content}' in OMML: {omml_xml}"
+
+
+@then("the math shape should contain a square root of \"{content}\"")
+def then_math_shape_should_contain_square_root(context, content):
+ omml_xml = context.math_shape.math.get_omml()
+ assert "" in omml_xml, "No radical element found"
+ assert f"{content}" in omml_xml, f"Content '{content}' not found in radical"
+
+
+@then("the math shape should contain a summation from i=1 to n of i")
+def then_math_shape_should_contain_summation(context):
+ omml_xml = context.math_shape.math.get_omml()
+ assert "" in omml_xml, "No n-ary element found"
+ assert '' in omml_xml or '' in omml_xml, "No summation character found"
+ assert "i=1" in omml_xml, "Lower limit not found"
+ assert "n" in omml_xml, "Upper limit not found"
+ assert "i" in omml_xml, "Expression not found"
+
+
+@then("the math shape left should be {left:d}")
+def then_math_shape_left_should_be(context, left):
+ assert context.math_shape.left == Emu(left), f"Expected left {left}, got {context.math_shape.left}"
+
+
+@then("the math shape top should be {top:d}")
+def then_math_shape_top_should_be(context, top):
+ assert context.math_shape.top == Emu(top), f"Expected top {top}, got {context.math_shape.top}"
+
+
+@then("the math shape width should be {width:d}")
+def then_math_shape_width_should_be(context, width):
+ assert context.math_shape.width == Emu(width), f"Expected width {width}, got {context.math_shape.width}"
+
+
+@then("the math shape height should be {height:d}")
+def then_math_shape_height_should_be(context, height):
+ assert context.math_shape.height == Emu(height), f"Expected height {height}, got {context.math_shape.height}"
+
+
+@then("the slide should contain two math shapes")
+def then_slide_should_contain_two_math_shapes(context):
+ math_shapes = [s for s in context.slide.shapes if hasattr(s, 'math')]
+ assert len(math_shapes) == 2, f"Expected 2 math shapes, found {len(math_shapes)}"
+
+
+@then('the first math shape should contain "{expected_content}"')
+def then_first_math_shape_should_contain(context, expected_content):
+ omml_xml = context.first_math_shape.math.get_omml()
+ assert expected_content in omml_xml, f"Expected '{expected_content}' in first OMML: {omml_xml}"
+
+
+@then('the second math shape should contain "{expected_content}"')
+def then_second_math_shape_should_contain(context, expected_content):
+ omml_xml = context.second_math_shape.math.get_omml()
+ assert expected_content in omml_xml, f"Expected '{expected_content}' in second OMML: {omml_xml}"
+
+
+@then("the OMML XML should be valid math markup")
+def then_omml_xml_should_be_valid(context):
+ omml_xml = context.omml_xml
+ assert "" in omml_xml, "No closing oMath element found"
+
+
+@then("the OMML XML should contain the original equation content")
+def then_omml_xml_should_contain_original_content(context):
+ omml_xml = context.omml_xml
+ assert "x = 2" in omml_xml, "Original equation content not found"
+
+
+@then("the math shape should have default properties")
+def then_math_shape_should_have_default_properties(context):
+ # Check that it's a proper shape with expected properties
+ assert hasattr(context.math_shape, 'left'), "Math shape missing left property"
+ assert hasattr(context.math_shape, 'top'), "Math shape missing top property"
+ assert hasattr(context.math_shape, 'width'), "Math shape missing width property"
+ assert hasattr(context.math_shape, 'height'), "Math shape missing height property"
+
+
+@then("the math shape should support rotation")
+def then_math_shape_should_support_rotation(context):
+ # Test setting rotation (should not raise an exception)
+ original_rotation = getattr(context.math_shape, 'rotation', None)
+ context.math_shape.rotation = 45
+ assert context.math_shape.rotation == 45, "Math shape rotation not supported"
+
+
+@then("the math shape should support shadow effects")
+def then_math_shape_should_support_shadow(context):
+ # Test shadow properties (should not raise an exception)
+ if hasattr(context.math_shape, 'shadow'):
+ context.math_shape.shadow.inherit = True
+ assert context.math_shape.shadow.inherit == True, "Math shape shadow not supported"
+
+
+@then("the math shape should be included in slide shapes collection")
+def then_math_shape_in_shapes_collection(context):
+ math_shapes = [s for s in context.slide.shapes if hasattr(s, 'math')]
+ assert len(math_shapes) > 0, "Math shape not found in shapes collection"
+ assert context.math_shape in math_shapes, "Math shape not in shapes collection"
+
+
+@then("the presentation should contain the same math equations")
+def then_presentation_should_contain_same_math_equations(context):
+ math_shapes = [s for s in context.slide.shapes if hasattr(s, 'math')]
+ assert len(math_shapes) > 0, "No math shapes found after reload"
+
+
+@then("the math equations should have the same content")
+def then_math_equations_should_have_same_content(context):
+ math_shapes = [s for s in context.slide.shapes if hasattr(s, 'math')]
+ for math_shape in math_shapes:
+ omml_xml = math_shape.math.get_omml()
+ assert "=3.8"
+requires-python = ">=3.11"
[project.urls]
Changelog = "https://github.com/scanny/python-pptx/blob/master/HISTORY.rst"
@@ -54,13 +53,14 @@ include = [
]
ignore = []
pythonPlatform = "All"
-pythonVersion = "3.9"
+pythonVersion = "3.11"
reportImportCycles = false
reportUnnecessaryCast = true
reportUnnecessaryTypeIgnoreComment = true
stubPath = "./typings"
typeCheckingMode = "strict"
verboseOutput = true
+enableExperimentalFeatures = true
[tool.pytest.ini_options]
filterwarnings = [
diff --git a/src/pptx/__init__.py b/src/pptx/__init__.py
index fb5c2d7e4..7dca33660 100644
--- a/src/pptx/__init__.py
+++ b/src/pptx/__init__.py
@@ -12,6 +12,7 @@
from pptx.parts.chart import ChartPart
from pptx.parts.coreprops import CorePropertiesPart
from pptx.parts.image import ImagePart
+from pptx.parts.math import MathPart
from pptx.parts.media import MediaPart
from pptx.parts.presentation import PresentationPart
from pptx.parts.slide import (
@@ -25,7 +26,7 @@
if TYPE_CHECKING:
from pptx.opc.package import Part
-__version__ = "1.0.2"
+__version__ = "1.1.0"
sys.modules["pptx.exceptions"] = exceptions
del sys
@@ -44,6 +45,7 @@
CT.PML_SLIDE_LAYOUT: SlideLayoutPart,
CT.PML_SLIDE_MASTER: SlideMasterPart,
CT.DML_CHART: ChartPart,
+ CT.OFFICE_MATH: MathPart,
CT.BMP: ImagePart,
CT.GIF: ImagePart,
CT.JPEG: ImagePart,
diff --git a/src/pptx/math/__init__.py b/src/pptx/math/__init__.py
new file mode 100644
index 000000000..94ff27bed
--- /dev/null
+++ b/src/pptx/math/__init__.py
@@ -0,0 +1,7 @@
+"""Math components for OMML support."""
+
+from __future__ import annotations
+
+from .math import Math
+
+__all__ = ["Math"]
diff --git a/src/pptx/math/math.py b/src/pptx/math/math.py
new file mode 100644
index 000000000..357136cd9
--- /dev/null
+++ b/src/pptx/math/math.py
@@ -0,0 +1,126 @@
+"""High-level Math class for OMML equation management."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from pptx.oxml.xmlchemy import OxmlElement
+from pptx.oxml.math import CT_OMath
+
+if TYPE_CHECKING:
+ from pptx.oxml.shapes import ShapeElement
+
+
+class Math:
+ """High-level interface for OMML math equations."""
+
+ def __init__(self, shape_element: ShapeElement, parent):
+ """Initialize Math with shape element and parent."""
+ self._shape_element = shape_element
+ self._parent = parent
+ self._omml_element = None
+
+ def _create_rpr(self):
+ """Create standard rPr formatting properties for PowerPoint 16.105.2."""
+ rpr = OxmlElement("a:rPr")
+ rpr.set("lang", "en-US")
+ rpr.set("i", "1")
+ rpr.set("smtClean", "0")
+
+ latin = OxmlElement("a:latin")
+ latin.set("typeface", "Cambria Math")
+ latin.set("panose", "02040503050406030204")
+ latin.set("pitchFamily", "18")
+ latin.set("charset", "0")
+ rpr.append(latin)
+
+ return rpr
+
+ def _ensure_formatting(self, omath_element):
+ """Ensure all text runs have proper PowerPoint formatting."""
+ # Add formatting to all elements that lack it
+ for r_element in omath_element.xpath(".//*[local-name() = 'r']"):
+ if not r_element.xpath(".//*[local-name() = 'rPr']"):
+ rpr = self._create_rpr()
+
+ # Insert rPr as first child of m:r
+ if len(r_element) > 0:
+ r_element.insert(0, rpr)
+ else:
+ r_element.append(rpr)
+
+ def _add_to_slide_container(self, omath_element):
+ """Add OMML element to the shape itself, not to separate container shapes."""
+ # Store OMML element in this math shape
+ self._omml_element = omath_element
+
+ # Apply automatic formatting
+ self._ensure_formatting(omath_element)
+
+ @property
+ def omml_element(self) -> CT_OMath:
+ """Get or create the underlying OMML element."""
+ if self._omml_element is None:
+ # Create OMML element
+ self._omml_element = OxmlElement("m:oMath")
+ return self._omml_element
+
+ def get_omml(self) -> str:
+ """Get OMML XML string."""
+ from pptx.oxml.xmlchemy import serialize_for_reading
+ return serialize_for_reading(self.omml_element)
+
+ def set_omml(self, omml_xml: str):
+ """Replace current OMML with new XML string."""
+ from pptx.oxml import parse_xml
+ from pptx.oxml.ns import qn
+
+ # Parse the XML and extract the oMath element
+ new_element = parse_xml(omml_xml)
+
+ # Find the actual oMath element (it might be nested)
+ omath_element = new_element
+ expected_tags = [
+ qn("m:oMath"), # Standard namespace
+ "{http://purl.oclc.org/ooxml/officeDocument/math}oMath", # Alternative namespace
+ ]
+
+ if new_element.tag not in expected_tags:
+ # Look for oMath child with proper namespace
+ omath_elements = new_element.xpath(".//*[local-name() = 'oMath']")
+ if omath_elements:
+ omath_element = omath_elements[0]
+ else:
+ raise ValueError("Root element must be m:oMath")
+
+ # Replace the OMML element in the shape
+ self._omml_element = omath_element
+
+ def add_omml(self, omml_xml: str):
+ """Add OMML content to the equation with automatic formatting."""
+ from pptx.oxml import parse_xml
+
+ # Parse the XML and extract the oMath element
+ new_element = parse_xml(omml_xml)
+
+ # Find the actual oMath element (it might be nested)
+ omath_element = new_element
+ expected_tags = [
+ "m:oMath", # Standard namespace
+ "{http://schemas.openxmlformats.org/officeDocument/2006/math}oMath", # Test namespace
+ "{http://purl.oclc.org/ooxml/officeDocument/math}oMath", # Alternative namespace
+ ]
+
+ if new_element.tag not in expected_tags:
+ # Look for oMath child with proper namespace
+ omath_elements = new_element.xpath(".//*[local-name() = 'oMath']")
+ if omath_elements:
+ omath_element = omath_elements[0]
+ else:
+ raise ValueError("Root element must be m:oMath")
+
+ # Add to slide-level container with automatic formatting
+ self._add_to_slide_container(omath_element)
+
+ # Store reference for get_omml
+ self._omml_element = omath_element
diff --git a/src/pptx/opc/constants.py b/src/pptx/opc/constants.py
index e1b08a93a..d69c7ef13 100644
--- a/src/pptx/opc/constants.py
+++ b/src/pptx/opc/constants.py
@@ -43,6 +43,7 @@ class CONTENT_TYPE:
OFC_THEME = "application/vnd.openxmlformats-officedocument.theme+xml"
OFC_THEME_OVERRIDE = "application/vnd.openxmlformats-officedocument.themeOverride+xml"
OFC_VML_DRAWING = "application/vnd.openxmlformats-officedocument.vmlDrawing"
+ OFFICE_MATH = "application/vnd.openxmlformats-officedocument.mathml+xml"
OPC_CORE_PROPERTIES = "application/vnd.openxmlformats-package.core-properties+xml"
OPC_DIGITAL_SIGNATURE_CERTIFICATE = (
"application/vnd.openxmlformats-package.digital-signature-certificate"
diff --git a/src/pptx/oxml/__init__.py b/src/pptx/oxml/__init__.py
index 21afaa921..398732d63 100644
--- a/src/pptx/oxml/__init__.py
+++ b/src/pptx/oxml/__init__.py
@@ -368,6 +368,45 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]):
register_element_cls("p:pic", CT_Picture)
+from pptx.oxml.math import ( # noqa: E402
+ CT_OMath,
+ CT_R,
+ CT_T,
+ CT_F,
+ CT_Num,
+ CT_Den,
+ CT_SSup,
+ CT_E,
+ CT_Rad,
+ CT_RadPr,
+ CT_Deg,
+ CT_Nary,
+ CT_NaryPr,
+ CT_Char,
+ CT_LimLoc,
+ CT_Sub,
+ CT_Sup,
+)
+
+register_element_cls("m:oMath", CT_OMath)
+register_element_cls("m:r", CT_R)
+register_element_cls("m:t", CT_T)
+register_element_cls("m:f", CT_F)
+register_element_cls("m:num", CT_Num)
+register_element_cls("m:den", CT_Den)
+register_element_cls("m:sSup", CT_SSup)
+register_element_cls("m:e", CT_E)
+register_element_cls("m:rad", CT_Rad)
+register_element_cls("m:radPr", CT_RadPr)
+register_element_cls("m:deg", CT_Deg)
+register_element_cls("m:nary", CT_Nary)
+register_element_cls("m:naryPr", CT_NaryPr)
+register_element_cls("m:chr", CT_Char)
+register_element_cls("m:limLoc", CT_LimLoc)
+register_element_cls("m:sub", CT_Sub)
+register_element_cls("m:sup", CT_Sup)
+
+
from pptx.oxml.shapes.shared import ( # noqa: E402
CT_ApplicationNonVisualDrawingProps,
CT_LineProperties,
diff --git a/src/pptx/oxml/math.py b/src/pptx/oxml/math.py
new file mode 100644
index 000000000..b7045ab54
--- /dev/null
+++ b/src/pptx/oxml/math.py
@@ -0,0 +1,333 @@
+"""Custom element classes for OMML (Office Math Markup Language) elements."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Callable
+
+from pptx.oxml.xmlchemy import (
+ BaseOxmlElement,
+ ZeroOrMore,
+ ZeroOrOne,
+)
+
+if TYPE_CHECKING:
+ pass
+
+
+class CT_OMath(BaseOxmlElement):
+ """`m:oMath` custom element class - root element for a math equation."""
+
+ # Type hints for public API methods that will be created by manual implementations below
+ # These match the pattern used by other python-pptx classes like text and table elements
+ add_r: Callable[[], "CT_R"] # Add a math run element
+ add_f: Callable[[], "CT_F"] # Add a fraction element
+ add_sSup: Callable[[], "CT_SSup"] # Add a superscript element
+ add_rad: Callable[[], "CT_Rad"] # Add a radical element
+ add_nary: Callable[[], "CT_Nary"] # Add an n-ary operator element
+
+ # Declarative element definitions that create internal _add_* methods via metaclass
+ # ZeroOrMore allows multiple instances and creates _add_r, _add_f, etc. methods
+ r: "ZeroOrMore" = ZeroOrMore("m:r")
+ f: "ZeroOrMore" = ZeroOrMore("m:f")
+ sSup: "ZeroOrMore" = ZeroOrMore("m:sSup")
+ rad: "ZeroOrMore" = ZeroOrMore("m:rad")
+ nary: "ZeroOrMore" = ZeroOrMore("m:nary")
+
+ # Manual implementations of public API methods that delegate to internal metaclass methods
+ # This pattern matches how text and table classes handle ZeroOrMore/ZeroOrOne elements
+ def add_r(self) -> "CT_R":
+ """Add a math run element."""
+ return self._add_r()
+
+ def add_f(self) -> "CT_F":
+ """Add a fraction element."""
+ return self._add_f()
+
+ def add_sSup(self) -> "CT_SSup":
+ """Add a superscript element."""
+ return self._add_sSup()
+
+ def add_rad(self) -> "CT_Rad":
+ """Add a radical element."""
+ return self._add_rad()
+
+ def add_nary(self) -> "CT_Nary":
+ """Add an n-ary operator element."""
+ return self._add_nary()
+
+
+class CT_R(BaseOxmlElement):
+ """`m:r` custom element class - math run (text container)."""
+
+ add_t: Callable[[], "CT_T"]
+ add_rPr: Callable[[], "CT_RPr"]
+
+ t: "ZeroOrOne" = ZeroOrOne("m:t")
+ rPr: "ZeroOrOne" = ZeroOrOne("m:rPr")
+
+ def add_t(self) -> "CT_T":
+ """Add a text element."""
+ return self._add_t()
+
+ def add_rPr(self) -> "CT_RPr":
+ """Add run properties."""
+ return self._add_rPr()
+
+
+class CT_T(BaseOxmlElement):
+ """`m:t` custom element class - text content."""
+
+ @property
+ def content(self) -> str:
+ """Get text content."""
+ return self.text or ""
+
+ @content.setter
+ def content(self, value: str):
+ """Set text content."""
+ # Use lxml's built-in text assignment
+ object.__setattr__(self, 'text', value)
+
+
+class CT_RPr(BaseOxmlElement):
+ """`m:rPr` custom element class - run properties."""
+ # Math run properties would be defined here
+ pass
+
+
+class CT_F(BaseOxmlElement):
+ """`m:f` custom element class - fraction."""
+
+ add_num: Callable[[], "CT_Num"]
+ add_den: Callable[[], "CT_Den"]
+
+ num: "ZeroOrOne" = ZeroOrOne("m:num")
+ den: "ZeroOrOne" = ZeroOrOne("m:den")
+
+ def add_num(self) -> "CT_Num":
+ """Add numerator element."""
+ return self._add_num()
+
+ def add_den(self) -> "CT_Den":
+ """Add denominator element."""
+ return self._add_den()
+
+
+class CT_Num(BaseOxmlElement):
+ """`m:num` custom element class - fraction numerator."""
+
+ add_r: Callable[[], "CT_R"]
+ add_f: Callable[[], "CT_F"]
+ add_sSup: Callable[[], "CT_SSup"]
+ add_rad: Callable[[], "CT_Rad"]
+
+ r: "ZeroOrMore" = ZeroOrMore("m:r")
+ f: "ZeroOrMore" = ZeroOrMore("m:f")
+ sSup: "ZeroOrMore" = ZeroOrMore("m:sSup")
+ rad: "ZeroOrMore" = ZeroOrMore("m:rad")
+
+ def add_r(self) -> "CT_R":
+ """Add a math run element."""
+ return self._add_r()
+
+ def add_f(self) -> "CT_F":
+ """Add a fraction element."""
+ return self._add_f()
+
+ def add_sSup(self) -> "CT_SSup":
+ """Add a superscript element."""
+ return self._add_sSup()
+
+ def add_rad(self) -> "CT_Rad":
+ """Add a radical element."""
+ return self._add_rad()
+
+
+class CT_Den(BaseOxmlElement):
+ """`m:den` custom element class - fraction denominator."""
+
+ add_r: Callable[[], "CT_R"]
+ add_f: Callable[[], "CT_F"]
+ add_sSup: Callable[[], "CT_SSup"]
+ add_rad: Callable[[], "CT_Rad"]
+
+ r: "ZeroOrMore" = ZeroOrMore("m:r")
+ f: "ZeroOrMore" = ZeroOrMore("m:f")
+ sSup: "ZeroOrMore" = ZeroOrMore("m:sSup")
+ rad: "ZeroOrMore" = ZeroOrMore("m:rad")
+
+ def add_r(self) -> "CT_R":
+ """Add a math run element."""
+ return self._add_r()
+
+ def add_f(self) -> "CT_F":
+ """Add a fraction element."""
+ return self._add_f()
+
+ def add_sSup(self) -> "CT_SSup":
+ """Add a superscript element."""
+ return self._add_sSup()
+
+ def add_rad(self) -> "CT_Rad":
+ """Add a radical element."""
+ return self._add_rad()
+
+
+class CT_SSup(BaseOxmlElement):
+ """`m:sSup` custom element class - superscript."""
+
+ add_e: Callable[[], "CT_E"]
+
+ e: "ZeroOrOne" = ZeroOrOne("m:e")
+
+ def add_e(self) -> "CT_E":
+ """Add element."""
+ return self._add_e()
+
+
+class CT_E(BaseOxmlElement):
+ """`m:e` custom element class - expression (base of superscript/subscript)."""
+
+ add_r: Callable[[], "CT_R"]
+ add_f: Callable[[], "CT_F"]
+ add_sSup: Callable[[], "CT_SSup"]
+
+ r: "ZeroOrMore" = ZeroOrMore("m:r")
+ f: "ZeroOrMore" = ZeroOrMore("m:f")
+ sSup: "ZeroOrMore" = ZeroOrMore("m:sSup")
+
+ def add_r(self) -> "CT_R":
+ """Add a math run element."""
+ return self._add_r()
+
+ def add_f(self) -> "CT_F":
+ """Add a fraction element."""
+ return self._add_f()
+
+ def add_sSup(self) -> "CT_SSup":
+ """Add a superscript element."""
+ return self._add_sSup()
+
+
+class CT_Rad(BaseOxmlElement):
+ """`m:rad` custom element class - radical (square root)."""
+
+ add_radPr: Callable[[], "CT_RadPr"]
+ add_deg: Callable[[], "CT_Deg"]
+ add_e: Callable[[], "CT_E"]
+
+ radPr: "ZeroOrOne" = ZeroOrOne("m:radPr")
+ deg: "ZeroOrOne" = ZeroOrOne("m:deg")
+ e: "ZeroOrOne" = ZeroOrOne("m:e")
+
+ def add_radPr(self) -> "CT_RadPr":
+ """Add radical properties."""
+ return self._add_radPr()
+
+ def add_deg(self) -> "CT_Deg":
+ """Add degree."""
+ return self._add_deg()
+
+ def add_e(self) -> "CT_E":
+ """Add element."""
+ return self._add_e()
+
+
+class CT_RadPr(BaseOxmlElement):
+ """`m:radPr` custom element class - radical properties."""
+ # Radical properties would be defined here
+ pass
+
+
+class CT_Deg(BaseOxmlElement):
+ """`m:deg` custom element class - radical degree (for nth roots)."""
+
+ add_r: Callable[[], "CT_R"]
+
+ r: "ZeroOrMore" = ZeroOrMore("m:r")
+
+ def add_r(self) -> "CT_R":
+ """Add a math run element."""
+ return self._add_r()
+
+
+class CT_Nary(BaseOxmlElement):
+ """`m:nary` custom element class - n-ary operators (summation, integral, etc.)."""
+
+ add_naryPr: Callable[[], "CT_NaryPr"]
+ add_sub: Callable[[], "CT_Sub"]
+ add_sup: Callable[[], "CT_Sup"]
+ add_e: Callable[[], "CT_E"]
+
+ naryPr: "ZeroOrOne" = ZeroOrOne("m:naryPr")
+ sub: "ZeroOrOne" = ZeroOrOne("m:sub")
+ sup: "ZeroOrOne" = ZeroOrOne("m:sup")
+ e: "ZeroOrOne" = ZeroOrOne("m:e")
+
+ def add_naryPr(self) -> "CT_NaryPr":
+ """Add n-ary operator properties."""
+ return self._add_naryPr()
+
+ def add_sub(self) -> "CT_Sub":
+ """Add subscript."""
+ return self._add_sub()
+
+ def add_sup(self) -> "CT_Sup":
+ """Add superscript."""
+ return self._add_sup()
+
+ def add_e(self) -> "CT_E":
+ """Add element."""
+ return self._add_e()
+
+
+class CT_NaryPr(BaseOxmlElement):
+ """`m:naryPr` custom element class - n-ary operator properties."""
+
+ add_chr: Callable[[], "CT_Char"]
+ add_limLoc: Callable[[], "CT_LimLoc"]
+
+ chr: "ZeroOrOne" = ZeroOrOne("m:chr")
+ limLoc: "ZeroOrOne" = ZeroOrOne("m:limLoc")
+
+ def add_chr(self) -> "CT_Char":
+ """Add character element."""
+ return self._add_chr()
+
+ def add_limLoc(self) -> "CT_LimLoc":
+ """Add limit location."""
+ return self._add_limLoc()
+
+
+class CT_Char(BaseOxmlElement):
+ """`m:chr` custom element class - character for n-ary operators."""
+ pass
+
+
+class CT_LimLoc(BaseOxmlElement):
+ """`m:limLoc` custom element class - limit location for n-ary operators."""
+ pass
+
+
+class CT_Sub(BaseOxmlElement):
+ """`m:sub` custom element class - subscript for n-ary operators."""
+
+ add_r: Callable[[], "CT_R"]
+
+ r: "ZeroOrMore" = ZeroOrMore("m:r")
+
+ def add_r(self) -> "CT_R":
+ """Add a math run element."""
+ return self._add_r()
+
+
+class CT_Sup(BaseOxmlElement):
+ """`m:sup` custom element class - superscript for n-ary operators."""
+
+ add_r: Callable[[], "CT_R"]
+
+ r: "ZeroOrMore" = ZeroOrMore("m:r")
+
+ def add_r(self) -> "CT_R":
+ """Add a math run element."""
+ return self._add_r()
diff --git a/src/pptx/parts/math.py b/src/pptx/parts/math.py
new file mode 100644
index 000000000..d0124ed12
--- /dev/null
+++ b/src/pptx/parts/math.py
@@ -0,0 +1,45 @@
+"""Math parts for OMML content."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from pptx.opc.constants import CONTENT_TYPE as CT
+from pptx.opc.package import XmlPart
+from pptx.oxml.math import CT_OMath
+
+if TYPE_CHECKING:
+ from pptx.math.math import Math
+
+
+class MathPart(XmlPart):
+ """Part containing OMML (Office Math Markup Language) content."""
+
+ content_type: str = CT.OFFICE_MATH
+
+ def __init__(self, package, partname, content_type=None, element=None, blob=None):
+ super().__init__(package, partname, content_type, element, blob)
+ self._math = None
+
+ @property
+ def math(self) -> Math:
+ """Math object providing access to OMML content."""
+ if self._math is None:
+ from pptx.math.math import Math
+ self._math = Math(self._element)
+ return self._math
+
+ def get_or_add_omml_element(self) -> CT_OMath:
+ """Get or create the OMML element."""
+ if not isinstance(self._element, CT_OMath):
+ from pptx.oxml.xmlchemy import OxmlElement
+ self._element = OxmlElement("m:oMath")
+ return self._element
+
+ def parse_omml_string(self, omml_xml: str) -> CT_OMath:
+ """Parse OMML XML string into CT_OMath element."""
+ from pptx.oxml import parse_xml
+ element = parse_xml(omml_xml)
+ if not isinstance(element, CT_OMath):
+ raise ValueError("Root element must be m:oMath")
+ return element
diff --git a/src/pptx/shapes/math.py b/src/pptx/shapes/math.py
new file mode 100644
index 000000000..a75b6a71f
--- /dev/null
+++ b/src/pptx/shapes/math.py
@@ -0,0 +1,30 @@
+"""Math shape objects."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from pptx.shapes.base import BaseShape
+from pptx.math.math import Math
+
+if TYPE_CHECKING:
+ from pptx.oxml.shapes import ShapeElement
+ from pptx.types import ProvidesPart
+
+
+class MathShape(BaseShape):
+ """Shape representing a mathematical equation on a slide."""
+
+ def __init__(self, sp: ShapeElement, parent: ProvidesPart):
+ super(MathShape, self).__init__(sp, parent)
+ self._math = None
+
+ @property
+ def math(self) -> Math:
+ """Math object providing access to OMML content."""
+ if self._math is None:
+ from pptx.math.math import Math
+ # Get or create the OMML element from the shape
+ # Pass the parent so Math can access the slide
+ self._math = Math(self._element, self._parent)
+ return self._math
diff --git a/src/pptx/shapes/shapetree.py b/src/pptx/shapes/shapetree.py
index 29623f1f5..352f09898 100644
--- a/src/pptx/shapes/shapetree.py
+++ b/src/pptx/shapes/shapetree.py
@@ -20,6 +20,7 @@
from pptx.shapes.freeform import FreeformBuilder
from pptx.shapes.graphfrm import GraphicFrame
from pptx.shapes.group import GroupShape
+from pptx.shapes.math import MathShape
from pptx.shapes.picture import Movie, Picture
from pptx.shapes.placeholder import (
ChartPlaceholder,
@@ -395,6 +396,51 @@ def add_textbox(self, left: Length, top: Length, width: Length, height: Length)
self._recalculate_extents()
return cast(Shape, self._shape_factory(sp))
+ def add_math_equation(
+ self,
+ left: Length | None = None,
+ top: Length | None = None,
+ width: Length | None = None,
+ height: Length | None = None
+ ) -> MathShape:
+ """Return newly added math equation shape appended to this shape tree.
+
+ The math equation shape is created with the specified size and position. If no dimensions
+ are provided, default values are used.
+ """
+ from pptx.shapes.math import MathShape
+ from pptx.util import Emu
+ from pptx.enum.shapes import MSO_SHAPE
+
+ # Default dimensions if not provided
+ if left is None:
+ left = Emu(914400) # 1 inch
+ if top is None:
+ top = Emu(685800) # 0.75 inch
+ if width is None:
+ width = Emu(1828800) # 2 inches
+ if height is None:
+ height = Emu(914400) # 1 inch
+
+ # Create a basic shape element for math
+ sp = self._add_textbox_sp(left, top, width, height)
+ self._recalculate_extents()
+
+ # Add a marker to identify this as a math shape
+ # We'll add a custom property to the shape's non-visual properties
+ nvPr = sp.nvSpPr.nvPr
+ from pptx.oxml.ns import qn
+ from pptx.oxml.xmlchemy import OxmlElement
+ math_marker = OxmlElement("p:extLst")
+ ext = OxmlElement("p:ext")
+ ext.set("uri", "http://schemas.openxmlformats.org/presentationml/2006/main/math")
+ math_marker.append(ext)
+ nvPr.append(math_marker)
+
+ # Create MathShape directly
+ math_shape = MathShape(sp, self)
+ return math_shape
+
def build_freeform(
self, start_x: float = 0, start_y: float = 0, scale: tuple[float, float] | float = 1.0
) -> FreeformBuilder:
@@ -810,6 +856,20 @@ def BaseShapeFactory(shape_elm: ShapeElement, parent: ProvidesPart) -> BaseShape
return Movie(shape_elm, parent)
return Picture(shape_elm, parent)
+ # Check for math equation shapes
+ if isinstance(shape_elm, CT_Shape):
+ # Check if this shape has the math marker
+ try:
+ nvPr = shape_elm.xpath(".//p:nvPr")[0]
+ extLst = nvPr.xpath("./p:extLst")
+ if extLst:
+ for ext in extLst[0].xpath(".//*[local-name() = 'ext']"):
+ uri = ext.get("uri")
+ if uri == "http://schemas.openxmlformats.org/presentationml/2006/main/math":
+ return MathShape(shape_elm, parent)
+ except (IndexError, AttributeError):
+ pass
+
shape_cls = {
qn("p:cxnSp"): Connector,
qn("p:grpSp"): GroupShape,
diff --git a/tests/test_math.py b/tests/test_math.py
new file mode 100644
index 000000000..621d84fb5
--- /dev/null
+++ b/tests/test_math.py
@@ -0,0 +1,278 @@
+"""Unit tests for math functionality."""
+
+from __future__ import annotations
+
+import pytest
+
+from pptx.oxml.xmlchemy import OxmlElement
+from pptx.oxml.math import (
+ CT_OMath, CT_R, CT_T, CT_F, CT_Num, CT_Den, CT_SSup, CT_E, CT_Rad,
+ CT_Nary, CT_Char, CT_LimLoc, CT_Sub, CT_Sup
+)
+
+
+class TestCT_OMath:
+ """Test CT_OMath element class."""
+
+ def test_ct_omml_creation(self):
+ """Test CT_OMath element creation."""
+ omath = OxmlElement("m:oMath")
+ assert isinstance(omath, CT_OMath)
+ # The tag includes the full namespace URL
+ assert "oMath" in omath.tag
+ assert "http://schemas.openxmlformats.org/officeDocument/2006/math" in omath.tag
+
+ def test_ct_omml_add_r(self):
+ """Test adding text run to OMML element."""
+ omath = OxmlElement("m:oMath")
+ r = omath.add_r()
+ assert isinstance(r, CT_R)
+ assert r in omath
+
+ def test_ct_omml_add_f(self):
+ """Test adding fraction to OMML element."""
+ omath = OxmlElement("m:oMath")
+ f = omath.add_f()
+ assert isinstance(f, CT_F)
+ assert f in omath
+
+ def test_ct_omml_add_sSup(self):
+ """Test adding superscript to OMML element."""
+ omath = OxmlElement("m:oMath")
+ sup = omath.add_sSup()
+ assert isinstance(sup, CT_SSup)
+ assert sup in omath
+
+ def test_ct_omml_add_rad(self):
+ """Test adding radical to OMML element."""
+ omath = OxmlElement("m:oMath")
+ rad = omath.add_rad()
+ assert isinstance(rad, CT_Rad)
+ assert rad in omath
+
+ def test_ct_omml_add_nary(self):
+ """Test adding n-ary operator to OMML element."""
+ omath = OxmlElement("m:oMath")
+ nary = omath.add_nary()
+ assert isinstance(nary, CT_Nary)
+ assert nary in omath
+
+
+class TestCT_R:
+ """Test CT_R text run element class."""
+
+ def test_ct_r_creation(self):
+ """Test CT_R element creation."""
+ r = OxmlElement("m:r")
+ assert isinstance(r, CT_R)
+ # Check for the namespace URL in the tag
+ assert "r" in r.tag
+ assert "http://schemas.openxmlformats.org/officeDocument/2006/math" in r.tag
+
+ def test_ct_r_add_t(self):
+ """Test adding text to run."""
+ r = OxmlElement("m:r")
+ t = r.add_t()
+ assert isinstance(t, CT_T)
+ assert t in r
+
+ def test_ct_r_add_rPr(self):
+ """Test adding run properties."""
+ r = OxmlElement("m:r")
+ rPr = r.add_rPr()
+ assert rPr is not None
+
+
+class TestCT_T:
+ """Test CT_T text element class."""
+
+ def test_ct_t_creation(self):
+ """Test CT_T element creation."""
+ t = OxmlElement("m:t")
+ assert isinstance(t, CT_T)
+ # Check for the namespace URL in the tag
+ assert "t" in t.tag
+ assert "http://schemas.openxmlformats.org/officeDocument/2006/math" in t.tag
+
+ def test_ct_t_text_property(self):
+ """Test text property getter and setter."""
+ t = OxmlElement("m:t")
+
+ # Test default
+ assert t.content == ""
+
+ # Test setter
+ t.content = "Hello Math"
+ assert t.content == "Hello Math"
+
+
+class TestCT_F:
+ """Test CT_F fraction element class."""
+
+ def test_ct_f_creation(self):
+ """Test CT_F element creation."""
+ f = OxmlElement("m:f")
+ assert isinstance(f, CT_F)
+ # Check for the namespace URL in the tag
+ assert "f" in f.tag
+ assert "http://schemas.openxmlformats.org/officeDocument/2006/math" in f.tag
+
+ def test_ct_f_add_num(self):
+ """Test adding numerator to fraction."""
+ f = OxmlElement("m:f")
+ num = f.add_num()
+ assert isinstance(num, CT_Num)
+ assert num in f
+
+ def test_ct_f_add_den(self):
+ """Test adding denominator to fraction."""
+ f = OxmlElement("m:f")
+ den = f.add_den()
+ assert isinstance(den, CT_Den)
+ assert den in f
+
+
+class TestCT_Num:
+ """Test CT_Num numerator element class."""
+
+ def test_ct_num_creation(self):
+ """Test CT_Num element creation."""
+ num = OxmlElement("m:num")
+ assert isinstance(num, CT_Num)
+ # Check for the namespace URL in the tag
+ assert "num" in num.tag
+ assert "http://schemas.openxmlformats.org/officeDocument/2006/math" in num.tag
+
+ def test_ct_num_add_r(self):
+ """Test adding run to numerator."""
+ num = OxmlElement("m:num")
+ r = num.add_r()
+ assert isinstance(r, CT_R)
+ assert r in num
+
+
+class TestCT_Den:
+ """Test CT_Den denominator element class."""
+
+ def test_ct_den_creation(self):
+ """Test CT_Den element creation."""
+ den = OxmlElement("m:den")
+ assert isinstance(den, CT_Den)
+ # Check for the namespace URL in the tag
+ assert "den" in den.tag
+ assert "http://schemas.openxmlformats.org/officeDocument/2006/math" in den.tag
+
+ def test_ct_den_add_r(self):
+ """Test adding run to denominator."""
+ den = OxmlElement("m:den")
+ r = den.add_r()
+ assert isinstance(r, CT_R)
+ assert r in den
+
+
+class TestOMMLIntegration:
+ """Test OMML integration and XML generation."""
+
+ def test_simple_fraction_xml(self):
+ """Test generating XML for simple fraction."""
+ omath = OxmlElement("m:oMath")
+
+ f = omath.add_f()
+ num = f.add_num()
+ r_num = num.add_r()
+ t_num = r_num.add_t()
+ t_num.text = "x"
+
+ den = f.add_den()
+ r_den = den.add_r()
+ t_den = r_den.add_t()
+ t_den.text = "2"
+
+ from pptx.oxml.xmlchemy import serialize_for_reading
+ xml = serialize_for_reading(omath)
+
+ assert "m:oMath" in xml
+ assert "m:f" in xml
+ assert "m:num" in xml
+ assert "m:den" in xml
+ assert "x" in xml
+ assert "2" in xml
+
+ def test_superscript_xml(self):
+ """Test generating XML for superscript."""
+ omath = OxmlElement("m:oMath")
+
+ # Base x
+ r_base = omath.add_r()
+ t_base = r_base.add_t()
+ t_base.text = "x"
+
+ # Superscript 2
+ sup = omath.add_sSup()
+ e = sup.add_e()
+ r_sup = e.add_r()
+ t_sup = r_sup.add_t()
+ t_sup.text = "2"
+
+ from pptx.oxml.xmlchemy import serialize_for_reading
+ xml = serialize_for_reading(omath)
+
+ assert "m:oMath" in xml
+ assert "m:sSup" in xml
+ assert "x" in xml
+ assert "2" in xml
+
+ def test_radical_xml(self):
+ """Test generating XML for radical."""
+ omath = OxmlElement("m:oMath")
+
+ rad = omath.add_rad()
+ radPr = rad.add_radPr()
+ deg = rad.add_deg()
+ e = rad.add_e()
+ r = e.add_r()
+ t = r.add_t()
+ t.text = "x"
+
+ from pptx.oxml.xmlchemy import serialize_for_reading
+ xml = serialize_for_reading(omath)
+
+ assert "m:oMath" in xml
+ assert "m:rad" in xml
+ assert "m:radPr" in xml
+ assert "m:deg" in xml
+ assert "x" in xml
+
+ def test_nary_xml(self):
+ """Test generating XML for n-ary operator."""
+ omath = OxmlElement("m:oMath")
+
+ nary = omath.add_nary()
+ naryPr = nary.add_naryPr()
+ chr_elem = naryPr.add_chr()
+ chr_elem.set("val", "∑")
+
+ sub = nary.add_sub()
+ r_sub = sub.add_r()
+ t_sub = r_sub.add_t()
+ t_sub.text = "i=1"
+
+ sup = nary.add_sup()
+ r_sup = sup.add_r()
+ t_sup = r_sup.add_t()
+ t_sup.text = "n"
+
+ e = nary.add_e()
+ r_e = e.add_r()
+ t_e = r_e.add_t()
+ t_e.text = "i"
+
+ from pptx.oxml.xmlchemy import serialize_for_reading
+ xml = serialize_for_reading(omath)
+
+ assert "m:oMath" in xml
+ assert "m:nary" in xml
+ assert "∑" in xml
+ assert "i=1" in xml
+ assert "n" in xml
+ assert "i" in xml
diff --git a/tests/unitutil/cxml.py b/tests/unitutil/cxml.py
index 79e217c20..c456d8d34 100644
--- a/tests/unitutil/cxml.py
+++ b/tests/unitutil/cxml.py
@@ -19,8 +19,8 @@
alphanums,
alphas,
dblQuotedString,
- delimitedList,
- removeQuotes,
+ DelimitedList,
+ remove_quotes,
stringEnd,
)
@@ -43,8 +43,8 @@ def element(cxel_str: str) -> BaseOxmlElement:
def xml(cxel_str: str) -> str:
"""Return the XML generated from `cxel_str`."""
- root_node.parseWithTabs()
- root_token = root_node.parseString(cxel_str)
+ root_node.parse_with_tabs()
+ root_token = root_node.parse_string(cxel_str)
xml = root_token.element.xml
return xml
@@ -254,28 +254,28 @@ def grammar():
attr_name = Word(alphas + ":")
attr_val = Word(alphanums + " %-./:_")
attr_def = Group(attr_name + equal + attr_val)
- attr_list = open_brace + delimitedList(attr_def) + close_brace
+ attr_list = open_brace + DelimitedList(attr_def) + close_brace
- text = dblQuotedString.setParseAction(removeQuotes)
+ text = dblQuotedString.set_parse_action(remove_quotes)
# w:jc{val=right} ----------------------------
element = (
tagname("tagname")
+ Group(Optional(attr_list))("attr_list")
+ Optional(text, default="")("text")
- ).setParseAction(Element.from_token)
+ ).set_parse_action(Element.from_token)
child_node_list = Forward()
node = Group(
element("element") + Group(Optional(slash + child_node_list))("child_node_list")
- ).setParseAction(connect_node_children)
+ ).set_parse_action(connect_node_children)
- child_node_list << (open_paren + delimitedList(node) + close_paren | node)
+ child_node_list << (open_paren + DelimitedList(node) + close_paren | node)
root_node = (
element("element") + Group(Optional(slash + child_node_list))("child_node_list") + stringEnd
- ).setParseAction(connect_root_node_children)
+ ).set_parse_action(connect_root_node_children)
return root_node