#!/usr/bin/env python3 """Propagate a spec_lock.md value change to both the lock file and svg_output/*.svg. Examples: python3 update_spec.py primary=#0066AA python3 update_spec.py colors.text=#111111 python3 update_spec.py typography.font_family='"PingFang SC", "Microsoft YaHei", sans-serif' v2 scope: - `colors.*` — HEX value replacement across svg_output/*.svg (case-insensitive match). - `typography.font_family` — replaces the inner value of every `font-family="..."` / `font-family='...'` attribute in svg_output/*.svg. This is a global replace: every text element becomes the new family, regardless of role. Bare `key=value` (no dot) is treated as `colors.key=value` for backward compat. Other keys (typography sizes, per-role `typography.*_family` overrides, icons, images, canvas, forbidden) are intentionally NOT supported — they involve attribute-scoped or semantic replacements whose risk/benefit does not warrant bulk propagation. For per-role family changes, edit spec_lock.md and re-author the affected pages. """ from __future__ import annotations import argparse import re import sys try: # zcbot: Windows GBK 控制台兼容,避免 emoji/© 等触发 UnicodeEncodeError sys.stdout.reconfigure(encoding="utf-8", errors="replace") sys.stderr.reconfigure(encoding="utf-8", errors="replace") except Exception: pass from pathlib import Path HEX_RE = re.compile(r"^#(?:[0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$") FONT_FAMILY_RE = re.compile(r"""(font-family\s*=\s*)(["'])(.*?)\2""") def parse_lock(lock_path: Path) -> dict[str, dict[str, str]]: """Return {section_name: {key: value}} parsed from spec_lock.md. The format is: ## section - key: value """ sections: dict[str, dict[str, str]] = {} current: str | None = None for raw in lock_path.read_text(encoding="utf-8").splitlines(): line = raw.rstrip() if line.startswith("## "): current = line[3:].strip() sections.setdefault(current, {}) continue if current is None: continue m = re.match(r"^-\s+([A-Za-z0-9_]+)\s*:\s*(.+?)\s*$", line) if m: sections[current][m.group(1)] = m.group(2) return sections def rewrite_lock(lock_path: Path, section: str, key: str, new_value: str) -> None: """Rewrite the single `- key: old_value` line under `## section`.""" lines = lock_path.read_text(encoding="utf-8").splitlines(keepends=True) in_section = False for i, raw in enumerate(lines): stripped = raw.rstrip("\n") if stripped.startswith("## "): in_section = stripped[3:].strip() == section continue if not in_section: continue m = re.match(r"^(-\s+)([A-Za-z0-9_]+)(\s*:\s*)(.+?)(\s*)$", stripped) if m and m.group(2) == key: lines[i] = f"{m.group(1)}{m.group(2)}{m.group(3)}{new_value}{m.group(5)}\n" lock_path.write_text("".join(lines), encoding="utf-8") return raise KeyError(f"key {key!r} not found under section {section!r} in {lock_path}") def replace_color_in_svgs( svg_dir: Path, old_hex: str, new_hex: str, *, dry_run: bool = False ) -> list[tuple[Path, int]]: """Replace old_hex with new_hex in every .svg under svg_dir. Returns a list of (path, replacement_count) for each changed file. The count comes straight from re.subn so callers can spot anomalies — e.g. one file with 50 hits when the rest have 4-8 is likely a stray HEX literal inside content rather than a styling attribute. Two-phase: plan all file updates in memory, then write to disk. If any exception is raised during planning (e.g. bad HEX, read failure), no files are touched. This keeps svg_output/ and the caller's spec_lock.md write in a consistent pair: either everything is applied or nothing is. When dry_run=True, the planning phase still runs (so bad HEX still raises and callers see which files would change), but no disk writes happen. The returned list describes the would-change files. """ if not HEX_RE.match(old_hex) or not HEX_RE.match(new_hex): raise ValueError(f"not a HEX color: old={old_hex!r} new={new_hex!r}") pattern = re.compile(re.escape(old_hex), re.IGNORECASE) planned: list[tuple[Path, str, int]] = [] for svg in sorted(svg_dir.glob("*.svg")): text = svg.read_text(encoding="utf-8") new_text, n = pattern.subn(new_hex, text) if n > 0: planned.append((svg, new_text, n)) if not dry_run: for svg, new_text, _ in planned: svg.write_text(new_text, encoding="utf-8") return [(p, n) for p, _, n in planned] def replace_font_family_in_svgs( svg_dir: Path, new_value: str, *, dry_run: bool = False ) -> list[tuple[Path, int]]: """Replace the inner value of every `font-family="..."` / `font-family='...'` attribute in every .svg under svg_dir. Returns a list of (path, replacement_count) for each changed file. Preserves the outer quote character when possible; if the new value contains that same quote type, switches the outer quote to the other kind. Two-phase: plan all file updates in memory, then write to disk. The inner `_sub` may raise ValueError when the new value contains both quote kinds — when that happens in the planning phase, no files have been touched yet. When dry_run=True, the planning phase still runs (so the ValueError still fires and callers see which files would change), but no disk writes happen. The returned list describes the would-change files. """ def _sub(m: re.Match[str]) -> str: prefix, quote, _inner = m.group(1), m.group(2), m.group(3) outer = quote if outer in new_value: outer = "'" if quote == '"' else '"' if outer in new_value: raise ValueError( f"new font_family value contains both ' and \" — cannot embed: {new_value!r}" ) return f"{prefix}{outer}{new_value}{outer}" planned: list[tuple[Path, str, int]] = [] for svg in sorted(svg_dir.glob("*.svg")): text = svg.read_text(encoding="utf-8") new_text, n = FONT_FAMILY_RE.subn(_sub, text) if n > 0 and new_text != text: planned.append((svg, new_text, n)) if not dry_run: for svg, new_text, _ in planned: svg.write_text(new_text, encoding="utf-8") return [(p, n) for p, _, n in planned] def main() -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("project_path", type=Path, help="project folder containing spec_lock.md and svg_output/") ap.add_argument( "assignment", help="section.key=value (e.g. colors.primary=#0066AA, typography.font_family='\"Inter\", Arial, sans-serif'). " "Bare key=value is treated as colors.key=value.", ) ap.add_argument( "--dry-run", "-n", action="store_true", help="preview which SVGs would change; do not write anything to disk.", ) args = ap.parse_args() project = args.project_path.resolve() lock = project / "spec_lock.md" svg_dir = project / "svg_output" if not lock.exists(): print(f"error: spec_lock.md not found at {lock}", file=sys.stderr) return 2 if not svg_dir.exists(): print(f"error: svg_output/ not found at {svg_dir}", file=sys.stderr) return 2 if "=" not in args.assignment: print("error: assignment must be [section.]key=value", file=sys.stderr) return 2 lhs, new_value = args.assignment.split("=", 1) lhs = lhs.strip() new_value = new_value.strip() if "." in lhs: section, key = lhs.split(".", 1) section = section.strip() key = key.strip() else: section, key = "colors", lhs sections = parse_lock(lock) section_map = sections.get(section, {}) if key not in section_map: known = {s: sorted(v) for s, v in sections.items()} print( f"error: {key!r} not found under `## {section}` in spec_lock.md.\n" f"known keys: {known}", file=sys.stderr, ) return 2 old_value = section_map[key] if section == "colors": if not HEX_RE.match(new_value): print(f"error: new value for colors.{key} must be a HEX color (got {new_value!r})", file=sys.stderr) return 2 if old_value == new_value: print(f"no change: colors.{key} already = {new_value}") return 0 # SVGs first (may raise on bad HEX), then lock. Writing lock last # avoids a state where lock claims new_value but SVGs still hold # old_value — that state silences re-runs (parse_lock would then # see new_value == old_value and exit early). changed = replace_color_in_svgs(svg_dir, old_value, new_value, dry_run=args.dry_run) if not args.dry_run: rewrite_lock(lock, "colors", key, new_value) elif section == "typography" and key == "font_family": if old_value == new_value: print(f"no change: typography.font_family already = {new_value}") return 0 try: changed = replace_font_family_in_svgs(svg_dir, new_value, dry_run=args.dry_run) except ValueError as e: print(f"error: {e}", file=sys.stderr) return 2 if not args.dry_run: rewrite_lock(lock, "typography", key, new_value) else: print( f"error: {section}.{key} is not supported by update_spec.py.\n" f"v2 supports: colors.* (HEX), typography.font_family.\n" f"Edit spec_lock.md and the affected SVGs by hand for other changes.", file=sys.stderr, ) return 2 if args.dry_run: print(f"[dry-run] spec_lock.md: {section}.{key} {old_value} → {new_value}") print(f"[dry-run] svg_output/: {len(changed)} file(s) would be updated") else: print(f"spec_lock.md: {section}.{key} {old_value} → {new_value}") print(f"svg_output/: {len(changed)} file(s) updated") for p, n in changed: suffix = "replacement" if n == 1 else "replacements" print(f" - {p.name} ({n} {suffix})") return 0 if __name__ == "__main__": sys.exit(main())