forked from originalankur/maptoposter
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy paththemes.py
More file actions
128 lines (101 loc) · 4.33 KB
/
Copy paththemes.py
File metadata and controls
128 lines (101 loc) · 4.33 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
"""Theme discovery, loading, and validation."""
import json
import math
import os
import re
# Anchored to this file, not the cwd: `maptoposter-ui` is a console script that
# can be launched from anywhere, and the bundled themes ship beside this module.
THEMES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes")
FILE_ENCODING = "utf-8"
REQUIRED_KEYS = (
"bg", "text", "gradient_color", "water", "parks",
"road_motorway", "road_primary", "road_secondary",
"road_tertiary", "road_residential", "road_default",
)
_HEX_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
_FALLBACK_TERRACOTTA = {
"name": "Terracotta",
"description": "Mediterranean warmth - burnt orange and clay tones on cream",
"bg": "#F5EDE4",
"text": "#8B4513",
"gradient_color": "#F5EDE4",
"water": "#A8C4C4",
"parks": "#CBD2AE",
"road_motorway": "#A0522D",
"road_primary": "#B8653A",
"road_secondary": "#C9846A",
"road_tertiary": "#D9A08A",
"road_residential": "#E5C4B0",
"road_default": "#D9A08A",
}
class ThemeError(ValueError):
"""Raised when a theme file fails validation."""
def get_available_themes():
"""Scans the themes directory and returns a list of available theme names."""
if not os.path.exists(THEMES_DIR):
os.makedirs(THEMES_DIR)
return []
themes = []
for file in sorted(os.listdir(THEMES_DIR)):
if file.endswith(".json"):
themes.append(file[:-5])
return themes
def validate_theme(theme, source):
"""Validate required keys and hex colors. Raises ThemeError listing every problem."""
errors = []
for key in REQUIRED_KEYS:
if key not in theme:
errors.append(f"missing key '{key}'")
elif not isinstance(theme[key], str) or not _HEX_RE.match(theme[key]):
errors.append(f"key '{key}' is not a #RRGGBB hex color (got {theme[key]!r})")
if errors:
raise ThemeError(f"Invalid theme '{source}': " + "; ".join(errors))
def load_theme(theme_name="terracotta"):
"""Load and validate a theme from the themes directory."""
theme_file = os.path.join(THEMES_DIR, f"{theme_name}.json")
if not os.path.exists(theme_file):
print(f"⚠ Theme file '{theme_file}' not found. Using default terracotta theme.")
return dict(_FALLBACK_TERRACOTTA)
with open(theme_file, "r", encoding=FILE_ENCODING) as f:
theme = json.load(f)
validate_theme(theme, theme_file)
print(f"✓ Loaded theme: {theme.get('name', theme_name)}")
if "description" in theme:
print(f" {theme['description']}")
return theme
def _relative_luminance(hex_color):
channels = [int(hex_color[i:i + 2], 16) / 255 for i in (1, 3, 5)]
linear = [
c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
for c in channels
]
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
def contrast_ratio(hex_a, hex_b):
"""WCAG contrast ratio between two #RRGGBB colors (1.0 to 21.0)."""
lum_a, lum_b = _relative_luminance(hex_a), _relative_luminance(hex_b)
lighter, darker = max(lum_a, lum_b), min(lum_a, lum_b)
return (lighter + 0.05) / (darker + 0.05)
def _srgb_to_lab(hex_color):
"""CIE L*a*b* (D65, 2 deg observer) for a #RRGGBB colour."""
channels = [int(hex_color[i:i + 2], 16) / 255 for i in (1, 3, 5)]
red, green, blue = [
c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
for c in channels
]
x = (red * 0.4124564 + green * 0.3575761 + blue * 0.1804375) / 0.95047
y = red * 0.2126729 + green * 0.7151522 + blue * 0.0721750
z = (red * 0.0193339 + green * 0.1191920 + blue * 0.9503041) / 1.08883
def f(t):
return t ** (1 / 3) if t > 0.008856 else 7.787 * t + 16 / 116
fx, fy, fz = f(x), f(y), f(z)
return (116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz))
def delta_e(hex_a, hex_b):
"""
CIE76 perceptual distance between two #RRGGBB colours (0 to ~100).
contrast_ratio answers "can I read text on this?". delta_e answers "are these
two large flat fills the same colour?" -- the parks-versus-background
question. Under ~10 reads as one colour at poster viewing distance; the
project floor for parks is 15 (see tests/test_themes.py).
"""
lab_a, lab_b = _srgb_to_lab(hex_a), _srgb_to_lab(hex_b)
return math.sqrt(sum((a - b) ** 2 for a, b in zip(lab_a, lab_b)))