| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | PPT Master - Native Formula Run Property Merge |
| 4 | |
| 5 | Merge marker-level DrawingML defaults with compiler-owned local formula style |
| 6 | without discarding either source. |
| 7 | |
| 8 | See references/native-formula.md for the owning formula-style contract. |
| 9 | |
| 10 | Usage: |
| 11 | Imported by block and inline native formula builders. |
| 12 | |
| 13 | Examples: |
| 14 | merge_formula_run_properties(run, marker_properties) |
| 15 | |
| 16 | Dependencies: |
| 17 | None (only uses standard library) |
| 18 | """ |
| 19 | |
| 20 | from __future__ import annotations |
| 21 | |
| 22 | from copy import deepcopy |
| 23 | from xml.etree import ElementTree as ET |
| 24 | |
| 25 | from .formula_omml import validate_omml_resource_limits |
| 26 | from .formula_parser import FormulaCompileError |
| 27 | |
| 28 | |
| 29 | DML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main" |
| 30 | MATH_NS = "http://schemas.openxmlformats.org/officeDocument/2006/math" |
| 31 | |
| 32 | _DML_RUN_PROPERTIES = f"{{{DML_NS}}}rPr" |
| 33 | _MATH_RUN_PROPERTIES = f"{{{MATH_NS}}}rPr" |
| 34 | _MATH_CONTROL_PROPERTIES = f"{{{MATH_NS}}}ctrlPr" |
| 35 | _CONTROL_OWNER_TAGS = frozenset({ |
| 36 | f"{{{MATH_NS}}}{name}" |
| 37 | for name in ( |
| 38 | "accPr", |
| 39 | "barPr", |
| 40 | "borderBoxPr", |
| 41 | "boxPr", |
| 42 | "dPr", |
| 43 | "fPr", |
| 44 | "groupChrPr", |
| 45 | "naryPr", |
| 46 | "radPr", |
| 47 | ) |
| 48 | }) |
| 49 | _FILL_TAGS = frozenset({ |
| 50 | f"{{{DML_NS}}}blipFill", |
| 51 | f"{{{DML_NS}}}gradFill", |
| 52 | f"{{{DML_NS}}}grpFill", |
| 53 | f"{{{DML_NS}}}noFill", |
| 54 | f"{{{DML_NS}}}pattFill", |
| 55 | f"{{{DML_NS}}}solidFill", |
| 56 | }) |
| 57 | _COLOR_TAGS = frozenset({ |
| 58 | f"{{{DML_NS}}}{name}" |
| 59 | for name in ("hslClr", "prstClr", "schemeClr", "scrgbClr", "srgbClr", "sysClr") |
| 60 | }) |
| 61 | _ALPHA_TRANSFORM_TAGS = frozenset({ |
| 62 | f"{{{DML_NS}}}{name}" |
| 63 | for name in ("alpha", "alphaMod", "alphaOff") |
| 64 | }) |
| 65 | _RUN_PROPERTY_ORDER = { |
| 66 | f"{{{DML_NS}}}{name}": index |
| 67 | for index, names in enumerate(( |
| 68 | ("ln",), |
| 69 | ("blipFill", "gradFill", "grpFill", "noFill", "pattFill", "solidFill"), |
| 70 | ("effectDag", "effectLst"), |
| 71 | ("highlight",), |
| 72 | ("uLnTx",), |
| 73 | ("uLn",), |
| 74 | ("uFillTx",), |
| 75 | ("uFill",), |
| 76 | ("latin",), |
| 77 | ("ea",), |
| 78 | ("cs",), |
| 79 | ("sym",), |
| 80 | ("hlinkClick",), |
| 81 | ("hlinkMouseOver",), |
| 82 | ("rtl",), |
| 83 | ("extLst",), |
| 84 | )) |
| 85 | for name in names |
| 86 | } |
| 87 | |
| 88 | |
| 89 | def _replace_child_group( |
| 90 | target: ET.Element, |
| 91 | local: ET.Element, |
| 92 | tags: frozenset[str], |
| 93 | ) -> None: |
| 94 | local_children = [child for child in local if child.tag in tags] |
| 95 | if not local_children: |
| 96 | return |
| 97 | positions = [index for index, child in enumerate(target) if child.tag in tags] |
| 98 | insert_at = positions[0] if positions else 0 |
| 99 | for child in list(target): |
| 100 | if child.tag in tags: |
| 101 | target.remove(child) |
| 102 | for offset, child in enumerate(local_children): |
| 103 | target.insert(insert_at + offset, deepcopy(child)) |
| 104 | |
| 105 | |
| 106 | def _inherit_default_fill_alpha( |
| 107 | default_properties: ET.Element, |
| 108 | local_properties: ET.Element, |
| 109 | ) -> None: |
| 110 | default_fill = next( |
| 111 | (child for child in default_properties if child.tag in _FILL_TAGS), |
| 112 | None, |
| 113 | ) |
| 114 | local_fill = next( |
| 115 | (child for child in local_properties if child.tag in _FILL_TAGS), |
| 116 | None, |
| 117 | ) |
| 118 | if default_fill is None or local_fill is None: |
| 119 | return |
| 120 | default_color = next( |
| 121 | (child for child in default_fill if child.tag in _COLOR_TAGS), |
| 122 | None, |
| 123 | ) |
| 124 | local_color = next( |
| 125 | (child for child in local_fill if child.tag in _COLOR_TAGS), |
| 126 | None, |
| 127 | ) |
| 128 | if default_color is None or local_color is None: |
| 129 | return |
| 130 | if any(child.tag in _ALPHA_TRANSFORM_TAGS for child in local_color): |
| 131 | return |
| 132 | for child in default_color: |
| 133 | if child.tag in _ALPHA_TRANSFORM_TAGS: |
| 134 | local_color.append(deepcopy(child)) |
| 135 | |
| 136 | |
| 137 | def _normalize_child_order(properties: ET.Element) -> None: |
| 138 | children = list(properties) |
| 139 | ordered = sorted( |
| 140 | enumerate(children), |
| 141 | key=lambda item: ( |
| 142 | _RUN_PROPERTY_ORDER.get(item[1].tag, len(_RUN_PROPERTY_ORDER)), |
| 143 | item[0], |
| 144 | ), |
| 145 | ) |
| 146 | if [child for _, child in ordered] == children: |
| 147 | return |
| 148 | for child in children: |
| 149 | properties.remove(child) |
| 150 | for _, child in ordered: |
| 151 | properties.append(child) |
| 152 | |
| 153 | |
| 154 | def _merged_properties( |
| 155 | default_properties: ET.Element, |
| 156 | local_properties: ET.Element | None, |
| 157 | ) -> ET.Element: |
| 158 | merged = deepcopy(default_properties) |
| 159 | if local_properties is None: |
| 160 | _normalize_child_order(merged) |
| 161 | return merged |
| 162 | local = deepcopy(local_properties) |
| 163 | _inherit_default_fill_alpha(merged, local) |
| 164 | merged.attrib.update(local.attrib) |
| 165 | _replace_child_group(merged, local, _FILL_TAGS) |
| 166 | for child in local: |
| 167 | if child.tag in _FILL_TAGS: |
| 168 | continue |
| 169 | for existing in list(merged): |
| 170 | if existing.tag == child.tag: |
| 171 | merged.remove(existing) |
| 172 | merged.append(deepcopy(child)) |
| 173 | _normalize_child_order(merged) |
| 174 | return merged |
| 175 | |
| 176 | |
| 177 | def merge_formula_run_properties( |
| 178 | run: ET.Element, |
| 179 | default_properties: ET.Element, |
| 180 | ) -> None: |
| 181 | """Merge one formula run's local a:rPr over marker-level defaults.""" |
| 182 | if default_properties.tag != _DML_RUN_PROPERTIES: |
| 183 | raise RuntimeError("Formula run defaults must use one a:rPr root") |
| 184 | local_properties = next( |
| 185 | (child for child in run if child.tag == _DML_RUN_PROPERTIES), |
| 186 | None, |
| 187 | ) |
| 188 | merged = _merged_properties(default_properties, local_properties) |
| 189 | if local_properties is not None: |
| 190 | run.remove(local_properties) |
| 191 | insert_at = 1 if len(run) and run[0].tag == _MATH_RUN_PROPERTIES else 0 |
| 192 | run.insert(insert_at, merged) |
| 193 | |
| 194 | |
| 195 | def merge_formula_control_properties( |
| 196 | root: ET.Element, |
| 197 | default_properties: ET.Element, |
| 198 | ) -> None: |
| 199 | """Merge marker defaults onto visible non-selectable math controls.""" |
| 200 | if default_properties.tag != _DML_RUN_PROPERTIES: |
| 201 | raise RuntimeError("Formula control defaults must use one a:rPr root") |
| 202 | for owner in root.iter(): |
| 203 | if owner.tag not in _CONTROL_OWNER_TAGS: |
| 204 | continue |
| 205 | control = next( |
| 206 | (child for child in owner if child.tag == _MATH_CONTROL_PROPERTIES), |
| 207 | None, |
| 208 | ) |
| 209 | if control is None: |
| 210 | control = ET.SubElement(owner, _MATH_CONTROL_PROPERTIES) |
| 211 | local_properties = next( |
| 212 | (child for child in control if child.tag == _DML_RUN_PROPERTIES), |
| 213 | None, |
| 214 | ) |
| 215 | merged = _merged_properties(default_properties, local_properties) |
| 216 | if local_properties is not None: |
| 217 | control.remove(local_properties) |
| 218 | control.insert(0, merged) |
| 219 | |
| 220 | |
| 221 | def serialize_styled_formula_omml(root: ET.Element) -> str: |
| 222 | """Serialize styled OMML under the compiler's final size and depth limits.""" |
| 223 | try: |
| 224 | xml = ET.tostring(root, encoding="unicode", short_empty_elements=True) |
| 225 | return validate_omml_resource_limits(xml) |
| 226 | except (FormulaCompileError, RecursionError) as exc: |
| 227 | raise RuntimeError(f"Invalid styled formula OMML: {exc}") from exc |
| 228 | |
| 229 | |
| 230 | __all__ = [ |
| 231 | "merge_formula_control_properties", |
| 232 | "merge_formula_run_properties", |
| 233 | "serialize_styled_formula_omml", |
| 234 | ] |
| 235 |