返回 ppt-master
formula.py
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
244 lines PYTHON