use super::color_ext::{computed, named_repr};
use super::{arg, as_color, channel, max_positional, num, require, require_legacy_color};
use crate::error::Error;
use crate::scanner::Pos;
use crate::value::{fmt_num, CalcNode, Color, List, ListSep, Number, Value};
fn is_special(v: &Value) -> bool {
match v {
Value::Calc(_) => true,
Value::Str(s) => !s.quoted && s.text.contains('('),
_ => false,
}
}
fn is_special_legacy(v: &Value) -> bool {
match v {
Value::Calc(node) => degenerate_const(node).is_none(),
other => is_special(other),
}
}
fn degenerate_const(node: &CalcNode) -> Option<f64> {
if let CalcNode::Str(s) = node {
return match s.trim().to_ascii_lowercase().as_str() {
"infinity" => Some(f64::INFINITY),
"-infinity" => Some(f64::NEG_INFINITY),
"nan" => Some(f64::NAN),
_ => None,
};
}
None
}
pub(super) fn is_none_keyword(v: &Value) -> bool {
matches!(v, Value::Str(s) if !s.quoted && s.text.eq_ignore_ascii_case("none"))
}
fn special_call(name: &str, args: &[&Value]) -> Value {
let parts: Vec<String> = args.iter().map(|v| v.to_css(false)).collect();
Value::Str(crate::value::SassStr {
text: format!("{name}({})", parts.join(", ")),
quoted: false,
})
}
fn verbatim_call(name: &str, channels: &Value) -> Value {
Value::Str(crate::value::SassStr {
text: format!("{name}({})", channels.to_css(false)),
quoted: false,
})
}
fn legacy_channel_name(names: &[&str], i: usize) -> String {
match names.get(i) {
Some(name) => format!("{name} channel"),
None => format!("channel {}", i + 1),
}
}
pub(super) fn try_call(
name: &str,
pos_args: &[Value],
named: &[(String, Value)],
pos: Pos,
) -> Option<Result<Value, Error>> {
Some(match name {
"rgb" | "rgba" => fn_rgb(pos_args, named, pos),
"hsl" | "hsla" => fn_hsl(pos_args, named, pos),
"hwb" => fn_hwb(pos_args, named, pos),
"lab" | "lch" | "oklab" | "oklch" => fn_lab_family(name, pos_args, named, pos),
"color" => fn_color(pos_args, named, pos),
"mix" => fn_mix(pos_args, named, pos),
"lighten" => fn_adjust_lightness(name, pos_args, named, pos, 1.0),
"darken" => fn_adjust_lightness(name, pos_args, named, pos, -1.0),
"percentage" => fn_percentage(pos_args, named, pos),
"red" | "green" | "blue" => fn_channel(name, pos_args, named, pos),
"alpha" => fn_alpha(pos_args, named, pos),
_ => return try_call_modern(name, pos_args, named, pos),
})
}
fn fn_rgb(pos_args: &[Value], named: &[(String, Value)], pos: Pos) -> Result<Value, Error> {
let params = ["red", "green", "blue", "alpha"];
let n = pos_args.len() + named.len();
if n > 4 {
return Err(Error::at(
format!("Only 4 arguments allowed, but {n} were passed."),
pos,
));
}
if n == 2 {
let color = pos_args
.first()
.or_else(|| named.iter().find(|(k, _)| k == "color").map(|(_, v)| v));
if let Some(Value::Color(c)) = color {
let alpha = pos_args
.get(1)
.or_else(|| named.iter().find(|(k, _)| k == "alpha").map(|(_, v)| v));
if let Some(alpha) = alpha {
if is_special_legacy(alpha) {
let r = Value::Number(int_num(c.r));
let g = Value::Number(int_num(c.g));
let b = Value::Number(int_num(c.b));
return Ok(special_call("rgb", &[&r, &g, &b, alpha]));
}
let a = alpha_value(alpha, pos)?;
return Ok(Value::Color(computed(c.r, c.g, c.b, a)));
}
}
}
let channels = Channels::collect("rgb", ¶ms, pos_args, named, pos)?;
if let Some(verbatim) = channels.relative_passthrough("rgb") {
return Ok(verbatim);
}
if let Some(c) = legacy_none_color(&channels, ColorSpace::Rgb, pos)? {
return Ok(c);
}
if let Some(verbatim) = channels.special_passthrough("rgb") {
return Ok(verbatim);
}
channels.validate_numeric(&["red", "green", "blue"], pos)?;
channels.validate_count("rgb", pos)?;
let Channels { comps, alpha, .. } = channels;
let r = rgb_channel(&comps[0], pos)?;
let g = rgb_channel(&comps[1], pos)?;
let b = rgb_channel(&comps[2], pos)?;
let a = match &alpha {
Some(v) => alpha_value(v, pos)?,
None => 1.0,
};
let mut c = Color::rgb(r, g, b, a);
c.repr = Some(rgb_repr(r, g, b, a));
Ok(Value::Color(c))
}
fn int_num(v: f64) -> Number {
Number::unitless(v.round())
}
fn alpha_value(v: &Value, pos: Pos) -> Result<f64, Error> {
if let Some(c) = degenerate_value(v) {
return Ok(clamp_alpha(c));
}
match v {
Value::Number(num) => {
let raw = if num.unit() == "%" {
num.value / 100.0
} else if num.is_unitless() {
num.value
} else {
return Err(Error::at(
format!(
"$alpha: Expected {} to have unit \"%\" or no units.",
num.to_css(false)
),
pos,
));
};
Ok(clamp_alpha(raw))
}
Value::Slash(num, _) => Ok(clamp_alpha(num.value)),
other => Err(Error::at(
format!("$alpha: {} is not a number.", other.to_css(false)),
pos,
)),
}
}
fn clamp_alpha(v: f64) -> f64 {
if v.is_nan() {
0.0
} else {
v.clamp(0.0, 1.0)
}
}
fn rgb_channel(v: &Value, pos: Pos) -> Result<f64, Error> {
if let Value::Slash(num, _) = v {
return Ok(clamp_finite(num.value, 0.0, 255.0));
}
if let Some(c) = degenerate_value(v) {
if c.is_nan() {
return Ok(0.0);
}
return Ok(clamp_finite(c, 0.0, 255.0));
}
channel(v, pos)
}
fn clamp_finite(v: f64, lo: f64, hi: f64) -> f64 {
if v.is_nan() {
lo
} else {
v.clamp(lo, hi)
}
}
struct Channels {
comps: Vec<Value>,
alpha: Option<Value>,
single: Option<Value>,
alpha_split: bool,
}
impl Channels {
fn collect(
fname: &str,
params: &[&str],
pos_args: &[Value],
named: &[(String, Value)],
pos: Pos,
) -> Result<Channels, Error> {
let count = pos_args.len() + named.len();
if count >= 3 {
let c0 = require(params, pos_args, named, 0, fname, pos)?.clone();
let c1 = require(params, pos_args, named, 1, fname, pos)?.clone();
let c2 = require(params, pos_args, named, 2, fname, pos)?.clone();
let alpha = arg(params, pos_args, named, 3).cloned();
return Ok(Channels {
comps: vec![c0, c1, c2],
alpha,
single: None,
alpha_split: false,
});
}
let channels = match arg(params, pos_args, named, 0) {
Some(v) => v.clone(),
None => named
.iter()
.find(|(n, _)| n == "channels")
.map(|(_, v)| v.clone())
.ok_or_else(|| Error::at(format!("Missing argument $channels for {fname}()."), pos))?,
};
if let Value::List(l) = &channels {
let comma = l.sep == ListSep::Comma;
if l.bracketed || comma {
let kind = if l.bracketed && comma {
"an unbracketed, space- or slash-separated list"
} else if l.bracketed {
"an unbracketed list"
} else {
"a space- or slash-separated list"
};
let shown = if l.bracketed {
channels.to_css(false)
} else {
list_paren_css(&channels)
};
return Err(Error::at(format!("$channels: Expected {kind}, was {shown}"), pos));
}
}
let extra_alpha = arg(params, pos_args, named, 1).cloned();
let SplitChannels {
comps,
mut alpha,
mut alpha_split,
} = split_channels(&channels);
if extra_alpha.is_some() {
alpha = extra_alpha;
alpha_split = false;
}
Ok(Channels {
comps,
alpha,
single: Some(channels),
alpha_split,
})
}
fn validate_numeric(&self, names: &[&str], pos: Pos) -> Result<(), Error> {
if self.single.is_none() {
return Ok(());
}
for (i, comp) in self.comps.iter().enumerate() {
let numeric = matches!(comp, Value::Number(_) | Value::Slash(..)) || is_degenerate_calc(comp);
if !numeric {
return Err(Error::at(
format!(
"$channels: Expected {} to be a number, was {}.",
legacy_channel_name(names, i),
comp.to_css(false)
),
pos,
));
}
}
Ok(())
}
fn validate_count(&self, space: &str, pos: Pos) -> Result<(), Error> {
if let Some(single) = &self.single {
if self.comps.len() != 3 {
return Err(Error::at(
format!(
"$channels: The {space} color space has 3 channels but {} has {}.",
list_paren_css(single),
self.comps.len()
),
pos,
));
}
}
Ok(())
}
fn relative_passthrough(&self, name: &str) -> Option<Value> {
let is_relative = self
.comps
.first()
.is_some_and(|v| matches!(v, Value::Str(s) if !s.quoted && s.text.eq_ignore_ascii_case("from")));
if !is_relative {
return None;
}
Some(self.verbatim_passthrough(name))
}
fn special_passthrough(&self, name: &str) -> Option<Value> {
let comps_special = self.comps.iter().any(is_special_legacy);
let alpha_special = self.alpha.as_ref().is_some_and(is_special_legacy);
let comps_none = self.comps.iter().any(is_none_keyword);
let alpha_none = self.alpha.as_ref().is_some_and(is_none_keyword);
let has_special = comps_special || alpha_special;
let has_none = comps_none || alpha_none;
if !has_special && !has_none {
return None;
}
if has_special {
if self.comps.len() == 3 {
let mut args: Vec<&Value> = self.comps.iter().collect();
if let Some(a) = &self.alpha {
args.push(a);
}
return Some(special_call(name, &args));
}
return Some(self.verbatim_passthrough(name));
}
if self.comps.len() != 3 {
return None;
}
let is_hsl = name.eq_ignore_ascii_case("hsl") || name.eq_ignore_ascii_case("hsla");
Some(self.none_verbatim(name, is_hsl))
}
fn verbatim_passthrough(&self, name: &str) -> Value {
if let Some(single) = &self.single {
if self.alpha.is_none() || self.alpha_split {
return verbatim_call(name, single);
}
}
let mut args: Vec<&Value> = self.comps.iter().collect();
if let Some(a) = &self.alpha {
args.push(a);
}
special_call(name, &args)
}
fn none_verbatim(&self, name: &str, is_hsl: bool) -> Value {
let hue = match &self.comps[0] {
Value::Number(n) if is_hsl && n.is_unitless() => {
format!("{}deg", fmt_num(n.value, false))
}
other => other.to_css(false),
};
let body = format!(
"{} {} {}",
hue,
self.comps[1].to_css(false),
self.comps[2].to_css(false)
);
let text = match &self.alpha {
Some(a) => format!("{name}({body} / {})", a.to_css(false)),
None => format!("{name}({body})"),
};
Value::Str(crate::value::SassStr { text, quoted: false })
}
}
struct SplitChannels {
comps: Vec<Value>,
alpha: Option<Value>,
alpha_split: bool,
}
fn split_channels(channels: &Value) -> SplitChannels {
let no_split = |comps: Vec<Value>| SplitChannels {
comps,
alpha: None,
alpha_split: false,
};
let Value::List(l) = channels else {
return no_split(vec![channels.clone()]);
};
if l.sep == ListSep::Slash {
if l.items.len() == 2 {
let comps = match &l.items[0] {
Value::List(inner) if inner.sep == ListSep::Space && !inner.bracketed => inner.items.clone(),
other => vec![other.clone()],
};
return SplitChannels {
comps,
alpha: Some(l.items[1].clone()),
alpha_split: true,
};
}
return no_split(l.items.clone());
}
if l.sep != ListSep::Space {
return no_split(l.items.clone());
}
let mut items: Vec<Value> = l.items.clone();
if let Some(Value::Slash(_, repr)) = items.last() {
if let Some((lhs, rhs)) = repr.split_once('/') {
let token = |s: &str| parse_number_token(s).or_else(|| parse_degenerate_token(s));
if let (Some(last), Some(alpha)) = (token(lhs), token(rhs)) {
items.pop();
items.push(Value::Number(last));
return SplitChannels {
comps: items,
alpha: Some(Value::Number(alpha)),
alpha_split: true,
};
}
}
}
if let Some(Value::Str(s)) = items.last() {
if !s.quoted {
if let Some(idx) = top_level_slash(&s.text) {
let lhs = s.text[..idx].trim();
let rhs = s.text[idx + 1..].trim();
if !lhs.is_empty() && !rhs.is_empty() {
let last = channel_token(lhs);
let alpha = channel_token(rhs);
items.pop();
items.push(last);
return SplitChannels {
comps: items,
alpha: Some(alpha),
alpha_split: true,
};
}
}
}
}
no_split(items)
}
fn top_level_slash(s: &str) -> Option<usize> {
let mut depth: i32 = 0;
let mut found = None;
for (i, c) in s.char_indices() {
match c {
'(' | '[' => depth += 1,
')' | ']' => depth -= 1,
'/' if depth == 0 => found = Some(i),
_ => {}
}
}
found
}
fn channel_token(s: &str) -> Value {
if let Some(n) = parse_number_token(s) {
if fmt_token_matches(&n, s) {
return Value::Number(n);
}
}
if let Some(inner) = degenerate_calc_str(s) {
return Value::Calc(CalcNode::Str(inner));
}
Value::Str(crate::value::SassStr {
text: s.to_string(),
quoted: false,
})
}
fn degenerate_calc_str(s: &str) -> Option<String> {
let s = s.trim();
if !s.to_ascii_lowercase().starts_with("calc(") || !s.ends_with(')') {
return None;
}
let inner = s[5..s.len() - 1].trim();
match inner.to_ascii_lowercase().as_str() {
"nan" | "infinity" | "-infinity" => Some(inner.to_string()),
_ => None,
}
}
fn fmt_token_matches(n: &Number, s: &str) -> bool {
format!("{}{}", fmt_num(n.value, false), n.unit()) == s
}
fn parse_degenerate_token(s: &str) -> Option<Number> {
let t = s.trim();
let inner = t.strip_prefix("calc(")?.strip_suffix(')')?.trim();
let (const_part, unit) = match inner.split_once('*') {
Some((c, u)) => (c.trim(), u.trim().strip_prefix('1')?.to_string()),
None => (inner, String::new()),
};
let value = match const_part.to_ascii_lowercase().as_str() {
"nan" => f64::NAN,
"infinity" => f64::INFINITY,
"-infinity" => f64::NEG_INFINITY,
_ => return None,
};
Some(Number::with_unit(value, unit))
}
fn parse_number_token(s: &str) -> Option<Number> {
let s = s.trim();
let split = s
.char_indices()
.find(|(_, c)| !(c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E')))
.map(|(i, _)| i)
.unwrap_or(s.len());
let (num_part, unit) = s.split_at(split);
let value = num_part.parse::<f64>().ok()?;
Some(Number::with_unit(value, unit.to_string()))
}
pub(super) fn rgb_repr(r: f64, g: f64, b: f64, a: f64) -> String {
if (a - 1.0).abs() < f64::EPSILON {
format!(
"rgb({}, {}, {})",
fmt_num(r, false),
fmt_num(g, false),
fmt_num(b, false)
)
} else {
format!(
"rgba({}, {}, {}, {})",
fmt_num(r, false),
fmt_num(g, false),
fmt_num(b, false),
fmt_num(a, false)
)
}
}
fn fn_hsl(pos_args: &[Value], named: &[(String, Value)], pos: Pos) -> Result<Value, Error> {
let params = ["hue", "saturation", "lightness", "alpha"];
let n = pos_args.len() + named.len();
if n > 4 {
return Err(Error::at(
format!("Only 4 arguments allowed, but {n} were passed."),
pos,
));
}
let channels = Channels::collect("hsl", ¶ms, pos_args, named, pos)?;
if let Some(verbatim) = channels.relative_passthrough("hsl") {
return Ok(verbatim);
}
if let Some(c) = legacy_none_color(&channels, ColorSpace::Hsl, pos)? {
return Ok(c);
}
if let Some(verbatim) = channels.special_passthrough("hsl") {
return Ok(verbatim);
}
if channels.comps.len() == 3 && channels.comps.iter().any(is_degenerate_calc) {
return hsl_degenerate(&channels, pos);
}
channels.validate_numeric(&["hue", "saturation", "lightness"], pos)?;
channels.validate_count("hsl", pos)?;
let Channels { comps, alpha, .. } = channels;
let h = hsl_hue(&comps[0], pos)?;
let s_raw = num(&comps[1], pos)?;
let l_raw = num(&comps[2], pos)?;
let s_pct = if s_raw.is_nan() { 0.0 } else { s_raw.max(0.0) };
let l_pct = if l_raw.is_nan() { 0.0 } else { l_raw };
let a = match &alpha {
Some(v) => alpha_value(v, pos)?,
None => 1.0,
};
let mut c = Color::from_hsl(
h,
(s_pct / 100.0).clamp(0.0, 1.0),
(l_pct / 100.0).clamp(0.0, 1.0),
a,
);
let h_norm = h.rem_euclid(360.0);
c.modern = Some(Box::new(ModernColor {
space: ColorSpace::Hsl,
channels: [Some(h_norm), Some(s_pct), Some(l_pct)],
alpha: Some(a),
}));
Ok(Value::Color(c))
}
fn hsl_hue(v: &Value, pos: Pos) -> Result<f64, Error> {
match v {
Value::Number(num) => Ok(match num.unit() {
"rad" => num.value.to_degrees(),
"grad" => num.value * 360.0 / 400.0,
"turn" => num.value * 360.0,
_ => num.value,
}),
Value::Slash(num, _) => Ok(num.value),
other => Err(Error::at(
format!("{} is not a number.", other.to_css(false)),
pos,
)),
}
}
fn is_degenerate_calc(v: &Value) -> bool {
degenerate_value(v).is_some()
}
fn degenerate_value(v: &Value) -> Option<f64> {
match v {
Value::Number(n) if !n.value.is_finite() => Some(n.value),
Value::Calc(node) => match node {
CalcNode::Number(n) if !n.value.is_finite() => Some(n.value),
_ => degenerate_const(node),
},
_ => None,
}
}
fn fold_degenerate(v: &Value) -> Value {
if let Value::Calc(node) = v {
if let Some(c) = degenerate_const(node) {
return Value::Number(Number::unitless(c));
}
if let CalcNode::Number(n) = node {
if !n.value.is_finite() {
return Value::Number(n.clone());
}
}
}
v.clone()
}
fn hsl_degenerate(channels: &Channels, pos: Pos) -> Result<Value, Error> {
let hue = hsl_degenerate_hue(&channels.comps[0], pos)?;
let sat = hsl_degenerate_pct(&channels.comps[1], true, pos)?;
let light = hsl_degenerate_pct(&channels.comps[2], false, pos)?;
let name = match &channels.alpha {
Some(a) => {
let av = alpha_value(a, pos)?;
return Ok(Value::Str(crate::value::SassStr {
text: format!("hsla({hue}, {sat}, {light}, {})", fmt_num(av, false)),
quoted: false,
}));
}
None => "hsl",
};
Ok(Value::Str(crate::value::SassStr {
text: format!("{name}({hue}, {sat}, {light})"),
quoted: false,
}))
}
fn hsl_degenerate_hue(v: &Value, pos: Pos) -> Result<String, Error> {
if is_degenerate_calc(v) {
return Ok("calc(NaN)".to_string());
}
let h = hsl_hue(v, pos)?;
Ok(fmt_num(h.rem_euclid(360.0), false))
}
fn hsl_degenerate_pct(v: &Value, is_saturation: bool, pos: Pos) -> Result<String, Error> {
if let Some(c) = degenerate_value(v) {
{
if is_saturation && (c.is_nan() || c <= 0.0) {
return Ok("0%".to_string());
}
let token = if c.is_nan() {
"NaN"
} else if c.is_sign_negative() {
"-infinity"
} else {
"infinity"
};
return Ok(format!("calc({token} * 1%)"));
}
}
let raw = num(v, pos)?;
let pct = if is_saturation {
if raw.is_nan() {
0.0
} else {
raw.max(0.0)
}
} else if raw.is_nan() {
0.0
} else {
raw
};
Ok(format!("{}%", fmt_num(pct, false)))
}
fn fn_hwb(pos_args: &[Value], named: &[(String, Value)], pos: Pos) -> Result<Value, Error> {
let params = ["channels"];
let n = pos_args.len() + named.len();
if n != 1 {
return Err(Error::at(
format!("Only 1 argument allowed, but {n} were passed."),
pos,
));
}
let channels = require(¶ms, pos_args, named, 0, "hwb", pos)?.clone();
if let Value::List(l) = &channels {
let comma = l.sep == ListSep::Comma;
if l.bracketed || comma {
let kind = if l.bracketed && comma {
"an unbracketed, space- or slash-separated list"
} else if l.bracketed {
"an unbracketed list"
} else {
"a space- or slash-separated list"
};
let shown = if l.bracketed {
channels.to_css(false)
} else {
list_paren_css(&channels)
};
return Err(Error::at(format!("$channels: Expected {kind}, was {shown}"), pos));
}
}
let SplitChannels { comps, alpha, .. } = split_channels(&channels);
let is_relative = comps
.first()
.is_some_and(|v| matches!(v, Value::Str(s) if !s.quoted && s.text.eq_ignore_ascii_case("from")));
let comps_func = comps.iter().any(|v| is_special(v) && !is_degenerate_calc(v));
let alpha_func = alpha
.as_ref()
.is_some_and(|v| is_special(v) && !is_degenerate_calc(v));
if is_relative || comps_func || alpha_func {
return Ok(verbatim_call("hwb", &channels));
}
let comps: Vec<Value> = comps.iter().map(fold_degenerate).collect();
for (i, comp) in comps.iter().enumerate() {
let numeric = matches!(comp, Value::Number(_) | Value::Slash(..))
|| is_none_keyword(comp)
|| is_degenerate_calc(comp);
if !numeric {
return Err(Error::at(
format!(
"$channels: Expected {} to be a number, was {}.",
legacy_channel_name(&["hue", "whiteness", "blackness"], i),
comp.to_css(false)
),
pos,
));
}
}
if comps.len() != 3 {
return Err(Error::at(
format!(
"$channels: The hwb color space has 3 channels but {} has {}.",
list_paren_css(&channels),
comps.len()
),
pos,
));
}
let comps_none = comps.iter().any(is_none_keyword);
let alpha_none = alpha.as_ref().is_some_and(is_none_keyword);
if comps_none || alpha_none {
let h = if is_none_keyword(&comps[0]) {
None
} else {
modern_hue(&comps[0])
};
let mut w = modern_channel(&comps[1], 100.0);
let mut bl = modern_channel(&comps[2], 100.0);
if let (Some(wv), Some(bv)) = (w, bl) {
if wv + bv > 100.0 {
let t = wv + bv;
w = Some(wv / t * 100.0);
bl = Some(bv / t * 100.0);
}
}
let mc = ModernColor {
space: ColorSpace::Hwb,
channels: [h, w, bl],
alpha: modern_alpha(alpha.as_ref()),
};
return Ok(Value::Color(make_modern(mc)));
}
for (i, cname) in [(1usize, "whiteness"), (2usize, "blackness")] {
if let Value::Number(num) = &comps[i] {
if num.unit() != "%" {
return Err(Error::at(
format!(
"${cname}: Expected {} to have unit \"%\".",
comps[i].to_css(false)
),
pos,
));
}
}
}
let h = hsl_hue(&comps[0], pos)?;
let mut w_pct = num(&comps[1], pos)?;
let mut b_pct = num(&comps[2], pos)?;
let a = match &alpha {
Some(v) => alpha_value(v, pos)?,
None => 1.0,
};
if w_pct + b_pct > 100.0 {
let t = w_pct + b_pct;
w_pct = w_pct / t * 100.0;
b_pct = b_pct / t * 100.0;
}
let mut out = hwb_to_color(h, w_pct, b_pct, a);
let h_norm = h.rem_euclid(360.0);
out.modern = Some(Box::new(ModernColor {
space: ColorSpace::Hwb,
channels: [Some(h_norm), Some(w_pct), Some(b_pct)],
alpha: Some(a),
}));
Ok(Value::Color(out))
}
pub(super) fn call_module_member(
member: &str,
pos_args: &[Value],
named: &[(String, Value)],
pos: Pos,
) -> Option<Result<Value, Error>> {
match member {
"hwb" => Some(fn_color_hwb(pos_args, named, pos)),
_ => None,
}
}
fn fn_color_hwb(pos_args: &[Value], named: &[(String, Value)], pos: Pos) -> Result<Value, Error> {
let n = pos_args.len() + named.len();
if n > 4 {
return Err(Error::at(
format!("Only 4 arguments allowed, but {n} were passed."),
pos,
));
}
if n <= 1 {
return fn_hwb(pos_args, named, pos);
}
let params = ["hue", "whiteness", "blackness", "alpha"];
let ch = Channels::collect("hwb", ¶ms, pos_args, named, pos)?;
let space = Value::List(List {
items: ch.comps,
sep: ListSep::Space,
bracketed: false,
keywords: None,
});
let channels = match ch.alpha {
Some(a) => Value::List(List {
items: vec![space, a],
sep: ListSep::Slash,
bracketed: false,
keywords: None,
}),
None => space,
};
fn_hwb(&[channels], &[], pos)
}
fn hwb_to_color(h: f64, w_pct: f64, b_pct: f64, a: f64) -> Color {
let mut w = w_pct / 100.0;
let mut b = b_pct / 100.0;
if w + b > 1.0 {
let sum = w + b;
w /= sum;
b /= sum;
}
let base = Color::from_hsl(h, 1.0, 0.5, a);
let mix = |v: f64| ((v / 255.0) * (1.0 - w - b) + w) * 255.0;
Color::rgb(mix(base.r), mix(base.g), mix(base.b), a)
}
fn lab_channel_names(name: &str) -> [&'static str; 3] {
match name {
"lch" | "oklch" => ["lightness", "chroma", "hue"],
_ => ["lightness", "a", "b"],
}
}
fn fn_lab_family(
name: &str,
pos_args: &[Value],
named: &[(String, Value)],
pos: Pos,
) -> Result<Value, Error> {
let params = ["channels"];
let n = pos_args.len() + named.len();
if n == 0 {
return Err(Error::at("Missing argument $channels.".to_string(), pos));
}
if n > 1 {
return Err(Error::at(
format!("Only 1 argument allowed, but {n} were passed."),
pos,
));
}
let channels = require(¶ms, pos_args, named, 0, name, pos)?.clone();
if let Value::List(l) = &channels {
let comma = l.sep == ListSep::Comma;
if l.bracketed || comma {
let kind = if l.bracketed && comma {
"an unbracketed, space- or slash-separated list"
} else if l.bracketed {
"an unbracketed list"
} else {
"a space- or slash-separated list"
};
let shown = if l.bracketed {
channels.to_css(false)
} else {
list_paren_css(&channels)
};
return Err(Error::at(format!("$channels: Expected {kind}, was {shown}"), pos));
}
if l.items.is_empty() {
return Err(Error::at(
"$channels: Color component list may not be empty.".to_string(),
pos,
));
}
if l.sep == ListSep::Slash && l.items.len() != 2 {
return Err(Error::at(
format!(
"$channels: Only 2 slash-separated elements allowed, but {} were passed.",
l.items.len()
),
pos,
));
}
}
let SplitChannels { comps, alpha, .. } = split_channels(&channels);
let is_relative = comps
.first()
.is_some_and(|v| matches!(v, Value::Str(s) if !s.quoted && s.text.eq_ignore_ascii_case("from")));
let special = |v: &Value| is_special(v) && !is_degenerate_calc(v);
let has_special = comps.iter().any(special) || alpha.as_ref().is_some_and(special);
if is_relative || has_special {
return Ok(verbatim_call(name, &channels));
}
let names = lab_channel_names(name);
if comps.len() != 3 {
return Err(Error::at(
format!(
"$channels: The {} color space has 3 channels but {} has {}.",
name,
list_paren_css(&channels),
comps.len()
),
pos,
));
}
let is_hue = |i: usize| matches!(name, "lch" | "oklch") && i == 2;
for (i, comp) in comps.iter().enumerate() {
if is_none_keyword(comp) || is_degenerate_calc(comp) {
continue;
}
match comp {
Value::Number(num) => {
if is_hue(i) {
let ok = num.is_unitless() || matches!(num.unit(), "deg" | "grad" | "rad" | "turn");
if !ok {
return Err(Error::at(
format!(
"$hue: Expected {} to have an angle unit (deg, grad, rad, turn).",
num.to_css(false)
),
pos,
));
}
} else if !num.is_unitless() && num.unit() != "%" {
return Err(Error::at(
format!(
"${}: Expected {} to have unit \"%\" or no units.",
names[i],
num.to_css(false)
),
pos,
));
}
}
Value::Slash(..) => {}
other => {
return Err(Error::at(
format!(
"$channels: Expected {} channel to be a number, was {}.",
names[i],
other.to_css(false)
),
pos,
));
}
}
}
if let Some(a) = &alpha {
if !is_none_keyword(a) {
alpha_value(a, pos)?;
}
}
let (space, l_max, l_base) = match name {
"lab" => (ColorSpace::Lab, 100.0, 100.0),
"lch" => (ColorSpace::Lch, 100.0, 100.0),
"oklab" => (ColorSpace::Oklab, 1.0, 1.0),
_ => (ColorSpace::Oklch, 1.0, 1.0),
};
let is_polar = matches!(name, "lch" | "oklch");
let (ab_base, chroma_base) = match name {
"lab" => (125.0, 0.0),
"lch" => (0.0, 150.0),
"oklab" => (0.4, 0.0),
_ => (0.0, 0.4), };
let l = modern_channel(&comps[0], l_base).map(|v| if v.is_nan() { 0.0 } else { v.clamp(0.0, l_max) });
let c1;
let c2;
if is_polar {
c1 = modern_channel(&comps[1], chroma_base).map(|v| v.max(0.0));
c2 = modern_hue(&comps[2]);
} else {
c1 = modern_channel(&comps[1], ab_base);
c2 = modern_channel(&comps[2], ab_base);
}
let mc = ModernColor {
space,
channels: [l, c1, c2],
alpha: modern_alpha(alpha.as_ref()),
};
Ok(Value::Color(make_modern(mc)))
}
fn list_paren_css(v: &Value) -> String {
format!("({})", v.to_css(false))
}
fn is_known_color_space(name: &str) -> bool {
matches!(
name,
"srgb"
| "srgb-linear"
| "display-p3"
| "display-p3-linear"
| "a98-rgb"
| "prophoto-rgb"
| "rec2020"
| "xyz"
| "xyz-d50"
| "xyz-d65"
)
}
fn fn_color(pos_args: &[Value], named: &[(String, Value)], pos: Pos) -> Result<Value, Error> {
let params = ["description"];
let n = pos_args.len() + named.len();
if n == 0 {
return Err(Error::at("Missing argument $description.".to_string(), pos));
}
if n > 1 {
return Err(Error::at(
format!("Only 1 argument allowed, but {n} were passed."),
pos,
));
}
let desc = require(¶ms, pos_args, named, 0, "color", pos)?.clone();
if let Value::List(l) = &desc {
let comma = l.sep == ListSep::Comma;
if l.bracketed || comma {
let kind = if l.bracketed && comma {
"an unbracketed, space- or slash-separated list"
} else if l.bracketed {
"an unbracketed list"
} else {
"a space- or slash-separated list"
};
let shown = if l.bracketed {
desc.to_css(false)
} else {
list_paren_css(&desc)
};
return Err(Error::at(
format!("$description: Expected {kind}, was {shown}"),
pos,
));
}
}
let SplitChannels {
comps: items, alpha, ..
} = split_channels(&desc);
let space = items.first().ok_or_else(|| {
Error::at(
"$description: Color component list may not be empty.".to_string(),
pos,
)
})?;
let space_name = match space {
Value::Str(s) if !s.quoted => s.text.clone(),
Value::Str(s) => {
return Err(Error::at(
format!("$description: Expected \"{}\" to be an unquoted string.", s.text),
pos,
));
}
other => {
return Err(Error::at(
format!("$description: {} is not a string.", other.to_css(false)),
pos,
));
}
};
let space_lower = space_name.to_ascii_lowercase();
let channels = &items[1..];
let is_relative = space_name.eq_ignore_ascii_case("from");
let special_chan = |v: &Value| is_special(v) && !is_degenerate_calc(v);
let has_special = channels.iter().any(special_chan) || alpha.as_ref().is_some_and(special_chan);
if is_relative || has_special {
return Ok(verbatim_call("color", &desc));
}
if !is_known_color_space(&space_lower) {
return Err(Error::at(
format!("$description: Unknown color space \"{space_name}\"."),
pos,
));
}
let names = ["red", "green", "blue"];
for (i, comp) in channels.iter().enumerate() {
let name = names.get(i).copied().unwrap_or("");
if is_none_keyword(comp) || is_degenerate_calc(comp) {
continue;
}
match comp {
Value::Number(num) => {
if !num.is_unitless() && num.unit() != "%" {
return Err(Error::at(
format!(
"${name}: Expected {} to have unit \"%\" or no units.",
num.to_css(false)
),
pos,
));
}
}
Value::Slash(..) => {}
Value::Calc(_) if is_degenerate_calc(comp) => {}
other => {
return Err(Error::at(
format!(
"$description: Expected {name} channel to be a number, was {}.",
other.to_css(false)
),
pos,
));
}
}
}
if channels.len() != 3 {
return Err(Error::at(
format!(
"$description: The {} color space has 3 channels but {} has {}.",
space_lower,
color_desc_css(&desc),
channels.len()
),
pos,
));
}
if let Some(a) = &alpha {
if !is_none_keyword(a) {
alpha_value(a, pos)?;
}
}
let space = match predefined_space(&space_lower) {
Some(s) => s,
None => return Ok(verbatim_call("color", &desc)),
};
let degenerate =
channels.iter().any(is_degenerate_calc) || alpha.as_ref().is_some_and(is_degenerate_calc);
if degenerate {
return Ok(modern_color(&space_name, channels, alpha.as_ref(), pos));
}
let ch = [
modern_channel(&channels[0], 1.0),
modern_channel(&channels[1], 1.0),
modern_channel(&channels[2], 1.0),
];
let mc = ModernColor {
space,
channels: ch,
alpha: modern_alpha(alpha.as_ref()),
};
Ok(Value::Color(make_modern(mc)))
}
fn modern_color(space: &str, channels: &[Value], alpha: Option<&Value>, pos: Pos) -> Value {
let a = match alpha {
Some(v) if is_none_keyword(v) => 1.0,
Some(v) => alpha_value(v, pos).unwrap_or(1.0),
None => 1.0,
};
let body: Vec<String> = channels.iter().map(|v| v.to_css(false)).collect();
let body = body.join(" ");
let text = if (a - 1.0).abs() < f64::EPSILON {
format!("color({space} {body})")
} else {
format!("color({space} {body} / {})", fmt_num(a, false))
};
Value::Str(crate::value::SassStr { text, quoted: false })
}
fn color_desc_css(desc: &Value) -> String {
match desc {
Value::List(l) if l.items.len() > 1 => list_paren_css(desc),
_ => desc.to_css(false),
}
}
fn fn_mix(pos_args: &[Value], named: &[(String, Value)], pos: Pos) -> Result<Value, Error> {
let params = ["color1", "color2", "weight", "method"];
let n = pos_args.len() + named.len();
if n > 4 {
return Err(Error::at(
format!("Only 4 arguments allowed, but {n} were passed."),
pos,
));
}
let c1 = as_color(require(¶ms, pos_args, named, 0, "mix", pos)?, pos)?;
let c2 = as_color(require(¶ms, pos_args, named, 1, "mix", pos)?, pos)?;
let weight = match arg(¶ms, pos_args, named, 2) {
Some(Value::Number(w)) => {
if w.value < 0.0 || w.value > 100.0 {
return Err(Error::at(
format!("$weight: Expected {} to be within 0% and 100%.", w.to_css(false)),
pos,
));
}
w.value
}
Some(other) => {
return Err(Error::at(
format!("$weight: {} is not a number.", other.to_css(false)),
pos,
))
}
None => 50.0,
};
if let Some(method) = arg(¶ms, pos_args, named, 3) {
let (space, hue_method) = validate_mix_method(method, pos)?;
return Ok(Value::Color(interpolate_mix(&c1, &c2, weight, space, hue_method)));
}
for (i, c) in [&c1, &c2].iter().enumerate() {
if !color_space_of(c).is_legacy() {
return Err(Error::at(
format!(
"$color{}: To use color.mix() with non-legacy color {}, you must provide a $method.",
i + 1,
c.to_css(false)
),
pos,
));
}
}
let p = weight / 100.0;
let w = p * 2.0 - 1.0;
let a = c1.a - c2.a;
let w1 = ((if (w * a) == -1.0 {
w
} else {
(w + a) / (1.0 + w * a)
}) + 1.0)
/ 2.0;
let w2 = 1.0 - w1;
let r = c1.r * w1 + c2.r * w2;
let g = c1.g * w1 + c2.g * w2;
let b = c1.b * w1 + c2.b * w2;
let alpha = c1.a * p + c2.a * (1.0 - p);
Ok(Value::Color(computed(r, g, b, alpha)))
}
fn mix_method_space(name: &str) -> Option<bool> {
match name {
"hsl" | "hwb" | "lch" | "oklch" => Some(true),
"rgb" | "srgb" | "srgb-linear" | "display-p3" | "a98-rgb" | "prophoto-rgb" | "rec2020" | "xyz"
| "xyz-d50" | "xyz-d65" | "lab" | "oklab" => Some(false),
_ => None,
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum HueMethod {
Shorter,
Longer,
Increasing,
Decreasing,
}
fn validate_mix_method(method: &Value, pos: Pos) -> Result<(ColorSpace, HueMethod), Error> {
let err = |msg: String| Err(Error::at(msg, pos));
let items: Vec<&Value> = match method {
Value::List(l) if l.sep == ListSep::Space => l.items.iter().collect(),
single => vec![single],
};
let space_val = items[0];
let space = match space_val {
Value::Str(s) if !s.quoted => s.text.clone(),
Value::Str(s) => {
return err(format!(
"$method: Expected \"{}\" to be an unquoted string.",
s.text
));
}
other => {
return err(format!("$method: {} is not a string.", other.to_css(false)));
}
};
let space = space.to_ascii_lowercase();
let polar = match mix_method_space(&space) {
Some(p) => p,
None => return err(format!("$method: Unknown color space \"{space}\".")),
};
let cspace = ColorSpace::from_name(&space).unwrap_or(ColorSpace::Srgb);
if items.len() == 1 {
return Ok((cspace, HueMethod::Shorter));
}
let method_token = match items[1] {
Value::Str(s) if !s.quoted => s.text.clone(),
Value::List(_) => return err(format!("$method: {} is not a string.", list_paren_css(items[1]))),
other => return err(format!("$method: {} is not a string.", other.to_css(false))),
};
let hue_method = match method_token.to_ascii_lowercase().as_str() {
"shorter" => HueMethod::Shorter,
"longer" => HueMethod::Longer,
"increasing" => HueMethod::Increasing,
"decreasing" => HueMethod::Decreasing,
"specified" => return err("$method: Unknown hue interpolation method specified.".to_string()),
other => return err(format!("$method: Unknown hue interpolation method {other}.")),
};
let last = items[items.len() - 1];
let last_is_hue = matches!(last, Value::Str(s) if !s.quoted && s.text.eq_ignore_ascii_case("hue"));
if items.len() == 2 {
return err(format!(
"$method: Expected unquoted string \"hue\" after ({}).",
method.to_css(false)
));
}
if !last_is_hue {
return err(format!(
"$method: Expected unquoted string \"hue\" at the end of ({}), was {}.",
method.to_css(false),
last.to_css(false)
));
}
if !polar {
return err(format!(
"$method: Hue interpolation method \"HueInterpolationMethod.{method_token} hue\" \
may not be set for rectangular color space {space}."
));
}
Ok((cspace, hue_method))
}
fn fn_adjust_lightness(
name: &str,
pos_args: &[Value],
named: &[(String, Value)],
pos: Pos,
sign: f64,
) -> Result<Value, Error> {
let params = ["color", "amount"];
let n = pos_args.len() + named.len();
if n > 2 {
return Err(Error::at(
format!("Only 2 arguments allowed, but {n} were passed."),
pos,
));
}
let c = as_color(require(¶ms, pos_args, named, 0, name, pos)?, pos)?;
require_legacy_color(&c, name, pos)?;
let amount = match require(¶ms, pos_args, named, 1, name, pos)? {
Value::Number(num) => {
if num.value < 0.0 || num.value > 100.0 {
return Err(Error::at(
format!("$amount: Expected {} to be within 0 and 100.", num.to_css(false)),
pos,
));
}
num.value
}
other => {
return Err(Error::at(
format!("$amount: {} is not a number.", other.to_css(false)),
pos,
))
}
};
let (h, s, l) = c.to_hsl();
let new_l = (l + sign * amount / 100.0).clamp(0.0, 1.0);
let mut out = Color::from_hsl(h, s, new_l, c.a);
out.repr = named_repr(out.r, out.g, out.b, out.a);
Ok(Value::Color(out))
}
fn fn_percentage(pos_args: &[Value], named: &[(String, Value)], pos: Pos) -> Result<Value, Error> {
let params = ["number"];
max_positional(pos_args, params.len(), pos)?;
let arg = require(¶ms, pos_args, named, 0, "percentage", pos)?;
if let Value::Number(num) = arg {
if !num.is_unitless() {
return Err(Error::at(
format!("$number: Expected {} to have no units.", num.to_css(false)),
pos,
));
}
}
let n = num(arg, pos)?;
Ok(Value::Number(Number::with_unit(n * 100.0, "%".to_string())))
}
fn fn_channel(name: &str, pos_args: &[Value], named: &[(String, Value)], pos: Pos) -> Result<Value, Error> {
let params = ["color"];
max_positional(pos_args, params.len(), pos)?;
let c = as_color(require(¶ms, pos_args, named, 0, name, pos)?, pos)?;
if c.modern.as_ref().is_some_and(|m| !m.space.is_legacy()) {
return Err(Error::at(
format!(
"color.{name}() is only supported for legacy colors. Please use color.channel() \
instead with an explicit $space argument."
),
pos,
));
}
let v = match name {
"red" => c.r,
"green" => c.g,
"blue" => c.b,
_ => 0.0,
};
Ok(Value::Number(Number::unitless(v.round())))
}
fn is_ms_filter_arg(text: &str) -> bool {
let mut chars = text.char_indices().peekable();
let mut saw_letter = false;
while let Some(&(_, c)) = chars.peek() {
if c.is_ascii_alphabetic() {
saw_letter = true;
chars.next();
} else {
break;
}
}
if !saw_letter {
return false;
}
while let Some(&(_, c)) = chars.peek() {
if c.is_whitespace() {
chars.next();
} else {
break;
}
}
matches!(chars.peek(), Some(&(_, '=')))
}
fn fn_alpha(pos_args: &[Value], named: &[(String, Value)], pos: Pos) -> Result<Value, Error> {
let params = ["color"];
if named.is_empty()
&& !pos_args.is_empty()
&& pos_args
.iter()
.all(|v| matches!(v, Value::Str(s) if !s.quoted && is_ms_filter_arg(&s.text)))
{
let inner = pos_args
.iter()
.map(|v| v.to_css(false))
.collect::<Vec<_>>()
.join(", ");
return Ok(Value::Str(crate::value::SassStr {
text: format!("alpha({inner})"),
quoted: false,
}));
}
let n = pos_args.len() + named.len();
if n > 1 {
return Err(Error::at(
format!("Only 1 argument allowed, but {n} were passed."),
pos,
));
}
let c = as_color(require(¶ms, pos_args, named, 0, "alpha", pos)?, pos)?;
if c.modern.as_ref().is_some_and(|m| !m.space.is_legacy()) {
return Err(Error::at(
"color.alpha() is only supported for legacy colors. Please use color.channel() \
instead."
.to_string(),
pos,
));
}
Ok(Value::Number(Number::unitless(c.a)))
}
use crate::value::{ColorSpace, ModernColor};
fn z(v: Option<f64>) -> f64 {
v.unwrap_or(0.0)
}
#[derive(PartialEq, Eq, Clone, Copy)]
enum ChannelCategory {
Red,
Green,
Blue,
Lightness,
Colorfulness, Hue,
LabA,
LabB,
Whiteness,
Blackness,
}
fn channel_category(space: ColorSpace, idx: usize) -> Option<ChannelCategory> {
use ChannelCategory::*;
use ColorSpace::*;
Some(match (space, idx) {
(Rgb, 0)
| (Srgb, 0)
| (SrgbLinear, 0)
| (DisplayP3, 0)
| (DisplayP3Linear, 0)
| (A98Rgb, 0)
| (ProphotoRgb, 0)
| (Rec2020, 0) => Red,
(Rgb, 1)
| (Srgb, 1)
| (SrgbLinear, 1)
| (DisplayP3, 1)
| (DisplayP3Linear, 1)
| (A98Rgb, 1)
| (ProphotoRgb, 1)
| (Rec2020, 1) => Green,
(Rgb, 2)
| (Srgb, 2)
| (SrgbLinear, 2)
| (DisplayP3, 2)
| (DisplayP3Linear, 2)
| (A98Rgb, 2)
| (ProphotoRgb, 2)
| (Rec2020, 2) => Blue,
(Hsl, 0) | (Hwb, 0) | (Lch, 2) | (Oklch, 2) => Hue,
(Hsl, 1) | (Lch, 1) | (Oklch, 1) => Colorfulness,
(Hsl, 2) | (Lab, 0) | (Lch, 0) | (Oklab, 0) | (Oklch, 0) => Lightness,
(Hwb, 1) => Whiteness,
(Hwb, 2) => Blackness,
(Lab, 1) | (Oklab, 1) => LabA,
(Lab, 2) | (Oklab, 2) => LabB,
(XyzD65, 0) | (XyzD50, 0) => Red,
(XyzD65, 1) | (XyzD50, 1) => Green,
(XyzD65, 2) | (XyzD50, 2) => Blue,
_ => return None,
})
}
pub(crate) fn convert_modern(mc: &ModernColor, target: ColorSpace) -> ModernColor {
if mc.space == target {
return mc.clone();
}
let src = [z(mc.channels[0]), z(mc.channels[1]), z(mc.channels[2])];
let out = super::colorspace::convert(mc.space, target, src);
let missing_in_src = |cat: ChannelCategory| {
(0..3).any(|i| mc.channels[i].is_none() && channel_category(mc.space, i) == Some(cat))
};
let mk = |i: usize| match channel_category(target, i) {
Some(cat) if missing_in_src(cat) => None,
_ => Some(out[i]),
};
let mut channels = [mk(0), mk(1), mk(2)];
if matches!(target, ColorSpace::Lch | ColorSpace::Oklch) && out[1].abs() < 1e-10 {
channels[2] = None;
}
let lch_to_lab = mc.space == ColorSpace::Lch && target == ColorSpace::Lab;
if lch_to_lab && (channels[0].is_none() || out[0].abs() < 1e-11) {
channels[1] = None;
channels[2] = None;
}
if target == ColorSpace::Hsl && out[1].abs() < 1e-11 {
channels[0] = None;
}
if target == ColorSpace::Hwb {
let sum = out[1] + out[2];
if sum > 100.0 || (sum - 100.0).abs() < 1e-11 {
channels[0] = None;
}
}
ModernColor {
space: target,
channels,
alpha: mc.alpha,
}
}
fn convert_modern_filled(mc: &ModernColor, target: ColorSpace) -> ModernColor {
let out = convert_modern(mc, target);
if mc.space == target || !target.is_legacy() {
return out;
}
ModernColor {
space: target,
channels: [
Some(out.channels[0].unwrap_or(0.0)),
Some(out.channels[1].unwrap_or(0.0)),
Some(out.channels[2].unwrap_or(0.0)),
],
alpha: Some(out.alpha.unwrap_or(0.0)),
}
}
pub(super) fn legacy_to_modern(c: &Color) -> ModernColor {
if let Some(m) = &c.modern {
return (**m).clone();
}
ModernColor {
space: ColorSpace::Rgb,
channels: [Some(c.r), Some(c.g), Some(c.b)],
alpha: Some(c.a),
}
}
fn make_modern(mc: ModernColor) -> Color {
let mc = normalize_polar(mc);
let srgb = convert_modern(&mc, ColorSpace::Rgb);
let mut c = Color::rgb(
z(srgb.channels[0]).clamp(0.0, 255.0),
z(srgb.channels[1]).clamp(0.0, 255.0),
z(srgb.channels[2]).clamp(0.0, 255.0),
mc.alpha.unwrap_or(1.0),
);
c.modern = Some(Box::new(mc));
c
}
fn normalize_hue(hue: f64, invert: bool) -> f64 {
let shift = if invert { 180.0 } else { 0.0 };
(hue % 360.0 + 360.0 + shift) % 360.0
}
fn normalize_polar(mut mc: ModernColor) -> ModernColor {
let fuzzy_zero = |v: f64| v.abs() < 1e-11;
let (hue_idx, mag_idx) = match mc.space {
ColorSpace::Hsl => (0, Some(1)),
ColorSpace::Hwb => (0, None),
ColorSpace::Lch | ColorSpace::Oklch => (2, Some(1)),
_ => return mc,
};
let mut invert = false;
if let Some(i) = mag_idx {
if let Some(c) = mc.channels[i] {
invert = c < 0.0 && !fuzzy_zero(c);
mc.channels[i] = Some(c.abs());
}
}
if let Some(h) = mc.channels[hue_idx] {
mc.channels[hue_idx] = Some(normalize_hue(h, invert));
}
mc
}
fn predefined_space(name: &str) -> Option<ColorSpace> {
match name {
"srgb" => Some(ColorSpace::Srgb),
"srgb-linear" => Some(ColorSpace::SrgbLinear),
"display-p3" => Some(ColorSpace::DisplayP3),
"display-p3-linear" => Some(ColorSpace::DisplayP3Linear),
"a98-rgb" => Some(ColorSpace::A98Rgb),
"prophoto-rgb" => Some(ColorSpace::ProphotoRgb),
"rec2020" => Some(ColorSpace::Rec2020),
"xyz" | "xyz-d65" => Some(ColorSpace::XyzD65),
"xyz-d50" => Some(ColorSpace::XyzD50),
_ => None,
}
}
pub(super) fn modern_channel(v: &Value, pct_base: f64) -> Option<f64> {
if is_none_keyword(v) {
return None;
}
if let Value::Calc(node) = v {
if let Some(c) = degenerate_const(node) {
return Some(c);
}
}
match v {
Value::Number(num) => {
if num.unit() == "%" {
Some(num.value / 100.0 * pct_base)
} else {
Some(num.value)
}
}
Value::Slash(num, _) => Some(num.value),
_ => Some(0.0),
}
}
pub(super) fn modern_hue(v: &Value) -> Option<f64> {
if is_none_keyword(v) {
return None;
}
if let Value::Calc(node) = v {
if let Some(c) = degenerate_const(node) {
return Some(c);
}
}
match v {
Value::Number(num) => Some(match num.unit() {
"rad" => num.value.to_degrees(),
"grad" => num.value * 360.0 / 400.0,
"turn" => num.value * 360.0,
_ => num.value,
}),
Value::Slash(num, _) => Some(num.value),
_ => Some(0.0),
}
}
pub(super) fn modern_alpha(v: Option<&Value>) -> Option<f64> {
match v {
None => Some(1.0),
Some(a) if is_none_keyword(a) => None,
Some(a) => {
if let Some(c) = degenerate_value(a) {
return Some(if c.is_nan() { 0.0 } else { c.clamp(0.0, 1.0) });
}
match a {
Value::Number(num) => {
let val = if num.unit() == "%" {
num.value / 100.0
} else {
num.value
};
Some(val.clamp(0.0, 1.0))
}
Value::Slash(num, _) => Some(num.value.clamp(0.0, 1.0)),
_ => Some(1.0),
}
}
}
}
fn color_space_of(c: &Color) -> ColorSpace {
c.modern.as_ref().map(|m| m.space).unwrap_or(ColorSpace::Rgb)
}
pub(super) fn try_call_modern(
name: &str,
pos_args: &[Value],
named: &[(String, Value)],
pos: Pos,
) -> Option<Result<Value, Error>> {
Some(match name {
"color-space" => fn_space(pos_args, named, pos),
"color-channel" => fn_color_channel(pos_args, named, pos),
"color-to-space" => fn_to_space(pos_args, named, pos),
"color-is-legacy" => fn_is_legacy(pos_args, named, pos),
"color-is-missing" => fn_is_missing(pos_args, named, pos),
"color-is-in-gamut" => fn_is_in_gamut(pos_args, named, pos),
"color-is-powerless" => fn_is_powerless(pos_args, named, pos),
"color-to-gamut" => fn_to_gamut(pos_args, named, pos),
"color-same" => fn_same(pos_args, named, pos),
_ => return None,
})
}
pub(super) fn channel_index_in(space: ColorSpace, channel: &str) -> Option<usize> {
space.channel_names().iter().position(|n| *n == channel)
}
fn channel_name_arg(v: &Value, pos: Pos) -> Result<String, Error> {
match v {
Value::Str(s) if s.quoted => Ok(s.text.clone()),
Value::Str(s) => Err(Error::at(
format!("$channel: Expected {} to be a quoted string.", s.text),
pos,
)),
other => Err(Error::at(
format!("$channel: {} is not a string.", other.to_css(false)),
pos,
)),
}
}
pub(super) fn space_arg(v: &Value, pos: Pos) -> Result<ColorSpace, Error> {
let name = match v {
Value::Str(s) if !s.quoted => s.text.clone(),
Value::Str(s) => {
return Err(Error::at(
format!("$space: Expected \"{}\" to be an unquoted string.", s.text),
pos,
))
}
other => {
return Err(Error::at(
format!("$space: {} is not a string.", other.to_css(false)),
pos,
))
}
};
ColorSpace::from_name(&name)
.ok_or_else(|| Error::at(format!("$space: Unknown color space \"{name}\"."), pos))
}
fn fn_space(pos_args: &[Value], named: &[(String, Value)], pos: Pos) -> Result<Value, Error> {
let params = ["color"];
max_positional(pos_args, params.len(), pos)?;
let c = as_color(require(¶ms, pos_args, named, 0, "space", pos)?, pos)?;
Ok(Value::Str(crate::value::SassStr {
text: color_space_of(&c).name().to_string(),
quoted: false,
}))
}
fn fn_is_legacy(pos_args: &[Value], named: &[(String, Value)], pos: Pos) -> Result<Value, Error> {
let params = ["color"];
max_positional(pos_args, params.len(), pos)?;
let c = as_color(require(¶ms, pos_args, named, 0, "is-legacy", pos)?, pos)?;
Ok(Value::Bool(color_space_of(&c).is_legacy()))
}
fn fn_to_space(pos_args: &[Value], named: &[(String, Value)], pos: Pos) -> Result<Value, Error> {
let params = ["color", "space"];
let n = pos_args.len() + named.len();
if n > 2 {
return Err(Error::at(
format!("Only 2 arguments allowed, but {n} were passed."),
pos,
));
}
let c = as_color(require(¶ms, pos_args, named, 0, "to-space", pos)?, pos)?;
let space = space_arg(require(¶ms, pos_args, named, 1, "to-space", pos)?, pos)?;
let mc = legacy_to_modern(&c);
if mc.space == space {
return Ok(Value::Color(c));
}
let out = convert_to_space(&mc, space);
Ok(Value::Color(make_modern_in(out, space)))
}
pub(super) fn make_modern_in(mc: ModernColor, _space: ColorSpace) -> Color {
if mc.space == ColorSpace::Rgb && mc.channels.iter().all(|c| c.is_some()) && mc.alpha.is_some() {
let r = mc.channels[0].unwrap_or(0.0);
let g = mc.channels[1].unwrap_or(0.0);
let b = mc.channels[2].unwrap_or(0.0);
let a = mc.alpha.unwrap_or(1.0);
let mut c = Color::rgb(r, g, b, a);
let in_gamut = |v: f64| (-1e-9..=255.0 + 1e-9).contains(&v);
if !(in_gamut(r) && in_gamut(g) && in_gamut(b)) {
c.modern = Some(Box::new(mc));
} else {
c.repr = named_repr(r, g, b, a);
}
return c;
}
make_modern(mc)
}
fn convert_to_space(mc: &ModernColor, space: ColorSpace) -> ModernColor {
convert_modern_filled(mc, space)
}
fn fn_color_channel(pos_args: &[Value], named: &[(String, Value)], pos: Pos) -> Result<Value, Error> {
let params = ["color", "channel", "space"];
let n = pos_args.len() + named.len();
if n > 3 {
return Err(Error::at(
format!("Only 3 arguments allowed, but {n} were passed."),
pos,
));
}
let c = as_color(require(¶ms, pos_args, named, 0, "channel", pos)?, pos)?;
let chan = channel_name_arg(require(¶ms, pos_args, named, 1, "channel", pos)?, pos)?;
let mc = legacy_to_modern(&c);
let space = match arg(¶ms, pos_args, named, 2) {
Some(v) => space_arg(v, pos)?,
None => mc.space,
};
if chan == "alpha" {
let a = mc.alpha.unwrap_or(0.0);
return Ok(Value::Number(Number::unitless(a)));
}
let target = convert_modern(&mc, space);
let idx = channel_index_in(space, &chan).ok_or_else(|| {
Error::at(
format!("$channel: Color {} has no channel named {chan}.", c.to_css(false)),
pos,
)
})?;
let raw = target.channels[idx].unwrap_or(0.0);
Ok(Value::Number(channel_number(space, idx, raw)))
}
fn channel_number(space: ColorSpace, idx: usize, raw: f64) -> Number {
use ColorSpace::*;
let names = space.channel_names();
let cname = names[idx];
let pct = |v: f64, max: f64| Number::with_unit(v * 100.0 / max, "%".to_string());
let deg = |v: f64| Number::with_unit(v, "deg".to_string());
let plain = |v: f64| Number::unitless(v);
match (space, cname) {
(Hsl, "saturation") | (Hsl, "lightness") => pct(raw, 100.0),
(Hwb, "whiteness") | (Hwb, "blackness") => pct(raw, 100.0),
(Lab, "lightness") | (Lch, "lightness") => pct(raw, 100.0),
(Oklab, "lightness") | (Oklch, "lightness") => pct(raw, 1.0),
(_, "hue") => deg(raw),
_ => plain(raw),
}
}
fn fn_is_missing(pos_args: &[Value], named: &[(String, Value)], pos: Pos) -> Result<Value, Error> {
let params = ["color", "channel"];
max_positional(pos_args, params.len(), pos)?;
let c = as_color(require(¶ms, pos_args, named, 0, "is-missing", pos)?, pos)?;
let chan = channel_name_arg(require(¶ms, pos_args, named, 1, "is-missing", pos)?, pos)?;
let mc = legacy_to_modern(&c);
let missing = if chan == "alpha" {
mc.alpha.is_none()
} else {
match channel_index_in(mc.space, &chan) {
Some(idx) => mc.channels[idx].is_none(),
None => {
return Err(Error::at(
format!(
"$channel: Color {} doesn't have a channel named \"{chan}\".",
c.to_css(false)
),
pos,
));
}
}
};
Ok(Value::Bool(missing))
}
fn fn_is_in_gamut(pos_args: &[Value], named: &[(String, Value)], pos: Pos) -> Result<Value, Error> {
let params = ["color", "space"];
let n = pos_args.len() + named.len();
if n > 2 {
return Err(Error::at(
format!("Only 2 arguments allowed, but {n} were passed."),
pos,
));
}
let c = as_color(require(¶ms, pos_args, named, 0, "is-in-gamut", pos)?, pos)?;
let mc = legacy_to_modern(&c);
let space = match arg(¶ms, pos_args, named, 1) {
Some(v) => space_arg(v, pos)?,
None => mc.space,
};
Ok(Value::Bool(in_gamut(&mc, space)))
}
fn channel_bounds(space: ColorSpace) -> Option<[Option<(f64, f64)>; 3]> {
use ColorSpace::*;
Some(match space {
Rgb => [Some((0.0, 255.0)); 3],
Srgb | SrgbLinear | DisplayP3 | DisplayP3Linear | A98Rgb | ProphotoRgb | Rec2020 => {
[Some((0.0, 1.0)); 3]
}
Hsl | Hwb => [None, Some((0.0, 100.0)), Some((0.0, 100.0))],
_ => return None,
})
}
fn is_in_gamut(mc: &ModernColor) -> bool {
let Some(bounds) = channel_bounds(mc.space) else {
return true;
};
let fuzzy_eq = |a: f64, b: f64| (a - b).abs() < 1e-11;
for (bound, channel) in bounds.iter().zip(&mc.channels) {
if let Some((min, max)) = bound {
let v = channel.unwrap_or(0.0);
let ok = (v < *max || fuzzy_eq(v, *max)) && (v > *min || fuzzy_eq(v, *min));
if !ok {
return false;
}
}
}
true
}
fn clip_in_own_space(mc: &ModernColor) -> ModernColor {
let Some(bounds) = channel_bounds(mc.space) else {
return mc.clone();
};
let clamp1 = |v: Option<f64>, b: Option<(f64, f64)>| match (v, b) {
(Some(v), Some((min, max))) => Some(if v.is_nan() { min } else { v.clamp(min, max) }),
_ => v,
};
ModernColor {
space: mc.space,
channels: [
clamp1(mc.channels[0], bounds[0]),
clamp1(mc.channels[1], bounds[1]),
clamp1(mc.channels[2], bounds[2]),
],
alpha: mc.alpha,
}
}
fn in_gamut(mc: &ModernColor, space: ColorSpace) -> bool {
if mc.space == space {
return is_in_gamut(mc);
}
is_in_gamut(&convert_modern(mc, space))
}
fn fn_is_powerless(pos_args: &[Value], named: &[(String, Value)], pos: Pos) -> Result<Value, Error> {
let params = ["color", "channel", "space"];
let n = pos_args.len() + named.len();
if n > 3 {
return Err(Error::at(
format!("Only 3 arguments allowed, but {n} were passed."),
pos,
));
}
let c = as_color(require(¶ms, pos_args, named, 0, "is-powerless", pos)?, pos)?;
let chan = channel_name_arg(require(¶ms, pos_args, named, 1, "is-powerless", pos)?, pos)?;
let mc = legacy_to_modern(&c);
let space = match arg(¶ms, pos_args, named, 2) {
Some(v) => space_arg(v, pos)?,
None => mc.space,
};
let conv = convert_modern(&mc, space);
if chan == "alpha" {
return Ok(Value::Bool(false));
}
let idx = channel_index_in(space, &chan).ok_or_else(|| {
Error::at(
format!(
"$channel: Color {} doesn't have a channel named \"{chan}\".",
c.to_css(false)
),
pos,
)
})?;
Ok(Value::Bool(channel_powerless(space, idx, &conv)))
}
fn channel_powerless(space: ColorSpace, idx: usize, conv: &ModernColor) -> bool {
use ColorSpace::*;
let ch = |i: usize| conv.channels[i].unwrap_or(0.0);
let fuzzy_zero = |v: f64| v.abs() < 1e-11;
match (space, idx) {
(Hsl, 0) => fuzzy_zero(ch(1)),
(Hwb, 0) => ch(1) + ch(2) >= 100.0 - 1e-11,
(Lch, 2) | (Oklch, 2) => fuzzy_zero(ch(1)),
_ => false,
}
}
fn null_powerless(mc: &ModernColor, space: ColorSpace) -> ModernColor {
let mut out = mc.clone();
for idx in 0..3 {
if channel_powerless(space, idx, mc) {
out.channels[idx] = None;
}
}
out
}
fn fn_same(pos_args: &[Value], named: &[(String, Value)], pos: Pos) -> Result<Value, Error> {
let params = ["color1", "color2"];
max_positional(pos_args, params.len(), pos)?;
let c1 = as_color(require(¶ms, pos_args, named, 0, "same", pos)?, pos)?;
let c2 = as_color(require(¶ms, pos_args, named, 1, "same", pos)?, pos)?;
let m1 = legacy_to_modern(&c1);
let m2 = legacy_to_modern(&c2);
let fill0 = |m: &ModernColor| ModernColor {
space: m.space,
channels: [
Some(z(m.channels[0])),
Some(z(m.channels[1])),
Some(z(m.channels[2])),
],
alpha: m.alpha,
};
let x1 = convert_modern(&fill0(&m1), ColorSpace::XyzD65);
let x2 = convert_modern(&fill0(&m2), ColorSpace::XyzD65);
let close = |a: f64, b: f64| (a - b).abs() < 1e-7;
let same = close(z(x1.channels[0]), z(x2.channels[0]))
&& close(z(x1.channels[1]), z(x2.channels[1]))
&& close(z(x1.channels[2]), z(x2.channels[2]))
&& close(m1.alpha.unwrap_or(1.0), m2.alpha.unwrap_or(1.0));
Ok(Value::Bool(same))
}
fn fn_to_gamut(pos_args: &[Value], named: &[(String, Value)], pos: Pos) -> Result<Value, Error> {
let params = ["color", "space", "method"];
max_positional(pos_args, params.len(), pos)?;
let c = as_color(require(¶ms, pos_args, named, 0, "to-gamut", pos)?, pos)?;
let mc = legacy_to_modern(&c);
let space = match arg(¶ms, pos_args, named, 1) {
Some(v) => space_arg(v, pos)?,
None => mc.space,
};
let method = match arg(¶ms, pos_args, named, 2) {
Some(v) => v,
None => {
return Err(Error::at(
"$method: color.to-gamut() requires a $method argument for \
forwards-compatibility with changes in the CSS spec. Suggestion:\n\n\
$method: local-minde"
.to_string(),
pos,
))
}
};
let clip = match method {
Value::Str(s) if !s.quoted && s.text.eq_ignore_ascii_case("clip") => true,
Value::Str(s) if !s.quoted && s.text.eq_ignore_ascii_case("local-minde") => false,
Value::Str(s) if s.quoted => {
return Err(Error::at(
format!("$method: Expected \"{}\" to be an unquoted string.", s.text),
pos,
))
}
other => {
return Err(Error::at(
format!(
"$method: {} must be either clip or local-minde.",
other.to_css(false)
),
pos,
))
}
};
if channel_bounds(space).is_none() {
return Ok(Value::Color(c));
}
let work = convert_modern(&mc, space);
let mapped = if is_in_gamut(&work) {
work
} else if clip {
clip_in_own_space(&work)
} else {
gamut_map(&work)
};
let back = convert_modern_filled(&mapped, mc.space);
Ok(Value::Color(make_modern_in(back, mc.space)))
}
fn gamut_map(color: &ModernColor) -> ModernColor {
let fuzzy_eq = |a: f64, b: f64| (a - b).abs() < 1e-11;
let origin_oklch = convert_modern(color, ColorSpace::Oklch);
let lightness = origin_oklch.channels[0];
let hue = origin_oklch.channels[2];
let alpha = color.alpha;
let l = lightness.unwrap_or(0.0);
if l > 1.0 || fuzzy_eq(l, 1.0) {
return if color.space.is_legacy() {
convert_modern(
&ModernColor {
space: ColorSpace::Rgb,
channels: [Some(255.0); 3],
alpha,
},
color.space,
)
} else {
ModernColor {
space: color.space,
channels: [Some(1.0); 3],
alpha,
}
};
}
if l < 0.0 || fuzzy_eq(l, 0.0) {
return convert_modern(
&ModernColor {
space: ColorSpace::Rgb,
channels: [Some(0.0); 3],
alpha,
},
color.space,
);
}
let mut clipped = if is_in_gamut(color) {
color.clone()
} else {
clip_in_own_space(color)
};
if delta_eok(&clipped, color) < 0.02 {
return clipped;
}
let mut max = origin_oklch.channels[1].unwrap_or(0.0);
let mut min = 0.0;
let mut min_in_gamut = true;
while max - min > 0.0001 {
let chroma = (min + max) / 2.0;
let current = convert_modern(
&ModernColor {
space: ColorSpace::Oklch,
channels: [lightness, Some(chroma), hue],
alpha,
},
color.space,
);
if min_in_gamut && is_in_gamut(¤t) {
min = chroma;
continue;
}
clipped = if is_in_gamut(¤t) {
current.clone()
} else {
clip_in_own_space(¤t)
};
let e = delta_eok(&clipped, ¤t);
if e < 0.02 {
if 0.02 - e < 0.0001 {
return clipped;
}
min = chroma;
min_in_gamut = false;
} else {
max = chroma;
}
}
clipped
}
fn delta_eok(a: &ModernColor, b: &ModernColor) -> f64 {
let a = convert_modern(a, ColorSpace::Oklab);
let b = convert_modern(b, ColorSpace::Oklab);
let dl = z(a.channels[0]) - z(b.channels[0]);
let da = z(a.channels[1]) - z(b.channels[1]);
let db = z(a.channels[2]) - z(b.channels[2]);
(dl * dl + da * da + db * db).sqrt()
}
fn legacy_none_color(channels: &Channels, space: ColorSpace, _pos: Pos) -> Result<Option<Value>, Error> {
let comps_special = channels.comps.iter().any(is_special_legacy);
let alpha_special = channels.alpha.as_ref().is_some_and(is_special_legacy);
if comps_special || alpha_special {
return Ok(None);
}
let comps_none = channels.comps.iter().any(is_none_keyword);
let alpha_none = channels.alpha.as_ref().is_some_and(is_none_keyword);
if !(comps_none || alpha_none) {
return Ok(None);
}
if channels.comps.len() != 3 {
return Ok(None);
}
let comps = &channels.comps;
let ch = match space {
ColorSpace::Hsl => [
if is_none_keyword(&comps[0]) {
None
} else {
modern_hue(&comps[0])
},
modern_channel(&comps[1], 100.0),
modern_channel(&comps[2], 100.0),
],
_ => [
modern_channel(&comps[0], 255.0),
modern_channel(&comps[1], 255.0),
modern_channel(&comps[2], 255.0),
],
};
let mc = ModernColor {
space,
channels: ch,
alpha: modern_alpha(channels.alpha.as_ref()),
};
Ok(Some(Value::Color(make_modern(mc))))
}
fn interpolate_mix(c1: &Color, c2: &Color, weight: f64, space: ColorSpace, hue_method: HueMethod) -> Color {
let p = weight / 100.0;
let m1 = convert_modern(&blank_powerless(legacy_to_modern(c1)), space);
let m2 = convert_modern(&blank_powerless(legacy_to_modern(c2)), space);
let a1 = m1.alpha;
let a2 = m2.alpha;
let ra1 = a1.unwrap_or_else(|| a2.unwrap_or(1.0));
let ra2 = a2.unwrap_or_else(|| a1.unwrap_or(1.0));
let result_alpha = ra1 * p + ra2 * (1.0 - p);
let hue_idx = match space {
ColorSpace::Hsl | ColorSpace::Hwb => Some(0),
ColorSpace::Lch | ColorSpace::Oklch => Some(2),
_ => None,
};
let mut out = [None; 3];
for (i, slot) in out.iter_mut().enumerate() {
let v1 = m1.channels[i];
let v2 = m2.channels[i];
if v1.is_none() && v2.is_none() {
*slot = None;
continue;
}
let x1 = v1.unwrap_or_else(|| v2.unwrap_or(0.0));
let x2 = v2.unwrap_or_else(|| v1.unwrap_or(0.0));
if Some(i) == hue_idx {
*slot = Some(interpolate_hue(x1, x2, p, hue_method));
} else {
let pa1 = a1.unwrap_or(1.0);
let pa2 = a2.unwrap_or(1.0);
let premul = x1 * pa1 * p + x2 * pa2 * (1.0 - p);
*slot = Some(if result_alpha.abs() < 1e-12 {
x1 * p + x2 * (1.0 - p)
} else {
premul / result_alpha
});
}
}
let alpha = if a1.is_none() && a2.is_none() {
None
} else {
Some(result_alpha)
};
let mc = ModernColor {
space,
channels: out,
alpha,
};
let dest = legacy_to_modern(c1).space;
let back = convert_modern_filled(&mc, dest);
make_modern_in(back, dest)
}
fn interpolate_hue(h1: f64, h2: f64, p: f64, method: HueMethod) -> f64 {
let mut a = h1.rem_euclid(360.0);
let mut b = h2.rem_euclid(360.0);
match method {
HueMethod::Shorter => {
let diff = b - a;
if diff > 180.0 {
a += 360.0;
} else if diff < -180.0 {
b += 360.0;
}
}
HueMethod::Longer => {
let diff = b - a;
if (0.0..180.0).contains(&diff) {
b += 360.0;
} else if (-180.0..0.0).contains(&diff) {
a += 360.0;
}
}
HueMethod::Increasing => {
if b < a {
b += 360.0;
}
}
HueMethod::Decreasing => {
if a < b {
a += 360.0;
}
}
}
a * p + b * (1.0 - p)
}
pub(super) fn blank_powerless(mut mc: ModernColor) -> ModernColor {
let ch = |i: usize| mc.channels[i].unwrap_or(0.0);
match mc.space {
ColorSpace::Hsl if ch(1) == 0.0 => mc.channels[0] = None,
ColorSpace::Hwb if ch(1) + ch(2) >= 100.0 => mc.channels[0] = None,
ColorSpace::Lch | ColorSpace::Oklch if ch(1) == 0.0 => mc.channels[2] = None,
_ => {}
}
mc
}
#[derive(Clone, Copy)]
pub(super) enum ModifyOp {
Change,
Adjust,
Scale,
}
fn scale_bounds(space: ColorSpace, idx: usize) -> Option<(f64, f64)> {
use ColorSpace::*;
let names = space.channel_names();
if names[idx] == "hue" {
return None;
}
Some(match space {
Rgb => (0.0, 255.0),
Srgb | SrgbLinear | DisplayP3 | DisplayP3Linear | A98Rgb | ProphotoRgb | Rec2020 => (0.0, 1.0),
Hsl | Hwb => (0.0, 100.0),
Lab => {
if idx == 0 {
(0.0, 100.0)
} else {
(-125.0, 125.0)
}
}
Lch => {
if idx == 0 {
(0.0, 100.0)
} else {
(0.0, 150.0)
}
}
Oklab => {
if idx == 0 {
(0.0, 1.0)
} else {
(-0.4, 0.4)
}
}
Oklch => {
if idx == 0 {
(0.0, 1.0)
} else {
(0.0, 0.4)
}
}
XyzD65 | XyzD50 => (0.0, 1.0),
})
}
fn modify_channel_value(space: ColorSpace, idx: usize, v: &Value) -> Option<f64> {
if space.is_polar(idx) {
modern_hue(v)
} else {
modern_channel(v, channel_pct_base(space, idx))
}
}
impl ColorSpace {
fn is_polar(self, index: usize) -> bool {
self.channel_names()[index] == "hue"
}
}
fn channel_pct_base(space: ColorSpace, idx: usize) -> f64 {
use ColorSpace::*;
let names = space.channel_names();
match (space, names[idx]) {
(Rgb, _) => 255.0,
(Srgb | SrgbLinear | DisplayP3 | DisplayP3Linear | A98Rgb | ProphotoRgb | Rec2020, _) => 1.0,
(Hsl | Hwb, _) => 100.0,
(Lab, "lightness") | (Lch, "lightness") => 100.0,
(Oklab, "lightness") | (Oklch, "lightness") => 1.0,
(Lab, _) => 125.0,
(Oklab, _) => 0.4,
(Lch, "chroma") => 150.0,
(Oklch, "chroma") => 0.4,
(XyzD65 | XyzD50, _) => 1.0,
_ => 1.0,
}
}
pub(super) fn modify_in_space(
c: &Color,
space: ColorSpace,
chans: &[(String, &Value)],
op: ModifyOp,
pos: Pos,
) -> Result<Value, Error> {
modify_in_space_full(c, space, chans, op, false, true, pos)
}
pub(super) fn modify_in_space_opt(
c: &Color,
space: ColorSpace,
chans: &[(String, &Value)],
op: ModifyOp,
powerless_check: bool,
pos: Pos,
) -> Result<Value, Error> {
modify_in_space_full(c, space, chans, op, powerless_check, false, pos)
}
#[allow(clippy::too_many_arguments)]
pub(super) fn modify_in_space_full(
c: &Color,
space: ColorSpace,
chans: &[(String, &Value)],
op: ModifyOp,
powerless_check: bool,
legacy_format: bool,
pos: Pos,
) -> Result<Value, Error> {
let orig = legacy_to_modern(c);
let mut work = convert_modern(&orig, space);
if orig.space != space {
let missing_in_src = |cat: ChannelCategory| {
(0..3).any(|i| orig.channels[i].is_none() && channel_category(orig.space, i) == Some(cat))
};
for i in 0..3 {
if work.channels[i].is_none() {
let authored = channel_category(space, i).is_some_and(&missing_in_src);
if !authored {
work.channels[i] = Some(0.0);
}
}
}
}
let combining = matches!(op, ModifyOp::Adjust | ModifyOp::Scale);
let powerless_active = powerless_check && orig.space != space;
let orig_work = work.clone();
let missing_channel_error = |name: &str| {
let color = if powerless_active {
make_modern(null_powerless(&orig_work, space))
} else {
make_modern(orig_work.clone())
};
missing_channel_err(name, &Value::Color(color), pos)
};
for (name, v) in chans {
if name == "alpha" {
if combining && orig_work.alpha.is_none() {
return Err(missing_channel_error(name));
}
work.alpha = apply_alpha(work.alpha.unwrap_or(1.0), v, op, pos)?;
continue;
}
let idx = channel_index_in(space, name).ok_or_else(|| {
Error::at(
format!(
"${name}: Color space {} doesn't have a channel with this name.",
space.name()
),
pos,
)
})?;
if !matches!(op, ModifyOp::Scale) && !is_none_keyword(v) {
validate_modify_unit(space, idx, name, v, pos)?;
}
let powerless = powerless_active && channel_powerless(space, idx, &orig_work);
if combining && (orig_work.channels[idx].is_none() || powerless) {
return Err(missing_channel_error(name));
}
match op {
ModifyOp::Change => {
if is_none_keyword(v) {
work.channels[idx] = None;
} else {
work.channels[idx] = modify_channel_value(space, idx, v);
}
}
ModifyOp::Adjust => {
let amt = modify_channel_value(space, idx, v).unwrap_or(0.0);
let cur = work.channels[idx].unwrap_or(0.0);
work.channels[idx] = Some(clamp_adjust_channel(space, idx, cur + amt));
}
ModifyOp::Scale => {
let bounds = scale_bounds(space, idx)
.ok_or_else(|| Error::at(format!("${name}: Channel isn't scalable."), pos))?;
let factor = scale_pct(v, pos)?;
let cur = work.channels[idx].unwrap_or(0.0);
work.channels[idx] = Some(scale_to(cur, factor, bounds));
}
}
}
if matches!(op, ModifyOp::Change) && space == ColorSpace::Hwb {
if let (Some(wv), Some(bv)) = (work.channels[1], work.channels[2]) {
if wv + bv > 100.0 {
let t = wv + bv;
work.channels[1] = Some(wv / t * 100.0);
work.channels[2] = Some(bv / t * 100.0);
}
}
}
let dest = if legacy_format && !in_gamut(&work, ColorSpace::Rgb) {
space
} else {
orig.space
};
let back = convert_modern_filled(&work, dest);
Ok(Value::Color(make_modern_in(back, dest)))
}
pub(super) fn missing_channel_err(name: &str, color: &Value, pos: Pos) -> Error {
Error::at(
format!(
"${name}: Because the CSS working group is still deciding on the best \
behavior, Sass doesn't currently support modifying missing channels \
(color: {}).",
color.to_css(false)
),
pos,
)
}
fn apply_alpha(cur: f64, v: &Value, op: ModifyOp, pos: Pos) -> Result<Option<f64>, Error> {
Ok(match op {
ModifyOp::Change => {
if is_none_keyword(v) {
return Ok(None);
}
match v {
Value::Number(n) => {
let max_disp = if n.unit() == "%" { 100.0 } else { 1.0 };
if n.value < 0.0 || n.value > max_disp {
let (b0, b1) = if n.unit() == "%" {
("0%".to_string(), "100%".to_string())
} else {
(format!("0{}", n.unit()), format!("1{}", n.unit()))
};
return Err(Error::at(
format!("$alpha: Expected {} to be within {b0} and {b1}.", n.to_css(false)),
pos,
));
}
Some(if n.unit() == "%" { n.value / 100.0 } else { n.value })
}
Value::Slash(n, _) => Some(n.value),
other => {
return Err(Error::at(
format!("$alpha: {} is not a number.", other.to_css(false)),
pos,
))
}
}
}
ModifyOp::Adjust => {
let amt = match v {
Value::Number(n) => n.value,
Value::Slash(n, _) => n.value,
other => {
return Err(Error::at(
format!("$alpha: {} is not a number.", other.to_css(false)),
pos,
))
}
};
Some((cur + amt).clamp(0.0, 1.0))
}
ModifyOp::Scale => {
let factor = scale_pct(v, pos)?;
Some(scale_to(cur, factor, (0.0, 1.0)))
}
})
}
fn scale_pct(v: &Value, pos: Pos) -> Result<f64, Error> {
match v {
Value::Number(n) if n.unit() == "%" => {
if n.value < -100.0 || n.value > 100.0 {
return Err(Error::at(
format!("Expected {} to be within -100% and 100%.", n.to_css(false)),
pos,
));
}
Ok(n.value / 100.0)
}
Value::Number(n) => Err(Error::at(
format!("$amount: Expected {} to have unit \"%\".", n.to_css(false)),
pos,
)),
other => Err(Error::at(
format!("$amount: {} is not a number.", other.to_css(false)),
pos,
)),
}
}
fn scale_to(current: f64, factor: f64, bounds: (f64, f64)) -> f64 {
if factor == 0.0 {
current
} else if factor > 0.0 {
if current >= bounds.1 {
current
} else {
current + (bounds.1 - current) * factor
}
} else if current <= bounds.0 {
current
} else {
current + (current - bounds.0) * factor
}
}
fn validate_modify_unit(space: ColorSpace, idx: usize, name: &str, v: &Value, pos: Pos) -> Result<(), Error> {
let num = match v {
Value::Number(n) => n,
Value::Slash(..) | Value::Calc(_) => return Ok(()),
other => {
return Err(Error::at(
format!("${name}: {} is not a number.", other.to_css(false)),
pos,
))
}
};
if space.is_polar(idx) {
if space.is_legacy() {
return Ok(());
}
let ok = num.is_unitless() || matches!(num.unit(), "deg" | "grad" | "rad" | "turn");
if !ok {
return Err(Error::at(
format!(
"${name}: Expected {} to have an angle unit (deg, grad, rad, turn).",
num.to_css(false)
),
pos,
));
}
} else if space == ColorSpace::Hsl {
} else if space == ColorSpace::Hwb {
if num.unit() != "%" {
return Err(Error::at(
format!("${name}: Expected {} to have unit \"%\".", num.to_css(false)),
pos,
));
}
} else if !num.is_unitless() && num.unit() != "%" {
return Err(Error::at(
format!(
"${name}: Expected {} to have unit \"%\" or no units.",
num.to_css(false)
),
pos,
));
}
Ok(())
}
fn clamp_adjust_channel(space: ColorSpace, idx: usize, v: f64) -> f64 {
use ColorSpace::*;
let names = space.channel_names();
match (space, names[idx]) {
(Rgb, _) => v.clamp(0.0, 255.0),
(Lab, "lightness") | (Lch, "lightness") => v.clamp(0.0, 100.0),
(Oklab, "lightness") | (Oklch, "lightness") => v.clamp(0.0, 1.0),
(Lch, "chroma") | (Oklch, "chroma") => v.max(0.0),
(Hsl, "saturation") => v.max(0.0),
_ => v,
}
}
pub(super) fn invert_in_space(
c: &Color,
space: ColorSpace,
weight: f64,
powerless_check: bool,
pos: Pos,
) -> Result<Color, Error> {
let orig = legacy_to_modern(c);
let dest = orig.space;
let src = convert_modern(&orig, space);
let powerless_active = powerless_check && orig.space != space;
for idx in 0..3 {
if !invert_modifies(space, idx) {
continue;
}
let powerless = powerless_active && channel_powerless(space, idx, &src);
if src.channels[idx].is_none() || powerless {
let name = space.channel_names()[idx];
let color = if powerless_active {
make_modern(null_powerless(&src, space))
} else {
make_modern(src.clone())
};
return Err(missing_channel_err(name, &Value::Color(color), pos));
}
}
let inverted = invert_channels(space, &src);
if (weight - 1.0).abs() < 1e-11 {
let back = convert_modern_filled(&inverted, dest);
return Ok(make_modern_in(back, dest));
}
let mix = |a: Option<f64>, b: Option<f64>, hue: bool| -> Option<f64> {
match (a, b) {
(Some(x), Some(y)) => {
if hue {
Some(interpolate_hue(x, y, weight, HueMethod::Shorter))
} else {
Some(x * weight + y * (1.0 - weight))
}
}
_ => a.or(b),
}
};
let hue_idx = match space {
ColorSpace::Hsl | ColorSpace::Hwb => Some(0),
ColorSpace::Lch | ColorSpace::Oklch => Some(2),
_ => None,
};
let channels = [
mix(inverted.channels[0], src.channels[0], hue_idx == Some(0)),
mix(inverted.channels[1], src.channels[1], hue_idx == Some(1)),
mix(inverted.channels[2], src.channels[2], hue_idx == Some(2)),
];
let mc = ModernColor {
space,
channels,
alpha: src.alpha,
};
let back = convert_modern_filled(&mc, dest);
Ok(make_modern_in(back, dest))
}
fn invert_channels(space: ColorSpace, src: &ModernColor) -> ModernColor {
use ColorSpace::*;
let ch = src.channels;
let inv_max = |v: Option<f64>, max: f64| v.map(|x| max - x);
let negate = |v: Option<f64>| v.map(|x| -x);
let shift_hue = |v: Option<f64>| v.map(|x| x + 180.0);
let channels = match space {
Rgb => [
inv_max(ch[0], 255.0),
inv_max(ch[1], 255.0),
inv_max(ch[2], 255.0),
],
Srgb | SrgbLinear | DisplayP3 | DisplayP3Linear | A98Rgb | ProphotoRgb | Rec2020 | XyzD65
| XyzD50 => [inv_max(ch[0], 1.0), inv_max(ch[1], 1.0), inv_max(ch[2], 1.0)],
Hsl => [shift_hue(ch[0]), ch[1], inv_max(ch[2], 100.0)],
Hwb => [shift_hue(ch[0]), ch[2], ch[1]],
Lab => [inv_max(ch[0], 100.0), negate(ch[1]), negate(ch[2])],
Oklab => [inv_max(ch[0], 1.0), negate(ch[1]), negate(ch[2])],
Lch => [inv_max(ch[0], 100.0), ch[1], shift_hue(ch[2])],
Oklch => [inv_max(ch[0], 1.0), ch[1], shift_hue(ch[2])],
};
ModernColor {
space,
channels,
alpha: src.alpha,
}
}
fn invert_modifies(space: ColorSpace, idx: usize) -> bool {
use ColorSpace::*;
!matches!(
(space, idx),
(Hsl, 1) | (Hwb, 1) | (Hwb, 2) | (Lch, 1) | (Oklch, 1)
)
}
pub(super) fn grayscale_modern(c: &Color) -> Color {
let orig = legacy_to_modern(c);
let dest = orig.space;
let mut oklch = convert_modern(&orig, ColorSpace::Oklch);
oklch.channels[1] = Some(0.0);
let back = convert_modern(&oklch, dest);
make_modern_in(back, dest)
}