From c5d97d37be8e13052f50ab3cbb1b5f20f9b0e365 Mon Sep 17 00:00:00 2001 From: troutrot <150467122+troutrot@users.noreply.github.com> Date: Wed, 20 May 2026 19:57:15 +0900 Subject: [PATCH 1/4] Add inline LaTeX math support via OMML injection Introduces $...$ inline math syntax that converts LaTeX expressions to native PowerPoint math objects (OMML) using MML2OMML.XSL. Activated by the new `mathxsl` metadata option. Falls back to literal text if the option is unset or conversion fails. - pptx_math.py: new module for LaTeX -> MathML -> OMML conversion - paragraph.py: sentinel-based inline math parser and OMML injector - md2pptx: `mathxsl` processing option and math inserter initialisation - docs/user-guide.md: document `mathxsl` option and inline math syntax - README.md: note latex2mathml and lxml as optional dependencies --- README.md | 4 ++ docs/user-guide.md | 36 +++++++++++++++ md2pptx | 10 +++++ paragraph.py | 50 +++++++++++++++++++++ pptx_math.py | 109 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 209 insertions(+) create mode 100644 pptx_math.py diff --git a/README.md b/README.md index fa4b0d0..0457e8d 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,10 @@ You can install python-pptx with `pip3 install python-pptx` +Some optional features require additional packages: + +* **Inline math** (`mathxsl` option): `pip3 install latex2mathml lxml` + (On a Raspberry Pi you might want to use `pip3` (or `python3 -m pip`) to install for Python 3.) You will probably need to issue the following command from the directory where you install it: diff --git a/docs/user-guide.md b/docs/user-guide.md index 0e337c4..875bcaa 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -70,6 +70,7 @@ As you can see in the [change log](#change-log), md2pptx is frequently updated - * [Sample Macro To Remove The First Slide And Save As A .pptx File](#sample-macro-to-remove-the-first-slide-and-save-as-a-pptx-file) * [HTML Comments](#html-comments) * [Special Text Formatting](#special-text-formatting) + * [Inline Math](#inline-math) * [Using HTML `` Elements To Specify Text Effects](#using-html--elements-to-specify-text-effects) * [Using HTML `` Elements with `class`](#using-html--elements-with-class) * [Using HTML `` Elements with `style`](#using-html--elements-with-style) @@ -97,6 +98,7 @@ As you can see in the [change log](#change-log), md2pptx is frequently updated - * [Presentation Subtitle Size - `presSubtitleSize`](#presentation-subtitle-size-pressubtitlesize) * [Page Title Alignment `pagetitlealign`](#page-title-alignment-pagetitlealign) * [Monospace Font - `monoFont`](#monospace-font-monofont) + * [Inline Math - `mathxsl`](#inline-math-mathxsl) * [Margin size - `marginBase` and `tableMargin`](#margin-size-marginbase-and-tablemargin) * [Controlling Adjusting Title Positions And Sizes - `AdjustTitles`](#controlling-adjusting-title-positions-and-sizes-adjusttitles) * [Associating A Class Name with A Background Colour With `style.bgcolor`](#associating-a-class-name-with-a-background-colour-with-stylebgcolor) @@ -1659,6 +1661,15 @@ Some other HTML-originated text effects work - as Markdown allows you to embed H **Note:** Superscript works by raising the text baseline. Subscript works by lowering it. This is how Powerpoint itself does it. +### Inline Math + + +If you have configured the [`mathxsl`](#inline-math-mathxsl) metadata option, you can embed LaTeX inline math expressions by surrounding them with dollar signs: + + * The Euler identity $e^{i\pi}+1=0$ is beautiful. + +Each `$...$` expression is converted to a native PowerPoint math object (OMML). See [Inline Math - `mathxsl`](#inline-math-mathxsl) for setup instructions. + ### Using HTML `` Elements To Specify Text Effects @@ -2091,6 +2102,31 @@ Example: The default is Courier. +#### Inline Math - `mathxsl` + + +To render LaTeX inline math expressions as native PowerPoint math objects, specify the path to `MML2OMML.XSL` (supplied with Microsoft Office): + + mathxsl: /path/to/MML2OMML.XSL + +Once set, surround any LaTeX expression with dollar signs in your bullet text: + + * The Euler identity $e^{i\pi}+1=0$ is beautiful. + * The quadratic formula $\frac{-b \pm \sqrt{b^2-4ac}}{2a}$ solves ax²+bx+c=0. + +md2pptx converts each `$...$` expression to OMML (Office Math Markup Language) and embeds it directly in the slide XML, so the formula renders as a native PowerPoint equation object. + +**Requirements:** + +* `MML2OMML.XSL` — included with a local Microsoft Office installation (typically under `Office/root/`) +* `pip3 install latex2mathml lxml` + +**Notes:** + +* If `mathxsl` is not specified, `$...$` is left as-is in the output text. +* If conversion fails for a particular expression, md2pptx falls back to rendering the literal `$...$` text. +* `~` in the XSL path is expanded to the user's home directory. + #### Margin size - `marginBase` and `tableMargin` diff --git a/md2pptx b/md2pptx index 87f71ca..ed8a95b 100755 --- a/md2pptx +++ b/md2pptx @@ -4963,6 +4963,8 @@ globals.processingOptions.setOptionValuesArray( globals.processingOptions.setOptionValues("monoFont", "Courier") +globals.processingOptions.setOptionValues("mathxsl", "") + topHeadingLevel = 1 titleLevel = topHeadingLevel sectionLevel = titleLevel + 1 @@ -5648,6 +5650,9 @@ for line in metadata_lines: applescriptPrologueFiles.append(prologFile) globals.processingOptions.setOptionValues("applescriptprologue", applescriptPrologueFiles) + elif name == "mathxsl": + globals.processingOptions.setOptionValues(name, os.path.expanduser(value)) + elif name in ["applescriptepilog", "applescriptepilogue"]: if value[:1] == "!": epilogFile = md2pptx_path + os.sep + "applescript" + os.sep + value[1:] @@ -5658,6 +5663,11 @@ for line in metadata_lines: applescriptEpilogueFiles.append(epilogFile) globals.processingOptions.setOptionValues("applescriptepilogue", applescriptEpilogueFiles) +# Set up inline math inserter if mathxsl option is provided +if (mathxsl_path := globals.processingOptions.getCurrentOption("mathxsl")) != "": + from paragraph import setup_math_inserter + setup_math_inserter(mathxsl_path) + # Add any prologue file contents to the AppleScript that will be generated if (applescriptPrologueFiles := globals.processingOptions.getCurrentOption("applescriptprologue")) is not []: for prologueFile in applescriptPrologueFiles: diff --git a/paragraph.py b/paragraph.py index 792df01..079fb30 100644 --- a/paragraph.py +++ b/paragraph.py @@ -15,6 +15,26 @@ from symbols import resolveSymbols from colour import * +try: + from pptx_math import MathInserter, inject_inline_math as _inject_inline_math + _PPTX_MATH_AVAILABLE = True +except ImportError: + _PPTX_MATH_AVAILABLE = False + +_math_inserter = None + + +def setup_math_inserter(xsl_path: str) -> None: + """Initialise the module-level math inserter with the given MML2OMML.XSL path.""" + global _math_inserter + if not _PPTX_MATH_AVAILABLE: + print("Warning: pptx_math module not found. Math insertion disabled.") + return + try: + _math_inserter = MathInserter(xsl_path=xsl_path) + except Exception as e: + print(f"Warning: Failed to initialise math inserter: {e}") + # Following functions are workarounds for python-pptx not having these functions for the font object def setSubscript(font): if font.size is None: @@ -115,6 +135,9 @@ def parseText(text): if text2[-2:] == " *": text2 = text2[:-2] + " ∗" + # Replace any $...$ (inline math) with sentinel \uFDE4...\uFDE4 + text2 = re.sub(r'\$([^$\n]+)\$', lambda m: u"\uFDE4" + m.group(1) + u"\uFDE4", text2) + # Replace any footnote reference starts with char "\uFDD0" text2 = text2.replace("[^", u"\uFDD0") @@ -532,6 +555,18 @@ def parseText(text): fragment = "" state = "fnref" + + elif c == u"\uFDE4": + # Inline math delimiter: $...$ + if state == "N": + textArray.append([state, fragment]) + fragment = "" + state = "Math" + else: + textArray.append([state, fragment]) + fragment = "" + state = "N" + else: fragment = fragment + c @@ -595,6 +630,21 @@ def addFormattedText(p, text): # Ensure "\#" is rendered as a literal octothorpe subfragment = subfragment.replace("#", "#") + # Inline math: inject OMML directly into the paragraph XML, skip run creation + if fragType == "Math": + if _math_inserter is not None and _PPTX_MATH_AVAILABLE: + try: + omath = _math_inserter.make_inline_omml(subfragment) + _inject_inline_math(p._p, omath) + except Exception: + run = p.add_run() + run.text = f"${subfragment}$" + else: + run = p.add_run() + run.text = f"${subfragment}$" + flattenedText = flattenedText + f"${subfragment}$" + continue + run = p.add_run() if fragType not in ["Link", "fnref", "Gloss"]: diff --git a/pptx_math.py b/pptx_math.py new file mode 100644 index 0000000..5989365 --- /dev/null +++ b/pptx_math.py @@ -0,0 +1,109 @@ +""" +pptx_math.py +============ +Insert native PowerPoint math (OMML) into slides via python-pptx. + +This is a minimal build for md2pptx integration. + +Requirements: + pip install python-pptx latex2mathml lxml + MML2OMML.XSL (from a local Microsoft Office installation) + +Usage in md2pptx: + Specify the XSL path in the Markdown metadata header: + mathxsl: /path/to/MML2OMML.XSL + Then use inline math in bullet text: + * The formula $E = mc^2$ is well known. +""" + +from __future__ import annotations + +from pathlib import Path +from lxml import etree + +# ── Namespace URIs ──────────────────────────────────────────────────────────── + +A14 = "http://schemas.microsoft.com/office/drawing/2010/main" +M = "http://schemas.openxmlformats.org/officeDocument/2006/math" +A = "http://schemas.openxmlformats.org/drawingml/2006/main" + + +def _t(ns: str, local: str) -> str: + return f"{{{ns}}}{local}" + + +# ── LaTeX → OMML conversion ─────────────────────────────────────────────────── + +def _latex_to_omml(latex: str, transform: etree.XSLT) -> etree._Element: + """Convert LaTeX to an OMML element via MathML and MML2OMML.XSL.""" + import latex2mathml.converter + mathml_str = latex2mathml.converter.convert(latex) + mathml = etree.fromstring(mathml_str.encode()) + result = transform(mathml) + root = result.getroot() + if root is None: + raise ValueError(f"XSLT produced no output for: {latex!r}") + return root + + +def _extract_oMath(elem: etree._Element) -> etree._Element: + """ + Return an element. + - If the root is already (typical MML2OMML.XSL output), return it directly. + - If the root is , return its child. + """ + if elem.tag == _t(M, "oMath"): + return elem + oMath = elem.find(_t(M, "oMath")) + if oMath is None: + raise ValueError(f"No found in: {etree.tostring(elem)}") + return oMath + + +# ── Inline math injection ───────────────────────────────────────────────────── + +def inject_inline_math(p_elem: etree._Element, omath_elem: etree._Element) -> None: + """ + Append ... to a paragraph's lxml element. + + Inserted immediately before so the order matches what + python-pptx's add_run() produces. + + Parameters + ---------- + p_elem : the paragraph's _p attribute (lxml element) + omath_elem: an element (inline math, not wrapped in oMathPara) + """ + a14m = etree.Element(_t(A14, "m"), nsmap={"a14": A14}) + a14m.append(omath_elem) + endParaRPr = p_elem.find(_t(A, "endParaRPr")) + if endParaRPr is not None: + endParaRPr.addprevious(a14m) + else: + p_elem.append(a14m) + + +# ── Public API ──────────────────────────────────────────────────────────────── + +class MathInserter: + """ + Convert LaTeX inline math and inject it into a python-pptx paragraph. + + Parameters + ---------- + xsl_path : path to MML2OMML.XSL (required) + """ + + def __init__(self, xsl_path: str | Path): + xsl_path = Path(xsl_path) + if not xsl_path.exists(): + raise FileNotFoundError(f"MML2OMML.XSL not found: {xsl_path}") + self._transform = etree.XSLT(etree.parse(str(xsl_path))) + + def make_inline_omml(self, latex: str) -> etree._Element: + """ + Convert a LaTeX expression to an inline element. + Pass the result to inject_inline_math() to embed it in a paragraph. + """ + omml = _latex_to_omml(latex, self._transform) + return _extract_oMath(omml) From f4d1569bdd8b97c193f69ad0b39a384db654a058 Mon Sep 17 00:00:00 2001 From: troutrot <150467122+troutrot@users.noreply.github.com> Date: Wed, 27 May 2026 12:49:24 +0900 Subject: [PATCH 2/4] Add block math via fenced code blocks with right-aligned equation numbers Replace inline \$...\$ approach with fenced \`\`\`math blocks handled by PptxMath. Equation numbers (e.g. \`\`\`math 1.2.4) use eqArr + centerGroup so the number is right-aligned. Remove wrap="none" from bodyPr so the tab-stop layout has a defined right boundary. Co-Authored-By: Claude Sonnet 4.6 --- md2pptx | 16 ++-- paragraph.py | 49 ----------- pptx_math.py | 236 +++++++++++++++++++++++++++++++++++++++++---------- 3 files changed, 202 insertions(+), 99 deletions(-) diff --git a/md2pptx b/md2pptx index ed8a95b..374e93c 100755 --- a/md2pptx +++ b/md2pptx @@ -44,6 +44,7 @@ from pptx.oxml import parse_xml import uuid import funnel import runPython +import pptx_math from card import Card from rectangle import Rectangle from colour import * @@ -1579,6 +1580,10 @@ def handleRunPython(pythonType, prs, slide, renderingRectangle, codeLinesOrFile, else: r.runFromFile(codeLinesOrFile[0], prs, slide, renderingRectangle) +def handleMath(prs, slide, renderingRectangle, codeLines, codeType): + m = pptx_math.PptxMath() + m.run(prs, slide, renderingRectangle, codeLines, codeType) + def createCodeBlock(slideInfo, slide, renderingRectangle, codeBlockNumber): monoFont = globals.processingOptions.getCurrentOption("monoFont") baseTextSize = globals.processingOptions.getCurrentOption("baseTextSize") @@ -1633,6 +1638,11 @@ def createCodeBlock(slideInfo, slide, renderingRectangle, codeBlockNumber): return slide + elif codeType.startswith("math") or codeType.startswith("maths"): + handleMath(prs, slide, renderingRectangle, codeLines[1:-1], codeType) + + return slide + else: # Some other type with backticks codeType = "backticks" @@ -4675,6 +4685,7 @@ else: print("\nInternal Dependencies:") print(f"\n funnel: {funnel.version}") print(f" runPython: {runPython.version}") +print(f" pptx_math: {pptx_math.version}") input_file = [] @@ -5663,11 +5674,6 @@ for line in metadata_lines: applescriptEpilogueFiles.append(epilogFile) globals.processingOptions.setOptionValues("applescriptepilogue", applescriptEpilogueFiles) -# Set up inline math inserter if mathxsl option is provided -if (mathxsl_path := globals.processingOptions.getCurrentOption("mathxsl")) != "": - from paragraph import setup_math_inserter - setup_math_inserter(mathxsl_path) - # Add any prologue file contents to the AppleScript that will be generated if (applescriptPrologueFiles := globals.processingOptions.getCurrentOption("applescriptprologue")) is not []: for prologueFile in applescriptPrologueFiles: diff --git a/paragraph.py b/paragraph.py index 079fb30..55583b0 100644 --- a/paragraph.py +++ b/paragraph.py @@ -15,26 +15,6 @@ from symbols import resolveSymbols from colour import * -try: - from pptx_math import MathInserter, inject_inline_math as _inject_inline_math - _PPTX_MATH_AVAILABLE = True -except ImportError: - _PPTX_MATH_AVAILABLE = False - -_math_inserter = None - - -def setup_math_inserter(xsl_path: str) -> None: - """Initialise the module-level math inserter with the given MML2OMML.XSL path.""" - global _math_inserter - if not _PPTX_MATH_AVAILABLE: - print("Warning: pptx_math module not found. Math insertion disabled.") - return - try: - _math_inserter = MathInserter(xsl_path=xsl_path) - except Exception as e: - print(f"Warning: Failed to initialise math inserter: {e}") - # Following functions are workarounds for python-pptx not having these functions for the font object def setSubscript(font): if font.size is None: @@ -135,9 +115,6 @@ def parseText(text): if text2[-2:] == " *": text2 = text2[:-2] + " ∗" - # Replace any $...$ (inline math) with sentinel \uFDE4...\uFDE4 - text2 = re.sub(r'\$([^$\n]+)\$', lambda m: u"\uFDE4" + m.group(1) + u"\uFDE4", text2) - # Replace any footnote reference starts with char "\uFDD0" text2 = text2.replace("[^", u"\uFDD0") @@ -556,17 +533,6 @@ def parseText(text): fragment = "" state = "fnref" - elif c == u"\uFDE4": - # Inline math delimiter: $...$ - if state == "N": - textArray.append([state, fragment]) - fragment = "" - state = "Math" - else: - textArray.append([state, fragment]) - fragment = "" - state = "N" - else: fragment = fragment + c @@ -630,21 +596,6 @@ def addFormattedText(p, text): # Ensure "\#" is rendered as a literal octothorpe subfragment = subfragment.replace("#", "#") - # Inline math: inject OMML directly into the paragraph XML, skip run creation - if fragType == "Math": - if _math_inserter is not None and _PPTX_MATH_AVAILABLE: - try: - omath = _math_inserter.make_inline_omml(subfragment) - _inject_inline_math(p._p, omath) - except Exception: - run = p.add_run() - run.text = f"${subfragment}$" - else: - run = p.add_run() - run.text = f"${subfragment}$" - flattenedText = flattenedText + f"${subfragment}$" - continue - run = p.add_run() if fragType not in ["Link", "fnref", "Gloss"]: diff --git a/pptx_math.py b/pptx_math.py index 5989365..36d47b9 100644 --- a/pptx_math.py +++ b/pptx_math.py @@ -1,31 +1,24 @@ -""" -pptx_math.py -============ -Insert native PowerPoint math (OMML) into slides via python-pptx. - -This is a minimal build for md2pptx integration. - -Requirements: - pip install python-pptx latex2mathml lxml - MML2OMML.XSL (from a local Microsoft Office installation) +from __future__ import annotations -Usage in md2pptx: - Specify the XSL path in the Markdown metadata header: - mathxsl: /path/to/MML2OMML.XSL - Then use inline math in bullet text: - * The formula $E = mc^2$ is well known. +""" +pptx_math """ -from __future__ import annotations +version = "0.1" +import sys from pathlib import Path from lxml import etree +import globals + # ── Namespace URIs ──────────────────────────────────────────────────────────── A14 = "http://schemas.microsoft.com/office/drawing/2010/main" M = "http://schemas.openxmlformats.org/officeDocument/2006/math" A = "http://schemas.openxmlformats.org/drawingml/2006/main" +P = "http://schemas.openxmlformats.org/presentationml/2006/main" +MC = "http://schemas.openxmlformats.org/markup-compatibility/2006" def _t(ns: str, local: str) -> str: @@ -35,7 +28,6 @@ def _t(ns: str, local: str) -> str: # ── LaTeX → OMML conversion ─────────────────────────────────────────────────── def _latex_to_omml(latex: str, transform: etree.XSLT) -> etree._Element: - """Convert LaTeX to an OMML element via MathML and MML2OMML.XSL.""" import latex2mathml.converter mathml_str = latex2mathml.converter.convert(latex) mathml = etree.fromstring(mathml_str.encode()) @@ -47,11 +39,6 @@ def _latex_to_omml(latex: str, transform: etree.XSLT) -> etree._Element: def _extract_oMath(elem: etree._Element) -> etree._Element: - """ - Return an element. - - If the root is already (typical MML2OMML.XSL output), return it directly. - - If the root is , return its child. - """ if elem.tag == _t(M, "oMath"): return elem oMath = elem.find(_t(M, "oMath")) @@ -60,20 +47,27 @@ def _extract_oMath(elem: etree._Element) -> etree._Element: return oMath -# ── Inline math injection ───────────────────────────────────────────────────── +def _add_cambria_math_rpr(omath_elem: etree._Element) -> None: + """Add to each bare . -def inject_inline_math(p_elem: etree._Element, omath_elem: etree._Element) -> None: + Google Slides requires explicit Cambria Math declarations on math runs + to recognise and render OMML equations. MML2OMML.XSL does not add them. """ - Append ... to a paragraph's lxml element. + for mr in omath_elem.iter(_t(M, "r")): + if mr.find(_t(A, "rPr")) is None: + rPr = etree.Element(_t(A, "rPr")) + latin = etree.SubElement(rPr, _t(A, "latin")) + latin.set("typeface", "Cambria Math") + latin.set("panose", "02040503050406030204") + latin.set("pitchFamily","18") + latin.set("charset", "0") + mr.insert(0, rPr) - Inserted immediately before so the order matches what - python-pptx's add_run() produces. - Parameters - ---------- - p_elem : the paragraph's _p attribute (lxml element) - omath_elem: an element (inline math, not wrapped in oMathPara) - """ +# ── Math injection ──────────────────────────────────────────────────────────── + +def inject_inline_math(p_elem: etree._Element, omath_elem: etree._Element) -> None: + """Append ... to a paragraph's lxml element.""" a14m = etree.Element(_t(A14, "m"), nsmap={"a14": A14}) a14m.append(omath_elem) endParaRPr = p_elem.find(_t(A, "endParaRPr")) @@ -83,17 +77,120 @@ def inject_inline_math(p_elem: etree._Element, omath_elem: etree._Element) -> No p_elem.append(a14m) -# ── Public API ──────────────────────────────────────────────────────────────── - -class MathInserter: +def _rPr(lang: str = "en-US", bold: bool = False, italic: bool = False) -> etree._Element: + """Build with Cambria Math font.""" + el = etree.Element(_t(A, "rPr")) + el.set("lang", lang) + el.set("b", "1" if bold else "0") + el.set("i", "1" if italic else "0") + el.set("smtClean", "0") + latin = etree.SubElement(el, _t(A, "latin")) + latin.set("typeface", "Cambria Math") + latin.set("panose", "02040503050406030204") + latin.set("pitchFamily", "18") + latin.set("charset", "0") + return el + + +def inject_block_math(p_elem: etree._Element, omath_elem: etree._Element, + eq_number: str | None = None) -> None: + """Inject block math via with optional equation number. + + When eq_number is given, wraps the equation in and appends a + # separator run followed by a delimiter — identical to the structure + PowerPoint produces when the user types '#(n)' in its own equation editor. """ - Convert LaTeX inline math and inject it into a python-pptx paragraph. + if eq_number: + eqArr = etree.Element(_t(M, "eqArr")) + + eqArrPr = etree.SubElement(eqArr, _t(M, "eqArrPr")) + ctrlPr = etree.SubElement(eqArrPr, _t(M, "ctrlPr")) + ctrlPr.append(_rPr()) + + e_elem = etree.SubElement(eqArr, _t(M, "e")) + + for child in list(omath_elem): + e_elem.append(child) + + hash_r = etree.SubElement(e_elem, _t(M, "r")) + hash_r.append(_rPr(italic=True)) + hash_t = etree.SubElement(hash_r, _t(M, "t")) + hash_t.text = "#" + + d_elem = etree.SubElement(e_elem, _t(M, "d")) + dPr = etree.SubElement(d_elem, _t(M, "dPr")) + ctrlPr2 = etree.SubElement(dPr, _t(M, "ctrlPr")) + ctrlPr2.append(_rPr()) + d_e = etree.SubElement(d_elem, _t(M, "e")) + num_r = etree.SubElement(d_e, _t(M, "r")) + num_r.append(_rPr()) + num_t = etree.SubElement(num_r, _t(M, "t")) + num_t.text = eq_number + + omath_elem.append(eqArr) - Parameters - ---------- - xsl_path : path to MML2OMML.XSL (required) + oMathPara = etree.Element(_t(M, "oMathPara"), nsmap={"m": M}) + oMathParaPr = etree.SubElement(oMathPara, _t(M, "oMathParaPr")) + jc = etree.SubElement(oMathParaPr, _t(M, "jc")) + jc.set(_t(M, "val"), "centerGroup") + oMathPara.append(omath_elem) + + a14m = etree.Element(_t(A14, "m"), nsmap={"a14": A14}) + a14m.append(oMathPara) + + endParaRPr = p_elem.find(_t(A, "endParaRPr")) + if endParaRPr is not None: + endParaRPr.addprevious(a14m) + else: + p_elem.append(a14m) + + +def _prep_math_shape(shape) -> None: + """Remove txBox="1" and fix auto-resize on a math shape.""" + sp = shape._element + tf = shape.text_frame + + cNvSpPr = sp.find(f'.//{{{P}}}cNvSpPr') + if cNvSpPr is not None: + cNvSpPr.attrib.pop('txBox', None) + + bodyPr = tf._txBody.bodyPr + bodyPr.attrib.pop("wrap", None) + for child in list(bodyPr): + if child.tag in (_t(A, "spAutoFit"), _t(A, "normAutofit")): + bodyPr.remove(child) + etree.SubElement(bodyPr, _t(A, "noAutofit")) + + +def _hoist_math_namespaces(shape) -> None: + """Hoist xmlns:a14 and xmlns:mc to the element. + + Google Slides' PPTX importer uses the presence of xmlns:a14 at the + shape level to identify OMML-containing shapes. lxml cannot add + namespace declarations to existing elements, so we serialise, patch + the opening tag, re-parse and replace in the slide tree. + Must be called AFTER all math content has been injected. """ + sp = shape._element + parent = sp.getparent() + if parent is None or A14 in sp.nsmap.values(): + return + + xml = etree.tostring(sp, encoding="unicode") + xml = xml.replace( + " etree._Element: - """ - Convert a LaTeX expression to an inline element. - Pass the result to inject_inline_math() to embed it in a paragraph. - """ - omml = _latex_to_omml(latex, self._transform) - return _extract_oMath(omml) + omml = _latex_to_omml(latex, self._transform) + omath = _extract_oMath(omml) + _add_cambria_math_rpr(omath) + return omath + + +# ── PptxMath — block math handler (mirrors RunPython pattern) ───────────────── + +class PptxMath: + def __init__(self): + pass + + def run(self, prs, slide, renderingRectangle, codeLines, codeType): + mathxsl_path = globals.processingOptions.getCurrentOption("mathxsl") + if not mathxsl_path: + sys.stderr.write("Math block skipped: mathxsl option not set.\n") + return + + try: + inserter = MathInserter(mathxsl_path) + except (FileNotFoundError, Exception) as e: + sys.stderr.write(f"Math block skipped: {e}\n") + return + + # Extract optional equation number: "math 1.2.4" → "1.2.4" + parts = codeType.split(None, 1) + eq_number = parts[1].strip() if len(parts) > 1 else None + + latex = "\n".join(codeLines) + + math_box = slide.shapes.add_textbox( + renderingRectangle.left, + renderingRectangle.top, + renderingRectangle.width, + renderingRectangle.height, + ) + + tf = math_box.text_frame + _prep_math_shape(math_box) + + p = tf.paragraphs[0] + + pPr = etree.Element(_t(A, "pPr")) + p._p.insert(0, pPr) + + try: + omath = inserter.make_inline_omml(latex) + inject_block_math(p._p, omath, eq_number) + except Exception as e: + sys.stderr.write(f"Math conversion failed: {e}\n") + p.text = latex + + # Must be called after injection: serialises and re-parses the sp + # element to hoist xmlns:a14 / xmlns:mc to level. + _hoist_math_namespaces(math_box) From 4fcee5280ab3e5e3c9f30761c1c60b20546080ef Mon Sep 17 00:00:00 2001 From: troutrot <150467122+troutrot@users.noreply.github.com> Date: Wed, 27 May 2026 13:17:42 +0900 Subject: [PATCH 3/4] Align eqArr ctrlPr with PowerPoint native output; add marL/indent to pPr Set italic=True on eqArrPr/dPr ctrlPr rPr to match PowerPoint's own structure. Add marL="0" indent="0" and buNone to paragraph properties so the tab-stop has a clean left boundary. Co-Authored-By: Claude Sonnet 4.6 --- pptx_math.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pptx_math.py b/pptx_math.py index 36d47b9..b78f839 100644 --- a/pptx_math.py +++ b/pptx_math.py @@ -105,7 +105,7 @@ def inject_block_math(p_elem: etree._Element, omath_elem: etree._Element, eqArrPr = etree.SubElement(eqArr, _t(M, "eqArrPr")) ctrlPr = etree.SubElement(eqArrPr, _t(M, "ctrlPr")) - ctrlPr.append(_rPr()) + ctrlPr.append(_rPr(italic=True)) e_elem = etree.SubElement(eqArr, _t(M, "e")) @@ -120,10 +120,10 @@ def inject_block_math(p_elem: etree._Element, omath_elem: etree._Element, d_elem = etree.SubElement(e_elem, _t(M, "d")) dPr = etree.SubElement(d_elem, _t(M, "dPr")) ctrlPr2 = etree.SubElement(dPr, _t(M, "ctrlPr")) - ctrlPr2.append(_rPr()) + ctrlPr2.append(_rPr(italic=True)) d_e = etree.SubElement(d_elem, _t(M, "e")) num_r = etree.SubElement(d_e, _t(M, "r")) - num_r.append(_rPr()) + num_r.append(_rPr(italic=True)) num_t = etree.SubElement(num_r, _t(M, "t")) num_t.text = eq_number @@ -241,6 +241,9 @@ def run(self, prs, slide, renderingRectangle, codeLines, codeType): p = tf.paragraphs[0] pPr = etree.Element(_t(A, "pPr")) + pPr.set("marL", "0") + pPr.set("indent", "0") + etree.SubElement(pPr, _t(A, "buNone")) p._p.insert(0, pPr) try: From 64d72dc39cee3522628a5feec2878950e9221b18 Mon Sep 17 00:00:00 2001 From: troutrot <150467122+troutrot@users.noreply.github.com> Date: Wed, 27 May 2026 13:25:09 +0900 Subject: [PATCH 4/4] Replace eqArr with DrawingML tab stops for equation number right-alignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the OMML eqArr+# approach (which PowerPoint did not render as right-aligned in textbox shapes). Instead, for numbered equations use two DrawingML tab stops — centred at shape mid-point and right-aligned at shape right edge — with the equation as inline and the number as plain text. Unnumbered equations keep the oMathPara centerGroup block layout unchanged. Co-Authored-By: Claude Sonnet 4.6 --- pptx_math.py | 107 ++++++++++++++++++++++++++------------------------- 1 file changed, 55 insertions(+), 52 deletions(-) diff --git a/pptx_math.py b/pptx_math.py index b78f839..f216239 100644 --- a/pptx_math.py +++ b/pptx_math.py @@ -77,58 +77,9 @@ def inject_inline_math(p_elem: etree._Element, omath_elem: etree._Element) -> No p_elem.append(a14m) -def _rPr(lang: str = "en-US", bold: bool = False, italic: bool = False) -> etree._Element: - """Build with Cambria Math font.""" - el = etree.Element(_t(A, "rPr")) - el.set("lang", lang) - el.set("b", "1" if bold else "0") - el.set("i", "1" if italic else "0") - el.set("smtClean", "0") - latin = etree.SubElement(el, _t(A, "latin")) - latin.set("typeface", "Cambria Math") - latin.set("panose", "02040503050406030204") - latin.set("pitchFamily", "18") - latin.set("charset", "0") - return el - - -def inject_block_math(p_elem: etree._Element, omath_elem: etree._Element, - eq_number: str | None = None) -> None: - """Inject block math via with optional equation number. - - When eq_number is given, wraps the equation in and appends a - # separator run followed by a delimiter — identical to the structure - PowerPoint produces when the user types '#(n)' in its own equation editor. - """ - if eq_number: - eqArr = etree.Element(_t(M, "eqArr")) - - eqArrPr = etree.SubElement(eqArr, _t(M, "eqArrPr")) - ctrlPr = etree.SubElement(eqArrPr, _t(M, "ctrlPr")) - ctrlPr.append(_rPr(italic=True)) - - e_elem = etree.SubElement(eqArr, _t(M, "e")) - - for child in list(omath_elem): - e_elem.append(child) - - hash_r = etree.SubElement(e_elem, _t(M, "r")) - hash_r.append(_rPr(italic=True)) - hash_t = etree.SubElement(hash_r, _t(M, "t")) - hash_t.text = "#" - - d_elem = etree.SubElement(e_elem, _t(M, "d")) - dPr = etree.SubElement(d_elem, _t(M, "dPr")) - ctrlPr2 = etree.SubElement(dPr, _t(M, "ctrlPr")) - ctrlPr2.append(_rPr(italic=True)) - d_e = etree.SubElement(d_elem, _t(M, "e")) - num_r = etree.SubElement(d_e, _t(M, "r")) - num_r.append(_rPr(italic=True)) - num_t = etree.SubElement(num_r, _t(M, "t")) - num_t.text = eq_number - - omath_elem.append(eqArr) +def inject_block_math(p_elem: etree._Element, omath_elem: etree._Element) -> None: + """Inject a centered block equation via .""" oMathPara = etree.Element(_t(M, "oMathPara"), nsmap={"m": M}) oMathParaPr = etree.SubElement(oMathPara, _t(M, "oMathParaPr")) jc = etree.SubElement(oMathParaPr, _t(M, "jc")) @@ -145,6 +96,45 @@ def inject_block_math(p_elem: etree._Element, omath_elem: etree._Element, p_elem.append(a14m) +def inject_numbered_block_math(p_elem: etree._Element, omath_elem: etree._Element, + eq_number: str) -> None: + """Inject a numbered block equation using DrawingML tab stops. + + The paragraph pPr must already contain a centred tab at shape_width//2 + and a right-aligned tab at shape_width (added by the caller). Content + layout: [tab→centre] [inline oMath] [tab→right] [(eq_number)] + """ + def _append(elem: etree._Element) -> None: + endParaRPr = p_elem.find(_t(A, "endParaRPr")) + if endParaRPr is not None: + endParaRPr.addprevious(elem) + else: + p_elem.append(elem) + + # Leading tab: moves insertion point to the centre tab stop + r_tab1 = etree.Element(_t(A, "r")) + etree.SubElement(r_tab1, _t(A, "rPr")) + etree.SubElement(r_tab1, _t(A, "t")).text = "\t" + _append(r_tab1) + + # Inline equation (no oMathPara wrapper so it stays on the same line) + a14m = etree.Element(_t(A14, "m"), nsmap={"a14": A14}) + a14m.append(omath_elem) + _append(a14m) + + # Trailing tab: moves insertion point to the right tab stop + r_tab2 = etree.Element(_t(A, "r")) + etree.SubElement(r_tab2, _t(A, "rPr")) + etree.SubElement(r_tab2, _t(A, "t")).text = "\t" + _append(r_tab2) + + # Equation number text + r_num = etree.Element(_t(A, "r")) + etree.SubElement(r_num, _t(A, "rPr")) + etree.SubElement(r_num, _t(A, "t")).text = f"({eq_number})" + _append(r_num) + + def _prep_math_shape(shape) -> None: """Remove txBox="1" and fix auto-resize on a math shape.""" sp = shape._element @@ -244,11 +234,24 @@ def run(self, prs, slide, renderingRectangle, codeLines, codeType): pPr.set("marL", "0") pPr.set("indent", "0") etree.SubElement(pPr, _t(A, "buNone")) + if eq_number: + # Centre tab at midpoint, right tab at shape right edge + w = renderingRectangle.width + tabLst = etree.SubElement(pPr, _t(A, "tabLst")) + tab_c = etree.SubElement(tabLst, _t(A, "tab")) + tab_c.set("pos", str(w // 2)) + tab_c.set("algn", "ctr") + tab_r = etree.SubElement(tabLst, _t(A, "tab")) + tab_r.set("pos", str(w)) + tab_r.set("algn", "r") p._p.insert(0, pPr) try: omath = inserter.make_inline_omml(latex) - inject_block_math(p._p, omath, eq_number) + if eq_number: + inject_numbered_block_math(p._p, omath, eq_number) + else: + inject_block_math(p._p, omath) except Exception as e: sys.stderr.write(f"Math conversion failed: {e}\n") p.text = latex