6.1 KiB · text History e745ef6
#!/usr/bin/env python3
"""Regenerates src/palette/generated.rs from the live theme sources.

The neiam palette lives in dms as daisyUI themes; the Casabeza palette was
recovered from the cbzauth APK after the source loss. Neither is retyped by
hand here — run this after either moves and the tool follows.

    tools/regen_palettes.py [--dms PATH] [--check]

--check exits non-zero if the generated file is stale, for CI.
"""
import argparse
import math
import pathlib
import re
import sys

HERE = pathlib.Path(__file__).resolve().parent.parent
DEFAULT_DMS = pathlib.Path.home() / "Development/projects/dms/assets/f/css/style.css"
OUT = HERE / "src/palette/generated.rs"

# Semantic roles worth exposing as icon colours. daisyUI defines more, but
# these are the ones that read as a palette rather than as UI plumbing.
ROLES = [
    "base-100", "base-200", "base-300", "base-content",
    "primary", "primary-content", "secondary", "secondary-content",
    "accent", "accent-content", "neutral", "neutral-content",
    "info", "success", "warning", "error",
]


def oklch_to_srgb(l, c, h_deg):
    """OKLCH -> sRGB u8 triple. Mirrors the conversion the Android clients use."""
    h = math.radians(h_deg)
    a, b = math.cos(h) * c, math.sin(h) * c
    l_ = l + 0.3963377774 * a + 0.2158037573 * b
    m_ = l - 0.1055613458 * a - 0.0638541728 * b
    s_ = l - 0.0894841775 * a - 1.2914855480 * b
    L, M, S = l_ ** 3, m_ ** 3, s_ ** 3
    r = 4.0767416621 * L - 3.3077115913 * M + 0.2309699292 * S
    g = -1.2684380046 * L + 2.6097574011 * M - 0.3413193965 * S
    bl = -0.0041960863 * L - 0.7034186147 * M + 1.7076147010 * S

    def enc(x):
        x = max(0.0, min(1.0, x))
        s = 12.92 * x if x <= 0.0031308 else 1.055 * (x ** (1 / 2.4)) - 0.055
        return max(0, min(255, round(s * 255)))

    return enc(r), enc(g), enc(bl)


def parse_css_color(raw):
    raw = raw.strip().rstrip(";").strip()
    m = re.fullmatch(r"oklch\(\s*([\d.]+)%?\s+([\d.]+)\s+([\d.]+)\s*\)", raw)
    if m:
        l, c, h = float(m[1]), float(m[2]), float(m[3])
        if "%" in raw.split()[0]:
            l /= 100.0
        elif l > 1.0:
            l /= 100.0
        return oklch_to_srgb(l, c, h)
    m = re.fullmatch(r"#([0-9a-fA-F]{6})", raw)
    if m:
        v = int(m[1], 16)
        return (v >> 16) & 255, (v >> 8) & 255, v & 255
    m = re.fullmatch(r"#([0-9a-fA-F]{3})", raw)
    if m:
        return tuple(int(ch * 2, 16) for ch in m[1])
    return None


def parse_dms(path):
    """Pull each @plugin "daisyui/theme" block out of the dms stylesheet."""
    css = path.read_text(encoding="utf-8", errors="replace")
    themes = []
    for block in re.finditer(r"\{([^{}]*?name:\s*\"[^\"]+\"[^{}]*?)\}", css, re.S):
        body = block[1]
        name = re.search(r'name:\s*"([^"]+)"', body)[1]
        scheme = re.search(r'color-scheme:\s*"?([a-z]+)"?', body)
        colors = {}
        for role in ROLES:
            m = re.search(rf"--color-{re.escape(role)}:\s*([^;]+);", body)
            if m:
                rgb = parse_css_color(m[1])
                if rgb:
                    colors[role] = rgb
        if len(colors) >= 8:
            themes.append({
                "name": name,
                "light": bool(scheme and scheme[1] == "light"),
                "colors": colors,
            })
    return themes


# Recovered from the cbzauth APK (io.gmp.cbzauth ui/theme/AppTheme.kt) after
# the source loss. Exact hex as compiled into the shipped app.
CASABEZA = {
    "name": "Casabeza",
    "light": False,
    "colors": {
        "base-100": (0x08, 0x1E, 0x20),      # BG_DARK
        "base-200": (0x0D, 0x2A, 0x2C),      # BG_DARK_ALT
        "base-content": (0xEE, 0xF9, 0xFA),  # TEXT_INV
        "primary": (0x00, 0xC9, 0xD4),       # ACCENT
        "accent": (0xAA, 0xD4, 0x00),        # LOGO_GREEN
        "neutral": (0x7A, 0x96, 0x98),       # TEXT_MUTED
    },
}
CASABEZA_LIGHT = {
    "name": "Casabeza Light",
    "light": True,
    "colors": {
        "base-100": (0xF6, 0xFB, 0xFB),      # BG_LIGHT
        "base-200": (0xE9, 0xF5, 0xF6),      # BG_LIGHT_ALT
        "base-content": (0x1A, 0x1A, 0x1A),  # TEXT_DARK
        "primary": (0x00, 0x5A, 0x62),       # PRIMARY_TEAL
        "accent": (0xC8, 0xF0, 0x17),        # LOGO_GREEN_LIGHT
        "neutral": (0x3D, 0x5F, 0x61),       # TEXT_MUTED_DARK
    },
}


def render(themes):
    out = [
        "// @generated by tools/regen_palettes.py — do not edit by hand.",
        "//",
        "// neiam themes are read from the dms stylesheet (daisyUI, OKLCH);",
        "// the Casabeza entries were recovered from the cbzauth APK.",
        "",
        "use super::{NamedColor, Theme};",
        "",
        f"pub const THEMES: &[Theme] = &[",
    ]
    for t in themes:
        out.append("    Theme {")
        out.append(f'        name: "{t["name"]}",')
        out.append(f'        light: {"true" if t["light"] else "false"},')
        out.append("        colors: &[")
        for role, (r, g, b) in sorted(t["colors"].items()):
            out.append(
                f'            NamedColor {{ role: "{role}", rgb: [0x{r:02X}, 0x{g:02X}, 0x{b:02X}] }},'
            )
        out.append("        ],")
        out.append("    },")
    out.append("];")
    out.append("")
    return "\n".join(out)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--dms", type=pathlib.Path, default=DEFAULT_DMS)
    ap.add_argument("--check", action="store_true")
    args = ap.parse_args()

    themes = [CASABEZA, CASABEZA_LIGHT]
    if args.dms.exists():
        found = parse_dms(args.dms)
        themes += found
        print(f"dms: {len(found)} themes from {args.dms}", file=sys.stderr)
    else:
        print(f"dms stylesheet not found at {args.dms}; "
              f"emitting Casabeza only", file=sys.stderr)

    text = render(themes)
    OUT.parent.mkdir(parents=True, exist_ok=True)

    if args.check:
        current = OUT.read_text() if OUT.exists() else ""
        if current != text:
            print("generated.rs is stale; run tools/regen_palettes.py", file=sys.stderr)
            return 1
        return 0

    OUT.write_text(text)
    print(f"wrote {OUT} ({len(themes)} themes)", file=sys.stderr)
    return 0


if __name__ == "__main__":
    sys.exit(main())