Files
edgetx/tools/convert-gfx.py
T
2026-08-03 16:37:20 +08:00

1141 lines
38 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Convert SVG files to PNG icons for different screen resolutions.
Manages conversion, validation, and incremental builds with CSV tracking.
"""
import sys
import csv
import logging
import shutil
import subprocess
import time
import argparse
import importlib.util
from pathlib import Path
from datetime import datetime
from typing import Tuple, Optional, List, Dict
def check_dependencies():
"""Check if required Python packages are installed."""
missing = []
if importlib.util.find_spec('PIL') is None:
missing.append('pillow')
if importlib.util.find_spec('rich') is None:
missing.append('rich')
if missing:
print("Error: Missing required packages:", file=sys.stderr)
for pkg in missing:
print(f" - {pkg}", file=sys.stderr)
print(file=sys.stderr)
if shutil.which('uv'):
print(f"Install with: uv pip install {' '.join(missing)}", file=sys.stderr)
else:
print(f"Install with: pip install {' '.join(missing)}", file=sys.stderr)
sys.exit(1)
# Check dependencies before importing external packages
check_dependencies()
from PIL import Image # noqa: E402
from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TaskProgressColumn # noqa: E402
from rich.logging import RichHandler # noqa: E402
from rich.console import Console # noqa: E402
# Set up logging and console
logger = logging.getLogger("convert-gfx")
console = Console(highlight=False)
# Configuration
SCRIPT_DIR = Path(__file__).parent.resolve()
BASE_DIR = SCRIPT_DIR.parent / "radio" / "src" / "bitmaps"
SRC_DIR = BASE_DIR / "img-src"
SRC_LIST = SCRIPT_DIR / "convert-gfx-list.csv"
RESOLUTIONS = {
"320x240": 0.8,
"480x272": 1.0,
"800x480": 1.375,
}
def get_png_dimensions(png_path: Path) -> Optional[Tuple[int, int]]:
"""Extract PNG dimensions using PIL."""
try:
if not png_path.exists():
return None
img = Image.open(png_path)
return img.size
except Exception:
return None
def get_svg_mtime(svg_path: Path) -> str:
"""Get SVG modification time as formatted string."""
if not svg_path.exists():
return ""
mtime = svg_path.stat().st_mtime
dt = datetime.fromtimestamp(mtime)
return dt.strftime("%Y-%m-%d %H:%M:%S")
def round_divide(value: int, scale: float) -> int:
"""Round division: value / scale rounded to nearest integer."""
if scale == 0:
return 0
return round(value / scale)
def check_command(cmd: str, instructions: Dict[str, str]) -> bool:
"""Check if a command is available in PATH."""
if shutil.which(cmd) is None:
print(f"Error: {cmd} is not installed or not in PATH")
print(f"Please install {cmd} to use this script")
print()
for key, val in instructions.items():
print(f"{key}: {val}")
return False
return True
def scale_dimension(value: int, scale: float) -> int:
"""Scale a dimension and round to nearest integer."""
return round(value * scale)
def format_duration(seconds: float) -> str:
"""Format duration in seconds to human-readable string.
Returns:
String like "450ms", "2.3s", or "1m 23s"
"""
if seconds < 1.0:
return f"{int(seconds * 1000)}ms"
elif seconds < 60:
return f"{seconds:.1f}s"
else:
minutes = int(seconds // 60)
secs = int(seconds % 60)
return f"{minutes}m {secs}s"
def read_csv_entries() -> Dict[str, Tuple[str, str]]:
"""Read CSV file and return dict of filename -> (width, height).
Returns:
Dictionary mapping filename to (width, height) tuple
"""
entries = {}
if not SRC_LIST.exists():
return entries
try:
with open(SRC_LIST, "r") as f:
reader = csv.DictReader(f)
for row in reader:
file_name = row.get("file", "").strip()
width = row.get("width", "").strip()
height = row.get("height", "").strip()
if file_name:
entries[file_name] = (width, height)
except Exception as e:
console.print(f"[red]Error reading CSV: {e}[/red]")
return entries
def run_conversion(
engine: str,
width: int,
height: int,
input_file: Path,
output_file: Path,
dry_run: bool = False,
) -> bool:
"""Run SVG to PNG conversion using rsvg-convert or resvg."""
if dry_run:
return True
output_file.parent.mkdir(parents=True, exist_ok=True)
try:
if engine == "resvg":
subprocess.run(
["resvg", "--width", str(width), "--height", str(height),
str(input_file), str(output_file)],
check=True,
capture_output=True,
text=True,
)
else: # rsvg-convert
subprocess.run(
["rsvg-convert", "--width", str(width), "--height", str(height),
"--output", str(output_file), str(input_file)],
check=True,
capture_output=True,
text=True,
)
return True
except subprocess.CalledProcessError as e:
error_msg = e.stderr.strip() if e.stderr else str(e)
console.print(f" [red]Error converting {input_file.name}: {error_msg}[/red]")
return False
except Exception as e:
console.print(f" [red]Unexpected error converting {input_file.name}: {e}[/red]")
return False
def update_src_list_csv(verbose: bool = False) -> int:
"""Regenerate convert-gfx-list.csv from existing PNGs and SVGs.
Preserves existing modified timestamps for unchanged entries.
Derives dimensions from existing PNGs (prefers 480x272 base resolution).
Maintains alphabetical sort order.
"""
start_time = time.time()
if not SRC_DIR.exists():
print(f"Error: SVG source directory not found: {SRC_DIR}")
return 1
print(f"Scanning SVG files in {SRC_DIR}...")
print(f"CSV output: {SRC_LIST}")
print()
# Load existing CSV to preserve modified dates
existing_dates: Dict[str, str] = {}
if SRC_LIST.exists():
try:
with open(SRC_LIST, "r") as f:
reader = csv.DictReader(f)
for row in reader:
file_name = row.get("file", "").strip()
modified = row.get("modified", "").strip()
if file_name and modified:
existing_dates[file_name] = modified
except Exception as e:
print(f"Warning: Could not read existing CSV: {e}")
# Collect SVG files first
svg_paths = sorted(SRC_DIR.glob("**/*.svg"))
# Scan all SVGs with progress bar
rows = []
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TaskProgressColumn(),
transient=False,
) as progress:
task = progress.add_task(
"Scanning SVGs...",
total=len(svg_paths),
)
for svg_path in svg_paths:
rel_path = svg_path.relative_to(SRC_DIR)
file_name = str(rel_path.with_suffix(""))
png_name = f"{file_name}.png"
# Try to get dimensions from existing PNGs (prefer 480x272 base resolution)
width = ""
height = ""
png_480 = BASE_DIR / "480x272" / png_name
png_320 = BASE_DIR / "320x240" / png_name
png_800 = BASE_DIR / "800x480" / png_name
dim_480 = get_png_dimensions(png_480)
if dim_480:
width, height = dim_480
else:
dim_320 = get_png_dimensions(png_320)
if dim_320:
w, h = dim_320
width = round_divide(w, 0.8)
height = round_divide(h, 0.8)
else:
dim_800 = get_png_dimensions(png_800)
if dim_800:
w, h = dim_800
width = round_divide(w, 1.375)
height = round_divide(h, 1.375)
# Preserve existing modified date, or use SVG mtime for new entries
if file_name in existing_dates:
modified = existing_dates[file_name]
else:
modified = get_svg_mtime(svg_path)
rows.append({
"file": file_name,
"width": str(width) if width else "",
"height": str(height) if height else "",
"modified": modified,
})
progress.update(task, advance=1)
# Write sorted CSV
try:
with open(SRC_LIST, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["file", "width", "height", "modified"])
writer.writeheader()
writer.writerows(rows)
except Exception as e:
print(f"Error writing CSV: {e}")
return 1
total_svg = len(rows)
elapsed = time.time() - start_time
print()
print(f"Total SVG files processed: {total_svg}")
console.print(f"[green]CSV updated: {SRC_LIST}[/green] [cyan]({format_duration(elapsed)})[/cyan]")
return 0
def validate_svg(verbose: bool = False) -> int:
"""Validate that SVG files match CSV entries."""
if not SRC_LIST.exists():
print(f"Error: CSV files list not found: {SRC_LIST}")
return 1
if not SRC_DIR.exists():
print(f"Error: SVG source directory not found: {SRC_DIR}")
return 1
print("Validating SVG source files")
print("---------------------------")
print()
has_errors = False
# Part 0: Check CSV for missing dimensions
print("Checking CSV for missing dimensions...")
missing_dimensions = 0
csv_entries = read_csv_entries()
if not csv_entries:
console.print("[red]Error: Could not read CSV or CSV is empty[/red]")
return 1
for file_name, (width, height) in csv_entries.items():
if not width or not height:
console.print(
f" [red]CSV entry missing dimensions: {file_name} "
f"(width: '{width}', height: '{height}')[/red]"
)
has_errors = True
missing_dimensions += 1
if missing_dimensions > 0:
print(f" Total entries with missing dimensions: {missing_dimensions}")
else:
console.print(" [green]All CSV entries have dimensions[/green]")
# Part 1: Check CSV entries against SVG directory
print()
print("Comparing CSV files list with SVG directory...")
missing_in_dir = 0
found_in_dir = 0
for file_name in sorted(csv_entries.keys()):
svg_path = SRC_DIR / f"{file_name}.svg"
if not svg_path.exists():
console.print(f" [red]CSV entry missing SVG file: {file_name}.svg[/red]")
has_errors = True
missing_in_dir += 1
else:
found_in_dir += 1
print(f" CSV entries - Found: {found_in_dir}, Missing in SVG dir: {missing_in_dir}")
# Part 2: Check SVG directory against CSV
print()
print("Comparing SVG directory with CSV files list...")
svg_files = {p.stem for p in SRC_DIR.glob("**/*.svg")}
csv_files = set(csv_entries.keys())
missing_in_csv = svg_files - csv_files
for file_name in sorted(missing_in_csv):
console.print(f" [red]SVG file missing in CSV list: {file_name}.svg[/red]")
has_errors = True
total_svg = len(svg_files)
print(f" SVG files - Total: {total_svg}, Missing in CSV: {len(missing_in_csv)}")
print()
if not has_errors and len(missing_in_csv) == 0:
console.print("[green]SVG source files validation passed: All files match[/green]")
return 0
else:
console.print("[red]SVG source files validation failed[/red]")
return 1
def validate_png(verbose: bool = False) -> int:
"""Validate that PNG files exist for all CSV entries."""
if not SRC_LIST.exists():
print(f"Error: CSV files list not found: {SRC_LIST}")
return 1
if not SRC_DIR.exists():
print(f"Error: SVG source directory not found: {SRC_DIR}")
return 1
print("Validating PNG files")
print("--------------------")
has_errors = False
# Read CSV - only include entries with valid dimensions
csv_entries = {name: dims for name, dims in read_csv_entries().items()
if dims[0] and dims[1]}
if not csv_entries:
console.print("[red]Error: No valid entries found in CSV[/red]")
return 1
for resolution in RESOLUTIONS:
print()
print(f"Checking resolution: {resolution}")
out_dir = BASE_DIR / resolution
if not out_dir.exists():
print(f" Warning: Output directory not found: {out_dir}")
has_errors = True
continue
missing_count = 0
found_count = 0
for file_name in csv_entries:
png_path = out_dir / f"{file_name}.png"
if not png_path.exists():
console.print(f" [red]Missing PNG: {file_name}.png[/red]")
has_errors = True
missing_count += 1
else:
found_count += 1
print(f" Found: {found_count}, Missing: {missing_count}")
# Check for orphaned PNGs across all resolutions
print()
print("Checking for orphaned PNG files...")
orphaned = find_orphaned_pngs()
total_orphaned = sum(len(files) for files in orphaned.values())
if total_orphaned > 0:
for resolution, files in orphaned.items():
if files:
console.print(f"[red]{resolution}: {len(files)} orphaned PNG file(s)[/red]")
for filename in sorted(files):
console.print(f" [red]Orphaned PNG: {filename}.png[/red]")
has_errors = True
else:
console.print(" [green]No orphaned PNG files found[/green]")
print()
if not has_errors:
console.print("[green]PNG validation passed: All PNG files valid[/green]")
return 0
else:
console.print("[red]PNG validation failed: See errors above[/red]")
return 1
def validate_all(verbose: bool = False) -> int:
"""Validate SVG and PNG files with interleaved per-file results."""
start_time = time.time()
if not SRC_LIST.exists():
print(f"Error: CSV files list not found: {SRC_LIST}")
return 1
if not SRC_DIR.exists():
print(f"Error: SVG source directory not found: {SRC_DIR}")
return 1
print("Validating SVG and PNG files")
print("----------------------------")
print()
has_errors = False
# Part 0: Check CSV for missing dimensions
print("Checking CSV for missing dimensions...")
missing_dimensions = 0
csv_entries = read_csv_entries()
if not csv_entries:
console.print("[red]Error: Could not read CSV or CSV is empty[/red]")
return 1
for file_name, (width, height) in csv_entries.items():
if not width or not height:
console.print(
f" [red]CSV entry missing dimensions: {file_name} "
f"(width: '{width}', height: '{height}')[/red]"
)
has_errors = True
missing_dimensions += 1
if missing_dimensions > 0:
print(f" Total entries with missing dimensions: {missing_dimensions}")
else:
console.print(" [green]All CSV entries have dimensions[/green]")
# Part 1: Check SVG/PNG for each file
print()
print("Validating each file across SVG and PNG...")
svg_files = {p.stem for p in SRC_DIR.glob("**/*.svg")}
csv_files = set(csv_entries.keys())
all_files = sorted(svg_files | csv_files)
svg_found = 0
svg_missing = 0
svg_unreferenced = 0
png_missing_by_res = {res: 0 for res in RESOLUTIONS}
png_found_by_res = {res: 0 for res in RESOLUTIONS}
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TaskProgressColumn(),
transient=False,
) as progress:
task = progress.add_task(
"Validating files...",
total=len(all_files),
)
for file_name in all_files:
# Check SVG
svg_path = SRC_DIR / f"{file_name}.svg"
in_csv = file_name in csv_entries
svg_exists = svg_path.exists()
if not svg_exists and in_csv:
progress.print(f" [red]SVG missing: {file_name}.svg (in CSV but not on disk)[/red]")
has_errors = True
svg_missing += 1
elif svg_exists and not in_csv:
progress.print(f" [red]SVG unreferenced: {file_name}.svg (on disk but not in CSV)[/red]")
svg_unreferenced += 1
elif svg_exists and in_csv:
svg_found += 1
# Check PNG for each resolution (only if in CSV with dimensions)
if in_csv:
width, height = csv_entries[file_name]
if width and height:
all_missing = True
for resolution in RESOLUTIONS:
out_dir = BASE_DIR / resolution
png_path = out_dir / f"{file_name}.png"
if png_path.exists():
all_missing = False
png_found_by_res[resolution] += 1
else:
png_missing_by_res[resolution] += 1
if all_missing:
progress.print(f" [red]PNG missing in all resolutions: {file_name}.png[/red]")
has_errors = True
progress.update(task, advance=1)
# Part 2: Check for orphaned PNGs (on disk but not in SVG/CSV)
print()
print("Checking for orphaned PNG files...")
orphaned = find_orphaned_pngs()
total_orphaned = sum(len(files) for files in orphaned.values())
if total_orphaned > 0:
for resolution, files in orphaned.items():
if files:
console.print(f"[red]{resolution}: {len(files)} orphaned PNG file(s)[/red]")
for filename in sorted(files):
console.print(f" [red]Orphaned PNG: {filename}.png[/red]")
has_errors = True
else:
console.print(" [green]No orphaned PNG files found[/green]")
elapsed = time.time() - start_time
print()
print(f"SVG validation - Found: {svg_found}, Missing: {svg_missing}, Unreferenced: {svg_unreferenced}")
for resolution in RESOLUTIONS:
found = png_found_by_res[resolution]
missing = png_missing_by_res[resolution]
total = found + missing
if total > 0:
print(f"PNG validation - {resolution}: Found: {found}/{total}, Missing: {missing}")
if total_orphaned > 0:
print(f"Orphaned PNG files - Total: {total_orphaned}")
print()
if not has_errors and svg_unreferenced == 0:
console.print(f"[green]All validations passed[/green] [cyan]({format_duration(elapsed)})[/cyan]")
return 0
elif (svg_unreferenced > 0 or total_orphaned > 0) and not has_errors:
console.print(f"[red]Validation complete: {svg_unreferenced} unreferenced SVG file(s) and {total_orphaned} orphaned PNG file(s) found[/red] [cyan]({format_duration(elapsed)})[/cyan]")
return 1
else:
console.print(f"[red]Validation failed: See errors above[/red] [cyan]({format_duration(elapsed)})[/cyan]")
return 1
def find_orphaned_pngs() -> Dict[str, List[str]]:
"""Find PNG files that don't have corresponding SVG sources.
Returns:
Dictionary mapping resolution -> list of orphaned PNG filenames
"""
orphaned: Dict[str, List[str]] = {res: [] for res in RESOLUTIONS}
# Get all SVG basenames from source directory
svg_files = {f.stem for f in SRC_DIR.glob("*.svg")}
# Check each resolution for PNGs without SVG sources
for resolution in RESOLUTIONS:
dest_dir = BASE_DIR / resolution
if not dest_dir.exists():
continue
for png_file in dest_dir.glob("*.png"):
basename = png_file.stem
if basename not in svg_files:
orphaned[resolution].append(basename)
return orphaned
def find_unreferenced_svgs() -> List[str]:
"""Find SVG files that aren't referenced in the CSV.
Returns:
List of unreferenced SVG filenames (without .svg extension)
"""
# Get all SVG files from source directory
svg_files = {f.stem for f in SRC_DIR.glob("**/*.svg")}
# Get all files referenced in CSV
csv_entries = read_csv_entries()
csv_files = set(csv_entries.keys())
# Return SVGs not in CSV
unreferenced = svg_files - csv_files
return sorted(unreferenced)
def cleanup_orphaned_pngs(dry_run: bool = False, verbose: bool = False, include_svg: bool = False) -> int:
"""Remove orphaned PNG files and optionally unreferenced SVG files.
Args:
dry_run: If True, only show what would be deleted without actually deleting
verbose: Show detailed output
include_svg: If True, also remove SVG files not referenced in CSV
"""
start_time = time.time()
orphaned_pngs = find_orphaned_pngs()
unreferenced_svgs = find_unreferenced_svgs() if include_svg else []
total_orphaned_pngs = sum(len(files) for files in orphaned_pngs.values())
total_unreferenced_svgs = len(unreferenced_svgs)
total_files = total_orphaned_pngs + total_unreferenced_svgs
if total_files == 0:
console.print("[green]No orphaned files found[/green]")
return 0
mode_str = "[DRY RUN] " if dry_run else ""
# Show PNG orphans
if total_orphaned_pngs > 0:
console.print(f"\n{mode_str}Found {total_orphaned_pngs} orphaned PNG file(s):")
deleted_count = 0
# Delete orphaned PNGs
for resolution, files in orphaned_pngs.items():
if not files:
continue
console.print(f"\n{resolution}: {len(files)} file(s)")
dest_dir = BASE_DIR / resolution
for filename in sorted(files):
png_path = dest_dir / f"{filename}.png"
if verbose or dry_run:
action = "Would delete" if dry_run else "Deleting"
console.print(f" {action}: {filename}.png")
if not dry_run:
try:
png_path.unlink()
deleted_count += 1
except Exception as e:
console.print(f" [red]Error deleting {filename}.png: {e}[/red]")
else:
deleted_count += 1
# Delete unreferenced SVGs
if include_svg and total_unreferenced_svgs > 0:
console.print(f"\n{mode_str}Found {total_unreferenced_svgs} unreferenced SVG file(s):")
for filename in unreferenced_svgs:
svg_path = SRC_DIR / f"{filename}.svg"
if verbose or dry_run:
action = "Would delete" if dry_run else "Deleting"
console.print(f" {action}: {filename}.svg")
if not dry_run:
try:
svg_path.unlink()
deleted_count += 1
except Exception as e:
console.print(f" [red]Error deleting {filename}.svg: {e}[/red]")
else:
deleted_count += 1
elapsed = time.time() - start_time
print()
if dry_run:
console.print(f"[yellow]Dry run complete: {deleted_count} file(s) would be deleted[/yellow] [cyan]({format_duration(elapsed)})[/cyan]")
else:
console.print(f"[green]Cleanup complete: {deleted_count} file(s) deleted[/green] [cyan]({format_duration(elapsed)})[/cyan]")
return 0
def process_resolution(
resolution: str,
engine: str,
update_mode: bool,
verbose: bool = False,
dry_run: bool = False,
) -> int:
"""Generate PNGs for a specific resolution."""
start_time = time.time()
if not SRC_LIST.exists():
print(f"Error: CSV files list not found: {SRC_LIST}")
return 1
if not SRC_DIR.exists():
print(f"Error: SVG source directory not found: {SRC_DIR}")
return 1
scale = RESOLUTIONS.get(resolution)
if scale is None:
print(f"Error: Unsupported resolution '{resolution}'")
print(f"Supported resolutions: {', '.join(RESOLUTIONS.keys())}")
return 1
print(f"Generating icons for resolution: {resolution} (scale: {scale}x)")
out_dir = BASE_DIR / resolution
if not out_dir.exists():
print(f"Error: Output directory not found: {out_dir}")
return 1
mode_msg = (
"Converting SVG files to PNG using {} (update mode - only changed files)"
if update_mode
else "Converting SVG files to PNG using {}"
)
print(mode_msg.format(engine))
has_errors = False
processed_count = 0
skipped_count = 0
# First pass: read CSV entries and validate
csv_rows = []
try:
with open(SRC_LIST, "r") as f:
reader = csv.DictReader(f)
for row in reader:
file_name = row.get("file", "").strip()
width_str = row.get("width", "").strip()
height_str = row.get("height", "").strip()
csv_date = row.get("modified", "").strip()
if not file_name:
continue
if not width_str or not height_str:
console.print(
f" [red]Error: Skipping {file_name}.svg - "
f"missing dimension(s) (width: '{width_str}', height: '{height_str}')[/red]"
)
has_errors = True
continue
try:
width = int(width_str)
height = int(height_str)
except ValueError:
console.print(
f" [red]Error: Invalid dimensions for {file_name}: "
f"'{width_str}x{height_str}'[/red]"
)
has_errors = True
continue
csv_rows.append({
"file_name": file_name,
"width": width,
"height": height,
"csv_date": csv_date,
})
except Exception as e:
print(f"Error reading CSV: {e}")
return 1
# Second pass: process files with progress bar
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TaskProgressColumn(),
TextColumn("[cyan]{task.fields[status]}"),
transient=False,
) as progress:
task = progress.add_task(
"Converting...",
total=len(csv_rows),
status="",
)
for row in csv_rows:
file_name = row["file_name"]
width = row["width"]
height = row["height"]
csv_date = row["csv_date"]
svg_path = SRC_DIR / f"{file_name}.svg"
png_path = out_dir / f"{file_name}.png"
scaled_width = scale_dimension(width, scale)
scaled_height = scale_dimension(height, scale)
should_process = True
status_msg = ""
if update_mode:
if not svg_path.exists():
progress.print(
f" [red]Error: SVG file not found: {file_name}.svg[/red]"
)
has_errors = True
progress.update(task, advance=1)
continue
svg_mtime = get_svg_mtime(svg_path)
if not png_path.exists():
status_msg = f"Adding {file_name}"
if verbose:
logger.debug(f"Adding new: {file_name}.svg (PNG missing for {resolution})")
should_process = True
elif not csv_date:
status_msg = f"Adding {file_name} (no date)"
if verbose:
logger.debug(f"Adding new: {file_name}.svg (no previous date in CSV)")
should_process = True
elif svg_mtime > csv_date:
status_msg = f"Updating {file_name}"
if verbose:
logger.debug(f"Updating: {file_name}.svg (SVG: {svg_mtime}, CSV: {csv_date})")
should_process = True
else:
png_mtime = datetime.fromtimestamp(
png_path.stat().st_mtime
).strftime("%Y-%m-%d %H:%M:%S")
if png_mtime < svg_mtime:
status_msg = f"Updating {file_name}"
if verbose:
logger.debug(f"Updating: {file_name}.svg (PNG older than SVG for {resolution})")
should_process = True
else:
skipped_count += 1
should_process = False
if should_process:
if run_conversion(engine, scaled_width, scaled_height, svg_path, png_path, dry_run):
processed_count += 1
status_msg = f"✓ {file_name}"
if verbose:
action = "Would convert" if dry_run else "Converted"
logger.debug(f"{action}: {file_name}.svg")
else:
has_errors = True
status_msg = f"✗ {file_name}"
if verbose:
logger.error(f"Failed to convert: {file_name}.svg")
progress.update(task, advance=1, status=status_msg)
elapsed = time.time() - start_time
print()
mode_str = "[DRY RUN] " if dry_run else ""
if update_mode:
console.print(f"[green]{mode_str}Processed: {processed_count}, Skipped: {skipped_count}[/green]")
if has_errors:
print()
console.print("[red]Warning: Some files were skipped due to missing dimensions[/red]")
console.print("Please run --validate png to see all entries with missing dimensions")
action = "Icons would be generated" if dry_run else "Icons generated"
console.print(f"[green]{mode_str}Done! {action} for {resolution}[/green] [cyan]({format_duration(elapsed)})[/cyan]")
return 0
def update_csv_dates(resolutions: List[str], verbose: bool = False) -> None:
"""Update CSV modification dates for processed files."""
if not SRC_LIST.exists():
return
print()
print("Updating CSV modification dates...")
rows = []
try:
with open(SRC_LIST, "r") as f:
reader = csv.DictReader(f)
for row in reader:
file_name = row.get("file", "").strip()
if file_name:
svg_path = SRC_DIR / f"{file_name}.svg"
mtime = get_svg_mtime(svg_path)
row["modified"] = mtime
rows.append(row)
except Exception as e:
print(f"Error reading CSV: {e}")
return
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TaskProgressColumn(),
transient=False,
) as progress:
task = progress.add_task(
"Updating CSV...",
total=len(rows),
)
try:
with open(SRC_LIST, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["file", "width", "height", "modified"])
writer.writeheader()
for row in rows:
writer.writerow(row)
progress.update(task, advance=1)
print("CSV modification dates updated.")
except Exception as e:
print(f"Error writing CSV: {e}")
def create_parser() -> argparse.ArgumentParser:
"""Create and configure the argument parser."""
parser = argparse.ArgumentParser(
description="Convert SVG files to PNG icons for different screen resolutions",
epilog="Requires: rsvg-convert (default) or resvg command line tool",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
# Global options
parser.add_argument(
'-v', '--verbose',
action='store_true',
help='Show detailed per-file logs while processing'
)
subparsers = parser.add_subparsers(dest='command', help='Command to execute')
# --validate command
validate_parser = subparsers.add_parser(
'validate',
help='Validate SVG and PNG files',
description='Validate SVG source files and generated PNG files'
)
validate_parser.add_argument(
'mode',
choices=['svg', 'png', 'all'],
help='What to validate: svg (source files), png (generated files), or all (both)'
)
validate_parser.add_argument(
'-v', '--verbose',
action='store_true',
help='Show detailed per-file logs while processing'
)
# --make command
make_parser = subparsers.add_parser(
'make',
help='Generate PNG files from SVG sources',
description='Convert SVG files to PNG icons for specified resolutions'
)
make_parser.add_argument(
'resolutions',
nargs='+',
metavar='RESOLUTION',
help=f'Resolutions to generate ({", ".join(RESOLUTIONS.keys())}, or "all")'
)
make_parser.add_argument(
'--update',
action='store_true',
help='Only regenerate PNGs for SVGs newer than CSV date (incremental build)'
)
make_parser.add_argument(
'--resvg',
action='store_true',
help='Use resvg engine instead of rsvg-convert (default)'
)
make_parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be done without actually creating/modifying files'
)
make_parser.add_argument(
'-v', '--verbose',
action='store_true',
help='Show detailed per-file logs while processing'
)
# --update-list command
update_list_parser = subparsers.add_parser(
'update-list',
help='Regenerate convert-gfx-list.csv from existing files',
description='Regenerate convert-gfx-list.csv with dimensions and modification dates from existing PNGs and SVGs'
)
update_list_parser.add_argument(
'-v', '--verbose',
action='store_true',
help='Show detailed per-file logs while processing'
)
# --cleanup command
cleanup_parser = subparsers.add_parser(
'cleanup',
help='Remove orphaned PNG files',
description='Remove PNG files that don\'t have corresponding SVG sources. Optionally remove unreferenced SVG files.'
)
cleanup_parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be deleted without actually deleting files'
)
cleanup_parser.add_argument(
'--include-svg',
action='store_true',
help='Also remove SVG files not referenced in CSV (CSV is source of truth)'
)
cleanup_parser.add_argument(
'-v', '--verbose',
action='store_true',
help='Show detailed per-file logs while processing'
)
return parser
def main() -> int:
"""Main entry point."""
parser = create_parser()
# Handle no arguments - show help
if len(sys.argv) == 1:
parser.print_help()
return 0
args = parser.parse_args()
# Set up logging based on verbose flag
if args.verbose:
# Configure rich handler for our logger only
handler = RichHandler(rich_tracebacks=True)
logger.setLevel(logging.DEBUG)
logger.addHandler(handler)
# Suppress debug output from other libraries (PIL, etc.)
logging.getLogger('PIL').setLevel(logging.WARNING)
else:
# Suppress logging when not verbose
logger.setLevel(logging.CRITICAL)
# Handle commands
if args.command == 'validate':
if args.mode == 'svg':
return validate_svg(args.verbose)
elif args.mode == 'png':
return validate_png(args.verbose)
elif args.mode == 'all':
return validate_all(args.verbose)
elif args.command == 'update-list':
return update_src_list_csv(args.verbose)
elif args.command == 'cleanup':
return cleanup_orphaned_pngs(args.dry_run, args.verbose, args.include_svg)
elif args.command == 'make':
# Expand "all" to list of resolutions
resolutions = []
for res in args.resolutions:
if res == 'all':
resolutions.extend(RESOLUTIONS.keys())
else:
resolutions.append(res)
# Determine engine
engine = "resvg" if args.resvg else "rsvg-convert"
# Check for conversion engine
engine_cmds = {
"resvg": {
"Ubuntu/Debian": "sudo apt-get install resvg",
"macOS": "brew install resvg",
"Windows": "Download from https://github.com/RazrFalcon/resvg/releases"
},
"rsvg-convert": {
"Ubuntu/Debian": "sudo apt-get install librsvg2-bin",
"macOS": "brew install librsvg",
"Windows (MSYS2)": "pacman -S mingw-w64-x86_64-librsvg"
},
}
if not check_command(engine, engine_cmds[engine]):
return 1
# Process each resolution
for resolution in resolutions:
if process_resolution(resolution, engine, args.update, args.verbose, args.dry_run) != 0:
return 1
# Update CSV dates if in update mode
if args.update:
update_csv_dates(resolutions, args.verbose)
print("All done!")
return 0
else:
parser.print_help()
return 1
if __name__ == "__main__":
sys.exit(main())