Files
create/icons/retheme.py
forbes d7b532255b
Some checks failed
Build and Test / build (pull_request) Has been cancelled
feat(icons): add icon theming infrastructure with Catppuccin color remapping
- Remove hand-crafted kindred-icons/ in favor of auto-generated themed icons
- Add icons/mappings/ with FCAD.csv (Tango palette) and kindred.csv (Catppuccin Mocha)
- Add icons/retheme.py script to remap upstream FreeCAD SVG colors
- Generate icons/themed/ with 1,595 themed SVGs (45,300 color replacements)
- BitmapFactory loads icons/themed/ as highest priority before default icons
- 157-color mapping covers the full Tango palette, interpolating between
  4 luminance anchors per color family

Regenerate: python3 icons/retheme.py
2026-02-15 20:34:22 -06:00

210 lines
6.4 KiB
Python

#!/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()