返回 ppt-master
compact_svg_coordinates.py
根目录 / skills / ppt-master / scripts / compact_svg_coordinates.py
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
346 lines PYTHON