414 lines
20 KiB
Python
414 lines
20 KiB
Python
"""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):
|
||
properties = _sub(parent, A, "rPr", 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")
|
||
_sub(paragraph, A, "pPr", algn={"left": "l", "center": "ctr", "right": "r"}[spec["align"]])
|
||
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)
|
||
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 = _sub(paragraph, A, "r")
|
||
_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"}
|