| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | PPT Master - Native Formula Shape Builder |
| 4 | |
| 5 | Build editable PowerPoint formula shapes from explicit LaTeX markers. |
| 6 | |
| 7 | See references/native-formula.md for the owning block-formula contract. |
| 8 | |
| 9 | Usage: |
| 10 | Imported by the SVG-to-PPTX native-object converter. |
| 11 | |
| 12 | Examples: |
| 13 | from svg_to_pptx.native_objects.formula import build_native_formula |
| 14 | |
| 15 | Dependencies: |
| 16 | None (only uses standard library and local PPT Master modules) |
| 17 | """ |
| 18 | |
| 19 | from __future__ import annotations |
| 20 | |
| 21 | import math |
| 22 | import re |
| 23 | from dataclasses import dataclass |
| 24 | from typing import Any |
| 25 | from xml.etree import ElementTree as ET |
| 26 | |
| 27 | from ..drawingml.context import ConvertContext, ShapeResult |
| 28 | from ..drawingml.utils import _xml_escape, font_px_to_hpt |
| 29 | from .formula_compiler import FormulaCompileError, compile_latex_to_omml |
| 30 | from .formula_run_properties import ( |
| 31 | merge_formula_control_properties, |
| 32 | merge_formula_run_properties, |
| 33 | serialize_styled_formula_omml, |
| 34 | ) |
| 35 | from .marker_common import _bounds, _hex_or_none |
| 36 | |
| 37 | |
| 38 | DML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main" |
| 39 | MATH_NS = "http://schemas.openxmlformats.org/officeDocument/2006/math" |
| 40 | A14_NS = "http://schemas.microsoft.com/office/drawing/2010/main" |
| 41 | MC_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006" |
| 42 | _MATH_RUN = f"{{{MATH_NS}}}r" |
| 43 | _DML_RUN_PROPERTIES = f"{{{DML_NS}}}rPr" |
| 44 | _LANGUAGE_RE = re.compile(r"^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$") |
| 45 | _ALIGNMENTS = { |
| 46 | "center": ("ctr", "center"), |
| 47 | "left": ("l", "left"), |
| 48 | "right": ("r", "right"), |
| 49 | } |
| 50 | |
| 51 | for _prefix, _uri in (("a", DML_NS), ("m", MATH_NS)): |
| 52 | try: |
| 53 | ET.register_namespace(_prefix, _uri) |
| 54 | except (ValueError, AttributeError): |
| 55 | pass |
| 56 | |
| 57 | |
| 58 | @dataclass(frozen=True) |
| 59 | class FormulaSpec: |
| 60 | """Validated formula source plus the PowerPoint text style to apply.""" |
| 61 | |
| 62 | omml: str |
| 63 | font_size_hpt: int |
| 64 | color: str |
| 65 | paragraph_alignment: str |
| 66 | math_alignment: str |
| 67 | language: str |
| 68 | |
| 69 | |
| 70 | def _positive_number(value: Any, field_name: str) -> float: |
| 71 | if isinstance(value, bool): |
| 72 | raise RuntimeError(f"Native PPTX formula {field_name} must be numeric") |
| 73 | try: |
| 74 | result = float(value) |
| 75 | except (TypeError, ValueError, OverflowError) as exc: |
| 76 | raise RuntimeError( |
| 77 | f"Native PPTX formula {field_name} must be numeric" |
| 78 | ) from exc |
| 79 | if not math.isfinite(result) or result <= 0: |
| 80 | raise RuntimeError( |
| 81 | f"Native PPTX formula {field_name} must be a positive finite number" |
| 82 | ) |
| 83 | return result |
| 84 | |
| 85 | |
| 86 | def _formula_language(payload: dict[str, Any], ctx: ConvertContext | None) -> str: |
| 87 | raw = payload.get("language") |
| 88 | if raw is None and ctx is not None: |
| 89 | raw = ctx.primary_language |
| 90 | language = str(raw or "en-US").strip() |
| 91 | if len(language) > 35 or _LANGUAGE_RE.fullmatch(language) is None: |
| 92 | raise RuntimeError( |
| 93 | "Native PPTX formula language must be a compact BCP-47 tag" |
| 94 | ) |
| 95 | return language |
| 96 | |
| 97 | |
| 98 | def validate_formula_payload( |
| 99 | payload: dict[str, Any], |
| 100 | *, |
| 101 | ctx: ConvertContext | None = None, |
| 102 | ) -> FormulaSpec: |
| 103 | """Validate and compile one block-formula payload.""" |
| 104 | latex = payload.get("latex") |
| 105 | if not isinstance(latex, str) or not latex.strip(): |
| 106 | raise RuntimeError("Native PPTX formula metadata requires non-empty `latex`") |
| 107 | |
| 108 | display = str(payload.get("display") or "block").strip().lower() |
| 109 | if display != "block": |
| 110 | raise RuntimeError( |
| 111 | "Native PPTX formula currently supports independent block formulas only" |
| 112 | ) |
| 113 | |
| 114 | raw_font_size = payload.get("font_size", 28) |
| 115 | font_size_px = _positive_number(raw_font_size, "font_size") |
| 116 | if font_size_px > 400: |
| 117 | raise RuntimeError("Native PPTX formula font_size must not exceed 400 px") |
| 118 | try: |
| 119 | font_size_hpt = font_px_to_hpt(font_size_px) |
| 120 | except ValueError as exc: |
| 121 | raise RuntimeError( |
| 122 | "Native PPTX formula font_size is outside the PowerPoint range" |
| 123 | ) from exc |
| 124 | |
| 125 | raw_color = payload.get("color", "#000000") |
| 126 | color = _hex_or_none(raw_color) |
| 127 | if color is None: |
| 128 | raise RuntimeError( |
| 129 | "Native PPTX formula color must be a visible CSS color or HEX value" |
| 130 | ) |
| 131 | |
| 132 | alignment = str(payload.get("align") or "center").strip().lower() |
| 133 | if alignment not in _ALIGNMENTS: |
| 134 | choices = ", ".join(sorted(_ALIGNMENTS)) |
| 135 | raise RuntimeError( |
| 136 | f"Native PPTX formula align must be one of: {choices}" |
| 137 | ) |
| 138 | paragraph_alignment, math_alignment = _ALIGNMENTS[alignment] |
| 139 | |
| 140 | try: |
| 141 | omml = compile_latex_to_omml(latex) |
| 142 | except FormulaCompileError as exc: |
| 143 | raise RuntimeError(f"Native PPTX formula LaTeX is unsupported: {exc}") from exc |
| 144 | |
| 145 | return FormulaSpec( |
| 146 | omml=omml, |
| 147 | font_size_hpt=font_size_hpt, |
| 148 | color=color, |
| 149 | paragraph_alignment=paragraph_alignment, |
| 150 | math_alignment=math_alignment, |
| 151 | language=_formula_language(payload, ctx), |
| 152 | ) |
| 153 | |
| 154 | |
| 155 | def _styled_omml(spec: FormulaSpec) -> str: |
| 156 | """Apply DrawingML run styling and alignment to validated OMML.""" |
| 157 | root = ET.fromstring(spec.omml) |
| 158 | if root.tag == f"{{{MATH_NS}}}oMathPara": |
| 159 | para_properties = root.find(f"{{{MATH_NS}}}oMathParaPr") |
| 160 | if para_properties is None: |
| 161 | para_properties = ET.Element(f"{{{MATH_NS}}}oMathParaPr") |
| 162 | root.insert(0, para_properties) |
| 163 | justification = para_properties.find(f"{{{MATH_NS}}}jc") |
| 164 | if justification is None: |
| 165 | justification = ET.SubElement( |
| 166 | para_properties, |
| 167 | f"{{{MATH_NS}}}jc", |
| 168 | ) |
| 169 | justification.set(f"{{{MATH_NS}}}val", spec.math_alignment) |
| 170 | |
| 171 | run_properties = ET.Element( |
| 172 | _DML_RUN_PROPERTIES, |
| 173 | { |
| 174 | "lang": spec.language, |
| 175 | "sz": str(spec.font_size_hpt), |
| 176 | "dirty": "0", |
| 177 | }, |
| 178 | ) |
| 179 | solid_fill = ET.SubElement(run_properties, f"{{{DML_NS}}}solidFill") |
| 180 | ET.SubElement( |
| 181 | solid_fill, |
| 182 | f"{{{DML_NS}}}srgbClr", |
| 183 | {"val": spec.color}, |
| 184 | ) |
| 185 | for tag in ("latin", "ea", "cs"): |
| 186 | ET.SubElement( |
| 187 | run_properties, |
| 188 | f"{{{DML_NS}}}{tag}", |
| 189 | {"typeface": "Cambria Math"}, |
| 190 | ) |
| 191 | |
| 192 | for run in root.iter(_MATH_RUN): |
| 193 | merge_formula_run_properties(run, run_properties) |
| 194 | merge_formula_control_properties(root, run_properties) |
| 195 | |
| 196 | return serialize_styled_formula_omml(root) |
| 197 | |
| 198 | |
| 199 | def build_native_formula( |
| 200 | elem: ET.Element, |
| 201 | ctx: ConvertContext, |
| 202 | payload: dict[str, Any], |
| 203 | spec: FormulaSpec | None = None, |
| 204 | ) -> ShapeResult: |
| 205 | """Build one Choice-only editable formula with no image fallback.""" |
| 206 | formula_spec = spec or validate_formula_payload(payload, ctx=ctx) |
| 207 | off_x, off_y, ext_cx, ext_cy = _bounds(elem, payload, ctx) |
| 208 | shape_id = ctx.next_id() |
| 209 | name = _xml_escape( |
| 210 | str(payload.get("name") or elem.get("id") or f"Formula {shape_id}") |
| 211 | ) |
| 212 | omml = _styled_omml(formula_spec) |
| 213 | |
| 214 | xml = f'''<mc:AlternateContent xmlns:mc="{MC_NS}"> |
| 215 | <mc:Choice xmlns:a14="{A14_NS}" Requires="a14"> |
| 216 | <p:sp> |
| 217 | <p:nvSpPr> |
| 218 | <p:cNvPr id="{shape_id}" name="{name}"/> |
| 219 | <p:cNvSpPr txBox="1"><a:spLocks noGrp="1"/></p:cNvSpPr> |
| 220 | <p:nvPr/> |
| 221 | </p:nvSpPr> |
| 222 | <p:spPr> |
| 223 | <a:xfrm><a:off x="{off_x}" y="{off_y}"/><a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm> |
| 224 | <a:prstGeom prst="rect"><a:avLst/></a:prstGeom> |
| 225 | <a:noFill/> |
| 226 | <a:ln><a:noFill/></a:ln> |
| 227 | </p:spPr> |
| 228 | <p:txBody> |
| 229 | <a:bodyPr lIns="0" tIns="0" rIns="0" bIns="0" wrap="none" anchor="ctr"> |
| 230 | <a:normAutofit/> |
| 231 | </a:bodyPr> |
| 232 | <a:lstStyle/> |
| 233 | <a:p><a:pPr algn="{formula_spec.paragraph_alignment}"/> |
| 234 | <a14:m>{omml}</a14:m> |
| 235 | </a:p> |
| 236 | </p:txBody> |
| 237 | </p:sp> |
| 238 | </mc:Choice> |
| 239 | </mc:AlternateContent>''' |
| 240 | return ShapeResult( |
| 241 | xml=xml, |
| 242 | bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy), |
| 243 | ) |
| 244 |