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..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 = [] @@ -4963,6 +4974,8 @@ globals.processingOptions.setOptionValuesArray( globals.processingOptions.setOptionValues("monoFont", "Courier") +globals.processingOptions.setOptionValues("mathxsl", "") + topHeadingLevel = 1 titleLevel = topHeadingLevel sectionLevel = titleLevel + 1 @@ -5648,6 +5661,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:] diff --git a/paragraph.py b/paragraph.py index 792df01..55583b0 100644 --- a/paragraph.py +++ b/paragraph.py @@ -532,6 +532,7 @@ def parseText(text): fragment = "" state = "fnref" + else: fragment = fragment + c diff --git a/pptx_math.py b/pptx_math.py new file mode 100644 index 0000000..f216239 --- /dev/null +++ b/pptx_math.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +""" +pptx_math +""" + +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: + return f"{{{ns}}}{local}" + + +# ── LaTeX → OMML conversion ─────────────────────────────────────────────────── + +def _latex_to_omml(latex: str, transform: etree.XSLT) -> etree._Element: + 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: + 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 + + +def _add_cambria_math_rpr(omath_elem: etree._Element) -> None: + """Add to each bare . + + Google Slides requires explicit Cambria Math declarations on math runs + to recognise and render OMML equations. MML2OMML.XSL does not add them. + """ + 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) + + +# ── 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")) + if endParaRPr is not None: + endParaRPr.addprevious(a14m) + else: + p_elem.append(a14m) + + + +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")) + 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 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 + 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: + 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")) + 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) + 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 + + # 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)