返回 ppt-master
inline_formula.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Native Inline Formula Contract
4
5 Validate SVG inline-formula markers and build editable PowerPoint math runs.
6
7 See references/native-formula.md for the owning inline-formula contract.
8
9 Usage:
10 Imported by the SVG quality checker and SVG-to-PPTX text converter.
11
12 Examples:
13 <tspan data-pptx-inline-formula="x_i^2">xᵢ²</tspan>
14
15 Dependencies:
16 None (only uses standard library and local PPT Master modules)
17 """
18
19 from __future__ import annotations
20
21 from xml.etree import ElementTree as ET
22
23 from ..drawingml.utils import parse_inline_style, parse_svg_color
24 from .formula_compiler import (
25 FormulaCompileError,
26 compile_latex_to_inline_omml,
27 )
28 from .formula_run_properties import (
29 merge_formula_control_properties,
30 merge_formula_run_properties,
31 serialize_styled_formula_omml,
32 )
33
34
35 SVG_NS = "http://www.w3.org/2000/svg"
36 DML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
37 OFFICE_REL_NS = (
38 "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
39 )
40 MATH_NS = "http://schemas.openxmlformats.org/officeDocument/2006/math"
41 A14_NS = "http://schemas.microsoft.com/office/drawing/2010/main"
42 MC_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006"
43 INLINE_FORMULA_ATTR = "data-pptx-inline-formula"
44 _SVG_TEXT = f"{{{SVG_NS}}}text"
45 _SVG_TSPAN = f"{{{SVG_NS}}}tspan"
46 _SVG_A = f"{{{SVG_NS}}}a"
47 _MATH_RUN = f"{{{MATH_NS}}}r"
48 _DML_RUN_PROPERTIES = f"{{{DML_NS}}}rPr"
49 _POSITION_ATTRIBUTES = ("x", "y", "dx", "dy")
50 _PARAGRAPH_ATTRIBUTES = (
51 "data-paragraph-line-height",
52 "data-paragraph-line-break",
53 "data-paragraph-soft-break",
54 "data-paragraph-space-before",
55 )
56 _NON_OUTPUT_ANCESTORS = frozenset({
57 "clipPath",
58 "desc",
59 "defs",
60 "filter",
61 "linearGradient",
62 "marker",
63 "mask",
64 "metadata",
65 "pattern",
66 "radialGradient",
67 "style",
68 "symbol",
69 "title",
70 })
71
72 for _prefix, _uri in (("a", DML_NS), ("m", MATH_NS)):
73 try:
74 ET.register_namespace(_prefix, _uri)
75 except (ValueError, AttributeError):
76 pass
77
78
79 def _marker_label(elem: ET.Element) -> str:
80 elem_id = (elem.get("id") or "").strip()
81 return f"<tspan id={elem_id!r}>" if elem_id else "<tspan>"
82
83
84 def inline_formula_marker_errors(root: ET.Element) -> list[str]:
85 """Return strict authoring errors for every inline-formula marker."""
86 errors: list[str] = []
87 parent_map = {child: parent for parent in root.iter() for child in parent}
88 markers = [
89 elem for elem in root.iter()
90 if elem.get(INLINE_FORMULA_ATTR) is not None
91 ]
92
93 for marker in markers:
94 label = _marker_label(marker)
95 if marker.tag != _SVG_TSPAN:
96 tag = marker.tag.rsplit("}", 1)[-1]
97 errors.append(
98 f"{INLINE_FORMULA_ATTR} is only valid on <tspan>, found <{tag}>"
99 )
100 continue
101
102 if marker.get("data-pptx-replace-with") is not None:
103 errors.append(
104 f"{label} cannot also declare data-pptx-replace-with"
105 )
106
107 source = marker.get(INLINE_FORMULA_ATTR) or ""
108 if not source.strip():
109 errors.append(f"{label} requires non-empty {INLINE_FORMULA_ATTR}")
110 else:
111 try:
112 compile_latex_to_inline_omml(source)
113 except FormulaCompileError as exc:
114 errors.append(f"{label} has unsupported inline LaTeX: {exc}")
115
116 if list(marker):
117 errors.append(
118 f"{label} must contain preview text directly and cannot nest elements"
119 )
120 if not (marker.text or "").strip():
121 errors.append(f"{label} requires non-empty visible SVG preview text")
122 elif marker.text != marker.text.strip():
123 errors.append(
124 f"{label} preview cannot contain leading or trailing whitespace; "
125 "place spacing in the surrounding text"
126 )
127
128 positioned = [name for name in _POSITION_ATTRIBUTES if marker.get(name) is not None]
129 if positioned:
130 errors.append(
131 f"{label} is an inline run and cannot set " + ", ".join(positioned)
132 )
133 paragraph_attrs = [
134 name for name in _PARAGRAPH_ATTRIBUTES
135 if marker.get(name) is not None
136 ]
137 if paragraph_attrs:
138 errors.append(
139 f"{label} cannot own paragraph layout metadata: "
140 + ", ".join(paragraph_attrs)
141 )
142
143 effective_fill: str | None = None
144 style_owner: ET.Element | None = marker
145 while style_owner is not None:
146 inline_style = parse_inline_style(style_owner.get("style"))
147 effective_fill = inline_style.get("fill")
148 if effective_fill is None:
149 effective_fill = style_owner.get("fill")
150 if effective_fill is not None:
151 break
152 style_owner = parent_map.get(style_owner)
153 color, alpha = parse_svg_color(effective_fill or "#000000")
154 if color is None or alpha <= 0:
155 errors.append(
156 f"{label} requires one visible solid fill color inherited "
157 "from itself or its text ancestors"
158 )
159
160 parent = parent_map.get(marker)
161 text_owner: ET.Element | None = None
162 nested_marker = False
163 inside_block_formula = False
164 inside_native_replacement = False
165 inside_preserved_text = False
166 inside_placeholder = False
167 inside_skipped_transport = False
168 inside_baseline_shift = marker.get("baseline-shift") is not None
169 invalid_inline_container: str | None = None
170 non_output_ancestor: str | None = None
171 fixed_structure_layer: str | None = None
172 while parent is not None:
173 parent_tag = parent.tag.rsplit("}", 1)[-1]
174 if text_owner is None and parent.tag == _SVG_TEXT:
175 text_owner = parent
176 elif (
177 text_owner is None
178 and parent.tag not in {_SVG_A, _SVG_TSPAN}
179 and invalid_inline_container is None
180 ):
181 invalid_inline_container = parent_tag
182 if parent_tag in _NON_OUTPUT_ANCESTORS:
183 non_output_ancestor = parent_tag
184 if parent.get(INLINE_FORMULA_ATTR) is not None:
185 nested_marker = True
186 if parent.get("baseline-shift") is not None:
187 inside_baseline_shift = True
188 replacement = (
189 parent.get("data-pptx-replace-with") or ""
190 ).strip().lower()
191 if replacement:
192 inside_native_replacement = True
193 inside_block_formula = (
194 inside_block_formula or replacement == "formula"
195 )
196 if (parent.get("data-pptx-part") or "").strip() in {
197 "geometry-detail",
198 "geometry-preview",
199 }:
200 inside_skipped_transport = True
201 if parent.get("data-pptx-placeholder") is not None:
202 inside_placeholder = True
203 layer = (parent.get("data-pptx-layer") or "").strip().lower()
204 if layer in {"master", "layout"}:
205 fixed_structure_layer = layer
206 if any(
207 child.tag.rsplit("}", 1)[-1] == "metadata"
208 and child.get("data-pptx-part") == "txbody"
209 for child in parent
210 ):
211 inside_preserved_text = True
212 parent = parent_map.get(parent)
213 if text_owner is None:
214 errors.append(f"{label} must be inside an SVG <text> element")
215 elif invalid_inline_container is not None:
216 errors.append(
217 f"{label} can only be nested through <tspan>/<a> elements before "
218 f"its owning <text>, found <{invalid_inline_container}>"
219 )
220 if non_output_ancestor is not None:
221 errors.append(
222 f"{label} cannot be placed inside non-output "
223 f"<{non_output_ancestor}> content"
224 )
225 if nested_marker:
226 errors.append(f"{label} cannot be nested inside another inline formula marker")
227 if inside_baseline_shift:
228 errors.append(
229 f"{label} cannot combine baseline-shift with an inline formula"
230 )
231 if inside_block_formula:
232 errors.append(
233 f"{label} cannot be placed inside a block formula preview"
234 )
235 elif inside_native_replacement:
236 errors.append(
237 f"{label} cannot be placed inside a native replacement subtree"
238 )
239 if inside_skipped_transport:
240 errors.append(
241 f"{label} cannot be placed inside non-output geometry transport"
242 )
243 if inside_preserved_text:
244 errors.append(
245 f"{label} cannot be placed inside an imported preserved txBody group"
246 )
247 if inside_placeholder:
248 errors.append(
249 f"{label} cannot be used inside a structured Layout placeholder"
250 )
251 if fixed_structure_layer is not None:
252 errors.append(
253 f"{label} cannot be used on the {fixed_structure_layer} layer; "
254 "inline formulas are slide-local text"
255 )
256
257 return errors
258
259
260 def _apply_run_properties(omml: str, run_properties_xml: str) -> str:
261 """Merge DrawingML defaults onto every Office Math leaf run."""
262 try:
263 root = ET.fromstring(omml)
264 wrapper = ET.fromstring(
265 f'<root xmlns:a="{DML_NS}" xmlns:r="{OFFICE_REL_NS}">'
266 f"{run_properties_xml}</root>"
267 )
268 except (ET.ParseError, RecursionError) as exc:
269 raise RuntimeError(f"Invalid inline formula XML: {exc}") from exc
270 if len(wrapper) != 1:
271 raise RuntimeError("Inline formula styling must contain one a:rPr root")
272 run_properties = wrapper[0]
273 if root.tag != f"{{{MATH_NS}}}oMath":
274 raise RuntimeError("Inline formula compiler must return one m:oMath root")
275 if run_properties.tag != _DML_RUN_PROPERTIES:
276 raise RuntimeError("Inline formula styling must use one a:rPr root")
277
278 for run in root.iter(_MATH_RUN):
279 merge_formula_run_properties(run, run_properties)
280 merge_formula_control_properties(root, run_properties)
281
282 return serialize_styled_formula_omml(root)
283
284
285 def build_inline_formula_xml(latex: str, run_properties_xml: str) -> str:
286 """Build one ``a14:m`` inline math zone for insertion inside ``a:p``."""
287 try:
288 omml = compile_latex_to_inline_omml(latex)
289 except FormulaCompileError as exc:
290 raise RuntimeError(f"Unsupported inline formula LaTeX: {exc}") from exc
291 styled = _apply_run_properties(omml, run_properties_xml)
292 return f"<a14:m>{styled}</a14:m>"
293
294
295 def wrap_inline_formula_shape(shape_xml: str) -> str:
296 """Wrap a text shape containing ``a14:m`` in its required MCE Choice."""
297 return (
298 f'<mc:AlternateContent xmlns:mc="{MC_NS}">'
299 f'<mc:Choice xmlns:a14="{A14_NS}" Requires="a14">'
300 f"{shape_xml}"
301 "</mc:Choice>"
302 "</mc:AlternateContent>"
303 )
304
305
306 __all__ = [
307 "INLINE_FORMULA_ATTR",
308 "build_inline_formula_xml",
309 "inline_formula_marker_errors",
310 "wrap_inline_formula_shape",
311 ]
312
312 lines PYTHON