返回 ppt-master
update_spec.py
根目录 / skills / ppt-master / scripts / update_spec.py
1 #!/usr/bin/env python3
2 """Propagate a spec_lock.md value change to both the lock file and svg_output/*.svg.
3
4 Examples:
5 python3 update_spec.py <project_path> primary=#0066AA
6 python3 update_spec.py <project_path> colors.text=#111111
7 python3 update_spec.py <project_path> \\
8 typography.font_family='Arial, "Microsoft YaHei", sans-serif'
9
10 v2 scope:
11 - `colors.*` — HEX value replacement across svg_output/*.svg (case-insensitive match).
12 - `typography.font_family` — replaces the inner value of every `font-family="..."`
13 / `font-family='...'` attribute in svg_output/*.svg. This is a global replace:
14 every text element becomes the new family, regardless of role, and every
15 existing `typography.*_family` lock row is updated to the same value.
16
17 Bare `key=value` (no dot) is treated as `colors.key=value` for backward compat.
18
19 Other keys as independent targets (typography sizes, per-role
20 `typography.*_family` overrides, icons, images, canvas, forbidden) are
21 intentionally NOT supported — they involve attribute-scoped or semantic
22 replacements whose risk/benefit does not warrant bulk propagation. For
23 per-role family changes, edit spec_lock.md and re-author the affected pages.
24 """
25 from __future__ import annotations
26
27 import argparse
28 import os
29 import re
30 import shutil
31 import sys
32 import tempfile
33 from pathlib import Path
34
35 from console_encoding import configure_utf8_stdio
36 from project_management.project_specs import parse_spec_lock as parse_lock
37
38 configure_utf8_stdio()
39
40 HEX_RE = re.compile(r"^#(?:[0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$")
41 FONT_FAMILY_RE = re.compile(r"""(font-family\s*=\s*)(["'])(.*?)\2""")
42
43
44 def plan_lock_values(
45 lock_path: Path, section: str, updates: dict[str, str]
46 ) -> str:
47 """Return a lock rewrite only after every target row validates."""
48 lines = lock_path.read_text(encoding="utf-8").splitlines(keepends=True)
49 in_section = False
50 found: set[str] = set()
51 for i, raw in enumerate(lines):
52 stripped = raw.rstrip("\r\n")
53 line_ending = raw[len(stripped) :]
54 if stripped.startswith("## "):
55 in_section = stripped[3:].strip() == section
56 continue
57 if not in_section:
58 continue
59 m = re.match(r"^(-\s+)([A-Za-z0-9_]+)(\s*:\s*)(.+?)(\s*)$", stripped)
60 if not m or m.group(2) not in updates:
61 continue
62 key = m.group(2)
63 if key in found:
64 raise ValueError(
65 f"duplicate key {key!r} under section {section!r} in {lock_path}"
66 )
67 found.add(key)
68 lines[i] = (
69 f"{m.group(1)}{key}{m.group(3)}{updates[key]}{m.group(5)}"
70 f"{line_ending}"
71 )
72
73 missing = set(updates) - found
74 if missing:
75 missing_list = ", ".join(sorted(missing))
76 raise KeyError(
77 f"key(s) {missing_list} not found under section {section!r} in {lock_path}"
78 )
79 return "".join(lines)
80
81
82 def _plan_color_updates(
83 svg_dir: Path,
84 old_hex: str,
85 new_hex: str,
86 ) -> list[tuple[Path, str, int]]:
87 """Return planned color rewrites without touching the SVG files."""
88 if not HEX_RE.match(old_hex) or not HEX_RE.match(new_hex):
89 raise ValueError(f"not a HEX color: old={old_hex!r} new={new_hex!r}")
90 pattern = re.compile(
91 rf"{re.escape(old_hex)}(?![0-9A-Fa-f])",
92 re.IGNORECASE,
93 )
94 planned: list[tuple[Path, str, int]] = []
95 for svg in sorted(svg_dir.glob("*.svg")):
96 text = svg.read_text(encoding="utf-8")
97 new_text, count = pattern.subn(new_hex, text)
98 if count > 0:
99 planned.append((svg, new_text, count))
100 return planned
101
102
103 def _plan_font_family_updates(
104 svg_dir: Path,
105 new_value: str,
106 ) -> list[tuple[Path, str, int]]:
107 """Return planned font-family rewrites without touching the SVG files."""
108
109 def replace_value(match: re.Match[str]) -> str:
110 prefix, quote, _inner = match.group(1), match.group(2), match.group(3)
111 outer = quote
112 if outer in new_value:
113 outer = "'" if quote == '"' else '"'
114 if outer in new_value:
115 raise ValueError(
116 f"new font_family value contains both ' and \" — cannot embed: "
117 f"{new_value!r}"
118 )
119 return f"{prefix}{outer}{new_value}{outer}"
120
121 planned: list[tuple[Path, str, int]] = []
122 for svg in sorted(svg_dir.glob("*.svg")):
123 text = svg.read_text(encoding="utf-8")
124 new_text, count = FONT_FAMILY_RE.subn(replace_value, text)
125 if count > 0 and new_text != text:
126 planned.append((svg, new_text, count))
127 return planned
128
129
130 def _publish_text_updates(updates: list[tuple[Path, str]]) -> None:
131 """Publish existing text files as one rollback unit."""
132 if not updates:
133 return
134 targets = [path for path, _text in updates]
135 if len(targets) != len(set(targets)):
136 raise ValueError("transaction contains duplicate output paths")
137 for target in targets:
138 if not target.is_file() or target.is_symlink():
139 raise OSError(f"transaction target must be a regular file: {target}")
140
141 transaction_dir = Path(
142 tempfile.mkdtemp(
143 prefix=".update-spec-",
144 dir=targets[0].parent,
145 )
146 )
147 staged_dir = transaction_dir / "staged"
148 backup_dir = transaction_dir / "previous"
149 preserve_transaction = False
150 published: list[tuple[Path, Path]] = []
151 try:
152 staged_dir.mkdir()
153 backup_dir.mkdir()
154 staged: list[tuple[Path, Path, Path]] = []
155 transaction_device = transaction_dir.stat().st_dev
156 for index, (target, text) in enumerate(updates):
157 if target.parent.stat().st_dev != transaction_device:
158 raise OSError(
159 f"cannot atomically publish across filesystems: {target}"
160 )
161 staged_path = staged_dir / f"{index:06d}.new"
162 backup_path = backup_dir / f"{index:06d}.bak"
163 staged_path.write_text(text, encoding="utf-8")
164 shutil.copymode(target, staged_path)
165 shutil.copy2(target, backup_path, follow_symlinks=False)
166 staged.append((target, staged_path, backup_path))
167
168 try:
169 for target, staged_path, backup_path in staged:
170 os.replace(staged_path, target)
171 published.append((target, backup_path))
172 except BaseException as publish_error:
173 rollback_errors: list[str] = []
174 for target, backup_path in reversed(published):
175 try:
176 os.replace(backup_path, target)
177 except BaseException as rollback_error:
178 rollback_errors.append(
179 f"could not restore {target}: "
180 f"{type(rollback_error).__name__}: {rollback_error}"
181 )
182 if rollback_errors:
183 preserve_transaction = True
184 raise RuntimeError(
185 f"update_spec publish failed ({publish_error}); rollback was "
186 f"incomplete: {'; '.join(rollback_errors)}; recovery directory: "
187 f"{transaction_dir}"
188 ) from publish_error
189 raise
190 finally:
191 if not preserve_transaction:
192 shutil.rmtree(transaction_dir, ignore_errors=True)
193
194
195 def replace_color_in_svgs(
196 svg_dir: Path, old_hex: str, new_hex: str, *, dry_run: bool = False
197 ) -> list[tuple[Path, int]]:
198 """Replace old_hex with new_hex in every .svg under svg_dir.
199
200 Returns a list of (path, replacement_count) for each changed file. The
201 count comes straight from re.subn so callers can spot anomalies —
202 e.g. one file with 50 hits when the rest have 4-8 is likely a stray
203 HEX literal inside <text> content rather than a styling attribute.
204
205 Two-phase: plan all file updates in memory, then publish them with rollback.
206 If planning or publishing fails, the original files are retained.
207
208 When dry_run=True, the planning phase still runs (so bad HEX still raises
209 and callers see which files would change), but no disk writes happen. The
210 returned list describes the would-change files.
211 """
212 planned = _plan_color_updates(svg_dir, old_hex, new_hex)
213 if not dry_run:
214 _publish_text_updates([
215 (svg, new_text)
216 for svg, new_text, _count in planned
217 ])
218 return [(path, count) for path, _text, count in planned]
219
220
221 def replace_font_family_in_svgs(
222 svg_dir: Path, new_value: str, *, dry_run: bool = False
223 ) -> list[tuple[Path, int]]:
224 """Replace the inner value of every `font-family="..."` / `font-family='...'`
225 attribute in every .svg under svg_dir.
226
227 Returns a list of (path, replacement_count) for each changed file.
228
229 Preserves the outer quote character when possible; if the new value contains
230 that same quote type, switches the outer quote to the other kind.
231
232 Two-phase: plan all file updates in memory, then publish them with rollback.
233 A conflicting quote style fails during planning, before files are touched.
234
235 When dry_run=True, the planning phase still runs (so the ValueError still
236 fires and callers see which files would change), but no disk writes happen.
237 The returned list describes the would-change files.
238 """
239 planned = _plan_font_family_updates(svg_dir, new_value)
240 if not dry_run:
241 _publish_text_updates([
242 (svg, new_text)
243 for svg, new_text, _count in planned
244 ])
245 return [(path, count) for path, _text, count in planned]
246
247
248 def main() -> int:
249 ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
250 ap.add_argument("project_path", type=Path, help="project folder containing spec_lock.md and svg_output/")
251 ap.add_argument(
252 "assignment",
253 help=(
254 "section.key=value (e.g. colors.primary=#0066AA, "
255 "typography.font_family='Arial, \"Microsoft YaHei\", sans-serif'). "
256 "Bare key=value is treated as colors.key=value."
257 ),
258 )
259 ap.add_argument(
260 "--dry-run",
261 "-n",
262 action="store_true",
263 help="preview which SVGs would change; do not write anything to disk.",
264 )
265 args = ap.parse_args()
266
267 project = args.project_path.resolve()
268 lock = project / "spec_lock.md"
269 svg_dir = project / "svg_output"
270
271 if not lock.exists():
272 print(f"error: spec_lock.md not found at {lock}", file=sys.stderr)
273 return 2
274 if not svg_dir.exists():
275 print(f"error: svg_output/ not found at {svg_dir}", file=sys.stderr)
276 return 2
277
278 if "=" not in args.assignment:
279 print("error: assignment must be [section.]key=value", file=sys.stderr)
280 return 2
281 lhs, new_value = args.assignment.split("=", 1)
282 lhs = lhs.strip()
283 new_value = new_value.strip()
284 if "." in lhs:
285 section, key = lhs.split(".", 1)
286 section = section.strip()
287 key = key.strip()
288 else:
289 section, key = "colors", lhs
290
291 sections = parse_lock(lock)
292 section_map = sections.get(section, {})
293 if key not in section_map:
294 known = {s: sorted(v) for s, v in sections.items()}
295 print(
296 f"error: {key!r} not found under `## {section}` in spec_lock.md.\n"
297 f"known keys: {known}",
298 file=sys.stderr,
299 )
300 return 2
301
302 old_value = section_map[key]
303 lock_changes: list[tuple[str, str, str]] = []
304
305 if section == "colors":
306 if not HEX_RE.match(new_value):
307 print(f"error: new value for colors.{key} must be a HEX color (got {new_value!r})", file=sys.stderr)
308 return 2
309 if old_value == new_value:
310 print(f"no change: colors.{key} already = {new_value}")
311 return 0
312 try:
313 planned_lock = plan_lock_values(lock, "colors", {key: new_value})
314 except (KeyError, ValueError) as exc:
315 print(f"error: {exc}", file=sys.stderr)
316 return 2
317 planned_svg = _plan_color_updates(svg_dir, old_value, new_value)
318 changed = [
319 (path, count)
320 for path, _text, count in planned_svg
321 ]
322 lock_changes = [(key, old_value, new_value)]
323 if not args.dry_run:
324 try:
325 _publish_text_updates([
326 *(
327 (path, new_text)
328 for path, new_text, _count in planned_svg
329 ),
330 (lock, planned_lock),
331 ])
332 except (OSError, RuntimeError, ValueError) as exc:
333 print(f"error: update was not published: {exc}", file=sys.stderr)
334 return 2
335 elif section == "typography" and key == "font_family":
336 family_keys = [
337 name
338 for name in section_map
339 if name == "font_family" or name.endswith("_family")
340 ]
341 lock_changes = [
342 (name, section_map[name], new_value)
343 for name in family_keys
344 if section_map[name] != new_value
345 ]
346 if not lock_changes:
347 print(f"no change: all typography.*_family rows already = {new_value}")
348 return 0
349 try:
350 planned_lock = plan_lock_values(
351 lock,
352 "typography",
353 {name: new_value for name in family_keys},
354 )
355 planned_svg = _plan_font_family_updates(svg_dir, new_value)
356 changed = [
357 (path, count)
358 for path, _text, count in planned_svg
359 ]
360 except (KeyError, ValueError) as e:
361 print(f"error: {e}", file=sys.stderr)
362 return 2
363 if not args.dry_run:
364 try:
365 _publish_text_updates([
366 *(
367 (path, new_text)
368 for path, new_text, _count in planned_svg
369 ),
370 (lock, planned_lock),
371 ])
372 except (OSError, RuntimeError, ValueError) as exc:
373 print(f"error: update was not published: {exc}", file=sys.stderr)
374 return 2
375 else:
376 print(
377 f"error: {section}.{key} is not supported by update_spec.py.\n"
378 f"v2 supports: colors.* (HEX), typography.font_family.\n"
379 f"Edit spec_lock.md and the affected SVGs by hand for other changes.",
380 file=sys.stderr,
381 )
382 return 2
383
384 if args.dry_run:
385 for lock_key, previous, replacement in lock_changes:
386 print(
387 f"[dry-run] spec_lock.md: "
388 f"{section}.{lock_key} {previous} → {replacement}"
389 )
390 print(f"[dry-run] svg_output/: {len(changed)} file(s) would be updated")
391 else:
392 for lock_key, previous, replacement in lock_changes:
393 print(
394 f"spec_lock.md: {section}.{lock_key} {previous} → {replacement}"
395 )
396 print(f"svg_output/: {len(changed)} file(s) updated")
397 for p, n in changed:
398 suffix = "replacement" if n == 1 else "replacements"
399 print(f" - {p.name} ({n} {suffix})")
400 return 0
401
402
403 if __name__ == "__main__":
404 sys.exit(main())
405
405 lines PYTHON