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 " 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