| 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 |