Files
Templated-PPT-Master/scripts/math_component.py
T

419 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"}