#!/usr/bin/env python3 """Extract color values from iOS Asset Catalogs into a machine-readable JSON file. Scans two asset catalogs: - iosApp/iosApp/Assets.xcassets/Colors/** (11 themes x 9 colors) - iosApp/HoneyDue/Assets.xcassets/** (widget accent + bg) Each color Contents.json defines up to two `colors` entries: - one universal (light-mode default) - one with appearances=[luminosity:dark] Component values may be: - float strings "0.000" .. "1.000" -> multiply by 255 and round - hex strings "0xHH" -> parse as int sRGB-only. If any display-p3 entry is encountered it is recorded separately (at the top level) so Android implementers can decide how to handle them; values are otherwise passed through as-is. Output schema (see plan in rc-android-ios-parity.md): { "displayP3_themes": [ ... optional ... ], "themes": { : { : { "light": "#RRGGBB", "dark": "#RRGGBB" } } }, "widget": { : { "light": "#RRGGBB", "dark": "#RRGGBB" } } } """ from __future__ import annotations import json import os import sys from pathlib import Path from typing import Any REPO_ROOT = Path(__file__).resolve().parent.parent COLOR_ROOT = REPO_ROOT / "iosApp" / "iosApp" / "Assets.xcassets" / "Colors" WIDGET_ROOT = REPO_ROOT / "iosApp" / "HoneyDue" / "Assets.xcassets" OUT_PATH = REPO_ROOT / "docs" / "ios-parity" / "colors.json" EXPECTED_THEME_COUNT = 11 EXPECTED_COLORS_PER_THEME = 9 EXPECTED_COLOR_NAMES = { "Primary", "Secondary", "Accent", "Error", "BackgroundPrimary", "BackgroundSecondary", "TextPrimary", "TextSecondary", "TextOnPrimary", } def component_to_int(value: str | float | int) -> int: """Convert a color component (hex string, float-as-string, or numeric) to 0..255.""" if isinstance(value, (int, float)): if 0.0 <= float(value) <= 1.0 and not (isinstance(value, int) and value > 1): return round(float(value) * 255) return int(value) s = str(value).strip() if s.lower().startswith("0x"): return int(s, 16) # float like "0.478" or "1.000" f = float(s) if 0.0 <= f <= 1.0: return round(f * 255) return int(f) def hex_string(r: int, g: int, b: int, a: float) -> str: if abs(a - 1.0) < 1e-6: return f"#{r:02X}{g:02X}{b:02X}" a_int = round(a * 255) if 0.0 <= a <= 1.0 else int(a) return f"#{r:02X}{g:02X}{b:02X}{a_int:02X}" def alpha_value(value: Any) -> float: if isinstance(value, (int, float)): return float(value) s = str(value).strip() if s.lower().startswith("0x"): return int(s, 16) / 255.0 return float(s) def parse_colorset(contents_path: Path) -> tuple[str | None, str | None, str | None]: """Return (light_hex, dark_hex, color_space) for a .colorset/Contents.json. Returns (None, None, None) if the colorset has no color data (e.g. Xcode placeholder `AccentColor` with only idiom but no components). """ with contents_path.open("r", encoding="utf-8") as f: data = json.load(f) light = None dark = None color_space = None for entry in data.get("colors", []): color = entry.get("color") if not color: continue components = color.get("components") or {} if not components: continue color_space = color.get("color-space") or color_space r = component_to_int(components.get("red", 0)) g = component_to_int(components.get("green", 0)) b = component_to_int(components.get("blue", 0)) a = alpha_value(components.get("alpha", 1.0)) hex_str = hex_string(r, g, b, a) appearances = entry.get("appearances") or [] is_dark = any( a.get("appearance") == "luminosity" and a.get("value") == "dark" for a in appearances ) if is_dark: dark = hex_str else: light = hex_str if light is None and dark is None: return None, None, None # If one variant is missing, mirror it if light is None: light = dark if dark is None: dark = light return light, dark, color_space def extract_theme_colors() -> tuple[dict[str, dict[str, dict[str, str]]], set[str]]: themes: dict[str, dict[str, dict[str, str]]] = {} display_p3_themes: set[str] = set() if not COLOR_ROOT.is_dir(): raise SystemExit(f"color root not found: {COLOR_ROOT}") for theme_dir in sorted(COLOR_ROOT.iterdir()): if not theme_dir.is_dir(): continue theme_name = theme_dir.name theme_colors: dict[str, dict[str, str]] = {} for colorset_dir in sorted(theme_dir.iterdir()): if not colorset_dir.is_dir() or not colorset_dir.name.endswith(".colorset"): continue color_name = colorset_dir.name[: -len(".colorset")] contents_path = colorset_dir / "Contents.json" if not contents_path.is_file(): continue light, dark, cs = parse_colorset(contents_path) if light is None: continue theme_colors[color_name] = {"light": light, "dark": dark} if cs and "display-p3" in cs.lower(): display_p3_themes.add(theme_name) if theme_colors: themes[theme_name] = theme_colors return themes, display_p3_themes def extract_widget_colors() -> dict[str, dict[str, str]]: widget: dict[str, dict[str, str]] = {} if not WIDGET_ROOT.is_dir(): print(f"[warn] widget asset root missing: {WIDGET_ROOT}", file=sys.stderr) return widget for entry in sorted(WIDGET_ROOT.iterdir()): if not entry.is_dir() or not entry.name.endswith(".colorset"): continue color_name = entry.name[: -len(".colorset")] contents_path = entry / "Contents.json" if not contents_path.is_file(): continue light, dark, _ = parse_colorset(contents_path) if light is None: # Asset catalog placeholder with no concrete color; skip. continue widget[color_name] = {"light": light, "dark": dark} return widget def main() -> int: themes, display_p3 = extract_theme_colors() widget = extract_widget_colors() # Validation errors: list[str] = [] if len(themes) != EXPECTED_THEME_COUNT: errors.append( f"Expected {EXPECTED_THEME_COUNT} themes, got {len(themes)}: " f"{sorted(themes.keys())}" ) for name, colors in sorted(themes.items()): if len(colors) != EXPECTED_COLORS_PER_THEME: errors.append( f"Theme {name!r} has {len(colors)} colors, expected " f"{EXPECTED_COLORS_PER_THEME}: {sorted(colors.keys())}" ) missing = EXPECTED_COLOR_NAMES - set(colors.keys()) extra = set(colors.keys()) - EXPECTED_COLOR_NAMES if missing: errors.append(f"Theme {name!r} missing colors: {sorted(missing)}") if extra: errors.append(f"Theme {name!r} has unexpected colors: {sorted(extra)}") if errors: print("[extract_ios_colors] VALIDATION FAILED:", file=sys.stderr) for e in errors: print(f" - {e}", file=sys.stderr) return 1 output: dict[str, Any] = {} if display_p3: output["displayP3_themes"] = sorted(display_p3) # Preserve key order: Primary, Secondary, Accent, Error, Backgrounds, Text color_order = [ "Primary", "Secondary", "Accent", "Error", "BackgroundPrimary", "BackgroundSecondary", "TextPrimary", "TextSecondary", "TextOnPrimary", ] ordered_themes: dict[str, dict[str, dict[str, str]]] = {} # Put Default first if present theme_order = sorted(themes.keys(), key=lambda n: (n != "Default", n)) for theme_name in theme_order: ordered_themes[theme_name] = { cname: themes[theme_name][cname] for cname in color_order if cname in themes[theme_name] } output["themes"] = ordered_themes output["widget"] = widget OUT_PATH.parent.mkdir(parents=True, exist_ok=True) with OUT_PATH.open("w", encoding="utf-8") as f: json.dump(output, f, indent=2) f.write("\n") print(f"[extract_ios_colors] wrote {OUT_PATH}") print( f" themes={len(ordered_themes)} " f"colors/theme={EXPECTED_COLORS_PER_THEME} " f"widget_entries={len(widget)} " f"displayP3_themes={sorted(display_p3) if display_p3 else 'none'}" ) return 0 if __name__ == "__main__": raise SystemExit(main())