Some checks failed
Build and Test / build (pull_request) Has been cancelled
- 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
210 lines
6.4 KiB
Python
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()
|