"""Build explicit mathematical expressions as native PowerPoint Office Math. Structured expressions are the source of truth. This is not a TeX parser. The wrapper replaces a temporary compiler token with OMML after compilation; the preserved upstream compiler is unchanged. A linear text fallback is kept for applications without Office Math support, not claimed as an equation. """ from __future__ import annotations import copy import hashlib import json import math from pathlib import Path import re import tempfile import unicodedata import zipfile import xml.etree.ElementTree as SVG_ET from lxml import etree as ET from component_typography import resolve_role import template_ref as ref SVG = "http://www.w3.org/2000/svg" A = "http://schemas.openxmlformats.org/drawingml/2006/main" P = "http://schemas.openxmlformats.org/presentationml/2006/main" M = "http://schemas.openxmlformats.org/officeDocument/2006/math" A14 = "http://schemas.microsoft.com/office/drawing/2010/main" MC = "http://schemas.openxmlformats.org/markup-compatibility/2006" NS = {"a": A, "p": P, "m": M, "a14": A14, "mc": MC} TOKEN_PREFIX = "NATIVE_MATH_" def _finite(value, name, *, positive=False): ref.require(type(value) in (int, float) and math.isfinite(value) and (value > 0 if positive else value >= 0), f"{name}: expected {'positive' if positive else 'nonnegative'} finite number") return float(value) def _text(value): ref.require(isinstance(value, str) and value.strip(), "math requires nonempty text") ref.require(not any(unicodedata.category(c) in ("Cc", "Cf") for c in value), "math text cannot contain control/format characters or line breaks") def validate_expression(expression): """Reject unknown constructs rather than silently changing mathematics.""" budget = [0, 0] def visit(expr, depth): budget[0] += 1 ref.require(depth <= 32 and budget[0] <= 1000, "math expression exceeds nesting/node limit") if isinstance(expr, str): _text(expr) budget[1] += len(expr) ref.require(budget[1] <= 8192, "math expression text exceeds limit") return ref.require(isinstance(expr, dict), "math expression requires a string or structured node") kind = expr.get("type") fields = { "row": ({"type", "items"}, set()), "text": ({"type", "text"}, {"style"}), "fraction": ({"type", "numerator", "denominator"}, set()), "script": ({"type", "base"}, {"sub", "sup"}), "radical": ({"type", "body"}, {"degree"}), "delimiter": ({"type", "body", "open", "close"}, set()), "nary": ({"type", "operator", "body"}, {"lower", "upper"}), "matrix": ({"type", "rows"}, set()), } ref.require(kind in fields, f"unsupported math node: {kind}") required, optional = fields[kind] ref.require(required <= set(expr) <= required | optional, f"{kind}: invalid fields") if kind == "text": ref.require(expr.get("style", "plain") in ("plain", "italic", "bold", "bold-italic"), "math text style invalid") _text(expr["text"]) visit(expr["text"], depth + 1) elif kind == "row": ref.require(isinstance(expr["items"], list) and expr["items"], "math row requires items") for value in expr["items"]: visit(value, depth + 1) elif kind == "matrix": rows = expr["rows"] ref.require(isinstance(rows, list) and 1 <= len(rows) <= 10 and all(isinstance(row, list) and 1 <= len(row) <= 10 for row in rows), "math matrix requires 1–10 rows/columns") ref.require(len({len(row) for row in rows}) == 1, "math matrix rows must have equal length") for row in rows: for value in row: visit(value, depth + 1) else: if kind == "script": ref.require("sub" in expr or "sup" in expr, "math script requires sub or sup") if kind == "delimiter": ref.require(expr["open"] in ("(", "[", "{", "|", "‖", "⌈", "⌊", "⟨") and expr["close"] in (")", "]", "}", "|", "‖", "⌉", "⌋", "⟩"), "math delimiter unsupported") if kind == "nary": ref.require(expr["operator"] in ("∑", "∏", "∫", "⋂", "⋃"), "math n-ary operator unsupported") children = {"fraction": ("numerator", "denominator"), "script": ("base", "sub", "sup"), "radical": ("body", "degree"), "delimiter": ("body",), "nary": ("lower", "upper", "body")}[kind] for key in children: if key in expr: visit(expr[key], depth + 1) visit(expression, 0) def linear_text(expr): """An unambiguous linear compatibility view, not a layout approximation.""" if isinstance(expr, str): return expr kind = expr["type"] if kind == "text": return expr["text"] if kind == "row": return "".join(linear_text(value) for value in expr["items"]) if kind == "fraction": return f"({linear_text(expr['numerator'])})/({linear_text(expr['denominator'])})" if kind == "script": result = f"({linear_text(expr['base'])})" return result + "".join(("_" if key == "sub" else "^") + "(" + linear_text(expr[key]) + ")" for key in ("sub", "sup") if key in expr) if kind == "radical": degree = "[" + linear_text(expr["degree"]) + "]" if "degree" in expr else "" return "√" + degree + "(" + linear_text(expr["body"]) + ")" if kind == "delimiter": return expr["open"] + linear_text(expr["body"]) + expr["close"] if kind == "nary": limits = "".join(("_" if key == "lower" else "^") + "(" + linear_text(expr[key]) + ")" for key in ("lower", "upper") if key in expr) return expr["operator"] + limits + "(" + linear_text(expr["body"]) + ")" return "[" + "; ".join(", ".join(linear_text(value) for value in row) for row in expr["rows"]) + "]" def _validate_spec(spec, canvas=None): expected = {"schema_version", "id", "expression", "bounds", "font_family", "font_size_pt", "color", "display", "align"} ref.require(isinstance(spec, dict) and set(spec) == expected and type(spec["schema_version"]) is int and spec["schema_version"] == 1, "native math requires schema_version 1 and declared fields") ref.identifier(spec["id"], "math ID") validate_expression(spec["expression"]) bounds = spec["bounds"] ref.require(isinstance(bounds, (list, tuple)) and len(bounds) == 4, "math bounds requires x/y/width/height") x, y = [_finite(v, "math position") for v in bounds[:2]] w, h = [_finite(v, "math size", positive=True) for v in bounds[2:]] if canvas: ref.require(x + w <= canvas["width"] and y + h <= canvas["height"], "math outside canvas") _text(spec["font_family"]) ref.require(_finite(spec["font_size_pt"], "math font size", positive=True) <= 400, "math font size outside supported range") ref.require(isinstance(spec["color"], str) and re.fullmatch(r"#[0-9a-fA-F]{6}", spec["color"]), "math requires an explicit RGB color") ref.require(spec["display"] in ("block", "inline") and spec["align"] in ("left", "center", "right"), "math display/alignment invalid") def render_math(data, style, theme, bounds, component_id, math_style=None): """Return a source group; font comes from a template or explicit project input. math_style may declare font_family/text_role/palette_index externally when a template has not yet introduced math roles. It is a locked build input, not a global font change. Inline creates a standalone equation frame; this API does not yet combine prose and math inside a flowing text paragraph. """ ref.require(isinstance(data, dict) and type(data.get("schema_version")) is int and data["schema_version"] == 1 and {"schema_version", "expression"} <= set(data) <= {"schema_version", "expression", "display", "align"}, "invalid math component data") display = data.get("display", "block") math_style = math_style or {} ref.require(isinstance(math_style, dict) and set(math_style) <= {"font_family", "text_role", "palette_index"}, "invalid math style declaration") family = math_style.get("font_family") or style["typography"]["families"].get("math", {}).get("family") ref.require(family, "Declare a math font in template typography.families.math or project math_style") role_name = math_style.get("text_role", "math_display" if display == "block" else "math_inline") role = resolve_role(style, role_name) spec = {"schema_version": 1, "id": component_id, "expression": data["expression"], "bounds": list(bounds), "font_family": family, "font_size_pt": role.size / style["canvas"]["pt_to_px"], "color": theme["palette"][math_style.get("palette_index", "dk1")], "display": display, "align": data.get("align", "left")} _validate_spec(spec, style["canvas"]) group = SVG_ET.Element("{" + SVG + "}g", {"id": component_id, "data-native-math": "1"}) metadata = SVG_ET.SubElement(group, "{" + SVG + "}metadata", {"type": "application/json"}) metadata.text = json.dumps(spec, ensure_ascii=False) x, y, w, h = bounds anchor = {"left": "start", "center": "middle", "right": "end"}[spec["align"]] tx = x if anchor == "start" else x + w / 2 if anchor == "middle" else x + w node = SVG_ET.SubElement(group, "{" + SVG + "}text", {"x": str(tx), "y": str(y + h / 2 + role.size * .32), "font-family": family, "font-size": str(role.size), "fill": spec["color"], "text-anchor": anchor}) node.text = linear_text(data["expression"]) return group def prepare_math(root, canvas): """Replace source math groups with unique temporary compiler tokens.""" specs, ids = [], set() def walk(element, transformed=False): transformed = transformed or bool(element.get("transform")) if element.get("data-native-math") is not None: ref.require(element.tag == "{" + SVG + "}g" and element.get("data-native-math") == "1", "native math marker must be a version-1 group") ref.require(not transformed, "native math markers do not support ancestor/group transforms") nodes = element.findall("{" + SVG + "}metadata") ref.require(len(nodes) == 1 and nodes[0].get("type") == "application/json", "math requires one JSON metadata node") spec = json.loads(nodes[0].text or "") _validate_spec(spec, canvas) ref.require(element.get("id") == spec["id"] and spec["id"] not in ids, "math ID mismatch or duplicate") visible = "".join(n.text or "" for n in element.iter() if n.tag in ("{" + SVG + "}text", "{" + SVG + "}tspan")) ref.require(visible == linear_text(spec["expression"]), "math fallback differs from structured expression; regenerate") ids.add(spec["id"]) token = TOKEN_PREFIX + hashlib.sha256(spec["id"].encode()).hexdigest()[:24] for child in list(element): element.remove(child) element.attrib.pop("data-native-math") x, y, _, _ = spec["bounds"] placeholder = SVG_ET.SubElement(element, "{" + SVG + "}text", { "x": str(x), "y": str(y + spec["font_size_pt"] * canvas["pt_to_px"]), "font-family": spec["font_family"], "font-size": str(spec["font_size_pt"] * canvas["pt_to_px"]), "fill": spec["color"]}) placeholder.text = token specs.append((token, spec)) return for child in element: walk(child, transformed) walk(root) return specs def _sub(parent, namespace, local, **attrs): return ET.SubElement(parent, "{" + namespace + "}" + local, **attrs) def _property(parent, name, value): return _sub(parent, M, name, **{"{" + M + "}val": str(value)}) def _run_properties(parent, spec, *, tag="rPr"): properties = _sub(parent, A, tag, sz=str(round(spec["font_size_pt"] * 100)), b="0", dirty="0") _sub(_sub(properties, A, "solidFill"), A, "srgbClr", val=spec["color"][1:]) for script in ("latin", "ea", "cs"): _sub(properties, A, script, typeface=spec["font_family"]) return properties def _control(parent, spec): _run_properties(_sub(parent, M, "ctrlPr"), spec) def _expression(parent, expr, spec): if isinstance(expr, str) or expr["type"] == "text": run = _sub(parent, M, "r") if isinstance(expr, dict): properties = _sub(run, M, "rPr") _property(properties, "sty", {"plain": "p", "italic": "i", "bold": "b", "bold-italic": "bi"}[expr.get("style", "plain")]) _run_properties(run, spec) text = _sub(run, M, "t") text.set("{http://www.w3.org/XML/1998/namespace}space", "preserve") text.text = expr if isinstance(expr, str) else expr["text"] return kind = expr["type"] if kind == "row": for value in expr["items"]: _expression(parent, value, spec) return if kind == "fraction": node = _sub(parent, M, "f") _control(_sub(node, M, "fPr"), spec) _expression(_sub(node, M, "num"), expr["numerator"], spec) _expression(_sub(node, M, "den"), expr["denominator"], spec) elif kind == "script": tag = "sSubSup" if "sub" in expr and "sup" in expr else "sSub" if "sub" in expr else "sSup" node = _sub(parent, M, tag) _control(_sub(node, M, tag + "Pr"), spec) _expression(_sub(node, M, "e"), expr["base"], spec) for key in ("sub", "sup"): if key in expr: _expression(_sub(node, M, key), expr[key], spec) elif kind == "radical": node = _sub(parent, M, "rad") props = _sub(node, M, "radPr") _property(props, "degHide", "0" if "degree" in expr else "1") _control(props, spec) degree = _sub(node, M, "deg") if "degree" in expr: _expression(degree, expr["degree"], spec) _expression(_sub(node, M, "e"), expr["body"], spec) elif kind == "delimiter": node = _sub(parent, M, "d") props = _sub(node, M, "dPr") _property(props, "begChr", expr["open"]) _property(props, "endChr", expr["close"]) _control(props, spec) _expression(_sub(node, M, "e"), expr["body"], spec) elif kind == "nary": node = _sub(parent, M, "nary") props = _sub(node, M, "naryPr") _property(props, "chr", expr["operator"]) _property(props, "limLoc", "undOvr" if spec["display"] == "block" else "subSup") _property(props, "subHide", "0" if "lower" in expr else "1") _property(props, "supHide", "0" if "upper" in expr else "1") _control(props, spec) for source, tag in (("lower", "sub"), ("upper", "sup")): child = _sub(node, M, tag) if source in expr: _expression(child, expr[source], spec) _expression(_sub(node, M, "e"), expr["body"], spec) else: node = _sub(parent, M, "m") props = _sub(node, M, "mPr") columns = _sub(props, M, "mcs") column = _sub(_sub(columns, M, "mc"), M, "mcPr") _property(column, "count", len(expr["rows"][0])) _control(props, spec) for row in expr["rows"]: native_row = _sub(node, M, "mr") for value in row: _expression(_sub(native_row, M, "e"), value, spec) def _equation_shape(original, spec, px_to_emu): shape = copy.deepcopy(original) identity = shape.find("p:nvSpPr/p:cNvPr", NS) identity.set("name", spec["id"]) identity.set("descr", linear_text(spec["expression"])) transform = shape.find("p:spPr/a:xfrm", NS) ref.require(transform is not None, "math placeholder has no native geometry") off, extent = transform.find("a:off", NS), transform.find("a:ext", NS) x, y, w, h = spec["bounds"] off.set("x", str(round(x * px_to_emu))); off.set("y", str(round(y * px_to_emu))) extent.set("cx", str(round(w * px_to_emu))); extent.set("cy", str(round(h * px_to_emu))) old_body = shape.find("p:txBody", NS) shape.remove(old_body) body = _sub(shape, P, "txBody") body_props = _sub(body, A, "bodyPr", wrap="none", anchor="ctr", lIns="0", rIns="0", tIns="0", bIns="0") _sub(body_props, A, "noAutofit") _sub(body, A, "lstStyle") paragraph = _sub(body, A, "p") paragraph_props = _sub(paragraph, A, "pPr", algn={"left": "l", "center": "ctr", "right": "r"}[spec["align"]]) # PowerPoint's math layout/export also reads the enclosing paragraph defaults. # Run/control fonts alone leave radicals and tall delimiters incorrectly sized. _run_properties(paragraph_props, spec, tag="defRPr") math_node = _sub(paragraph, A14, "m") if spec["display"] == "block": container = _sub(math_node, M, "oMathPara") _property(_sub(container, M, "oMathParaPr"), "jc", spec["align"]) else: container = math_node _expression(_sub(container, M, "oMath"), spec["expression"], spec) _run_properties(paragraph, spec, tag="endParaRPr") return shape def apply_math(path: Path, pages, canvas): """Install explicit Office Math choices into the temporary native package.""" if not any(pages): return {"native_math_objects": 0} scale = canvas["ppt_inches"][0] * 914400 / canvas["width"] result = [] with zipfile.ZipFile(path) as archive: entries = [(info, archive.read(info.filename)) for info in archive.infolist()] changes = {} for index, specs in enumerate(pages, 1): if not specs: continue part = f"ppt/slides/slide{index}.xml" raw = next(value for info, value in entries if info.filename == part) tree = ET.fromstring(raw) for token, spec in specs: matches = [sp for sp in tree.findall(".//p:sp", NS) if "".join(sp.itertext()).find(token) >= 0 and "".join(sp.xpath(".//a:t/text()", namespaces=NS)) == token] ref.require(len(matches) == 1, f"math compiler token missing/ambiguous: {spec['id']}") original = matches[0] for ancestor in original.iterancestors("{" + P + "}grpSp"): transform = ancestor.find("p:grpSpPr/a:xfrm", NS) ref.require(transform is not None, "math parent group lacks geometry") ref.require(transform.find("a:off", NS).attrib == transform.find("a:chOff", NS).attrib and transform.find("a:ext", NS).attrib == transform.find("a:chExt", NS).attrib, "math parent group coordinate transform is unsupported") native = _equation_shape(original, spec, scale) fallback = copy.deepcopy(native) body = fallback.find("p:txBody", NS) paragraph = body.find("a:p", NS) paragraph.remove(paragraph.find("a14:m", NS)) run = ET.Element("{" + A + "}r") paragraph.insert(paragraph.index(paragraph.find("a:endParaRPr", NS)), run) _run_properties(run, spec) _sub(run, A, "t").text = linear_text(spec["expression"]) alternative = ET.Element("{" + MC + "}AlternateContent", nsmap={"mc": MC}) choice = ET.SubElement(alternative, "{" + MC + "}Choice", Requires="a14", nsmap={"a14": A14, "m": M}) choice.append(native) _sub(alternative, MC, "Fallback").append(fallback) original.getparent().replace(original, alternative) result.append({"slide": index, "id": spec["id"], "font_family": spec["font_family"], "display": spec["display"], "object_type": "Office Math", "fallback": "linear ordinary text for non-math-capable applications"}) serialized = ET.tostring(tree, xml_declaration=True, encoding="utf-8", standalone=True) ref.require(TOKEN_PREFIX.encode() not in serialized, "temporary math token remains in output") changes[part] = serialized with tempfile.NamedTemporaryFile(prefix="office-math-", suffix=".pptx", dir=path.parent, delete=False) as temporary: target = Path(temporary.name) try: with zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as archive: for info, value in entries: archive.writestr(info, changes.get(info.filename, value)) target.replace(path) finally: target.unlink(missing_ok=True) return {"native_math_objects": len(result), "math_objects": result, "math_verification_scope": "native Office Math structure; application rendering/editing require separate checks"}