| 1 | """Materialize the supported inline SVG geometry-property subset. |
| 2 | |
| 3 | SVG 2 lets selected geometry values participate in CSS. PPT Master does not |
| 4 | run a CSS engine, but it can safely compile literal per-element ``style`` |
| 5 | declarations into the equivalent XML geometry attributes before any existing |
| 6 | SVG post-processing or DrawingML conversion runs. |
| 7 | """ |
| 8 | |
| 9 | from __future__ import annotations |
| 10 | |
| 11 | import copy |
| 12 | import math |
| 13 | import re |
| 14 | from pathlib import Path |
| 15 | from xml.etree import ElementTree as ET |
| 16 | |
| 17 | |
| 18 | SVG_NS = 'http://www.w3.org/2000/svg' |
| 19 | XLINK_NS = 'http://www.w3.org/1999/xlink' |
| 20 | |
| 21 | INLINE_GEOMETRY_PROPERTIES = { |
| 22 | 'rect': frozenset({'x', 'y', 'width', 'height', 'rx', 'ry'}), |
| 23 | 'circle': frozenset({'cx', 'cy', 'r'}), |
| 24 | 'ellipse': frozenset({'cx', 'cy', 'rx', 'ry'}), |
| 25 | 'image': frozenset({'x', 'y', 'width', 'height'}), |
| 26 | 'svg': frozenset({'x', 'y', 'width', 'height'}), |
| 27 | 'use': frozenset({'x', 'y', 'width', 'height'}), |
| 28 | } |
| 29 | |
| 30 | _GEOMETRY_LIKE_PROPERTIES = frozenset({ |
| 31 | 'x', 'y', 'width', 'height', 'rx', 'ry', 'cx', 'cy', 'r', |
| 32 | 'x1', 'y1', 'x2', 'y2', 'dx', 'dy', 'points', 'd', |
| 33 | }) |
| 34 | _NON_NEGATIVE_PROPERTIES = frozenset({'width', 'height', 'rx', 'ry', 'r'}) |
| 35 | _PX_LENGTH_RE = re.compile( |
| 36 | r'^\s*([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?)\s*(px)?\s*$', |
| 37 | re.IGNORECASE, |
| 38 | ) |
| 39 | |
| 40 | |
| 41 | class GeometryStyleError(ValueError): |
| 42 | """Reject inline geometry that cannot be compiled deterministically.""" |
| 43 | |
| 44 | |
| 45 | def _local_tag(elem: ET.Element) -> str: |
| 46 | tag = str(elem.tag) |
| 47 | return tag.rsplit('}', 1)[-1] if '}' in tag else tag |
| 48 | |
| 49 | |
| 50 | def _format_number(value: float) -> str: |
| 51 | if abs(value) < 1e-12: |
| 52 | value = 0.0 |
| 53 | return f'{value:.12g}' |
| 54 | |
| 55 | |
| 56 | def _normalize_px_length(raw: str, tag: str, prop: str) -> str: |
| 57 | """Return one finite CSS px literal as a unitless SVG user value.""" |
| 58 | match = _PX_LENGTH_RE.fullmatch(raw) |
| 59 | if match is None: |
| 60 | raise GeometryStyleError( |
| 61 | f'<{tag}> inline geometry {prop} requires a finite px literal; ' |
| 62 | f'got {raw!r}' |
| 63 | ) |
| 64 | value = float(match.group(1)) |
| 65 | if not math.isfinite(value): |
| 66 | raise GeometryStyleError( |
| 67 | f'<{tag}> inline geometry {prop} must be finite; got {raw!r}' |
| 68 | ) |
| 69 | if match.group(2) is None and value != 0: |
| 70 | raise GeometryStyleError( |
| 71 | f'<{tag}> inline geometry {prop} requires px for non-zero values; ' |
| 72 | f'got {raw!r}' |
| 73 | ) |
| 74 | if prop in _NON_NEGATIVE_PROPERTIES and value < 0: |
| 75 | raise GeometryStyleError( |
| 76 | f'<{tag}> inline geometry {prop} cannot be negative; got {raw!r}' |
| 77 | ) |
| 78 | return _format_number(value) |
| 79 | |
| 80 | |
| 81 | def materialize_inline_geometry_properties(root: ET.Element) -> int: |
| 82 | """Compile supported inline geometry declarations into XML attributes.""" |
| 83 | materialized = 0 |
| 84 | for elem in root.iter(): |
| 85 | style = elem.get('style') |
| 86 | if not style: |
| 87 | continue |
| 88 | tag = _local_tag(elem) |
| 89 | supported = INLINE_GEOMETRY_PROPERTIES.get(tag, frozenset()) |
| 90 | retained: list[str] = [] |
| 91 | element_materialized = 0 |
| 92 | for raw_declaration in style.split(';'): |
| 93 | declaration = raw_declaration.strip() |
| 94 | if not declaration: |
| 95 | continue |
| 96 | if ':' not in declaration: |
| 97 | retained.append(declaration) |
| 98 | continue |
| 99 | raw_name, raw_value = declaration.split(':', 1) |
| 100 | name = raw_name.strip().lower() |
| 101 | value = raw_value.strip() |
| 102 | if name not in _GEOMETRY_LIKE_PROPERTIES: |
| 103 | retained.append(declaration) |
| 104 | continue |
| 105 | if name not in supported: |
| 106 | raise GeometryStyleError( |
| 107 | f'<{tag}> does not support inline geometry property {name!r}; ' |
| 108 | 'use the element\'s XML geometry attribute instead' |
| 109 | ) |
| 110 | elem.set(name, _normalize_px_length(value, tag, name)) |
| 111 | materialized += 1 |
| 112 | element_materialized += 1 |
| 113 | |
| 114 | if element_materialized == 0: |
| 115 | continue |
| 116 | if retained: |
| 117 | elem.set('style', '; '.join(retained)) |
| 118 | else: |
| 119 | elem.attrib.pop('style', None) |
| 120 | return materialized |
| 121 | |
| 122 | |
| 123 | def validate_inline_geometry_properties(root: ET.Element) -> list[str]: |
| 124 | """Return inline geometry errors without mutating the caller's SVG tree.""" |
| 125 | try: |
| 126 | materialize_inline_geometry_properties(copy.deepcopy(root)) |
| 127 | except GeometryStyleError as exc: |
| 128 | return [str(exc)] |
| 129 | return [] |
| 130 | |
| 131 | |
| 132 | def materialize_inline_geometry_in_file(svg_path: Path) -> int: |
| 133 | """Materialize inline geometry in one SVG file in place.""" |
| 134 | tree = ET.parse(str(svg_path)) |
| 135 | count = materialize_inline_geometry_properties(tree.getroot()) |
| 136 | if count: |
| 137 | ET.register_namespace('', SVG_NS) |
| 138 | ET.register_namespace('xlink', XLINK_NS) |
| 139 | tree.write(str(svg_path), encoding='unicode', xml_declaration=False) |
| 140 | return count |
| 141 |