| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | PPT Master - SVG Coordinate Compactor |
| 4 | |
| 5 | Compact model-facing page-space SVG coordinates to at most two decimal places |
| 6 | without rounding normalized crop ratios or transform linear coefficients. |
| 7 | |
| 8 | Usage: |
| 9 | python3 scripts/compact_svg_coordinates.py <svg-file-or-directory> [--inplace] |
| 10 | |
| 11 | Examples: |
| 12 | python3 scripts/compact_svg_coordinates.py projects/example/templates --inplace |
| 13 | python3 scripts/compact_svg_coordinates.py imported/authoring-svg |
| 14 | |
| 15 | Dependencies: |
| 16 | None (standard library only). |
| 17 | """ |
| 18 | |
| 19 | from __future__ import annotations |
| 20 | |
| 21 | import argparse |
| 22 | import json |
| 23 | import math |
| 24 | import re |
| 25 | import stat |
| 26 | import sys |
| 27 | import tempfile |
| 28 | from dataclasses import dataclass |
| 29 | from pathlib import Path |
| 30 | from typing import Optional |
| 31 | from xml.etree import ElementTree as ET |
| 32 | |
| 33 | from console_encoding import configure_utf8_stdio |
| 34 | |
| 35 | configure_utf8_stdio() |
| 36 | |
| 37 | COORDINATE_DECIMAL_PLACES = 2 |
| 38 | _NUMBER_TOKEN = r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?" |
| 39 | _NUMBER_RE = re.compile(rf"^{_NUMBER_TOKEN}$") |
| 40 | _TRANSFORM_FUNCTION_RE = re.compile(r"([A-Za-z]+)\s*\(([^)]*)\)") |
| 41 | _COMPACTABLE_ATTRIBUTE_RE = re.compile( |
| 42 | r"(?<![A-Za-z0-9_.:-])" |
| 43 | r"(?P<name>data-pptx-frame|data-pptx-bounds|transform)" |
| 44 | r"(?P<spacing>\s*=\s*)" |
| 45 | r"(?P<quote>[\"'])" |
| 46 | r"(?P<value>.*?)" |
| 47 | r"(?P=quote)", |
| 48 | re.DOTALL, |
| 49 | ) |
| 50 | |
| 51 | |
| 52 | @dataclass |
| 53 | class CoordinateCompactionStats: |
| 54 | """Count safely compacted coordinate-bearing SVG attributes.""" |
| 55 | |
| 56 | native_frames: int = 0 |
| 57 | bounds: int = 0 |
| 58 | transforms: int = 0 |
| 59 | |
| 60 | @property |
| 61 | def changed_attributes(self) -> int: |
| 62 | return ( |
| 63 | self.native_frames |
| 64 | + self.bounds |
| 65 | + self.transforms |
| 66 | ) |
| 67 | |
| 68 | def merge(self, other: "CoordinateCompactionStats") -> None: |
| 69 | self.native_frames += other.native_frames |
| 70 | self.bounds += other.bounds |
| 71 | self.transforms += other.transforms |
| 72 | |
| 73 | def as_dict(self) -> dict[str, int]: |
| 74 | return { |
| 75 | "native_frames": self.native_frames, |
| 76 | "bounds": self.bounds, |
| 77 | "transforms": self.transforms, |
| 78 | "changed_attributes": self.changed_attributes, |
| 79 | } |
| 80 | |
| 81 | |
| 82 | def _number_tokens(value: str) -> list[str] | None: |
| 83 | stripped = value.strip() |
| 84 | if not stripped: |
| 85 | return None |
| 86 | tokens = re.split(r"[\s,]+", stripped) |
| 87 | if not tokens or any(_NUMBER_RE.fullmatch(token) is None for token in tokens): |
| 88 | return None |
| 89 | return tokens |
| 90 | |
| 91 | |
| 92 | def format_coordinate(value: str | float) -> str: |
| 93 | """Format one finite page-space coordinate with at most two decimals.""" |
| 94 | numeric = float(value) |
| 95 | if not math.isfinite(numeric): |
| 96 | raise ValueError(f"Coordinate is not finite: {value!r}") |
| 97 | compact = ( |
| 98 | f"{numeric:.{COORDINATE_DECIMAL_PLACES}f}".rstrip("0").rstrip(".") |
| 99 | ) |
| 100 | return "0" if compact in {"", "-0"} else compact |
| 101 | |
| 102 | |
| 103 | def _compact_coordinate_quad(value: str) -> str: |
| 104 | tokens = _number_tokens(value) |
| 105 | if tokens is None or len(tokens) != 4: |
| 106 | return value |
| 107 | return " ".join(format_coordinate(token) for token in tokens) |
| 108 | |
| 109 | |
| 110 | def _compact_transform(value: str) -> str: |
| 111 | def replace(match: re.Match[str]) -> str: |
| 112 | name, arguments = match.groups() |
| 113 | tokens = _number_tokens(arguments) |
| 114 | if tokens is None: |
| 115 | return match.group(0) |
| 116 | |
| 117 | lowered = name.lower() |
| 118 | compacted: list[str] |
| 119 | if lowered == "translate" and len(tokens) in {1, 2}: |
| 120 | compacted = [format_coordinate(token) for token in tokens] |
| 121 | elif lowered == "rotate" and len(tokens) == 3: |
| 122 | compacted = [ |
| 123 | tokens[0], |
| 124 | format_coordinate(tokens[1]), |
| 125 | format_coordinate(tokens[2]), |
| 126 | ] |
| 127 | elif lowered == "matrix" and len(tokens) == 6: |
| 128 | compacted = [ |
| 129 | *tokens[:4], |
| 130 | format_coordinate(tokens[4]), |
| 131 | format_coordinate(tokens[5]), |
| 132 | ] |
| 133 | else: |
| 134 | return match.group(0) |
| 135 | return f"{name}({' '.join(compacted)})" |
| 136 | |
| 137 | return _TRANSFORM_FUNCTION_RE.sub(replace, value) |
| 138 | |
| 139 | |
| 140 | def _compact_attribute_value( |
| 141 | name: str, |
| 142 | value: str, |
| 143 | *, |
| 144 | compact_native_frames: bool, |
| 145 | ) -> str: |
| 146 | if name == "data-pptx-frame": |
| 147 | return _compact_coordinate_quad(value) if compact_native_frames else value |
| 148 | if name == "data-pptx-bounds": |
| 149 | return _compact_coordinate_quad(value) |
| 150 | if name == "transform": |
| 151 | return _compact_transform(value) |
| 152 | return value |
| 153 | |
| 154 | |
| 155 | def _record_change(stats: CoordinateCompactionStats, name: str) -> None: |
| 156 | if name == "data-pptx-frame": |
| 157 | stats.native_frames += 1 |
| 158 | elif name == "data-pptx-bounds": |
| 159 | stats.bounds += 1 |
| 160 | elif name == "transform": |
| 161 | stats.transforms += 1 |
| 162 | |
| 163 | |
| 164 | def compact_svg_tree( |
| 165 | root: ET.Element, |
| 166 | *, |
| 167 | compact_native_frames: bool = True, |
| 168 | ) -> CoordinateCompactionStats: |
| 169 | """Compact safe coordinate metadata in one parsed SVG tree.""" |
| 170 | stats = CoordinateCompactionStats() |
| 171 | for element in root.iter(): |
| 172 | for name in ( |
| 173 | "data-pptx-frame", |
| 174 | "data-pptx-bounds", |
| 175 | "transform", |
| 176 | ): |
| 177 | current = element.get(name) |
| 178 | if current is None: |
| 179 | continue |
| 180 | compacted = _compact_attribute_value( |
| 181 | name, |
| 182 | current, |
| 183 | compact_native_frames=compact_native_frames, |
| 184 | ) |
| 185 | if compacted == current: |
| 186 | continue |
| 187 | element.set(name, compacted) |
| 188 | _record_change(stats, name) |
| 189 | return stats |
| 190 | |
| 191 | |
| 192 | def compact_svg_text( |
| 193 | text: str, |
| 194 | *, |
| 195 | compact_native_frames: bool = True, |
| 196 | ) -> tuple[str, CoordinateCompactionStats]: |
| 197 | """Compact safe coordinates while preserving unrelated SVG formatting.""" |
| 198 | stats = CoordinateCompactionStats() |
| 199 | |
| 200 | def replace(match: re.Match[str]) -> str: |
| 201 | name = match.group("name") |
| 202 | current = match.group("value") |
| 203 | compacted = _compact_attribute_value( |
| 204 | name, |
| 205 | current, |
| 206 | compact_native_frames=compact_native_frames, |
| 207 | ) |
| 208 | if compacted == current: |
| 209 | return match.group(0) |
| 210 | _record_change(stats, name) |
| 211 | return ( |
| 212 | f"{name}{match.group('spacing')}{match.group('quote')}" |
| 213 | f"{compacted}{match.group('quote')}" |
| 214 | ) |
| 215 | |
| 216 | return _COMPACTABLE_ATTRIBUTE_RE.sub(replace, text), stats |
| 217 | |
| 218 | |
| 219 | def _svg_files(input_path: Path) -> list[Path]: |
| 220 | if input_path.is_file(): |
| 221 | return [input_path] if input_path.suffix.lower() == ".svg" else [] |
| 222 | return sorted(path for path in input_path.rglob("*.svg") if path.is_file()) |
| 223 | |
| 224 | |
| 225 | def _write_atomic(path: Path, payload: str) -> None: |
| 226 | mode = stat.S_IMODE(path.stat().st_mode) |
| 227 | with tempfile.NamedTemporaryFile( |
| 228 | mode="w", |
| 229 | encoding="utf-8", |
| 230 | newline="\n", |
| 231 | prefix=f".{path.name}.", |
| 232 | suffix=".tmp", |
| 233 | dir=path.parent, |
| 234 | delete=False, |
| 235 | ) as handle: |
| 236 | temporary_path = Path(handle.name) |
| 237 | handle.write(payload) |
| 238 | try: |
| 239 | temporary_path.chmod(mode) |
| 240 | temporary_path.replace(path) |
| 241 | except OSError: |
| 242 | temporary_path.unlink(missing_ok=True) |
| 243 | raise |
| 244 | |
| 245 | |
| 246 | def build_parser() -> argparse.ArgumentParser: |
| 247 | parser = argparse.ArgumentParser( |
| 248 | description=( |
| 249 | "Compact safe page-space SVG coordinates to at most two decimal " |
| 250 | "places. Runs as a dry-run unless --inplace is supplied." |
| 251 | ), |
| 252 | ) |
| 253 | parser.add_argument("input", type=Path, help="SVG file or directory") |
| 254 | parser.add_argument( |
| 255 | "--inplace", |
| 256 | action="store_true", |
| 257 | help="Atomically replace changed SVG files", |
| 258 | ) |
| 259 | parser.add_argument( |
| 260 | "--keep-native-frames", |
| 261 | action="store_true", |
| 262 | help=( |
| 263 | "Leave data-pptx-frame unchanged while compacting " |
| 264 | "data-pptx-bounds and transform translations" |
| 265 | ), |
| 266 | ) |
| 267 | return parser |
| 268 | |
| 269 | |
| 270 | def main(argv: Optional[list[str]] = None) -> int: |
| 271 | args = build_parser().parse_args(argv) |
| 272 | input_path = args.input.resolve() |
| 273 | if not input_path.exists(): |
| 274 | print( |
| 275 | json.dumps( |
| 276 | {"error": f"Input does not exist: {input_path}"}, |
| 277 | ensure_ascii=False, |
| 278 | ), |
| 279 | file=sys.stderr, |
| 280 | ) |
| 281 | return 1 |
| 282 | |
| 283 | paths = _svg_files(input_path) |
| 284 | if not paths: |
| 285 | print( |
| 286 | json.dumps( |
| 287 | {"error": f"No SVG files found under {input_path}"}, |
| 288 | ensure_ascii=False, |
| 289 | ), |
| 290 | file=sys.stderr, |
| 291 | ) |
| 292 | return 1 |
| 293 | |
| 294 | staged: list[tuple[Path, str]] = [] |
| 295 | totals = CoordinateCompactionStats() |
| 296 | bytes_before = 0 |
| 297 | bytes_after = 0 |
| 298 | try: |
| 299 | for path in paths: |
| 300 | original = path.read_text(encoding="utf-8") |
| 301 | ET.fromstring(original) |
| 302 | compacted, stats = compact_svg_text( |
| 303 | original, |
| 304 | compact_native_frames=not args.keep_native_frames, |
| 305 | ) |
| 306 | totals.merge(stats) |
| 307 | before = len(original.encode("utf-8")) |
| 308 | after = len(compacted.encode("utf-8")) |
| 309 | bytes_before += before |
| 310 | bytes_after += after |
| 311 | if compacted != original: |
| 312 | staged.append((path, compacted)) |
| 313 | except (OSError, UnicodeDecodeError, ET.ParseError, ValueError) as exc: |
| 314 | print( |
| 315 | json.dumps({"error": str(exc)}, ensure_ascii=False), |
| 316 | file=sys.stderr, |
| 317 | ) |
| 318 | return 1 |
| 319 | |
| 320 | if args.inplace: |
| 321 | try: |
| 322 | for path, payload in staged: |
| 323 | _write_atomic(path, payload) |
| 324 | except OSError as exc: |
| 325 | print( |
| 326 | json.dumps({"error": str(exc)}, ensure_ascii=False), |
| 327 | file=sys.stderr, |
| 328 | ) |
| 329 | return 1 |
| 330 | |
| 331 | print(json.dumps({ |
| 332 | "input": str(input_path), |
| 333 | "inplace": args.inplace, |
| 334 | "files_scanned": len(paths), |
| 335 | "files_changed": len(staged), |
| 336 | "bytes_before": bytes_before, |
| 337 | "bytes_after": bytes_after, |
| 338 | "bytes_saved": bytes_before - bytes_after, |
| 339 | "coordinates": totals.as_dict(), |
| 340 | }, ensure_ascii=False, indent=2)) |
| 341 | return 0 |
| 342 | |
| 343 | |
| 344 | if __name__ == "__main__": |
| 345 | raise SystemExit(main()) |
| 346 |