261 lines
8.5 KiB
Python
261 lines
8.5 KiB
Python
#!/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": { <ThemeName>: { <ColorName>: { "light": "#RRGGBB", "dark": "#RRGGBB" } } },
|
|
"widget": { <ColorName>: { "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())
|