use super::*;
pub(super) fn fn_rgb(
name: &str,
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 let Some((color, alpha)) = legacy_color_alpha(pos_args, named) {
if let Value::Color(c) = color {
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(name, &[&r, &g, &b, alpha]));
}
let a = alpha_value(alpha, pos)?;
return Ok(Value::Color(computed(c.r, c.g, c.b, a)));
}
if !is_var(color) && !is_var(alpha) {
return Err(Error::at(
format!("$color: {} is not a color.", color_arg_css(color)),
pos,
));
}
}
let channels = Channels::collect("rgb", ¶ms, pos_args, named, pos)?;
if let Some(verbatim) = channels.relative_passthrough(name) {
return Ok(verbatim);
}
if let Some(c) = legacy_none_color(&channels, ColorSpace::Rgb, pos)? {
return Ok(c);
}
if let Some(verbatim) = channels.special_passthrough(name, "rgb") {
return Ok(verbatim);
}
channels.validate_numeric(&["red", "green", "blue"], pos)?;
channels.validate_count("rgb", pos)?;
channels.validate_rgb_units(&["red", "green", "blue"], 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 legacy_color_alpha<'a>(
pos_args: &'a [Value],
named: &'a [(String, Value)],
) -> Option<(&'a Value, &'a Value)> {
if pos_args.len() + named.len() != 2 {
return None;
}
let by_name = |key: &str| named.iter().find(|(k, _)| k == key).map(|(_, v)| v);
for (k, _) in named {
if k != "color" && k != "alpha" {
return None;
}
let filled_positionally =
(k == "color" && !pos_args.is_empty()) || (k == "alpha" && pos_args.len() >= 2);
if filled_positionally {
return None;
}
}
let color = pos_args.first().or_else(|| by_name("color"))?;
let alpha = pos_args.get(1).or_else(|| by_name("alpha"))?;
Some((color, alpha))
}
fn color_arg_css(v: &Value) -> String {
match v {
Value::List(l) if !l.bracketed && l.items.len() >= 2 => {
format!("({})", crate::builtins::inspect_value(v))
}
_ => crate::builtins::inspect_value(v),
}
}
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)
}
}
pub(super) 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_rgb_units(&self, names: &[&str], pos: Pos) -> Result<(), Error> {
if self.single.is_none() {
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!(
"${}: {} is not a number.",
names[i.min(names.len() - 1)],
comp.to_css(false)
),
pos,
));
}
}
}
for (i, comp) in self.comps.iter().enumerate() {
if let Some(num) = channel_unit_number(comp) {
let ok = num.is_unitless() || (!num.has_complex_units() && num.unit() == "%");
if !ok {
return Err(Error::at(
format!(
"${}: Expected {} to have unit \"%\" or no units.",
names[i.min(names.len() - 1)],
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, canonical: &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 = canonical.eq_ignore_ascii_case("hsl");
Some(self.none_verbatim(canonical, 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: text.into(),
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.to_vec(),
other => vec![other.clone()],
};
return SplitChannels {
comps,
alpha: Some(l.items[1].clone()),
alpha_split: true,
};
}
return no_split(l.items.to_vec());
}
if l.sep != ListSep::Space {
return no_split(l.items.to_vec());
}
let mut items: Vec<Value> = l.items.to_vec();
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().into(),
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)
)
}
}
pub(super) fn fn_hsl(
name: &str,
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(name) {
return Ok(verbatim);
}
if let Some(c) = legacy_none_color(&channels, ColorSpace::Hsl, pos)? {
return Ok(c);
}
if let Some(verbatim) = channels.special_passthrough(name, "hsl") {
return Ok(verbatim);
}
if pos_args.len() == 2 && named.is_empty() {
return Err(Error::at("Missing argument $lightness.".to_string(), pos));
}
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 channel_unit_number(v: &Value) -> Option<&Number> {
match v {
Value::Number(n) | Value::Slash(n, _) => Some(n),
Value::Calc(CalcNode::Number(n)) => Some(n),
_ => 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)).into(),
quoted: false,
}));
}
None => "hsl",
};
Ok(Value::Str(crate::value::SassStr {
text: format!("{name}({hue}, {sat}, {light})").into(),
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)))
}
pub(super) 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(crate) 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.into(),
sep: ListSep::Space,
bracketed: false,
keywords: None,
});
let channels = match ch.alpha {
Some(a) => Value::List(List {
items: vec![space, a].into(),
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"],
}
}
pub(super) 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 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"
)
}
pub(super) 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: text.into(),
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),
}
}
pub(super) 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)]
pub(super) 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))
}
pub(super) 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))
}
pub(super) 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())))
}
pub(super) 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(&(_, '=')))
}
pub(super) 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})").into(),
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)))
}
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))))
}