Files

346 lines
9.9 KiB
Python

#!/usr/bin/env python3
"""
PPT Master - SVG Coordinate Compactor
Compact model-facing page-space SVG coordinates to at most two decimal places
without rounding normalized crop ratios or transform linear coefficients.
Usage:
python3 scripts/compact_svg_coordinates.py <svg-file-or-directory> [--inplace]
Examples:
python3 scripts/compact_svg_coordinates.py projects/example/templates --inplace
python3 scripts/compact_svg_coordinates.py imported/authoring-svg
Dependencies:
None (standard library only).
"""
from __future__ import annotations
import argparse
import json
import math
import re
import stat
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from xml.etree import ElementTree as ET
from console_encoding import configure_utf8_stdio
configure_utf8_stdio()
COORDINATE_DECIMAL_PLACES = 2
_NUMBER_TOKEN = r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?"
_NUMBER_RE = re.compile(rf"^{_NUMBER_TOKEN}$")
_TRANSFORM_FUNCTION_RE = re.compile(r"([A-Za-z]+)\s*\(([^)]*)\)")
_COMPACTABLE_ATTRIBUTE_RE = re.compile(
r"(?<![A-Za-z0-9_.:-])"
r"(?P<name>data-pptx-frame|data-pptx-bounds|transform)"
r"(?P<spacing>\s*=\s*)"
r"(?P<quote>[\"'])"
r"(?P<value>.*?)"
r"(?P=quote)",
re.DOTALL,
)
@dataclass
class CoordinateCompactionStats:
"""Count safely compacted coordinate-bearing SVG attributes."""
native_frames: int = 0
bounds: int = 0
transforms: int = 0
@property
def changed_attributes(self) -> int:
return (
self.native_frames
+ self.bounds
+ self.transforms
)
def merge(self, other: "CoordinateCompactionStats") -> None:
self.native_frames += other.native_frames
self.bounds += other.bounds
self.transforms += other.transforms
def as_dict(self) -> dict[str, int]:
return {
"native_frames": self.native_frames,
"bounds": self.bounds,
"transforms": self.transforms,
"changed_attributes": self.changed_attributes,
}
def _number_tokens(value: str) -> list[str] | None:
stripped = value.strip()
if not stripped:
return None
tokens = re.split(r"[\s,]+", stripped)
if not tokens or any(_NUMBER_RE.fullmatch(token) is None for token in tokens):
return None
return tokens
def format_coordinate(value: str | float) -> str:
"""Format one finite page-space coordinate with at most two decimals."""
numeric = float(value)
if not math.isfinite(numeric):
raise ValueError(f"Coordinate is not finite: {value!r}")
compact = (
f"{numeric:.{COORDINATE_DECIMAL_PLACES}f}".rstrip("0").rstrip(".")
)
return "0" if compact in {"", "-0"} else compact
def _compact_coordinate_quad(value: str) -> str:
tokens = _number_tokens(value)
if tokens is None or len(tokens) != 4:
return value
return " ".join(format_coordinate(token) for token in tokens)
def _compact_transform(value: str) -> str:
def replace(match: re.Match[str]) -> str:
name, arguments = match.groups()
tokens = _number_tokens(arguments)
if tokens is None:
return match.group(0)
lowered = name.lower()
compacted: list[str]
if lowered == "translate" and len(tokens) in {1, 2}:
compacted = [format_coordinate(token) for token in tokens]
elif lowered == "rotate" and len(tokens) == 3:
compacted = [
tokens[0],
format_coordinate(tokens[1]),
format_coordinate(tokens[2]),
]
elif lowered == "matrix" and len(tokens) == 6:
compacted = [
*tokens[:4],
format_coordinate(tokens[4]),
format_coordinate(tokens[5]),
]
else:
return match.group(0)
return f"{name}({' '.join(compacted)})"
return _TRANSFORM_FUNCTION_RE.sub(replace, value)
def _compact_attribute_value(
name: str,
value: str,
*,
compact_native_frames: bool,
) -> str:
if name == "data-pptx-frame":
return _compact_coordinate_quad(value) if compact_native_frames else value
if name == "data-pptx-bounds":
return _compact_coordinate_quad(value)
if name == "transform":
return _compact_transform(value)
return value
def _record_change(stats: CoordinateCompactionStats, name: str) -> None:
if name == "data-pptx-frame":
stats.native_frames += 1
elif name == "data-pptx-bounds":
stats.bounds += 1
elif name == "transform":
stats.transforms += 1
def compact_svg_tree(
root: ET.Element,
*,
compact_native_frames: bool = True,
) -> CoordinateCompactionStats:
"""Compact safe coordinate metadata in one parsed SVG tree."""
stats = CoordinateCompactionStats()
for element in root.iter():
for name in (
"data-pptx-frame",
"data-pptx-bounds",
"transform",
):
current = element.get(name)
if current is None:
continue
compacted = _compact_attribute_value(
name,
current,
compact_native_frames=compact_native_frames,
)
if compacted == current:
continue
element.set(name, compacted)
_record_change(stats, name)
return stats
def compact_svg_text(
text: str,
*,
compact_native_frames: bool = True,
) -> tuple[str, CoordinateCompactionStats]:
"""Compact safe coordinates while preserving unrelated SVG formatting."""
stats = CoordinateCompactionStats()
def replace(match: re.Match[str]) -> str:
name = match.group("name")
current = match.group("value")
compacted = _compact_attribute_value(
name,
current,
compact_native_frames=compact_native_frames,
)
if compacted == current:
return match.group(0)
_record_change(stats, name)
return (
f"{name}{match.group('spacing')}{match.group('quote')}"
f"{compacted}{match.group('quote')}"
)
return _COMPACTABLE_ATTRIBUTE_RE.sub(replace, text), stats
def _svg_files(input_path: Path) -> list[Path]:
if input_path.is_file():
return [input_path] if input_path.suffix.lower() == ".svg" else []
return sorted(path for path in input_path.rglob("*.svg") if path.is_file())
def _write_atomic(path: Path, payload: str) -> None:
mode = stat.S_IMODE(path.stat().st_mode)
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
newline="\n",
prefix=f".{path.name}.",
suffix=".tmp",
dir=path.parent,
delete=False,
) as handle:
temporary_path = Path(handle.name)
handle.write(payload)
try:
temporary_path.chmod(mode)
temporary_path.replace(path)
except OSError:
temporary_path.unlink(missing_ok=True)
raise
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Compact safe page-space SVG coordinates to at most two decimal "
"places. Runs as a dry-run unless --inplace is supplied."
),
)
parser.add_argument("input", type=Path, help="SVG file or directory")
parser.add_argument(
"--inplace",
action="store_true",
help="Atomically replace changed SVG files",
)
parser.add_argument(
"--keep-native-frames",
action="store_true",
help=(
"Leave data-pptx-frame unchanged while compacting "
"data-pptx-bounds and transform translations"
),
)
return parser
def main(argv: Optional[list[str]] = None) -> int:
args = build_parser().parse_args(argv)
input_path = args.input.resolve()
if not input_path.exists():
print(
json.dumps(
{"error": f"Input does not exist: {input_path}"},
ensure_ascii=False,
),
file=sys.stderr,
)
return 1
paths = _svg_files(input_path)
if not paths:
print(
json.dumps(
{"error": f"No SVG files found under {input_path}"},
ensure_ascii=False,
),
file=sys.stderr,
)
return 1
staged: list[tuple[Path, str]] = []
totals = CoordinateCompactionStats()
bytes_before = 0
bytes_after = 0
try:
for path in paths:
original = path.read_text(encoding="utf-8")
ET.fromstring(original)
compacted, stats = compact_svg_text(
original,
compact_native_frames=not args.keep_native_frames,
)
totals.merge(stats)
before = len(original.encode("utf-8"))
after = len(compacted.encode("utf-8"))
bytes_before += before
bytes_after += after
if compacted != original:
staged.append((path, compacted))
except (OSError, UnicodeDecodeError, ET.ParseError, ValueError) as exc:
print(
json.dumps({"error": str(exc)}, ensure_ascii=False),
file=sys.stderr,
)
return 1
if args.inplace:
try:
for path, payload in staged:
_write_atomic(path, payload)
except OSError as exc:
print(
json.dumps({"error": str(exc)}, ensure_ascii=False),
file=sys.stderr,
)
return 1
print(json.dumps({
"input": str(input_path),
"inplace": args.inplace,
"files_scanned": len(paths),
"files_changed": len(staged),
"bytes_before": bytes_before,
"bytes_after": bytes_after,
"bytes_saved": bytes_before - bytes_after,
"coordinates": totals.as_dict(),
}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())