#!/usr/bin/env python3 """ Themer - Wallpaper-based color extraction and theme generation. A CLI tool that extracts dominant colors from wallpaper images and generates Material Design or simpler accent-based color themes with light/dark variants. Usage: python3 template-processor.py IMAGE_PATH [OPTIONS] Options: --material Generate Material-style colors (default) --normal Generate simpler accent-based palette --dark Generate dark theme only --light Generate light theme only --both Generate both themes (default) --output FILE Write JSON to file (stdout if omitted) Example: python3 template-processor.py ~/wallpaper.png --material --both python3 template-processor.py ~/wallpaper.jpg --dark -o theme.json Author: Noctalia Team License: MIT """ from __future__ import annotations import argparse import json import re import struct import sys import zlib try: import tomllib except ImportError: # Fallback to tomli if available (for older python), or error try: import tomli as tomllib except ImportError: tomllib = None from dataclasses import dataclass from pathlib import Path from typing import Literal, Any # ============================================================================= # Type Definitions # ============================================================================= RGB = tuple[int, int, int] HSL = tuple[float, float, float] ThemeMode = Literal["dark", "light"] @dataclass class Color: """Represents a color with RGB values (0-255).""" r: int g: int b: int @classmethod def from_rgb(cls, rgb: RGB) -> Color: return cls(rgb[0], rgb[1], rgb[2]) @classmethod def from_hex(cls, hex_str: str) -> Color: """Parse hex color string (#RRGGBB or RRGGBB).""" hex_str = hex_str.lstrip('#') return cls( int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16) ) def to_rgb(self) -> RGB: return (self.r, self.g, self.b) def to_hex(self) -> str: """Convert to hex string (#RRGGBB).""" return f"#{self.r:02x}{self.g:02x}{self.b:02x}" def to_hsl(self) -> HSL: """Convert RGB to HSL.""" return rgb_to_hsl(self.r, self.g, self.b) @classmethod def from_hsl(cls, h: float, s: float, l: float) -> Color: """Create Color from HSL values.""" r, g, b = hsl_to_rgb(h, s, l) return cls(r, g, b) # ============================================================================= # Color Utilities (RGB/HSL Conversion) # ============================================================================= def rgb_to_hsl(r: int, g: int, b: int) -> HSL: """ Convert RGB (0-255) to HSL (0-360, 0-1, 0-1). Args: r: Red component (0-255) g: Green component (0-255) b: Blue component (0-255) Returns: Tuple of (hue, saturation, lightness) """ r_norm = r / 255.0 g_norm = g / 255.0 b_norm = b / 255.0 max_c = max(r_norm, g_norm, b_norm) min_c = min(r_norm, g_norm, b_norm) delta = max_c - min_c # Lightness l = (max_c + min_c) / 2.0 if delta == 0: h = 0.0 s = 0.0 else: # Saturation s = delta / (1 - abs(2 * l - 1)) if l != 0 and l != 1 else 0 # Hue if max_c == r_norm: h = 60.0 * (((g_norm - b_norm) / delta) % 6) elif max_c == g_norm: h = 60.0 * (((b_norm - r_norm) / delta) + 2) else: h = 60.0 * (((r_norm - g_norm) / delta) + 4) return (h, s, l) def hsl_to_rgb(h: float, s: float, l: float) -> RGB: """ Convert HSL (0-360, 0-1, 0-1) to RGB (0-255). Args: h: Hue (0-360) s: Saturation (0-1) l: Lightness (0-1) Returns: Tuple of (r, g, b) """ if s == 0: # Achromatic (gray) v = int(round(l * 255)) return (v, v, v) def hue_to_rgb(p: float, q: float, t: float) -> float: if t < 0: t += 1 if t > 1: t -= 1 if t < 1/6: return p + (q - p) * 6 * t if t < 1/2: return q if t < 2/3: return p + (q - p) * (2/3 - t) * 6 return p q = l * (1 + s) if l < 0.5 else l + s - l * s p = 2 * l - q h_norm = h / 360.0 r = hue_to_rgb(p, q, h_norm + 1/3) g = hue_to_rgb(p, q, h_norm) b = hue_to_rgb(p, q, h_norm - 1/3) return ( int(round(r * 255)), int(round(g * 255)), int(round(b * 255)) ) def adjust_lightness(color: Color, target_l: float) -> Color: """Adjust a color's lightness to a target value (0-1).""" h, s, _ = color.to_hsl() return Color.from_hsl(h, s, target_l) def shift_hue(color: Color, degrees: float) -> Color: """Shift a color's hue by specified degrees.""" h, s, l = color.to_hsl() new_h = (h + degrees) % 360 return Color.from_hsl(new_h, s, l) def _adjust_surface(color: Color, s_max: float, l_target: float) -> Color: """Derive a surface color from a base color with saturation limit and target lightness.""" h, s, _ = color.to_hsl() return Color.from_hsl(h, min(s, s_max), l_target) def saturate(color: Color, amount: float) -> Color: """Adjust saturation by amount (-1 to 1).""" h, s, l = color.to_hsl() new_s = max(0.0, min(1.0, s + amount)) return Color.from_hsl(h, new_s, l) # ============================================================================= # Contrast Utilities (WCAG Luminance/Contrast) # ============================================================================= def relative_luminance(r: int, g: int, b: int) -> float: """ Calculate relative luminance per WCAG 2.1. The formula converts sRGB to linear RGB, then applies the luminance formula: L = 0.2126 * R + 0.7152 * G + 0.0722 * B Args: r, g, b: RGB components (0-255) Returns: Relative luminance (0-1) """ def linearize(c: int) -> float: c_norm = c / 255.0 if c_norm <= 0.03928: return c_norm / 12.92 return ((c_norm + 0.055) / 1.055) ** 2.4 r_lin = linearize(r) g_lin = linearize(g) b_lin = linearize(b) return 0.2126 * r_lin + 0.7152 * g_lin + 0.0722 * b_lin def contrast_ratio(color1: Color, color2: Color) -> float: """ Calculate WCAG contrast ratio between two colors. Returns a value between 1:1 (identical) and 21:1 (black/white). """ l1 = relative_luminance(color1.r, color1.g, color1.b) l2 = relative_luminance(color2.r, color2.g, color2.b) lighter = max(l1, l2) darker = min(l1, l2) return (lighter + 0.05) / (darker + 0.05) def is_dark(color: Color) -> bool: """Determine if a color is perceptually dark.""" return relative_luminance(color.r, color.g, color.b) < 0.179 def ensure_contrast( foreground: Color, background: Color, min_ratio: float = 4.5, prefer_light: bool | None = None ) -> Color: """ Adjust foreground color to meet minimum contrast ratio against background. Args: foreground: The color to adjust background: The background color (not modified) min_ratio: Minimum contrast ratio (default 4.5 for WCAG AA) prefer_light: If True, prefer lightening; if False, prefer darkening; if None, auto-detect based on background Returns: Adjusted foreground color meeting contrast requirements """ current_ratio = contrast_ratio(foreground, background) if current_ratio >= min_ratio: return foreground h, s, l = foreground.to_hsl() bg_dark = is_dark(background) # Determine direction to adjust if prefer_light is None: prefer_light = bg_dark # Binary search for the right lightness if prefer_light: low, high = l, 1.0 else: low, high = 0.0, l best_color = foreground for _ in range(20): # Max iterations mid = (low + high) / 2 test_color = Color.from_hsl(h, s, mid) ratio = contrast_ratio(test_color, background) if ratio >= min_ratio: best_color = test_color if prefer_light: high = mid else: low = mid else: if prefer_light: low = mid else: high = mid return best_color # Alias for consistent naming _ensure_contrast = ensure_contrast def get_contrasting_color(background: Color, min_ratio: float = 4.5) -> Color: """Get a contrasting foreground color (black or white variant).""" if is_dark(background): # Light foreground for dark background fg = Color(243, 237, 247) # Off-white else: # Dark foreground for light background fg = Color(14, 14, 67) # Dark blue-black return ensure_contrast(fg, background, min_ratio) # ============================================================================= # Image Reader (PNG/JPEG Parsing) # ============================================================================= class ImageReadError(Exception): """Raised when image cannot be read or parsed.""" pass def read_png(path: Path) -> list[RGB]: """ Parse a PNG file and extract RGB pixels. Supports 8-bit RGB and RGBA color types (most common for wallpapers). Uses zlib for IDAT decompression and handles PNG filters. """ with open(path, 'rb') as f: data = f.read() # Verify PNG signature if data[:8] != b'\x89PNG\r\n\x1a\n': raise ImageReadError("Invalid PNG signature") pos = 8 width = 0 height = 0 bit_depth = 0 color_type = 0 idat_chunks: list[bytes] = [] while pos < len(data): # Read chunk length and type chunk_len = struct.unpack('>I', data[pos:pos+4])[0] chunk_type = data[pos+4:pos+8] chunk_data = data[pos+8:pos+8+chunk_len] pos += 12 + chunk_len # length + type + data + crc if chunk_type == b'IHDR': width = struct.unpack('>I', chunk_data[0:4])[0] height = struct.unpack('>I', chunk_data[4:8])[0] bit_depth = chunk_data[8] color_type = chunk_data[9] if bit_depth != 8: raise ImageReadError(f"Unsupported bit depth: {bit_depth}") if color_type not in (2, 6): # RGB or RGBA raise ImageReadError(f"Unsupported color type: {color_type}") elif chunk_type == b'IDAT': idat_chunks.append(chunk_data) elif chunk_type == b'IEND': break if not idat_chunks or width == 0: raise ImageReadError("Missing image data") # Decompress all IDAT chunks compressed = b''.join(idat_chunks) raw_data = zlib.decompress(compressed) # Calculate bytes per pixel and row bpp = 3 if color_type == 2 else 4 # RGB or RGBA stride = width * bpp + 1 # +1 for filter byte pixels: list[RGB] = [] prev_row: list[int] = [0] * (width * bpp) for y in range(height): row_start = y * stride filter_type = raw_data[row_start] row_data = list(raw_data[row_start + 1:row_start + stride]) # Apply PNG filter reconstruction unfiltered = _png_unfilter(row_data, prev_row, bpp, filter_type) prev_row = unfiltered # Extract RGB values (skip alpha if present) for x in range(width): idx = x * bpp r, g, b = unfiltered[idx], unfiltered[idx+1], unfiltered[idx+2] pixels.append((r, g, b)) return pixels def _png_unfilter( row: list[int], prev_row: list[int], bpp: int, filter_type: int ) -> list[int]: """Apply PNG filter reconstruction.""" result = [0] * len(row) for i in range(len(row)): x = row[i] a = result[i - bpp] if i >= bpp else 0 b = prev_row[i] c = prev_row[i - bpp] if i >= bpp else 0 if filter_type == 0: # None result[i] = x elif filter_type == 1: # Sub result[i] = (x + a) & 0xFF elif filter_type == 2: # Up result[i] = (x + b) & 0xFF elif filter_type == 3: # Average result[i] = (x + (a + b) // 2) & 0xFF elif filter_type == 4: # Paeth result[i] = (x + _paeth_predictor(a, b, c)) & 0xFF else: raise ImageReadError(f"Unknown PNG filter type: {filter_type}") return result def _paeth_predictor(a: int, b: int, c: int) -> int: """Paeth predictor for PNG filter reconstruction.""" p = a + b - c pa = abs(p - a) pb = abs(p - b) pc = abs(p - c) if pa <= pb and pa <= pc: return a elif pb <= pc: return b return c def read_jpeg(path: Path) -> list[RGB]: """ Parse a JPEG file and extract RGB pixels. Supports baseline (SOF0), extended (SOF1), and progressive (SOF2) JPEG. This is a simplified decoder that extracts dimensions then samples colors. """ with open(path, 'rb') as f: data = f.read() # Verify JPEG signature (SOI marker) if data[:2] != b'\xff\xd8': raise ImageReadError("Invalid JPEG signature") pos = 2 width = 0 height = 0 # SOF markers that contain image dimensions # SOF0=Baseline, SOF1=Extended, SOF2=Progressive, SOF3=Lossless # SOF5-7=Differential variants, SOF9-11=Arithmetic coding variants sof_markers = {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF} # Standalone markers (no length field) standalone_markers = {0xD0, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, # RST0-7 0xD8, # SOI 0xD9, # EOI 0x01} # TEM while pos < len(data) - 1: # Find next marker if data[pos] != 0xFF: pos += 1 continue # Skip padding 0xFF bytes while pos < len(data) and data[pos] == 0xFF: pos += 1 if pos >= len(data): break marker = data[pos] pos += 1 # Check for SOF marker (contains dimensions) if marker in sof_markers: if pos + 7 <= len(data): # Skip segment length (2 bytes), precision (1 byte) height = struct.unpack('>H', data[pos+3:pos+5])[0] width = struct.unpack('>H', data[pos+5:pos+7])[0] break # End of image if marker == 0xD9: break # Skip segment data for markers with length field if marker not in standalone_markers and marker != 0x00: if pos + 2 <= len(data): seg_len = struct.unpack('>H', data[pos:pos+2])[0] pos += seg_len if width == 0 or height == 0: raise ImageReadError("Could not parse JPEG dimensions") # Since full JPEG decoding is extremely complex without external libraries, # we fall back to sampling the raw data for color approximation. return _sample_jpeg_colors(data, width, height) def _read_image_imagemagick(path: Path) -> list[RGB]: """ Read image using ImageMagick's convert command. Converts image to PPM format (trivial to parse) and extracts RGB pixels. This method works accurately for any image format ImageMagick supports. """ import subprocess # Use magick or convert command # -depth 8: 8 bits per channel # -resize: downsample for performance (we don't need full resolution for color extraction) # ppm: output as PPM format (easy to parse) # Downsample to max 200x200 for performance resize_spec = "200x200>" try: # Try 'magick convert' first (ImageMagick 7+), fallback to 'convert' (ImageMagick 6) try: result = subprocess.run( ['magick', 'convert', str(path), '-resize', resize_spec, '-depth', '8', 'ppm:-'], capture_output=True, check=True ) except FileNotFoundError: result = subprocess.run( ['convert', str(path), '-resize', resize_spec, '-depth', '8', 'ppm:-'], capture_output=True, check=True ) except subprocess.CalledProcessError as e: raise ImageReadError(f"ImageMagick failed: {e.stderr.decode()}") except FileNotFoundError: raise ImageReadError("ImageMagick not found. Please install imagemagick.") ppm_data = result.stdout return _parse_ppm(ppm_data) def _parse_ppm(data: bytes) -> list[RGB]: """ Parse PPM (Portable Pixmap) binary format. PPM P6 format: P6 width height maxval """ pos = 0 tokens: list[str] = [] # Read header tokens (need 4: P6, width, height, maxval) while len(tokens) < 4 and pos < len(data): # Skip whitespace while pos < len(data) and data[pos:pos+1] in (b' ', b'\t', b'\n', b'\r'): pos += 1 # Skip comments if pos < len(data) and data[pos:pos+1] == b'#': while pos < len(data) and data[pos:pos+1] != b'\n': pos += 1 continue # Read token token_start = pos while pos < len(data) and data[pos:pos+1] not in (b' ', b'\t', b'\n', b'\r', b'#'): pos += 1 if pos > token_start: tokens.append(data[token_start:pos].decode('ascii')) if len(tokens) < 4 or tokens[0] != 'P6': raise ImageReadError(f"Invalid PPM format: {tokens}") width = int(tokens[1]) height = int(tokens[2]) maxval = int(tokens[3]) # Skip exactly one whitespace character after maxval (per PPM spec) if pos < len(data) and data[pos:pos+1] in (b' ', b'\t', b'\n', b'\r'): pos += 1 pixel_data = data[pos:] # Parse RGB triplets pixels: list[RGB] = [] scale = 255.0 / maxval if maxval != 255 else 1.0 for i in range(0, min(len(pixel_data), width * height * 3), 3): if i + 2 < len(pixel_data): r = int(pixel_data[i] * scale) g = int(pixel_data[i + 1] * scale) b = int(pixel_data[i + 2] * scale) pixels.append((r, g, b)) if not pixels: raise ImageReadError("No pixels extracted from PPM data") return pixels def read_image(path: Path) -> list[RGB]: """ Read an image file and return its pixels as RGB tuples. Uses ImageMagick for accurate color extraction from any format. Falls back to native PNG parsing if ImageMagick is unavailable. """ suffix = path.suffix.lower() # Try ImageMagick first (works for any format) try: return _read_image_imagemagick(path) except ImageReadError: # Fall back to native parsing for PNG if suffix == '.png': return read_png(path) raise # ============================================================================= # Palette Extraction (K-means Clustering) # ============================================================================= def downsample_pixels(pixels: list[RGB], factor: int = 4) -> list[RGB]: """ Downsample pixels for faster processing. Takes every Nth pixel to reduce dataset size while maintaining color distribution characteristics. """ if factor <= 1: return pixels # Calculate step based on factor squared (for 2D image) step = factor * factor return pixels[::step] def color_distance_hsl(c1: HSL, c2: HSL) -> float: """ Calculate perceptual distance between two colors in HSL space. Hue is weighted less for low-saturation colors (grays). """ h1, s1, l1 = c1 h2, s2, l2 = c2 # Hue distance (circular) dh = min(abs(h1 - h2), 360 - abs(h1 - h2)) / 180.0 # Weight hue by average saturation (grays have similar hues but shouldn't match) avg_sat = (s1 + s2) / 2 dh_weighted = dh * avg_sat ds = abs(s1 - s2) dl = abs(l1 - l2) return (dh_weighted ** 2 + ds ** 2 + dl ** 2) ** 0.5 def kmeans_cluster( colors: list[RGB], k: int = 5, iterations: int = 15 ) -> list[tuple[RGB, int]]: """ Perform K-means clustering on colors. Returns list of (centroid_rgb, cluster_size) tuples, sorted by cluster size. Uses deterministic initialization for reproducible results. """ if len(colors) < k: # Not enough colors, return what we have unique = list(set(colors)) return [(c, colors.count(c)) for c in unique[:k]] # Convert to HSL for perceptual clustering colors_hsl = [rgb_to_hsl(*c) for c in colors] # Deterministic initialization: pick evenly spaced colors from sorted list sorted_indices = sorted(range(len(colors_hsl)), key=lambda i: colors_hsl[i]) step = len(sorted_indices) // k centroids = [colors_hsl[sorted_indices[i * step]] for i in range(k)] # K-means iterations for _ in range(iterations): # Assign colors to nearest centroid clusters: list[list[HSL]] = [[] for _ in range(k)] for color in colors_hsl: min_dist = float('inf') min_idx = 0 for i, centroid in enumerate(centroids): dist = color_distance_hsl(color, centroid) if dist < min_dist: min_dist = dist min_idx = i clusters[min_idx].append(color) # Update centroids new_centroids = [] for i, cluster in enumerate(clusters): if cluster: avg_h = sum(c[0] for c in cluster) / len(cluster) avg_s = sum(c[1] for c in cluster) / len(cluster) avg_l = sum(c[2] for c in cluster) / len(cluster) new_centroids.append((avg_h, avg_s, avg_l)) else: new_centroids.append(centroids[i]) centroids = new_centroids # Final assignment and counting cluster_counts = [0] * k for color in colors_hsl: min_dist = float('inf') min_idx = 0 for i, centroid in enumerate(centroids): dist = color_distance_hsl(color, centroid) if dist < min_dist: min_dist = dist min_idx = i cluster_counts[min_idx] += 1 # Convert centroids back to RGB and pair with counts results = [] for i, centroid in enumerate(centroids): rgb = hsl_to_rgb(*centroid) results.append((rgb, cluster_counts[i])) # Sort by cluster size (most common first) results.sort(key=lambda x: -x[1]) return results def extract_palette(pixels: list[RGB], k: int = 5) -> list[Color]: """ Extract K dominant colors from pixel data. Args: pixels: List of RGB tuples k: Number of colors to extract Returns: List of Color objects, sorted by dominance """ # Downsample for performance sampled = downsample_pixels(pixels, factor=4) # Filter out very dark, very bright, and desaturated pixels # This ensures we get vibrant, usable theme colors filtered = [] for p in sampled: h, s, l = rgb_to_hsl(*p) # Keep colors that are: # - Not too dark (L > 0.15) # - Not too bright (L < 0.85) # - Reasonably saturated (S > 0.15) if 0.15 < l < 0.85 and s > 0.15: filtered.append(p) # Fall back to all pixels if filtering removed too many if len(filtered) < k * 10: # Try a less strict filter filtered = [] for p in sampled: lum = relative_luminance(*p) if 0.05 < lum < 0.95: filtered.append(p) if len(filtered) < k * 10: filtered = sampled # Cluster clusters = kmeans_cluster(filtered, k=k) # Post-filter: remove very dark colors from results result_colors = [] for rgb, count in clusters: color = Color.from_rgb(rgb) h, s, l = color.to_hsl() # Skip very dark colors (they'll be used for surfaces anyway) if l > 0.20 or len(result_colors) == 0: result_colors.append(color) # Ensure we have enough colors by deriving from primary while len(result_colors) < k: primary = result_colors[0] offset = len(result_colors) * 30 result_colors.append(shift_hue(primary, offset)) return result_colors[:k] def find_error_color(palette: list[Color]) -> Color: """ Find or generate an error color (red-biased). Looks for existing red in palette, otherwise returns a default. """ # Look for a red-ish color in the palette for color in palette: h, s, l = color.to_hsl() # Red hues: 0-30 or 330-360 if (h <= 30 or h >= 330) and s > 0.4 and 0.3 < l < 0.7: return color # Default error red return Color.from_hex("#FD4663") def derive_harmonious_colors(primary: Color) -> tuple[Color, Color, Color]: """ Derive secondary and tertiary colors as harmonious complements to primary. Uses color theory: - Secondary: Analogous (30° hue shift) - similar but distinct - Tertiary: Split-complementary (150° hue shift) - contrasting but harmonious - Quaternary: Complementary (180° hue shift) - for accents Returns: Tuple of (secondary, tertiary, quaternary) colors """ h, s, l = primary.to_hsl() # Secondary: analogous - similar warmth/coolness, shifted hue secondary = Color.from_hsl((h + 30) % 360, s, l) # Tertiary: split-complementary - provides contrast while staying harmonious tertiary = Color.from_hsl((h + 150) % 360, s, l) # Quaternary: complementary - opposite on color wheel quaternary = Color.from_hsl((h + 180) % 360, s, l) return secondary, tertiary, quaternary # ============================================================================= # Theme Generation (Material/Normal) # ============================================================================= def generate_material_dark(palette: list[Color]) -> dict[str, str]: """ Generate Material Design dark theme from palette. Dark theme characteristics: - Dark surfaces (low luminance backgrounds) - Bright, slightly desaturated accent colors - High contrast text - Secondary/tertiary derived as harmonious colors from primary """ primary = palette[0] if palette else Color(255, 245, 155) # Derive harmonious colors from primary secondary, tertiary, quaternary = derive_harmonious_colors(primary) error = find_error_color(palette) # Adjust colors for dark theme # Primary should be bright enough to stand out on dark surface h, s, _ = primary.to_hsl() primary_adjusted = Color.from_hsl(h, min(s, 0.7), 0.75) h, s, _ = secondary.to_hsl() secondary_adjusted = Color.from_hsl(h, min(s, 0.6), 0.70) h, s, _ = tertiary.to_hsl() tertiary_adjusted = Color.from_hsl(h, min(s, 0.6), 0.75) # Surface colors (very dark, slightly tinted with primary hue) surface_h, _, _ = primary.to_hsl() surface = Color.from_hsl(surface_h, 0.6, 0.05) surface_variant = Color.from_hsl(surface_h, 0.5, 0.10) base_primary = primary # Use primary for hue base mSurface = _adjust_surface(base_primary, 0.6, 0.05) mSurfaceVariant = _adjust_surface(base_primary, 0.45, 0.14) # Slightly lighter/diff saturation # Foreground colors - Ensure they are readable but not too saturated (avoid yellow text) # Use primary hue but low saturation (0.05) for text text_h, _, _ = base_primary.to_hsl() base_on_surface = Color.from_hsl(text_h, 0.05, 0.95) mOnSurface = _ensure_contrast(base_on_surface, mSurface, 4.5) base_on_surface_variant = Color.from_hsl(text_h, 0.05, 0.80) mOnSurfaceVariant = _ensure_contrast(base_on_surface_variant, mSurfaceVariant, 4.5) mOutline = _adjust_surface(base_primary, 0.30, 0.40) # Dark foreground for bright backgrounds dark_fg = Color.from_hsl(base_primary.to_hsl()[0], 0.7, 0.10) # Ensure contrast on_primary = _ensure_contrast(dark_fg, primary_adjusted, 4.5) on_secondary = _ensure_contrast(dark_fg, secondary_adjusted, 4.5) on_tertiary = _ensure_contrast(dark_fg, tertiary_adjusted, 4.5) on_error = _ensure_contrast(dark_fg, error, 4.5) # Outline and shadow shadow = mSurface return { "mPrimary": primary_adjusted.to_hex(), "mOnPrimary": on_primary.to_hex(), "mSecondary": secondary_adjusted.to_hex(), "mOnSecondary": on_secondary.to_hex(), "mTertiary": tertiary_adjusted.to_hex(), "mOnTertiary": on_tertiary.to_hex(), "mError": error.to_hex(), "mOnError": on_error.to_hex(), "mSurface": mSurface.to_hex(), "mOnSurface": mOnSurface.to_hex(), "mSurfaceVariant": mSurfaceVariant.to_hex(), "mOnSurfaceVariant": mOnSurfaceVariant.to_hex(), "mOutline": mOutline.to_hex(), "mShadow": shadow.to_hex(), } def generate_material_light(palette: list[Color]) -> dict[str, str]: """ Generate Material Design light theme from palette. Light theme characteristics: - Light surfaces (high luminance backgrounds) - Darker, more saturated accent colors - Dark text for readability """ primary = palette[0] if palette else Color(93, 101, 245) secondary = palette[1] if len(palette) > 1 else shift_hue(primary, 30) tertiary = palette[2] if len(palette) > 2 else shift_hue(primary, 180) error = find_error_color(palette) # Adjust colors for light theme # Primary should be darker to stand out on light surface h, s, _ = primary.to_hsl() primary_adjusted = Color.from_hsl(h, min(s + 0.1, 0.8), 0.50) h, s, _ = secondary.to_hsl() secondary_adjusted = Color.from_hsl(h, min(s, 0.5), 0.55) h, s, _ = tertiary.to_hsl() tertiary_adjusted = Color.from_hsl(h, 0.8, 0.15) # Surface colors (very light, slightly tinted with primary hue) base_primary = primary mSurface = _adjust_surface(base_primary, 0.4, 0.94) mSurfaceVariant = _adjust_surface(base_primary, 0.5, 0.97) # Foreground colors - Ensure they are readable but not too saturated (avoid yellow text) # Use primary hue but low saturation (0.05) for text text_h, _, _ = base_primary.to_hsl() base_on_surface = Color.from_hsl(text_h, 0.05, 0.10) mOnSurface = _ensure_contrast(base_on_surface, mSurface, 4.5) base_on_surface_variant = Color.from_hsl(text_h, 0.05, 0.45) mOnSurfaceVariant = _ensure_contrast(base_on_surface_variant, mSurfaceVariant, 4.5) # Light foreground for dark backgrounds light_fg = Color.from_hsl(base_primary.to_hsl()[0], 0.2, 0.90) # Ensure contrast on_primary = _ensure_contrast(light_fg, primary_adjusted, 4.5) on_secondary = _ensure_contrast(light_fg, secondary_adjusted, 4.5) on_tertiary = _ensure_contrast(Color(254, 242, 154), tertiary_adjusted, 4.5) on_error = _ensure_contrast(Color(14, 14, 67), error, 4.5) # Outline and shadow mOutline = _adjust_surface(base_primary, 0.5, 0.60) shadow = Color(243, 237, 247) return { "mPrimary": primary_adjusted.to_hex(), "mOnPrimary": on_primary.to_hex(), "mSecondary": secondary_adjusted.to_hex(), "mOnSecondary": on_secondary.to_hex(), "mTertiary": tertiary_adjusted.to_hex(), "mOnTertiary": on_tertiary.to_hex(), "mError": error.to_hex(), "mOnError": on_error.to_hex(), "mSurface": mSurface.to_hex(), "mOnSurface": mOnSurface.to_hex(), "mSurfaceVariant": mSurfaceVariant.to_hex(), "mOnSurfaceVariant": mOnSurfaceVariant.to_hex(), "mOutline": mOutline.to_hex(), "mShadow": shadow.to_hex(), } def generate_normal_dark(palette: list[Color]) -> dict[str, str]: """ Generate wallust-style dark theme from palette. More vibrant than Material - uses palette colors directly and keeps surfaces saturated with the primary hue. Outputs same keys as Material. """ # Use extracted colors directly (wallust style) primary = palette[0] if palette else Color(255, 245, 155) secondary = palette[1] if len(palette) > 1 else shift_hue(primary, 30) tertiary = palette[2] if len(palette) > 2 else shift_hue(primary, 60) quaternary = palette[3] if len(palette) > 3 else shift_hue(primary, 180) error = find_error_color(palette) # Keep colors vibrant - preserve saturation h, s, l = primary.to_hsl() primary_adjusted = Color.from_hsl(h, max(s, 0.7), max(l, 0.65)) h, s, l = secondary.to_hsl() secondary_adjusted = Color.from_hsl(h, max(s, 0.6), max(l, 0.60)) h, s, l = tertiary.to_hsl() tertiary_adjusted = Color.from_hsl(h, max(s, 0.5), max(l, 0.60)) # Surface: COLORFUL dark - a deep, saturated version of primary # Heuristic: Shift Cyan (160-200) slightly towards Blue (+10) to avoid "Teal" look surface_hue, s, _ = palette[0].to_hsl() if 160 <= surface_hue <= 200: surface_hue = (surface_hue + 10) % 360 base_surface = Color.from_hsl(surface_hue, s, 0.5) # l doesn't matter for next step # Preserving saturation (up to 0.9) to be true to primary color mSurface = _adjust_surface(base_surface, 0.90, 0.12) mSurfaceVariant = _adjust_surface(base_surface, 0.80, 0.16) # Text colors - desaturated text_h, _, _ = palette[0].to_hsl() base_on_surface = Color.from_hsl(text_h, 0.05, 0.95) mOnSurface = _ensure_contrast(base_on_surface, mSurface, 4.5) base_on_surface_variant = Color.from_hsl(text_h, 0.05, 0.80) mOnSurfaceVariant = _ensure_contrast(base_on_surface_variant, mSurfaceVariant, 4.5) mOutline = _adjust_surface(palette[0], 0.10, 0.30) # Contrasting foregrounds for accent colors dark_fg = Color.from_hsl(palette[0].to_hsl()[0], 0.3, 0.08) on_primary = _ensure_contrast(dark_fg, primary_adjusted, 4.5) on_secondary = _ensure_contrast(dark_fg, secondary_adjusted, 4.5) on_tertiary = _ensure_contrast(dark_fg, tertiary_adjusted, 4.5) on_error = _ensure_contrast(dark_fg, error, 4.5) # Outline uses primary hue, more saturated shadow = mSurface return { "mPrimary": primary_adjusted.to_hex(), "mOnPrimary": on_primary.to_hex(), "mSecondary": secondary_adjusted.to_hex(), "mOnSecondary": on_secondary.to_hex(), "mTertiary": tertiary_adjusted.to_hex(), "mOnTertiary": on_tertiary.to_hex(), "mError": error.to_hex(), "mOnError": on_error.to_hex(), "mSurface": mSurface.to_hex(), "mOnSurface": mOnSurface.to_hex(), "mSurfaceVariant": mSurfaceVariant.to_hex(), "mOnSurfaceVariant": mOnSurfaceVariant.to_hex(), "mOutline": mOutline.to_hex(), "mShadow": shadow.to_hex(), } def generate_normal_light(palette: list[Color]) -> dict[str, str]: """ Generate wallust-style light theme from palette. More vibrant than Material - uses palette colors directly and keeps surfaces saturated with the primary hue. Outputs same keys as Material. """ # Use extracted colors directly primary = palette[0] if palette else Color(93, 101, 245) secondary = palette[1] if len(palette) > 1 else shift_hue(primary, 30) tertiary = palette[2] if len(palette) > 2 else shift_hue(primary, 60) quaternary = palette[3] if len(palette) > 3 else shift_hue(primary, 180) error = find_error_color(palette) # Keep colors vibrant - darken for visibility on light bg h, s, l = primary.to_hsl() primary_adjusted = Color.from_hsl(h, max(s, 0.7), min(l, 0.45)) h, s, l = secondary.to_hsl() secondary_adjusted = Color.from_hsl(h, max(s, 0.6), min(l, 0.40)) h, s, l = tertiary.to_hsl() tertiary_adjusted = Color.from_hsl(h, max(s, 0.5), min(l, 0.35)) # Surface: COLORFUL light - a pastel, saturated version of primary # Preserving saturation (up to 0.9) to be true to primary color mSurface = _adjust_surface(palette[0], 0.90, 0.90) mSurfaceVariant = _adjust_surface(palette[0], 0.80, 0.85) # Foreground colors - tinted with primary hue text_h, _, _ = palette[0].to_hsl() base_on_surface = Color.from_hsl(text_h, 0.05, 0.10) mOnSurface = _ensure_contrast(base_on_surface, mSurface, 4.5) base_on_surface_variant = Color.from_hsl(text_h, 0.05, 0.35) mOnSurfaceVariant = _ensure_contrast(base_on_surface_variant, mSurfaceVariant, 4.5) # Contrasting foregrounds light_fg = Color.from_hsl(text_h, 0.1, 0.95) on_primary = ensure_contrast(light_fg, primary_adjusted, 4.5) on_secondary = ensure_contrast(light_fg, secondary_adjusted, 4.5) on_tertiary = ensure_contrast(light_fg, tertiary_adjusted, 4.5) on_error = ensure_contrast(light_fg, error, 4.5) # Outline uses primary hue, more saturated surface_h, surface_s, _ = palette[0].to_hsl() mOutline = Color.from_hsl(surface_h, max(surface_s * 0.4, 0.25), 0.65) shadow = Color.from_hsl(surface_h, max(surface_s * 0.3, 0.15), 0.80) return { "mPrimary": primary_adjusted.to_hex(), "mOnPrimary": on_primary.to_hex(), "mSecondary": secondary_adjusted.to_hex(), "mOnSecondary": on_secondary.to_hex(), "mTertiary": tertiary_adjusted.to_hex(), "mOnTertiary": on_tertiary.to_hex(), "mError": error.to_hex(), "mOnError": on_error.to_hex(), "mSurface": mSurface.to_hex(), "mOnSurface": mOnSurface.to_hex(), "mSurfaceVariant": mSurfaceVariant.to_hex(), "mOnSurfaceVariant": mOnSurfaceVariant.to_hex(), "mOutline": mOutline.to_hex(), "mShadow": shadow.to_hex(), } def generate_theme( palette: list[Color], mode: ThemeMode, material: bool = True ) -> dict[str, str]: """Generate theme for specified mode.""" if material: if mode == "dark": return generate_material_dark(palette) return generate_material_light(palette) else: if mode == "dark": return generate_normal_dark(palette) return generate_normal_light(palette) # ============================================================================= # Template Rendering (Matugen Compatibility) # ============================================================================= class TemplateRenderer: """ Renders templates using the generated theme colors. Compatible with Matugen-style {{colors.name.mode.format}} tags. """ # Map from Matugen color names to theme keys COLOR_MAP = { "primary": "mPrimary", "on_primary": "mOnPrimary", "primary_container": "mPrimary", # Mapped to Accent (Bright) "on_primary_container": "mOnPrimary", # Mapped to Text on Accent (Dark) "secondary": "mSecondary", "on_secondary": "mOnSecondary", "secondary_container": "mSecondary", # Mapped to Accent "on_secondary_container": "mOnSecondary",# Mapped to Text on Accent "tertiary": "mTertiary", "on_tertiary": "mOnTertiary", "tertiary_container": "mTertiary", # Mapped to Accent "on_tertiary_container": "mOnTertiary", # Mapped to Text on Accent "error": "mError", "on_error": "mOnError", "error_container": "mError", # Fallback "on_error_container": "mOnError", # Fallback "surface": "mSurface", "on_surface": "mOnSurface", "surface_variant": "mSurfaceVariant", "on_surface_variant": "mOnSurfaceVariant", "outline": "mOutline", "outline_variant": "mOutline", "shadow": "mShadow", "scrim": "mShadow", "inverse_surface": "mOnSurface", # Fallback "inverse_on_surface": "mSurface", # Fallback "inverse_primary": "mOnPrimary", # Fallback "background": "mSurface", "on_background": "mOnSurface", # Surface Containers (Material 3) "surface_container_lowest": "mSurface", # Fallback "surface_container_low": "mSurface", # Fallback "surface_container": "mSurfaceVariant", # Fallback "surface_container_high": "mSurfaceVariant", # Fallback "surface_container_highest": "mSurfaceVariant", # Fallback "surface_dim": "mSurface", # Fallback "surface_bright": "mSurfaceVariant", # Fallback # Fixed colors (Material 3) "primary_fixed": "mPrimary", # Fallback "primary_fixed_dim": "mPrimary", # Fallback "on_primary_fixed": "mOnPrimary", # Fallback "on_primary_fixed_variant": "mOnPrimary",# Fallback "secondary_fixed": "mSecondary", # Fallback "secondary_fixed_dim": "mSecondary", # Fallback "on_secondary_fixed": "mOnSecondary", # Fallback "on_secondary_fixed_variant": "mOnSecondary", # Fallback "tertiary_fixed": "mTertiary", # Fallback "tertiary_fixed_dim": "mTertiary", # Fallback "on_tertiary_fixed": "mOnTertiary", # Fallback "on_tertiary_fixed_variant": "mOnTertiary", # Fallback # Custom/Noctalia keys "hover": "mHover", "on_hover": "mOnHover", } def __init__(self, theme_data: dict[str, dict[str, str]]): self.theme_data = theme_data def _get_color_value(self, color_name: str, mode: str, format_type: str) -> str: """Get processed color value for a template tag.""" # Map color name to theme key key = self.COLOR_MAP.get(color_name) if not key: return f"{{{{UNKNOWN_COLOR_{color_name}}}}}" # Get relevant mode data # Handle 'default' mode (active mode if only one generated, or first available) if mode == "default": mode_data = self.theme_data.get("dark") or self.theme_data.get("light") else: mode_data = self.theme_data.get(mode) if not mode_data: return f"{{{{UNKNOWN_MODE_{mode}}}}}" hex_color = mode_data.get(key) if not hex_color: # Fallback to unmapped name (e.g. if input JSON uses standard keys) hex_color = mode_data.get(color_name) if not hex_color: return f"{{{{UNKNOWN_KEY_{key}}}}}" # Apply format if format_type == "hex": return hex_color elif format_type == "hex_stripped": return hex_color.lstrip('#') elif format_type == "rgb": c = Color.from_hex(hex_color) return f"{c.r}, {c.g}, {c.b}" elif format_type == "rgba": c = Color.from_hex(hex_color) return f"{c.r}, {c.g}, {c.b}, 1.0" elif format_type in ("hue", "saturation", "lightness"): c = Color.from_hex(hex_color) h, s, l = c.to_hsl() if format_type == "hue": return str(int(h)) if format_type == "saturation": return str(int(s * 100)) if format_type == "lightness": return str(int(l * 100)) return hex_color def render(self, template_text: str) -> str: """Replace all tags in template text.""" # Generic pattern for {{colors.name.mode.format}} pattern = r"\{\{\s*colors\.([a-z_0-9]+)\.([a-z_0-9]+)\.([a-z_0-9]+)\s*\}\}" def replace(match): color_name, mode, format_type = match.groups() return self._get_color_value(color_name, mode, format_type) return re.sub(pattern, replace, template_text) def render_file(self, input_path: Path, output_path: Path): """Render a template file to an output path.""" try: template_text = input_path.read_text() rendered_text = self.render(template_text) output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(rendered_text) except Exception as e: print(f"Error rendering template {input_path}: {e}", file=sys.stderr) def process_config_file(self, config_path: Path): """Process Matugen TOML configuration file.""" if not tomllib: print("Error: tomllib module not available (requires Python 3.11+)", file=sys.stderr) return try: with open(config_path, "rb") as f: data = tomllib.load(f) # Matugen config structure: https://github.com/InioX/matugen # [config] section (ignored) # [templates.name] sections templates = data.get("templates", {}) for name, template in templates.items(): input_path = template.get("input_path") output_path = template.get("output_path") if not input_path or not output_path: continue self.render_file(Path(input_path).expanduser(), Path(output_path).expanduser()) # Matugen supports post_hook, we probably can't easily support that blindly # without shell=True which is risky, but let's see if we need it. # TemplateProcessor.qml puts post_hook in the TOML. # We should execute it if possible to fully replicate behavior. post_hook = template.get("post_hook") if post_hook: import subprocess try: subprocess.run(post_hook, shell=True, check=False) except Exception as e: print(f"Error running post_hook for {name}: {e}", file=sys.stderr) except Exception as e: print(f"Error processing config file {config_path}: {e}", file=sys.stderr) def parse_args() -> argparse.Namespace: """Parse command-line arguments.""" parser = argparse.ArgumentParser( prog='themer', description='Extract color palettes from wallpapers and generate themes', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: python3 template-processor.py wallpaper.png --material --both python3 template-processor.py wallpaper.jpg --dark -o theme.json python3 template-processor.py ~/Pictures/bg.png --normal --light """ ) parser.add_argument( 'image', type=Path, help='Path to wallpaper image (PNG/JPG)' ) # Theme style (mutually exclusive) style_group = parser.add_mutually_exclusive_group() style_group.add_argument( '--material', action='store_true', default=True, help='Generate Material-style colors (default)' ) style_group.add_argument( '--normal', action='store_true', help='Generate simpler accent-based palette' ) # Theme mode (mutually exclusive) mode_group = parser.add_mutually_exclusive_group() mode_group.add_argument( '--dark', action='store_true', help='Generate dark theme only' ) mode_group.add_argument( '--light', action='store_true', help='Generate light theme only' ) mode_group.add_argument( '--both', action='store_true', default=True, help='Generate both dark and light themes (default)' ) parser.add_argument( '--output', '-o', type=Path, help='Write JSON output to file (stdout if omitted)' ) parser.add_argument( '--render', '-r', action='append', help='Render a template (input_path:output_path)' ) # Matugen compatibility arguments parser.add_argument( '--config', '-c', type=Path, help='Path to Matugen TOML configuration file' ) parser.add_argument( '--mode', choices=['dark', 'light'], help='Override theme mode (for Matugen compatibility)' ) parser.add_argument( '--type', '-t', help='Scheme type (ignored, for Matugen compatibility)' ) return parser.parse_args() def main() -> int: """Main entry point.""" args = parse_args() # Validate image path if not args.image.exists(): print(f"Error: Image not found: {args.image}", file=sys.stderr) return 1 # Initialize result dictionary result: dict[str, dict[str, str]] = {} # Check if input is a JSON palette (Predefined Scheme bypass) if args.image.suffix.lower() == '.json': try: with open(args.image, 'r') as f: input_data = json.load(f) # Expect {"colors": ...} or direct dict colors_data = input_data.get("colors", input_data) # Flatten QML-style object structure if needed # structure: key -> { default: { hex: "#..." } } or key -> "#..." flat_colors = {} for k, v in colors_data.items(): if isinstance(v, dict) and 'default' in v and 'hex' in v['default']: flat_colors[k] = v['default']['hex'] elif isinstance(v, str): flat_colors[k] = v else: # Best effort fallback flat_colors[k] = str(v) # Assign to both/all modes since predefined scheme usually provides the correct palette for the requested mode result["dark"] = flat_colors result["light"] = flat_colors # Skip extraction logic palette = None except Exception as e: print(f"Error reading JSON palette: {e}", file=sys.stderr) return 1 else: # Standard Image Extraction # Validate image path is a file if not args.image.is_file(): print(f"Error: Not a file: {args.image}", file=sys.stderr) return 1 # Read image try: pixels = read_image(args.image) except ImageReadError as e: print(f"Error reading image: {e}", file=sys.stderr) return 1 except Exception as e: print(f"Unexpected error reading image: {e}", file=sys.stderr) return 1 # Extract palette k = 5 palette = extract_palette(pixels, k=k) if not palette: print("Error: Could not extract colors from image", file=sys.stderr) return 1 # Determine which themes to generate use_material = not args.normal # Handle --mode compatibility arg_dark = args.dark arg_light = args.light arg_both = args.both if args.mode == 'dark': arg_dark = True arg_light = False arg_both = False elif args.mode == 'light': arg_dark = False arg_light = True arg_both = False if palette: if arg_dark: result["dark"] = generate_theme(palette, "dark", use_material) elif arg_light: result["light"] = generate_theme(palette, "light", use_material) else: # Generate both (default) result["dark"] = generate_theme(palette, "dark", use_material) result["light"] = generate_theme(palette, "light", use_material) # Output JSON json_output = json.dumps(result, indent=2) if args.output: try: args.output.write_text(json_output) print(f"Theme written to: {args.output}", file=sys.stderr) except IOError as e: print(f"Error writing output: {e}", file=sys.stderr) return 1 elif not args.render and not args.config: print(json_output) # Process templates if args.render or args.config: renderer = TemplateRenderer(result) if args.render: for render_spec in args.render: if ':' not in render_spec: print(f"Error: Invalid render spec (must be input:output): {render_spec}", file=sys.stderr) continue input_str, output_str = render_spec.split(':', 1) input_path = Path(input_str).expanduser() output_path = Path(output_str).expanduser() if not input_path.exists(): print(f"Error: Template not found: {input_path}", file=sys.stderr) continue renderer.render_file(input_path, output_path) if args.config: if not args.config.exists(): print(f"Error: Config file not found: {args.config}", file=sys.stderr) else: renderer.process_config_file(args.config) return 0 if __name__ == '__main__': sys.exit(main())