#!/usr/bin/env python3 """Retheme FreeCAD SVG icons by replacing color palettes. Reads two CSV files with matching rows (source and target palettes), builds a color mapping, and applies it to all SVG files found in the input directories. Usage: python icons/retheme.py [options] python icons/retheme.py --dry-run """ import argparse import csv import re import sys from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent REPO_ROOT = SCRIPT_DIR.parent DEFAULT_SOURCE = SCRIPT_DIR / "mappings" / "FCAD.csv" DEFAULT_TARGET = SCRIPT_DIR / "mappings" / "kindred.csv" DEFAULT_OUTPUT = SCRIPT_DIR / "themed" # Default input dirs: core GUI icons + all module icons DEFAULT_INPUT_DIRS = [ REPO_ROOT / "src" / "Gui" / "Icons", *sorted(REPO_ROOT.glob("src/Mod/*/Gui/Resources/icons")), *sorted(REPO_ROOT.glob("src/Mod/*/Resources/icons")), ] def load_palette(path: Path) -> list[list[str]]: """Load a palette CSV. Each row is a list of hex color strings.""" rows = [] with open(path) as f: reader = csv.reader(f) for row in reader: colors = [] for cell in row: cell = cell.strip() if cell: if not cell.startswith("#"): cell = "#" + cell colors.append(cell.lower()) if colors: rows.append(colors) return rows def build_mapping(source_path: Path, target_path: Path) -> dict[str, str]: """Build a {source_hex: target_hex} dict from two palette CSVs.""" source = load_palette(source_path) target = load_palette(target_path) if len(source) != len(target): print( f"Error: palette row count mismatch: " f"{source_path.name} has {len(source)} rows, " f"{target_path.name} has {len(target)} rows", file=sys.stderr, ) sys.exit(1) mapping = {} for i, (src_row, tgt_row) in enumerate(zip(source, target)): if len(src_row) != len(tgt_row): print( f"Error: column count mismatch on row {i + 1}: " f"{len(src_row)} source colors vs {len(tgt_row)} target colors", file=sys.stderr, ) sys.exit(1) for src, tgt in zip(src_row, tgt_row): mapping[src] = tgt return mapping def retheme_svg(content: str, mapping: dict[str, str]) -> tuple[str, dict[str, int]]: """Replace all mapped hex colors in SVG content. Returns the modified content and a dict of {color: replacement_count}. """ stats: dict[str, int] = {} def replacer(match: re.Match) -> str: color = match.group(0).lower() target = mapping.get(color) if target is not None: stats[color] = stats.get(color, 0) + 1 # Preserve original case style (all-lower for consistency) return target return match.group(0) result = re.sub(r"#[0-9a-fA-F]{6}\b", replacer, content) return result, stats def collect_svgs(input_dirs: list[Path]) -> list[Path]: """Collect all .svg files from input directories, recursively.""" svgs = [] for d in input_dirs: if d.is_dir(): svgs.extend(sorted(d.rglob("*.svg"))) return svgs def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--source-palette", type=Path, default=DEFAULT_SOURCE, help=f"Source palette CSV (default: {DEFAULT_SOURCE.relative_to(REPO_ROOT)})", ) parser.add_argument( "--target-palette", type=Path, default=DEFAULT_TARGET, help=f"Target palette CSV (default: {DEFAULT_TARGET.relative_to(REPO_ROOT)})", ) parser.add_argument( "--input-dir", type=Path, action="append", dest="input_dirs", help="Input directory containing SVGs (can be repeated; default: FreeCAD icon dirs)", ) parser.add_argument( "--output-dir", type=Path, default=DEFAULT_OUTPUT, help=f"Output directory for themed SVGs (default: {DEFAULT_OUTPUT.relative_to(REPO_ROOT)})", ) parser.add_argument( "--dry-run", action="store_true", help="Show what would be done without writing files", ) args = parser.parse_args() input_dirs = args.input_dirs or DEFAULT_INPUT_DIRS # Build color mapping mapping = build_mapping(args.source_palette, args.target_palette) print(f"Loaded {len(mapping)} color mappings") for src, tgt in mapping.items(): print(f" {src} -> {tgt}") # Collect SVGs svgs = collect_svgs(input_dirs) print(f"\nFound {len(svgs)} SVG files across {len(input_dirs)} directories") if not svgs: print("No SVGs found. Check --input-dir paths.", file=sys.stderr) sys.exit(1) # Process if not args.dry_run: args.output_dir.mkdir(parents=True, exist_ok=True) total_files = 0 total_replacements = 0 all_unmapped: dict[str, int] = {} seen_names: set[str] = set() for svg_path in svgs: # Skip duplicates (same filename from different subdirectories) if svg_path.name in seen_names: continue seen_names.add(svg_path.name) content = svg_path.read_text(encoding="utf-8") themed, stats = retheme_svg(content, mapping) total_files += 1 file_replacements = sum(stats.values()) total_replacements += file_replacements if not args.dry_run: out_path = args.output_dir / svg_path.name out_path.write_text(themed, encoding="utf-8") # Track unmapped colors in this file for match in re.finditer(r"#[0-9a-fA-F]{6}\b", content): color = match.group(0).lower() if color not in mapping: all_unmapped[color] = all_unmapped.get(color, 0) + 1 # Summary action = "Would write" if args.dry_run else "Wrote" print( f"\n{action} {total_files} themed SVGs with {total_replacements} color replacements" ) print(f" Output: {args.output_dir}") if all_unmapped: print(f"\nUnmapped colors ({len(all_unmapped)} unique):") for color, count in sorted(all_unmapped.items(), key=lambda x: -x[1])[:20]: print(f" {color} ({count} occurrences)") if len(all_unmapped) > 20: print(f" ... and {len(all_unmapped) - 20} more") if __name__ == "__main__": main()