perspective_viewer/utils/
css.rs1use std::collections::BTreeMap;
14
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
19pub struct GradientStopSpec {
20 pub color: String,
21 pub offset: f64,
22}
23
24pub fn canonicalize_gradient_stops(mut stops: Vec<GradientStopSpec>) -> Vec<GradientStopSpec> {
25 for stop in &mut stops {
26 stop.offset = (stop.offset.clamp(0.0, 1.0) * 1000.0).round() / 1000.0;
27 }
28
29 stops.sort_by(|a, b| {
30 a.offset
31 .partial_cmp(&b.offset)
32 .unwrap_or(std::cmp::Ordering::Equal)
33 });
34
35 stops
36}
37
38pub const USER_VAR_PREFIX: &str = "--psp-user--";
39
40#[derive(Clone, Debug, PartialEq)]
41pub struct NamedValue {
42 pub name: String,
43 pub value: String,
44}
45
46#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
47pub enum CssKind {
48 Gradient,
49 Palette,
50 Color,
51}
52
53impl CssKind {
54 pub const ALL: [CssKind; 3] = [CssKind::Gradient, CssKind::Palette, CssKind::Color];
55
56 pub fn var_prefix(self) -> &'static str {
57 match self {
58 CssKind::Gradient => "--psp-user--gradient-",
59 CssKind::Palette => "--psp-user--palette-",
60 CssKind::Color => "--psp-user--color-",
61 }
62 }
63
64 pub fn of_var(name: &str) -> Option<CssKind> {
65 CssKind::ALL
66 .into_iter()
67 .find(|kind| name.starts_with(kind.var_prefix()))
68 }
69
70 pub fn canonicalize(self, src: &str) -> Result<String, String> {
71 match self {
72 CssKind::Gradient => CssGradient::parse(src).map(|x| x.to_css()),
73 CssKind::Palette => CssPalette::parse(src).map(|x| x.to_css()),
74 CssKind::Color => CssColor::parse(src).map(|x| x.to_css()),
75 }
76 }
77
78 pub fn short_name(self, var_name: &str) -> String {
79 var_name
80 .strip_prefix(self.var_prefix())
81 .unwrap_or(var_name)
82 .to_owned()
83 }
84}
85
86pub fn parse_var_ref(src: &str) -> Option<&str> {
89 let inner = src.trim().strip_prefix("var(")?.strip_suffix(')')?.trim();
90 if !inner.starts_with(USER_VAR_PREFIX)
91 || inner.len() == USER_VAR_PREFIX.len()
92 || inner.contains(|c: char| c.is_whitespace() || c == ',' || c == ')')
93 {
94 return None;
95 }
96
97 Some(inner)
98}
99
100pub fn format_var_ref(name: &str) -> String {
102 format!("var({name})")
103}
104
105fn parse_kind_var(src: &str, kind: CssKind) -> Result<Option<String>, String> {
106 let Some(name) = parse_var_ref(src) else {
107 return Ok(None);
108 };
109
110 if name.starts_with(kind.var_prefix()) {
111 Ok(Some(name.to_owned()))
112 } else {
113 Err(format!(
114 "`{src}` is not a {} reference (expected `var({}…)`)",
115 kind_label(kind),
116 kind.var_prefix()
117 ))
118 }
119}
120
121fn kind_label(kind: CssKind) -> &'static str {
122 match kind {
123 CssKind::Gradient => "gradient",
124 CssKind::Palette => "palette",
125 CssKind::Color => "color",
126 }
127}
128
129fn hex_channel(src: &str) -> Result<u8, String> {
130 u8::from_str_radix(src, 16).map_err(|_| format!("invalid hex color `#{src}`"))
131}
132
133fn channel_from_token(token: &str) -> Result<u8, String> {
134 let token = token.trim();
135 let value = match token.strip_suffix('%') {
136 Some(pct) => {
137 pct.trim()
138 .parse::<f64>()
139 .map_err(|_| format!("invalid color channel `{token}`"))?
140 / 100.0
141 * 255.0
142 },
143 None => token
144 .parse::<f64>()
145 .map_err(|_| format!("invalid color channel `{token}`"))?,
146 };
147
148 Ok(value.round().clamp(0.0, 255.0) as u8)
149}
150
151pub fn normalize_css_color(src: &str) -> Result<String, String> {
154 let src = src.trim();
155 if let Some(hex) = src.strip_prefix('#') {
156 let (r, g, b) = match hex.len() {
157 3 | 4 => {
158 let d = |i: usize| hex_channel(&hex[i..i + 1]).map(|x| x * 17);
159 (d(0)?, d(1)?, d(2)?)
160 },
161 6 | 8 => (
162 hex_channel(&hex[0..2])?,
163 hex_channel(&hex[2..4])?,
164 hex_channel(&hex[4..6])?,
165 ),
166 _ => return Err(format!("invalid hex color `{src}`")),
167 };
168
169 return Ok(format!("#{r:02x}{g:02x}{b:02x}"));
170 }
171
172 let lower = src.to_ascii_lowercase();
173 let body = lower
174 .strip_prefix("rgba(")
175 .or_else(|| lower.strip_prefix("rgb("))
176 .and_then(|x| x.strip_suffix(')'))
177 .ok_or_else(|| format!("unsupported color `{src}`"))?;
178
179 let body = body.split('/').next().unwrap_or("");
180 let tokens: Vec<&str> = body
181 .split(|c: char| c == ',' || c.is_whitespace())
182 .filter(|x| !x.is_empty())
183 .collect();
184
185 if tokens.len() < 3 || tokens.len() > 4 {
186 return Err(format!("unsupported color `{src}`"));
187 }
188
189 Ok(format!(
190 "#{:02x}{:02x}{:02x}",
191 channel_from_token(tokens[0])?,
192 channel_from_token(tokens[1])?,
193 channel_from_token(tokens[2])?
194 ))
195}
196
197fn split_entries(body: &str) -> Vec<&str> {
198 let mut parts = vec![];
199 let mut depth = 0i32;
200 let mut start = 0;
201 for (i, ch) in body.char_indices() {
202 match ch {
203 '(' => depth += 1,
204 ')' => depth -= 1,
205 ',' if depth == 0 => {
206 parts.push(&body[start..i]);
207 start = i + 1;
208 },
209 _ => {},
210 }
211 }
212
213 parts.push(&body[start..]);
214 parts
215}
216
217fn is_direction_token(entry: &str) -> bool {
218 let entry = entry.trim().to_ascii_lowercase();
219 if entry.starts_with("to ") {
220 return true;
221 }
222
223 ["deg", "rad", "grad", "turn"].iter().any(|unit| {
224 entry
225 .strip_suffix(unit)
226 .map(|n| n.parse::<f64>().is_ok())
227 .unwrap_or(false)
228 })
229}
230
231fn tokenize_linear_gradient(src: &str) -> Result<Vec<(String, Option<f64>)>, String> {
232 let src = src.trim();
233 let lower = src.to_ascii_lowercase();
234 let body = lower
235 .strip_prefix("linear-gradient(")
236 .and_then(|x| x.strip_suffix(')'))
237 .ok_or_else(|| format!("expected `linear-gradient(…)`, got `{src}`"))?;
238
239 let entries = split_entries(body);
240 let mut out = vec![];
241 for (index, entry) in entries.iter().enumerate() {
242 let entry = entry.trim();
243 if entry.is_empty() {
244 return Err(format!("empty entry in `{src}`"));
245 }
246
247 if index == 0 && is_direction_token(entry) {
248 continue;
249 }
250
251 let (color, position) = match entry.rfind(|c: char| c.is_whitespace()) {
252 Some(at) if !entry[at..].contains(')') => {
253 let tail = entry[at..].trim();
254 if let Some(pct) = tail.strip_suffix('%') {
255 let value = pct
256 .parse::<f64>()
257 .map_err(|_| format!("invalid stop position `{tail}`"))?;
258 (entry[..at].trim(), Some(value / 100.0))
259 } else if tail.starts_with(|c: char| c.is_ascii_digit() || c == '.' || c == '-') {
260 return Err(format!(
261 "unsupported stop position `{tail}` (only `%` is accepted)"
262 ));
263 } else {
264 (entry, None)
265 }
266 },
267 _ => (entry, None),
268 };
269
270 out.push((normalize_css_color(color)?, position));
271 }
272
273 Ok(out)
274}
275
276fn format_percent(offset: f64) -> String {
277 let text = format!("{:.1}", offset * 100.0);
278 let text = text.strip_suffix(".0").unwrap_or(&text).to_owned();
279 format!("{text}%")
280}
281
282pub fn gradient_to_css(stops: &[GradientStopSpec]) -> String {
285 let body = canonicalize_gradient_stops(stops.to_vec())
286 .iter()
287 .map(|stop| format!("{} {}", stop.color, format_percent(stop.offset)))
288 .collect::<Vec<_>>()
289 .join(", ");
290
291 format!("linear-gradient(to right, {body})")
292}
293
294pub fn palette_to_css(colors: &[String]) -> String {
297 format!("linear-gradient(to right, {})", colors.join(", "))
298}
299
300#[derive(Clone, Debug, PartialEq)]
302pub enum CssGradient {
303 Literal(Vec<GradientStopSpec>),
304 Var(String),
305}
306
307impl CssGradient {
308 pub fn parse(src: &str) -> Result<Self, String> {
309 if let Some(name) = parse_kind_var(src, CssKind::Gradient)? {
310 return Ok(CssGradient::Var(name));
311 }
312
313 let entries = tokenize_linear_gradient(src)?;
314 if entries.len() < 2 {
315 return Err(format!("a gradient needs at least 2 stops: `{src}`"));
316 }
317
318 let mut offsets: Vec<Option<f64>> = entries.iter().map(|(_, p)| *p).collect();
319 let last = offsets.len() - 1;
320 offsets[0].get_or_insert(0.0);
321 offsets[last].get_or_insert(1.0);
322 let mut i = 1;
323 while i < last {
324 if offsets[i].is_some() {
325 i += 1;
326 continue;
327 }
328
329 let mut j = i + 1;
330 while offsets[j].is_none() {
331 j += 1;
332 }
333
334 let before = offsets[i - 1].unwrap();
335 let after = offsets[j].unwrap();
336 let span = (j - (i - 1)) as f64;
337 for (k, slot) in offsets.iter_mut().enumerate().take(j).skip(i) {
338 *slot = Some(before + ((k - (i - 1)) as f64 / span) * (after - before));
339 }
340
341 i = j;
342 }
343
344 let stops = entries
345 .into_iter()
346 .zip(offsets)
347 .map(|((color, _), offset)| GradientStopSpec {
348 color,
349 offset: offset.unwrap(),
350 })
351 .collect();
352
353 Ok(CssGradient::Literal(canonicalize_gradient_stops(stops)))
354 }
355
356 pub fn to_css(&self) -> String {
357 match self {
358 CssGradient::Literal(stops) => gradient_to_css(stops),
359 CssGradient::Var(name) => format_var_ref(name),
360 }
361 }
362}
363
364#[derive(Clone, Debug, PartialEq)]
366pub enum CssPalette {
367 Literal(Vec<String>),
368 Var(String),
369}
370
371impl CssPalette {
372 pub fn parse(src: &str) -> Result<Self, String> {
373 if let Some(name) = parse_kind_var(src, CssKind::Palette)? {
374 return Ok(CssPalette::Var(name));
375 }
376
377 let entries = tokenize_linear_gradient(src)?;
378 if entries.is_empty() {
379 return Err(format!("a palette needs at least 1 color: `{src}`"));
380 }
381
382 let mut colors = Vec::with_capacity(entries.len());
383 for (color, position) in entries {
384 if position.is_some() {
385 return Err(format!(
386 "palette entries must not carry positions (a positioned `linear-gradient()` \
387 is a gradient, not a palette): `{src}`"
388 ));
389 }
390
391 colors.push(color);
392 }
393
394 Ok(CssPalette::Literal(colors))
395 }
396
397 pub fn to_css(&self) -> String {
398 match self {
399 CssPalette::Literal(colors) => palette_to_css(colors),
400 CssPalette::Var(name) => format_var_ref(name),
401 }
402 }
403}
404
405#[derive(Clone, Debug, PartialEq)]
407pub enum CssColor {
408 Literal(String),
409 Var(String),
410}
411
412impl CssColor {
413 pub fn parse(src: &str) -> Result<Self, String> {
414 if let Some(name) = parse_kind_var(src, CssKind::Color)? {
415 return Ok(CssColor::Var(name));
416 }
417
418 normalize_css_color(src).map(CssColor::Literal)
419 }
420
421 pub fn to_css(&self) -> String {
422 match self {
423 CssColor::Literal(color) => color.clone(),
424 CssColor::Var(name) => format_var_ref(name),
425 }
426 }
427}
428
429pub fn canonicalize_css_gradient(src: &str) -> Result<String, String> {
430 CssKind::Gradient.canonicalize(src)
431}
432
433pub fn canonicalize_css_palette(src: &str) -> Result<String, String> {
434 CssKind::Palette.canonicalize(src)
435}
436
437pub fn canonicalize_css_color(src: &str) -> Result<String, String> {
438 CssKind::Color.canonicalize(src)
439}
440
441#[derive(Clone, Debug, PartialEq)]
444pub struct CssLiteralUse {
445 pub column: String,
446 pub key: String,
447 pub kind: CssKind,
448 pub literal: String,
449}
450
451pub fn assign_palette_names(
454 restored: &BTreeMap<String, String>,
455 host: &[NamedValue],
456 in_use: &[(CssKind, String)],
457 is_taken: &dyn Fn(&str) -> bool,
458) -> BTreeMap<String, String> {
459 let mut set = restored.clone();
460 for (kind, literal) in in_use {
461 if palette_name_for(&set, *kind, literal).is_some() {
462 continue;
463 }
464
465 let name = host
466 .iter()
467 .find(|entry| entry.name.starts_with(kind.var_prefix()) && entry.value == *literal)
468 .map(|entry| entry.name.clone())
469 .unwrap_or_else(|| {
470 (1..)
471 .map(|n| format!("{}{n}", kind.var_prefix()))
472 .find(|name| !set.contains_key(name) && !is_taken(name))
473 .unwrap()
474 });
475
476 set.insert(name, literal.clone());
477 }
478
479 set
480}
481
482pub fn palette_name_for(
484 set: &BTreeMap<String, String>,
485 kind: CssKind,
486 literal: &str,
487) -> Option<String> {
488 set.iter()
489 .find(|(name, value)| name.starts_with(kind.var_prefix()) && *value == literal)
490 .map(|(name, _)| name.clone())
491}
492
493pub fn resolve_css_refs(
497 entry: &mut serde_json::Map<String, Value>,
498 lookup: &dyn Fn(&str) -> Option<String>,
499) -> Vec<(String, String)> {
500 let mut dropped = vec![];
501 let refs: Vec<(String, String)> = entry
502 .iter()
503 .filter_map(|(key, value)| {
504 let name = parse_var_ref(value.as_str()?)?;
505 Some((key.clone(), name.to_owned()))
506 })
507 .collect();
508
509 for (key, name) in refs {
510 let resolved = CssKind::of_var(&name)
511 .and_then(|kind| lookup(&name).and_then(|raw| kind.canonicalize(&raw).ok()));
512
513 match resolved {
514 Some(literal) => {
515 entry.insert(key, Value::String(literal));
516 },
517 None => {
518 entry.remove(&key);
519 dropped.push((key, name));
520 },
521 }
522 }
523
524 dropped
525}