Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user