presenton/servers/fastapi/utils/theme_utils.py

357 lines
12 KiB
Python

from __future__ import annotations
import math
import random
from dataclasses import dataclass
from typing import Dict, Optional
from models.theme_data import GeneratedColorPalette
IS_DARK_BELOW = 0.65
BACKGROUND_RETRIES = 200
TEXT_RETRIES = 200
LIGHTNESS_VALUES: Dict[str, float] = {
"50": 0.97,
"100": 0.93,
"200": 0.86,
"300": 0.78,
"400": 0.70,
"500": 0.62,
"600": 0.54,
"700": 0.46,
"800": 0.38,
"900": 0.30,
}
@dataclass(frozen=True)
class Oklch:
l: float # noqa: E741
c: float
h: float
def _clamp(value: float, min_value: float = 0.0, max_value: float = 1.0) -> float:
return max(min_value, min(max_value, value))
def _get_random_value(min_value: float, max_value: float) -> float:
return min_value + random.random() * (max_value - min_value)
def _get_random_value_at_min_max_distance(
base_value: float,
min_value: float,
max_value: float,
min_distance: Optional[float] = None,
max_distance: Optional[float] = None,
) -> float:
normalized_min_distance = max(0.0, min_distance or 0.0)
normalized_max_distance = max_distance if max_distance is not None else math.inf
min_dist = min(normalized_min_distance, normalized_max_distance)
max_dist = max(normalized_min_distance, normalized_max_distance)
lower_start = max(min_value, base_value - max_dist)
lower_end = min(max_value, base_value - min_dist)
upper_start = max(min_value, base_value + min_dist)
upper_end = min(max_value, base_value + max_dist)
lower_size = max(0.0, lower_end - lower_start)
upper_size = max(0.0, upper_end - upper_start)
total_size = lower_size + upper_size
if total_size <= 0:
return _get_random_value(min_value, max_value)
picker = random.random() * total_size
if picker < lower_size:
return _get_random_value(lower_start, lower_end)
return _get_random_value(upper_start, upper_end)
def _srgb_to_linear(channel: float) -> float:
if channel <= 0.04045:
return channel / 12.92
return ((channel + 0.055) / 1.055) ** 2.4
def _linear_to_srgb(channel: float) -> float:
if channel <= 0.0031308:
return 12.92 * channel
return 1.055 * (channel ** (1 / 2.4)) - 0.055
def _oklch_to_srgb(color: Oklch) -> tuple[float, float, float]:
hue_rad = math.radians(color.h % 360)
a = color.c * math.cos(hue_rad)
b = color.c * math.sin(hue_rad)
l_ = (color.l + 0.3963377774 * a + 0.2158037573 * b) ** 3
m_ = (color.l - 0.1055613458 * a - 0.0638541728 * b) ** 3
s_ = (color.l - 0.0894841775 * a - 1.2914855480 * b) ** 3
r = 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_
g = -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_
b = -0.0041960863 * l_ - 0.7034186147 * m_ + 1.7076147010 * s_
return (
_clamp(_linear_to_srgb(r)),
_clamp(_linear_to_srgb(g)),
_clamp(_linear_to_srgb(b)),
)
def _srgb_to_oklch(r: float, g: float, b: float) -> Oklch:
r_lin = _srgb_to_linear(r)
g_lin = _srgb_to_linear(g)
b_lin = _srgb_to_linear(b)
l_ = 0.4122214708 * r_lin + 0.5363325363 * g_lin + 0.0514459929 * b_lin
m_ = 0.2119034982 * r_lin + 0.6806995451 * g_lin + 0.1073969566 * b_lin
s_ = 0.0883024619 * r_lin + 0.2817188376 * g_lin + 0.6299787005 * b_lin
l_cbrt = math.copysign(abs(l_) ** (1 / 3), l_)
m_cbrt = math.copysign(abs(m_) ** (1 / 3), m_)
s_cbrt = math.copysign(abs(s_) ** (1 / 3), s_)
lightness = 0.2104542553 * l_cbrt + 0.7936177850 * m_cbrt - 0.0040720468 * s_cbrt
a = 1.9779984951 * l_cbrt - 2.4285922050 * m_cbrt + 0.4505937099 * s_cbrt
b = 0.0259040371 * l_cbrt + 0.7827717662 * m_cbrt - 0.8086757660 * s_cbrt
chroma = math.hypot(a, b)
hue = math.degrees(math.atan2(b, a)) % 360
return Oklch(l=lightness, c=chroma, h=hue)
def _hex_to_oklch(hex_value: str) -> Oklch:
hex_value = hex_value.strip().lstrip("#")
if len(hex_value) != 6:
raise ValueError(f"Invalid hex color: {hex_value!r}")
r = int(hex_value[0:2], 16) / 255.0
g = int(hex_value[2:4], 16) / 255.0
b = int(hex_value[4:6], 16) / 255.0
return _srgb_to_oklch(r, g, b)
def _format_hex(color: Oklch) -> str:
r, g, b = _oklch_to_srgb(color)
return "#{:02x}{:02x}{:02x}".format(
int(round(r * 255)),
int(round(g * 255)),
int(round(b * 255)),
)
def _relative_luminance(color: Oklch) -> float:
r, g, b = _oklch_to_srgb(color)
r_lin = _srgb_to_linear(r)
g_lin = _srgb_to_linear(g)
b_lin = _srgb_to_linear(b)
return 0.2126 * r_lin + 0.7152 * g_lin + 0.0722 * b_lin
def _wcag_contrast(a: Oklch, b: Oklch) -> float:
l1 = _relative_luminance(a)
l2 = _relative_luminance(b)
lighter = max(l1, l2)
darker = min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
def _get_color_for_all_lightness_values(base_color: Oklch) -> Dict[str, str]:
colors: Dict[str, str] = {}
for name, value in LIGHTNESS_VALUES.items():
color = Oklch(l=value, c=base_color.c, h=base_color.h)
colors[name] = _format_hex(color)
return colors
def _generate_primary_color() -> Oklch:
lightness = _get_random_value(0.0, 1.0)
chroma = _get_random_value(0.0, 0.4)
hue = _get_random_value(0.0, 360.0)
return Oklch(l=lightness, c=chroma, h=hue)
def _generate_background_color(base_color: Oklch) -> Oklch:
for _ in range(BACKGROUND_RETRIES):
lightness = _get_random_value(0.0, 1.0)
chroma = _get_random_value(0.0, 0.4)
hue = _get_random_value(0.0, 360.0)
color = Oklch(l=lightness, c=chroma, h=hue)
if _wcag_contrast(color, base_color) >= 6:
return color
if base_color.l < IS_DARK_BELOW:
return Oklch(l=1.0, c=0.0, h=0.0)
return Oklch(l=0.0, c=0.0, h=0.0)
def _generate_accent_color(base_color: Oklch, n: int) -> Oklch:
lightness = _get_random_value_at_min_max_distance(base_color.l, 0.0, 1.0, 0.0, 0.1)
chroma = _get_random_value_at_min_max_distance(base_color.c, 0.0, 0.4, 0.0, 0.4)
hue = _get_random_value_at_min_max_distance(
base_color.h if base_color.h is not None else 0.0,
0.0,
360.0,
n * 90.0,
(n + 1) * 90.0,
)
return Oklch(l=lightness, c=chroma, h=hue)
def _generate_text_color(base_color: Oklch, text_type: str) -> Oklch:
is_base_dark = base_color.l < IS_DARK_BELOW
for _ in range(TEXT_RETRIES):
if text_type == "text_1":
lightness = (
_get_random_value(0.8, 1.0)
if is_base_dark
else _get_random_value(0.0, 0.2)
)
chroma = _get_random_value(0.0, 0.02)
elif text_type == "text_2":
lightness = (
_get_random_value(0.8, 1.0)
if is_base_dark
else _get_random_value(0.0, 0.2)
)
chroma = _get_random_value(0.0, 0.04)
else:
raise ValueError(f"Invalid text type: {text_type}")
hue = _get_random_value(0.0, 360.0)
color = Oklch(l=lightness, c=chroma, h=hue)
min_contrast = 6.0
max_contrast = None
contrast = _wcag_contrast(color, base_color)
if contrast >= min_contrast and (
max_contrast is None or contrast <= max_contrast
):
return color
if base_color.l < IS_DARK_BELOW:
return Oklch(l=1.0 if text_type == "text_1" else 0.9, c=0.0, h=0.0)
return Oklch(l=0.0 if text_type == "text_1" else 0.1, c=0.0, h=0.0)
def get_lightness_key_at_distance(
value: float,
min_distance: Optional[int] = None,
max_distance: Optional[int] = None,
prefer_dark: Optional[bool] = None,
) -> str:
items = sorted(LIGHTNESS_VALUES.items(), key=lambda item: item[1])
nearest_index = 0
nearest_distance = abs(items[0][1] - value)
for index, (_, lightness) in enumerate(items[1:], start=1):
distance = abs(lightness - value)
if distance < nearest_distance or (
distance == nearest_distance and lightness < items[nearest_index][1]
):
nearest_index = index
nearest_distance = distance
normalized_min = max(0, min_distance or 0)
normalized_max = max_distance if max_distance is not None else normalized_min
if normalized_max < normalized_min:
normalized_min, normalized_max = normalized_max, normalized_min
candidate_indices = []
for distance in range(normalized_min, normalized_max + 1):
lower_index = nearest_index - distance
upper_index = nearest_index + distance
if 0 <= lower_index < len(items):
candidate_indices.append(lower_index)
if upper_index != lower_index and 0 <= upper_index < len(items):
candidate_indices.append(upper_index)
if not candidate_indices:
return items[nearest_index][0]
if prefer_dark is True:
darker_candidates = [idx for idx in candidate_indices if idx <= nearest_index]
if darker_candidates:
return items[min(darker_candidates)][0]
return items[min(candidate_indices)][0]
if prefer_dark is False:
lighter_candidates = [idx for idx in candidate_indices if idx >= nearest_index]
if lighter_candidates:
return items[max(lighter_candidates)][0]
return items[max(candidate_indices)][0]
def distance_to_value(idx: int) -> float:
return abs(items[idx][1] - value)
closest_index = min(candidate_indices, key=lambda idx: (distance_to_value(idx), idx))
return items[closest_index][0]
def generate_color_palette(
provided_primary: Optional[str] = None,
provided_background: Optional[str] = None,
provided_accent_1: Optional[str] = None,
provided_accent_2: Optional[str] = None,
provided_text_1: Optional[str] = None,
provided_text_2: Optional[str] = None,
) -> GeneratedColorPalette:
primary = (
_hex_to_oklch(provided_primary) if provided_primary else _generate_primary_color()
)
background = (
_hex_to_oklch(provided_background)
if provided_background
else _generate_background_color(primary)
)
accent_1 = (
_hex_to_oklch(provided_accent_1)
if provided_accent_1
else _generate_accent_color(primary, 1)
)
accent_2 = (
_hex_to_oklch(provided_accent_2)
if provided_accent_2
else _generate_accent_color(primary, 2)
)
text_1 = (
_hex_to_oklch(provided_text_1)
if provided_text_1
else _generate_text_color(background, "text_1")
)
text_2 = (
_hex_to_oklch(provided_text_2)
if provided_text_2
else _generate_text_color(primary, "text_2")
)
primary_variations = _get_color_for_all_lightness_values(primary)
background_variations = _get_color_for_all_lightness_values(background)
accent_1_variations = _get_color_for_all_lightness_values(accent_1)
accent_2_variations = _get_color_for_all_lightness_values(accent_2)
return GeneratedColorPalette(
primary=_format_hex(primary),
background=_format_hex(background),
accent_1=_format_hex(accent_1),
accent_2=_format_hex(accent_2),
text_1=_format_hex(text_1),
text_2=_format_hex(text_2),
primary_variations=primary_variations,
background_variations=background_variations,
accent_1_variations=accent_1_variations,
accent_2_variations=accent_2_variations,
primary_lightness=primary.l,
background_lightness=background.l,
accent_1_lightness=accent_1.l,
accent_2_lightness=accent_2.l,
text_1_lightness=text_1.l,
text_2_lightness=text_2.l,
)