use std::rc::Rc;
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Value {
Number(Number),
Color(Color),
Str(SassStr),
List(List),
Map(Map),
Bool(bool),
Null,
Slash(Number, String),
Calc(CalcNode),
Function(SassFunction),
Mixin(Box<SassMixin>),
}
const _: () = assert!(std::mem::size_of::<Value>() <= 64);
#[derive(Clone)]
pub(crate) struct SassFunction {
pub name: String,
pub css: bool,
pub user: Option<std::rc::Rc<dyn std::any::Any>>,
}
impl std::fmt::Debug for SassFunction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SassFunction")
.field("name", &self.name)
.field("css", &self.css)
.field("user", &self.user.is_some())
.finish()
}
}
impl PartialEq for SassFunction {
fn eq(&self, other: &Self) -> bool {
match (&self.user, &other.user) {
(Some(a), Some(b)) => std::rc::Rc::ptr_eq(a, b),
(None, None) => self.name == other.name && self.css == other.css,
_ => false,
}
}
}
impl SassFunction {
pub(crate) fn inspect(&self) -> String {
format!("get-function(\"{}\")", self.name)
}
}
#[derive(Clone)]
pub(crate) struct SassMixin {
pub name: String,
pub user: Option<std::rc::Rc<dyn std::any::Any>>,
pub module: Option<std::rc::Rc<dyn std::any::Any>>,
pub origin: Option<MixinOrigin>,
}
#[derive(Clone)]
pub(crate) struct MixinOrigin {
pub diag_url: String,
pub file_dir: String,
pub canonical: String,
}
impl std::fmt::Debug for SassMixin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SassMixin")
.field("name", &self.name)
.field("user", &self.user.is_some())
.finish()
}
}
impl PartialEq for SassMixin {
fn eq(&self, other: &Self) -> bool {
match (&self.user, &other.user) {
(Some(a), Some(b)) => std::rc::Rc::ptr_eq(a, b),
(None, None) => self.name == other.name,
_ => false,
}
}
}
impl SassMixin {
pub(crate) fn inspect(&self) -> String {
format!("get-mixin(\"{}\")", self.name)
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum CalcNode {
Number(Number),
Str(String),
Op {
op: CalcOp,
left: Box<CalcNode>,
right: Box<CalcNode>,
},
Func { name: String, args: Vec<CalcNode> },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CalcOp {
Add,
Sub,
Mul,
Div,
}
impl CalcOp {
fn symbol(self) -> &'static str {
match self {
CalcOp::Add => "+",
CalcOp::Sub => "-",
CalcOp::Mul => "*",
CalcOp::Div => "/",
}
}
fn precedence(self) -> u8 {
match self {
CalcOp::Add | CalcOp::Sub => 1,
CalcOp::Mul | CalcOp::Div => 2,
}
}
}
impl CalcNode {
pub(crate) fn to_calc_css(&self, compressed: bool) -> String {
match self {
CalcNode::Number(n) => calc_number_css(n, compressed),
CalcNode::Str(s) => s.clone(),
CalcNode::Op { op, left, right } => {
let l = self.fmt_operand(left, *op, false, compressed);
let r = self.fmt_operand(right, *op, true, compressed);
let sep = match (op, compressed) {
(CalcOp::Mul | CalcOp::Div, true) => op.symbol().to_string(),
_ => format!(" {} ", op.symbol()),
};
if matches!(op, CalcOp::Add | CalcOp::Sub) && !compressed {
if let CalcNode::Number(n) = right.as_ref() {
if n.value.is_finite() && n.value.is_sign_negative() && n.value != 0.0 {
let flipped = if *op == CalcOp::Add { "-" } else { "+" };
let pos = n.copy_units(-n.value);
return format!("{l} {flipped} {}", pos.to_css(compressed));
}
}
}
format!("{l}{sep}{r}")
}
CalcNode::Func { name, args } => {
let parts: Vec<String> = args.iter().map(|a| a.to_calc_css(compressed)).collect();
let sep = if compressed { "," } else { ", " };
format!("{name}({})", parts.join(sep))
}
}
}
pub(crate) fn to_calc_value_css(&self, compressed: bool) -> String {
match self {
CalcNode::Func { .. } => self.to_calc_css(compressed),
_ => format!("calc({})", self.to_calc_css(compressed)),
}
}
fn fmt_operand(&self, operand: &CalcNode, parent: CalcOp, is_right: bool, compressed: bool) -> String {
let child_op = match operand {
CalcNode::Op { op, .. } => Some(*op),
CalcNode::Number(n) if !n.value.is_finite() && !n.is_unitless() => Some(CalcOp::Mul),
_ => None,
};
if let Some(child_op) = child_op {
let needs_paren = child_op.precedence() < parent.precedence()
|| (child_op.precedence() == parent.precedence()
&& is_right
&& matches!(parent, CalcOp::Sub | CalcOp::Div));
if needs_paren {
return format!("({})", operand.to_calc_css(compressed));
}
}
operand.to_calc_css(compressed)
}
}
fn calc_number_css(n: &Number, compressed: bool) -> String {
if n.value.is_finite() && !n.has_complex_units() {
return n.to_css(compressed);
}
let star = if compressed { "*" } else { " * " };
let slash = if compressed { "/" } else { " / " };
let mut out;
let mut numer = n.numer_units().iter();
if n.value.is_finite() {
out = match numer.next() {
Some(u) => format!("{}{u}", fmt_num(n.value, compressed)),
None => fmt_num(n.value, compressed),
};
} else {
out = if n.value.is_nan() {
"NaN".to_string()
} else if n.value > 0.0 {
"infinity".to_string()
} else {
"-infinity".to_string()
};
}
for u in numer {
out.push_str(star);
out.push('1');
out.push_str(u);
}
for u in n.denom_units() {
out.push_str(slash);
out.push('1');
out.push_str(u);
}
out
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Number {
pub value: f64,
units: Units,
}
#[derive(Debug, Clone, PartialEq)]
enum Units {
None,
Single(String),
Complex(Box<ComplexUnits>),
}
#[derive(Debug, Clone, PartialEq)]
struct ComplexUnits {
numer: Vec<String>,
denom: Vec<String>,
}
impl Number {
pub(crate) fn unitless(value: f64) -> Number {
Number {
value,
units: Units::None,
}
}
pub(crate) fn with_unit(value: f64, unit: impl Into<String>) -> Number {
let unit = unit.into();
Number {
value,
units: if unit.is_empty() {
Units::None
} else {
Units::Single(unit)
},
}
}
pub(crate) fn with_units(value: f64, mut numer: Vec<String>, denom: Vec<String>) -> Number {
let units = if denom.is_empty() && numer.len() <= 1 {
match numer.pop() {
None => Units::None,
Some(u) => Units::Single(u),
}
} else {
Units::Complex(Box::new(ComplexUnits { numer, denom }))
};
Number { value, units }
}
pub(crate) fn copy_units(&self, value: f64) -> Number {
Number {
value,
units: self.units.clone(),
}
}
pub(crate) fn is_unitless(&self) -> bool {
matches!(self.units, Units::None)
}
pub(crate) fn unit(&self) -> &str {
match &self.units {
Units::None => "",
Units::Single(u) => u,
Units::Complex(c) => c.numer.first().map(String::as_str).unwrap_or(""),
}
}
pub(crate) fn has_complex_units(&self) -> bool {
matches!(self.units, Units::Complex(_))
}
pub(crate) fn numer_units(&self) -> &[String] {
match &self.units {
Units::None => &[],
Units::Single(u) => std::slice::from_ref(u),
Units::Complex(c) => &c.numer,
}
}
pub(crate) fn denom_units(&self) -> &[String] {
match &self.units {
Units::None | Units::Single(_) => &[],
Units::Complex(c) => &c.denom,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct SassStr {
pub text: Rc<str>,
pub quoted: bool,
}
pub(crate) fn is_private_use(cp: u32) -> bool {
(0xE000..=0xF8FF).contains(&cp) || (0xF0000..=0x10FFFF).contains(&cp)
}
pub(crate) fn serialize_quoted(text: &str) -> String {
let has_double = text.contains('"');
let has_single = text.contains('\'');
let quote = if has_double && !has_single { '\'' } else { '"' };
let needs_escape = text.chars().any(|c| {
let cp = c as u32;
c == quote || c == '\\' || (cp <= 0x1F && c != '\t') || cp == 0x7F || is_private_use(cp)
});
if !needs_escape {
let mut out = String::with_capacity(text.len() + 2);
out.push(quote);
out.push_str(text);
out.push(quote);
return out;
}
let chars: Vec<char> = text.chars().collect();
let mut out = String::with_capacity(text.len() + 2);
out.push(quote);
for (i, &c) in chars.iter().enumerate() {
let cp = c as u32;
if c == quote || c == '\\' {
out.push('\\');
out.push(c);
} else if (cp <= 0x1F && c != '\t') || cp == 0x7F || is_private_use(cp) {
out.push('\\');
out.push_str(&format!("{cp:x}"));
let needs_space = chars
.get(i + 1)
.is_some_and(|n| n.is_ascii_hexdigit() || *n == ' ' || *n == '\t');
if needs_space {
out.push(' ');
}
} else {
out.push(c);
}
}
out.push(quote);
out
}
pub(crate) fn serialize_unquoted(text: &str) -> String {
if !text.chars().any(|c| c == '\n' || is_private_use(c as u32)) {
return text.to_string();
}
let chars: Vec<char> = text.chars().collect();
let mut out = String::with_capacity(text.len());
let mut after_newline = false;
for (i, &c) in chars.iter().enumerate() {
let cp = c as u32;
if is_private_use(cp) {
out.push('\\');
out.push_str(&format!("{cp:x}"));
let needs_space = chars
.get(i + 1)
.is_some_and(|n| n.is_ascii_hexdigit() || *n == ' ' || *n == '\t');
if needs_space {
out.push(' ');
}
after_newline = false;
continue;
}
match c {
'\n' => {
out.push(' ');
after_newline = true;
}
' ' => {
if !after_newline {
out.push(' ');
}
}
_ => {
out.push(c);
after_newline = false;
}
}
}
out
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct List {
pub items: Rc<[Value]>,
pub sep: ListSep,
pub bracketed: bool,
pub keywords: Option<Vec<(Value, Value)>>,
}
impl List {
pub(crate) fn new(items: impl Into<Rc<[Value]>>, sep: ListSep, bracketed: bool) -> Self {
List {
items: items.into(),
sep,
bracketed,
keywords: None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Map {
pub entries: Rc<Vec<(Value, Value)>>,
}
impl Map {
pub(crate) fn new(entries: Vec<(Value, Value)>) -> Self {
Map {
entries: Rc::new(entries),
}
}
pub(crate) fn get(&self, key: &Value) -> Option<&Value> {
self.entries.iter().find(|(k, _)| k.sass_eq(key)).map(|(_, v)| v)
}
pub(crate) fn insert(&mut self, key: Value, value: Value) {
let entries = Rc::make_mut(&mut self.entries);
if let Some(slot) = entries.iter_mut().find(|(k, _)| k.sass_eq(&key)) {
slot.1 = value;
} else {
entries.push((key, value));
}
}
pub(crate) fn to_css(&self, compressed: bool) -> String {
self.to_map_css(compressed)
}
fn to_map_css(&self, compressed: bool) -> String {
if self.entries.is_empty() {
return "()".to_string();
}
let sep = if compressed { "," } else { ", " };
let pair_sep = if compressed { ":" } else { ": " };
let inner = self
.entries
.iter()
.map(|(k, v)| format!("{}{pair_sep}{}", map_key_css(k), map_val_css(v)))
.collect::<Vec<_>>()
.join(sep);
format!("({inner})")
}
}
fn map_key_css(v: &Value) -> String {
match v {
Value::Str(s) if s.quoted => serialize_quoted(&s.text),
Value::Map(m) => m.to_map_css(false),
other => other.to_css(false),
}
}
fn map_val_css(v: &Value) -> String {
match v {
Value::Str(s) if s.quoted => serialize_quoted(&s.text),
Value::Map(m) => m.to_map_css(false),
Value::List(l) if l.sep == ListSep::Comma && !l.bracketed && l.items.len() >= 2 => {
format!("({})", l.to_css(false))
}
other => other.to_css(false),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ListSep {
Space,
Comma,
Slash,
Undecided,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Color {
pub r: f64,
pub g: f64,
pub b: f64,
pub a: f64,
pub repr: Option<String>,
pub modern: Option<Box<ModernColor>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ColorSpace {
Rgb,
Hsl,
Hwb,
Srgb,
SrgbLinear,
DisplayP3,
DisplayP3Linear,
A98Rgb,
ProphotoRgb,
Rec2020,
XyzD65,
XyzD50,
Lab,
Lch,
Oklab,
Oklch,
}
impl ColorSpace {
pub(crate) fn name(self) -> &'static str {
match self {
ColorSpace::Rgb => "rgb",
ColorSpace::Hsl => "hsl",
ColorSpace::Hwb => "hwb",
ColorSpace::Srgb => "srgb",
ColorSpace::SrgbLinear => "srgb-linear",
ColorSpace::DisplayP3 => "display-p3",
ColorSpace::DisplayP3Linear => "display-p3-linear",
ColorSpace::A98Rgb => "a98-rgb",
ColorSpace::ProphotoRgb => "prophoto-rgb",
ColorSpace::Rec2020 => "rec2020",
ColorSpace::XyzD65 => "xyz",
ColorSpace::XyzD50 => "xyz-d50",
ColorSpace::Lab => "lab",
ColorSpace::Lch => "lch",
ColorSpace::Oklab => "oklab",
ColorSpace::Oklch => "oklch",
}
}
pub(crate) fn from_name(s: &str) -> Option<ColorSpace> {
Some(match s.to_ascii_lowercase().as_str() {
"rgb" => ColorSpace::Rgb,
"hsl" => ColorSpace::Hsl,
"hwb" => ColorSpace::Hwb,
"srgb" => ColorSpace::Srgb,
"srgb-linear" => ColorSpace::SrgbLinear,
"display-p3" => ColorSpace::DisplayP3,
"display-p3-linear" => ColorSpace::DisplayP3Linear,
"a98-rgb" => ColorSpace::A98Rgb,
"prophoto-rgb" => ColorSpace::ProphotoRgb,
"rec2020" => ColorSpace::Rec2020,
"xyz" | "xyz-d65" => ColorSpace::XyzD65,
"xyz-d50" => ColorSpace::XyzD50,
"lab" => ColorSpace::Lab,
"lch" => ColorSpace::Lch,
"oklab" => ColorSpace::Oklab,
"oklch" => ColorSpace::Oklch,
_ => return None,
})
}
pub(crate) fn is_legacy(self) -> bool {
matches!(self, ColorSpace::Rgb | ColorSpace::Hsl | ColorSpace::Hwb)
}
pub(crate) fn channel_names(self) -> [&'static str; 3] {
match self {
ColorSpace::Rgb
| ColorSpace::Srgb
| ColorSpace::SrgbLinear
| ColorSpace::DisplayP3
| ColorSpace::DisplayP3Linear
| ColorSpace::A98Rgb
| ColorSpace::ProphotoRgb
| ColorSpace::Rec2020 => ["red", "green", "blue"],
ColorSpace::Hsl => ["hue", "saturation", "lightness"],
ColorSpace::Hwb => ["hue", "whiteness", "blackness"],
ColorSpace::XyzD65 | ColorSpace::XyzD50 => ["x", "y", "z"],
ColorSpace::Lab | ColorSpace::Oklab => ["lightness", "a", "b"],
ColorSpace::Lch | ColorSpace::Oklch => ["lightness", "chroma", "hue"],
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ModernColor {
pub space: ColorSpace,
pub channels: [Option<f64>; 3],
pub alpha: Option<f64>,
}
impl Value {
pub(crate) fn to_css(&self, compressed: bool) -> String {
match self {
Value::Number(n) => n.to_css(compressed),
Value::Color(c) => c.to_css(compressed),
Value::Str(s) => {
if s.quoted {
serialize_quoted(&s.text)
} else {
serialize_unquoted(&s.text)
}
}
Value::List(l) => l.to_css(compressed),
Value::Map(m) => m.to_map_css(compressed),
Value::Bool(b) => b.to_string(),
Value::Null => String::new(),
Value::Slash(_, repr) => repr.clone(),
Value::Calc(node) => node.to_calc_value_css(compressed),
Value::Function(func) => func.inspect(),
Value::Mixin(m) => m.inspect(),
}
}
pub(crate) fn to_interp(&self) -> String {
match self {
Value::Str(s) => s.text.to_string(),
Value::Null => String::new(),
Value::List(l) => l.to_interp(),
Value::Map(m) => m.to_map_css(false),
Value::Slash(_, repr) => repr.clone(),
Value::Calc(node) => node.to_calc_value_css(false),
other => other.to_css(false),
}
}
pub(crate) fn to_message(&self) -> String {
match self {
Value::Str(s) => s.text.to_string(),
other => crate::builtins::inspect_value(other),
}
}
pub(crate) fn to_error_message(&self) -> String {
crate::builtins::inspect_element(self, ListSep::Space)
}
pub(crate) fn type_name(&self) -> &'static str {
match self {
Value::Number(_) => "number",
Value::Color(_) => "color",
Value::Str(_) => "string",
Value::List(l) if l.keywords.is_some() => "arglist",
Value::List(_) => "list",
Value::Map(_) => "map",
Value::Bool(_) => "bool",
Value::Null => "null",
Value::Slash(_, _) => "number",
Value::Calc(_) => "calculation",
Value::Function(_) => "function",
Value::Mixin(_) => "mixin",
}
}
pub(crate) fn without_slash(self) -> Value {
match self {
Value::Slash(n, _) => Value::Number(n),
other => other,
}
}
pub(crate) fn is_truthy(&self) -> bool {
!matches!(self, Value::Bool(false) | Value::Null)
}
pub(crate) fn sass_eq(&self, other: &Value) -> bool {
if matches!(self, Value::Slash(_, _)) || matches!(other, Value::Slash(_, _)) {
let unwrap_num;
let lhs: &Value = match self {
Value::Slash(n, _) => {
unwrap_num = Value::Number(n.clone());
&unwrap_num
}
other => other,
};
let unwrap_num2;
let rhs: &Value = match other {
Value::Slash(n, _) => {
unwrap_num2 = Value::Number(n.clone());
&unwrap_num2
}
other => other,
};
return lhs.sass_eq(rhs);
}
match (self, other) {
(Value::Number(a), Value::Number(b)) => numbers_eq(a, b),
(Value::Calc(a), Value::Calc(b)) => a == b,
(Value::Str(a), Value::Str(b)) => a.text == b.text,
(Value::Color(a), Value::Color(b)) => color_sass_eq(a, b),
(Value::Bool(a), Value::Bool(b)) => a == b,
(Value::Null, Value::Null) => true,
(Value::List(a), Value::List(b)) => {
a.sep == b.sep
&& a.bracketed == b.bracketed
&& a.items.len() == b.items.len()
&& a.items.iter().zip(b.items.iter()).all(|(x, y)| x.sass_eq(y))
}
(Value::Map(a), Value::Map(b)) => {
a.entries.len() == b.entries.len()
&& a.entries
.iter()
.all(|(k, v)| b.get(k).is_some_and(|bv| bv.sass_eq(v)))
}
(Value::Map(m), Value::List(l)) | (Value::List(l), Value::Map(m)) => {
m.entries.is_empty() && l.items.is_empty()
}
(Value::Function(a), Value::Function(b)) => a == b,
(Value::Mixin(a), Value::Mixin(b)) => a == b,
_ => false,
}
}
}
const FUZZY_EPSILON: f64 = 1e-11;
fn fuzzy_eq(a: f64, b: f64) -> bool {
a == b || (a - b).abs() < FUZZY_EPSILON
}
fn color_sass_eq(a: &Color, b: &Color) -> bool {
let space = |c: &Color| c.modern.as_ref().map(|m| m.space).unwrap_or(ColorSpace::Rgb);
let chan_eq = |x: Option<f64>, y: Option<f64>| match (x, y) {
(Some(p), Some(q)) => fuzzy_eq(p, q),
(None, None) => true,
_ => false,
};
let channels = |c: &Color| -> [Option<f64>; 3] {
match &c.modern {
Some(m) => m.channels,
None => [Some(c.r), Some(c.g), Some(c.b)],
}
};
let alpha = |c: &Color| c.modern.as_ref().map_or(Some(c.a), |m| m.alpha);
let (sa, sb) = (space(a), space(b));
if sa != sb {
if sa.is_legacy() && sb.is_legacy() {
return fuzzy_eq(a.r, b.r) && fuzzy_eq(a.g, b.g) && fuzzy_eq(a.b, b.b) && fuzzy_eq(a.a, b.a);
}
return false;
}
let (ca, cb) = (channels(a), channels(b));
(0..3).all(|i| chan_eq(ca[i], cb[i])) && chan_eq(alpha(a), alpha(b))
}
fn numbers_eq(a: &Number, b: &Number) -> bool {
if a.unit() == b.unit() {
return fuzzy_eq(a.value, b.value);
}
if a.is_unitless() || b.is_unitless() {
return false;
}
if a.unit().bytes().any(|c| c.is_ascii_uppercase()) || b.unit().bytes().any(|c| c.is_ascii_uppercase()) {
return false;
}
match convert_factor(b.unit(), a.unit()) {
Some(factor) => fuzzy_eq(a.value, b.value * factor),
None => false,
}
}
fn push_unit_escaped(out: &mut String, unit: &str) {
if unit.chars().all(|c| (c as u32) >= 0x20 && (c as u32) != 0x7F) {
out.push_str(unit);
return;
}
for c in unit.chars() {
let cu = c as u32;
if cu < 0x20 || cu == 0x7F {
out.push('\\');
out.push_str(&format!("{cu:x}"));
out.push(' ');
} else {
out.push(c);
}
}
}
impl Number {
pub(crate) fn to_css(&self, compressed: bool) -> String {
if !self.value.is_finite() || self.has_complex_units() {
return format!("calc({})", calc_number_css(self, compressed));
}
let mut s = fmt_num(self.value, compressed);
push_unit_escaped(&mut s, self.unit());
s
}
pub(crate) fn unit_string(&self) -> String {
let numer = self.numer_units();
let denom = self.denom_units();
match (numer.is_empty(), denom.is_empty()) {
(_, true) => numer.join("*"),
(true, false) => {
if denom.len() == 1 {
format!("{}^-1", denom[0])
} else {
format!("({})^-1", denom.join("*"))
}
}
(false, false) => {
if denom.len() == 1 {
format!("{}/{}", numer.join("*"), denom[0])
} else {
format!("{}/({})", numer.join("*"), denom.join("*"))
}
}
}
}
pub(crate) fn mul(&self, other: &Number) -> Number {
if matches!(other.units, Units::None) {
return self.copy_units(self.value * other.value);
}
if matches!(self.units, Units::None) {
return other.copy_units(self.value * other.value);
}
multiply_units(
self.value * other.value,
self.numer_units().to_vec(),
self.denom_units().to_vec(),
other.numer_units().to_vec(),
other.denom_units().to_vec(),
)
}
pub(crate) fn div(&self, other: &Number) -> Number {
if matches!(other.units, Units::None) {
return self.copy_units(self.value / other.value);
}
if let (Units::Single(a), Units::Single(b)) = (&self.units, &other.units) {
if a == b {
return Number::unitless(self.value / other.value);
}
}
multiply_units(
self.value / other.value,
self.numer_units().to_vec(),
self.denom_units().to_vec(),
other.denom_units().to_vec(),
other.numer_units().to_vec(),
)
}
}
fn cancel_factor(numerator: &str, denominator: &str) -> Option<f64> {
if numerator == denominator {
return Some(1.0);
}
convert_factor(numerator, denominator)
}
pub(crate) fn unit_lists_factor(from: (&[String], &[String]), to: (&[String], &[String])) -> Option<f64> {
fn match_lists(from: &[String], to: &[String]) -> Option<f64> {
if from.len() != to.len() {
return None;
}
let mut factor = 1.0;
let mut remaining: Vec<&String> = to.iter().collect();
for f in from {
let i = remaining.iter().position(|t| cancel_factor(f, t).is_some())?;
if let Some(fac) = cancel_factor(f, remaining[i]) {
factor *= fac;
}
remaining.remove(i);
}
Some(factor)
}
let numer = match_lists(from.0, to.0)?;
let denom = match_lists(from.1, to.1)?;
Some(numer / denom)
}
fn multiply_units(
mut value: f64,
numer1: Vec<String>,
denom1: Vec<String>,
numer2: Vec<String>,
denom2: Vec<String>,
) -> Number {
let mut numer = Vec::new();
let mut denom2 = denom2;
for n in numer1 {
match denom2.iter().position(|d| cancel_factor(&n, d).is_some()) {
Some(i) => {
if let Some(factor) = cancel_factor(&n, &denom2[i]) {
value *= factor;
}
denom2.remove(i);
}
None => numer.push(n),
}
}
let mut denom1 = denom1;
for n in numer2 {
match denom1.iter().position(|d| cancel_factor(&n, d).is_some()) {
Some(i) => {
if let Some(factor) = cancel_factor(&n, &denom1[i]) {
value *= factor;
}
denom1.remove(i);
}
None => numer.push(n),
}
}
denom1.extend(denom2);
Number::with_units(value, numer, denom1)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Dim {
Length,
Angle,
Time,
Frequency,
Resolution,
}
pub(crate) fn unit_dimension(unit: &str) -> Option<Dim> {
match unit {
"px" | "in" | "cm" | "mm" | "q" | "pt" | "pc" => Some(Dim::Length),
"deg" | "grad" | "rad" | "turn" => Some(Dim::Angle),
"s" | "ms" => Some(Dim::Time),
"Hz" | "kHz" => Some(Dim::Frequency),
"dpi" | "dpcm" | "dppx" => Some(Dim::Resolution),
_ => None,
}
}
fn canonical_factor(unit: &str) -> Option<f64> {
use std::f64::consts::PI;
Some(match unit {
"px" => 1.0,
"in" => 96.0,
"cm" => 96.0 / 2.54,
"mm" => 96.0 / 25.4,
"q" => 96.0 / 101.6,
"pt" => 96.0 / 72.0,
"pc" => 16.0,
"deg" => 1.0,
"grad" => 9.0 / 10.0,
"rad" => 180.0 / PI,
"turn" => 360.0,
"s" => 1.0,
"ms" => 1.0 / 1000.0,
"Hz" => 1.0,
"kHz" => 1000.0,
"dpi" => 1.0,
"dpcm" => 2.54,
"dppx" => 96.0,
_ => return None,
})
}
pub(crate) fn convert_factor(from: &str, to: &str) -> Option<f64> {
if !units_compatible(from, to) {
return None;
}
let f = canonical_factor(from)?;
let t = canonical_factor(to)?;
Some(f / t)
}
pub(crate) fn units_compatible(a: &str, b: &str) -> bool {
if a == b {
return true;
}
match (unit_dimension(a), unit_dimension(b)) {
(Some(da), Some(db)) => da == db,
_ => false,
}
}
fn is_relative_length(unit: &str) -> bool {
matches!(
unit.to_ascii_lowercase().as_str(),
"em" | "rem"
| "ex"
| "rex"
| "cap"
| "rcap"
| "ch"
| "rch"
| "ic"
| "ric"
| "lh"
| "rlh"
| "vw"
| "svw"
| "lvw"
| "dvw"
| "vh"
| "svh"
| "lvh"
| "dvh"
| "vi"
| "svi"
| "lvi"
| "dvi"
| "vb"
| "svb"
| "lvb"
| "dvb"
| "vmin"
| "svmin"
| "lvmin"
| "dvmin"
| "vmax"
| "svmax"
| "lvmax"
| "dvmax"
| "cqw"
| "cqh"
| "cqi"
| "cqb"
| "cqmin"
| "cqmax"
)
}
fn known_calc_class(unit: &str) -> Option<u8> {
if is_relative_length(unit) {
return Some(0); }
match unit.to_ascii_lowercase().as_str() {
"px" | "in" | "cm" | "mm" | "q" | "pt" | "pc" => Some(0),
"deg" | "grad" | "rad" | "turn" => Some(1),
"s" | "ms" => Some(2),
"dpi" | "dpcm" | "dppx" => Some(3),
"hz" | "khz" => Some(4),
_ => None,
}
}
pub(crate) fn calc_units_incompatible(a: &str, b: &str) -> bool {
match (known_calc_class(a), known_calc_class(b)) {
(Some(ca), Some(cb)) => ca != cb,
_ => false,
}
}
fn value_is_blank(v: &Value) -> bool {
match v {
Value::Null => true,
Value::Str(s) => !s.quoted && s.text.is_empty(),
Value::List(l) => !l.bracketed && l.items.iter().all(value_is_blank),
_ => false,
}
}
impl List {
fn to_css(&self, compressed: bool) -> String {
let sep = match (self.sep, compressed) {
(ListSep::Space | ListSep::Undecided, _) => " ",
(ListSep::Comma, true) => ",",
(ListSep::Comma, false) => ", ",
(ListSep::Slash, true) => "/",
(ListSep::Slash, false) => " / ",
};
let inner = self
.items
.iter()
.filter(|v| !value_is_blank(v))
.map(|v| v.to_css(compressed))
.collect::<Vec<_>>()
.join(sep);
if self.bracketed {
format!("[{inner}]")
} else {
inner
}
}
fn to_interp(&self) -> String {
let sep = match self.sep {
ListSep::Space | ListSep::Undecided => " ",
ListSep::Comma => ", ",
ListSep::Slash => " / ",
};
let inner = self
.items
.iter()
.filter(|v| !value_is_blank(v))
.map(|v| match v {
Value::Str(s) => serialize_unquoted(&s.text),
other => other.to_interp(),
})
.collect::<Vec<_>>()
.join(sep);
if self.bracketed {
format!("[{inner}]")
} else {
inner
}
}
}
impl Color {
pub(crate) fn rgb(r: f64, g: f64, b: f64, a: f64) -> Self {
Color {
r,
g,
b,
a,
repr: None,
modern: None,
}
}
pub(crate) fn from_hex(digits: &str) -> Option<Color> {
let parse = |s: &str| u8::from_str_radix(s, 16).ok().map(|v| v as f64);
let (r, g, b, a) = match digits.len() {
3 => {
let d: Vec<char> = digits.chars().collect();
(
parse(&format!("{0}{0}", d[0]))?,
parse(&format!("{0}{0}", d[1]))?,
parse(&format!("{0}{0}", d[2]))?,
255.0,
)
}
4 => {
let d: Vec<char> = digits.chars().collect();
(
parse(&format!("{0}{0}", d[0]))?,
parse(&format!("{0}{0}", d[1]))?,
parse(&format!("{0}{0}", d[2]))?,
parse(&format!("{0}{0}", d[3]))?,
)
}
6 => (
parse(&digits[0..2])?,
parse(&digits[2..4])?,
parse(&digits[4..6])?,
255.0,
),
8 => (
parse(&digits[0..2])?,
parse(&digits[2..4])?,
parse(&digits[4..6])?,
parse(&digits[6..8])?,
),
_ => return None,
};
let a = a / 255.0;
let opaque = (a - 1.0).abs() < f64::EPSILON;
let repr = if opaque {
match digits.len() {
3 | 6 => Some(format!("#{digits}")),
_ => Some(format!(
"#{:02x}{:02x}{:02x}",
r.round() as u8,
g.round() as u8,
b.round() as u8
)),
}
} else {
None
};
Some(Color {
r,
g,
b,
a,
repr,
modern: None,
})
}
pub(crate) fn to_hsl(&self) -> (f64, f64, f64) {
let r = self.r / 255.0;
let g = self.g / 255.0;
let b = self.b / 255.0;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let l = (max + min) / 2.0;
let d = max - min;
let s = if d == 0.0 {
0.0
} else {
d / (1.0 - (2.0 * l - 1.0).abs())
};
let h = if d == 0.0 {
0.0
} else if max == r {
60.0 * (((g - b) / d).rem_euclid(6.0))
} else if max == g {
60.0 * ((b - r) / d + 2.0)
} else {
60.0 * ((r - g) / d + 4.0)
};
(h.rem_euclid(360.0), s, l)
}
pub(crate) fn from_hsl(h: f64, s: f64, l: f64, a: f64) -> Color {
let h = h.rem_euclid(360.0);
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let x = c * (1.0 - (((h / 60.0) % 2.0) - 1.0).abs());
let m = l - c / 2.0;
let (r1, g1, b1) = if h < 60.0 {
(c, x, 0.0)
} else if h < 120.0 {
(x, c, 0.0)
} else if h < 180.0 {
(0.0, c, x)
} else if h < 240.0 {
(0.0, x, c)
} else if h < 300.0 {
(x, 0.0, c)
} else {
(c, 0.0, x)
};
Color::rgb((r1 + m) * 255.0, (g1 + m) * 255.0, (b1 + m) * 255.0, a)
}
fn channels_are_int(&self) -> bool {
let int = |v: f64| (v - v.round()).abs() < 1e-9;
int(self.r) && int(self.g) && int(self.b)
}
fn compressed_rgb_family(&self) -> String {
let opaque = (self.a - 1.0).abs() < f64::EPSILON;
if opaque && self.channels_are_int() {
let r = self.r.round().clamp(0.0, 255.0) as u8;
let g = self.g.round().clamp(0.0, 255.0) as u8;
let b = self.b.round().clamp(0.0, 255.0) as u8;
let short = shorten_hex(&format!("#{r:02x}{g:02x}{b:02x}"));
if let Some(name) = compressed_color_name(r, g, b) {
if name.len() <= short.len() {
return name.to_string();
}
}
return short;
}
let (r, g, b) = (
fmt_num(self.r, true),
fmt_num(self.g, true),
fmt_num(self.b, true),
);
if opaque {
format!("rgb({r},{g},{b})")
} else {
format!("rgba({r},{g},{b},{})", fmt_num(self.a, true))
}
}
pub(crate) fn to_css(&self, compressed: bool) -> String {
if let Some(m) = &self.modern {
return m.to_css(compressed);
}
if !compressed {
if let Some(repr) = &self.repr {
return repr.clone();
}
}
let opaque = (self.a - 1.0).abs() < f64::EPSILON;
if opaque && self.channels_are_int() {
let r = self.r.round().clamp(0.0, 255.0) as u8;
let g = self.g.round().clamp(0.0, 255.0) as u8;
let b = self.b.round().clamp(0.0, 255.0) as u8;
let hex = format!("#{r:02x}{g:02x}{b:02x}");
if compressed {
let short = shorten_hex(&hex);
if let Some(name) = compressed_color_name(r, g, b) {
if name.len() <= short.len() {
return name.to_string();
}
}
return short;
}
return hex;
}
let (r, g, b) = (
fmt_num(self.r, compressed),
fmt_num(self.g, compressed),
fmt_num(self.b, compressed),
);
let rgb_css = if opaque {
if compressed {
format!("rgb({r},{g},{b})")
} else {
format!("rgb({r}, {g}, {b})")
}
} else {
let a = fmt_num(self.a, compressed);
if compressed {
format!("rgba({r},{g},{b},{a})")
} else {
format!("rgba({r}, {g}, {b}, {a})")
}
};
if compressed {
let in_gamut = |v: f64| (-1e-9..=255.0 + 1e-9).contains(&v);
if in_gamut(self.r) && in_gamut(self.g) && in_gamut(self.b) {
let hsl = crate::builtins::srgb_to_hsl([self.r / 255.0, self.g / 255.0, self.b / 255.0]);
let hh = fmt_num(hsl[0], true);
let ss = fmt_num(hsl[1], true);
let ll = fmt_num(hsl[2], true);
let hsl_css = if opaque {
format!("hsl({hh},{ss}%,{ll}%)")
} else {
format!("hsla({hh},{ss}%,{ll}%,{})", fmt_num(self.a, true))
};
if hsl_css.len() < rgb_css.len() {
return hsl_css;
}
}
}
rgb_css
}
}
pub(crate) fn rgb_name(r: f64, g: f64, b: f64) -> Option<&'static str> {
let int = |v: f64| {
let r = v.round();
if (v - r).abs() < 1e-9 && (0.0..=255.0).contains(&r) {
Some(r as u16)
} else {
None
}
};
let (r, g, b) = (int(r)?, int(g)?, int(b)?);
match (r, g, b) {
(0, 0, 0) => Some("black"),
(0, 0, 128) => Some("navy"),
(0, 0, 139) => Some("darkblue"),
(0, 0, 205) => Some("mediumblue"),
(0, 0, 255) => Some("blue"),
(0, 100, 0) => Some("darkgreen"),
(0, 128, 0) => Some("green"),
(0, 128, 128) => Some("teal"),
(0, 139, 139) => Some("darkcyan"),
(0, 191, 255) => Some("deepskyblue"),
(0, 206, 209) => Some("darkturquoise"),
(0, 250, 154) => Some("mediumspringgreen"),
(0, 255, 0) => Some("lime"),
(0, 255, 127) => Some("springgreen"),
(0, 255, 255) => Some("aqua"),
(25, 25, 112) => Some("midnightblue"),
(30, 144, 255) => Some("dodgerblue"),
(32, 178, 170) => Some("lightseagreen"),
(34, 139, 34) => Some("forestgreen"),
(46, 139, 87) => Some("seagreen"),
(47, 79, 79) => Some("darkslategray"),
(50, 205, 50) => Some("limegreen"),
(60, 179, 113) => Some("mediumseagreen"),
(64, 224, 208) => Some("turquoise"),
(65, 105, 225) => Some("royalblue"),
(70, 130, 180) => Some("steelblue"),
(72, 61, 139) => Some("darkslateblue"),
(72, 209, 204) => Some("mediumturquoise"),
(75, 0, 130) => Some("indigo"),
(85, 107, 47) => Some("darkolivegreen"),
(95, 158, 160) => Some("cadetblue"),
(100, 149, 237) => Some("cornflowerblue"),
(102, 51, 153) => Some("rebeccapurple"),
(102, 205, 170) => Some("mediumaquamarine"),
(105, 105, 105) => Some("dimgray"),
(106, 90, 205) => Some("slateblue"),
(107, 142, 35) => Some("olivedrab"),
(112, 128, 144) => Some("slategray"),
(119, 136, 153) => Some("lightslategray"),
(123, 104, 238) => Some("mediumslateblue"),
(124, 252, 0) => Some("lawngreen"),
(127, 255, 0) => Some("chartreuse"),
(127, 255, 212) => Some("aquamarine"),
(128, 0, 0) => Some("maroon"),
(128, 0, 128) => Some("purple"),
(128, 128, 0) => Some("olive"),
(128, 128, 128) => Some("gray"),
(135, 206, 235) => Some("skyblue"),
(135, 206, 250) => Some("lightskyblue"),
(138, 43, 226) => Some("blueviolet"),
(139, 0, 0) => Some("darkred"),
(139, 0, 139) => Some("darkmagenta"),
(139, 69, 19) => Some("saddlebrown"),
(143, 188, 143) => Some("darkseagreen"),
(144, 238, 144) => Some("lightgreen"),
(147, 112, 219) => Some("mediumpurple"),
(148, 0, 211) => Some("darkviolet"),
(152, 251, 152) => Some("palegreen"),
(153, 50, 204) => Some("darkorchid"),
(154, 205, 50) => Some("yellowgreen"),
(160, 82, 45) => Some("sienna"),
(165, 42, 42) => Some("brown"),
(169, 169, 169) => Some("darkgray"),
(173, 216, 230) => Some("lightblue"),
(173, 255, 47) => Some("greenyellow"),
(175, 238, 238) => Some("paleturquoise"),
(176, 196, 222) => Some("lightsteelblue"),
(176, 224, 230) => Some("powderblue"),
(178, 34, 34) => Some("firebrick"),
(184, 134, 11) => Some("darkgoldenrod"),
(186, 85, 211) => Some("mediumorchid"),
(188, 143, 143) => Some("rosybrown"),
(189, 183, 107) => Some("darkkhaki"),
(192, 192, 192) => Some("silver"),
(199, 21, 133) => Some("mediumvioletred"),
(205, 92, 92) => Some("indianred"),
(205, 133, 63) => Some("peru"),
(210, 105, 30) => Some("chocolate"),
(210, 180, 140) => Some("tan"),
(211, 211, 211) => Some("lightgray"),
(216, 191, 216) => Some("thistle"),
(218, 112, 214) => Some("orchid"),
(218, 165, 32) => Some("goldenrod"),
(219, 112, 147) => Some("palevioletred"),
(220, 20, 60) => Some("crimson"),
(220, 220, 220) => Some("gainsboro"),
(221, 160, 221) => Some("plum"),
(222, 184, 135) => Some("burlywood"),
(224, 255, 255) => Some("lightcyan"),
(230, 230, 250) => Some("lavender"),
(233, 150, 122) => Some("darksalmon"),
(238, 130, 238) => Some("violet"),
(238, 232, 170) => Some("palegoldenrod"),
(240, 128, 128) => Some("lightcoral"),
(240, 230, 140) => Some("khaki"),
(240, 248, 255) => Some("aliceblue"),
(240, 255, 240) => Some("honeydew"),
(240, 255, 255) => Some("azure"),
(244, 164, 96) => Some("sandybrown"),
(245, 222, 179) => Some("wheat"),
(245, 245, 220) => Some("beige"),
(245, 245, 245) => Some("whitesmoke"),
(245, 255, 250) => Some("mintcream"),
(248, 248, 255) => Some("ghostwhite"),
(250, 128, 114) => Some("salmon"),
(250, 235, 215) => Some("antiquewhite"),
(250, 240, 230) => Some("linen"),
(250, 250, 210) => Some("lightgoldenrodyellow"),
(253, 245, 230) => Some("oldlace"),
(255, 0, 0) => Some("red"),
(255, 0, 255) => Some("fuchsia"),
(255, 20, 147) => Some("deeppink"),
(255, 69, 0) => Some("orangered"),
(255, 99, 71) => Some("tomato"),
(255, 105, 180) => Some("hotpink"),
(255, 127, 80) => Some("coral"),
(255, 140, 0) => Some("darkorange"),
(255, 160, 122) => Some("lightsalmon"),
(255, 165, 0) => Some("orange"),
(255, 182, 193) => Some("lightpink"),
(255, 192, 203) => Some("pink"),
(255, 215, 0) => Some("gold"),
(255, 218, 185) => Some("peachpuff"),
(255, 222, 173) => Some("navajowhite"),
(255, 228, 181) => Some("moccasin"),
(255, 228, 196) => Some("bisque"),
(255, 228, 225) => Some("mistyrose"),
(255, 235, 205) => Some("blanchedalmond"),
(255, 239, 213) => Some("papayawhip"),
(255, 240, 245) => Some("lavenderblush"),
(255, 245, 238) => Some("seashell"),
(255, 248, 220) => Some("cornsilk"),
(255, 250, 205) => Some("lemonchiffon"),
(255, 250, 240) => Some("floralwhite"),
(255, 250, 250) => Some("snow"),
(255, 255, 0) => Some("yellow"),
(255, 255, 224) => Some("lightyellow"),
(255, 255, 240) => Some("ivory"),
(255, 255, 255) => Some("white"),
_ => None,
}
}
fn shorten_hex(hex: &str) -> String {
let b = hex.as_bytes();
if b.len() == 7 && b[1] == b[2] && b[3] == b[4] && b[5] == b[6] {
format!("#{}{}{}", b[1] as char, b[3] as char, b[5] as char)
} else {
hex.to_string()
}
}
fn compressed_color_name(r: u8, g: u8, b: u8) -> Option<&'static str> {
Some(match (r, g, b) {
(0, 255, 255) => "aqua",
(240, 255, 255) => "azure",
(245, 245, 220) => "beige",
(255, 228, 196) => "bisque",
(0, 0, 255) => "blue",
(165, 42, 42) => "brown",
(255, 127, 80) => "coral",
(220, 20, 60) => "crimson",
(139, 0, 0) => "darkred",
(105, 105, 105) => "dimgray",
(255, 215, 0) => "gold",
(128, 128, 128) => "gray",
(0, 128, 0) => "green",
(255, 105, 180) => "hotpink",
(75, 0, 130) => "indigo",
(255, 255, 240) => "ivory",
(240, 230, 140) => "khaki",
(0, 255, 0) => "lime",
(250, 240, 230) => "linen",
(128, 0, 0) => "maroon",
(0, 0, 128) => "navy",
(253, 245, 230) => "oldlace",
(128, 128, 0) => "olive",
(255, 165, 0) => "orange",
(218, 112, 214) => "orchid",
(205, 133, 63) => "peru",
(255, 192, 203) => "pink",
(221, 160, 221) => "plum",
(128, 0, 128) => "purple",
(255, 0, 0) => "red",
(250, 128, 114) => "salmon",
(160, 82, 45) => "sienna",
(192, 192, 192) => "silver",
(135, 206, 235) => "skyblue",
(255, 250, 250) => "snow",
(210, 180, 140) => "tan",
(0, 128, 128) => "teal",
(216, 191, 216) => "thistle",
(255, 99, 71) => "tomato",
(238, 130, 238) => "violet",
(245, 222, 179) => "wheat",
_ => return None,
})
}
impl ModernColor {
fn has_missing(&self) -> bool {
self.channels.iter().any(|c| c.is_none()) || self.alpha.is_none()
}
fn is_opaque(&self) -> bool {
matches!(self.alpha, Some(a) if (a - 1.0).abs() < f64::EPSILON)
}
fn chan(&self, i: usize, compressed: bool) -> String {
match self.channels[i] {
None => "none".to_string(),
Some(v) if !v.is_finite() => Number::unitless(v).to_css(compressed),
Some(v) => fmt_num(v, compressed),
}
}
fn chan_pct(&self, i: usize, denom: f64, compressed: bool) -> String {
match self.channels[i] {
None => "none".to_string(),
Some(v) if !v.is_finite() => Number::with_unit(v, "%".to_string()).to_css(compressed),
Some(v) => format!("{}%", fmt_num(v / denom * 100.0, compressed)),
}
}
fn chan_hue(&self, i: usize, compressed: bool) -> String {
match self.channels[i] {
None => "none".to_string(),
Some(v) if !v.is_finite() => Number::with_unit(v, "deg".to_string()).to_css(compressed),
Some(v) => format!("{}deg", fmt_num(v, compressed)),
}
}
fn alpha_str(&self, compressed: bool) -> String {
match self.alpha {
None => "none".to_string(),
Some(a) => fmt_num(a, compressed),
}
}
pub(crate) fn to_css(&self, compressed: bool) -> String {
use ColorSpace::*;
let sp = if compressed { "" } else { " " };
if self.space.is_legacy() && !self.has_missing() {
return self.legacy_css(compressed);
}
if matches!(self.space, Lab | Lch | Oklab | Oklch) {
if let (Some(l), Some(_), Some(_)) = (self.channels[0], self.channels[1], self.channels[2]) {
let max = if matches!(self.space, Lab | Lch) {
100.0
} else {
1.0
};
let fuzzy_eq = |a: f64, b: f64| (a - b).abs() < 1e-11;
let in_range = (l > 0.0 || fuzzy_eq(l, 0.0)) && (l < max || fuzzy_eq(l, max));
if !in_range {
return self.out_of_range_css(compressed);
}
}
}
match self.space {
Rgb | Hsl | Hwb => {
let body = match self.space {
Hsl => format!(
"{} {} {}",
self.chan_hue(0, compressed),
self.pct_or_none(1, compressed),
self.pct_or_none(2, compressed),
),
Hwb => format!(
"{} {} {}",
self.chan_hue(0, compressed),
self.pct_or_none(1, compressed),
self.pct_or_none(2, compressed),
),
_ => format!(
"{} {} {}",
self.chan(0, compressed),
self.chan(1, compressed),
self.chan(2, compressed),
),
};
self.wrap_modern(self.space.name(), &body, compressed)
}
Lab => {
let body = format!(
"{} {} {}",
self.chan_pct(0, 100.0, compressed),
self.chan(1, compressed),
self.chan(2, compressed),
);
self.wrap_modern("lab", &body, compressed)
}
Lch => {
let body = format!(
"{} {} {}",
self.chan_pct(0, 100.0, compressed),
self.chan(1, compressed),
self.chan_hue(2, compressed),
);
self.wrap_modern("lch", &body, compressed)
}
Oklab => {
let body = format!(
"{} {} {}",
self.chan_pct(0, 1.0, compressed),
self.chan(1, compressed),
self.chan(2, compressed),
);
self.wrap_modern("oklab", &body, compressed)
}
Oklch => {
let body = format!(
"{} {} {}",
self.chan_pct(0, 1.0, compressed),
self.chan(1, compressed),
self.chan_hue(2, compressed),
);
self.wrap_modern("oklch", &body, compressed)
}
_ => {
let body = format!(
"{}{sp}{}{sp}{}",
self.chan(0, compressed),
self.chan(1, compressed),
self.chan(2, compressed),
);
let space = self.space.name();
if self.is_opaque() {
format!("color({space}{sp}{body})")
} else {
format!("color({space}{sp}{body}{sp}/{sp}{})", self.alpha_str(compressed))
}
}
}
}
pub(crate) fn inspect_css(&self) -> String {
if self.space.is_legacy() && !self.has_missing() {
let a = self.alpha.unwrap_or(0.0);
let opaque = (a - 1.0).abs() < 1e-11;
match self.space {
ColorSpace::Hwb => {
let h = fmt_num(self.channels[0].unwrap_or(0.0), false);
let w = fmt_num(self.channels[1].unwrap_or(0.0), false);
let b = fmt_num(self.channels[2].unwrap_or(0.0), false);
return if opaque {
format!("hwb({h} {w}% {b}%)")
} else {
format!("hwb({h} {w}% {b}% / {})", fmt_num(a, false))
};
}
ColorSpace::Rgb => {
let in_gamut =
|i: usize| (-1e-9..=255.0 + 1e-9).contains(&self.channels[i].unwrap_or(0.0));
if !(in_gamut(0) && in_gamut(1) && in_gamut(2)) {
let r = fmt_num(self.channels[0].unwrap_or(0.0), false);
let g = fmt_num(self.channels[1].unwrap_or(0.0), false);
let b = fmt_num(self.channels[2].unwrap_or(0.0), false);
return if opaque {
format!("rgb({r}, {g}, {b})")
} else {
format!("rgba({r}, {g}, {b}, {})", fmt_num(a, false))
};
}
}
_ => {}
}
}
self.to_css(false)
}
fn out_of_range_css(&self, compressed: bool) -> String {
let xyz = crate::builtins::convert_modern(self, ColorSpace::XyzD65);
let x = xyz.chan(0, compressed);
let y = xyz.chan(1, compressed);
let z = xyz.chan(2, compressed);
let alpha = self.alpha.unwrap_or(0.0);
let opaque = self.alpha.is_some() && (alpha - 1.0).abs() < f64::EPSILON;
let inner = if opaque {
format!("color(xyz {x} {y} {z})")
} else if compressed {
format!("color(xyz {x} {y} {z}/{})", fmt_num(alpha, true))
} else {
format!("color(xyz {x} {y} {z} / {})", fmt_num(alpha, false))
};
let space = self.space.name();
if compressed {
format!("color-mix(in {space},{inner}100%,red)")
} else {
format!("color-mix(in {space}, {inner} 100%, black)")
}
}
fn pct_or_none(&self, i: usize, compressed: bool) -> String {
match self.channels[i] {
None => "none".to_string(),
Some(v) => format!("{}%", fmt_num(v, compressed)),
}
}
fn wrap_modern(&self, name: &str, body: &str, compressed: bool) -> String {
let sp = if compressed { "" } else { " " };
if self.is_opaque() && self.alpha.is_some() {
format!("{name}({body})")
} else {
format!("{name}({body}{sp}/{sp}{})", self.alpha_str(compressed))
}
}
fn legacy_css(&self, compressed: bool) -> String {
let a = self.alpha.unwrap_or(1.0);
let opaque = (a - 1.0).abs() < f64::EPSILON;
match self.space {
ColorSpace::Rgb => {
let r = self.channels[0].unwrap_or(0.0);
let g = self.channels[1].unwrap_or(0.0);
let b = self.channels[2].unwrap_or(0.0);
let in_gamut = |v: f64| (-1e-9..=255.0 + 1e-9).contains(&v);
if !(in_gamut(r) && in_gamut(g) && in_gamut(b)) {
let hsl = crate::builtins::srgb_to_hsl([r / 255.0, g / 255.0, b / 255.0]);
let hh = fmt_num(hsl[0], compressed);
let ss = fmt_num(hsl[1], compressed);
let ll = fmt_num(hsl[2], compressed);
let comma = if compressed { "," } else { ", " };
return if opaque {
format!("hsl({hh}{comma}{ss}%{comma}{ll}%)")
} else {
let aa = fmt_num(a, compressed);
format!("hsla({hh}{comma}{ss}%{comma}{ll}%{comma}{aa})")
};
}
Color {
r,
g,
b,
a,
repr: None,
modern: None,
}
.to_css(compressed)
}
ColorSpace::Hsl => {
let (h, s, l) = self.legacy_hsl_triple();
let hsl_css = self.hsl_comma_css(h, s, l, a, opaque, compressed);
if compressed {
let rgb = Color::from_hsl(h, s / 100.0, l / 100.0, a);
let in_gamut = |v: f64| (-1e-9..=255.0 + 1e-9).contains(&v);
if in_gamut(rgb.r) && in_gamut(rgb.g) && in_gamut(rgb.b) {
let other = rgb.compressed_rgb_family();
if other.len() <= hsl_css.len() {
return other;
}
}
}
hsl_css
}
ColorSpace::Hwb => {
let (rgb, h, s, l) = self.hwb_rgb_and_hsl();
let int = |v: f64| (v - v.round()).abs() < 1e-9;
let in_gamut = |v: f64| (-1e-9..=255.0 + 1e-9).contains(&v);
let rgb_ok = rgb.iter().all(|&v| int(v) && in_gamut(v));
if rgb_ok && opaque {
if !compressed {
if let Some(name) = rgb_name(rgb[0], rgb[1], rgb[2]) {
return name.to_string();
}
}
return Color {
r: rgb[0],
g: rgb[1],
b: rgb[2],
a,
repr: None,
modern: None,
}
.to_css(compressed);
}
self.hsl_comma_css(h, s, l, a, opaque, compressed)
}
_ => String::new(),
}
}
fn hsl_comma_css(&self, h: f64, s: f64, l: f64, a: f64, opaque: bool, compressed: bool) -> String {
let (h, s) = if s < 0.0 {
((h + 180.0).rem_euclid(360.0), -s)
} else {
(h, s)
};
let hh = if h.is_finite() {
fmt_num(h, compressed)
} else {
Number::unitless(h).to_css(compressed)
};
let pct = |v: f64| {
if v.is_finite() {
format!("{}%", fmt_num(v, compressed))
} else {
Number::with_unit(v, "%").to_css(compressed)
}
};
let ss = pct(s);
let ll = pct(l);
let comma = if compressed { "," } else { ", " };
if opaque {
format!("hsl({hh}{comma}{ss}{comma}{ll})")
} else {
let aa = fmt_num(a, compressed);
format!("hsla({hh}{comma}{ss}{comma}{ll}{comma}{aa})")
}
}
fn hwb_rgb_and_hsl(&self) -> ([f64; 3], f64, f64, f64) {
let hwb = [
self.channels[0].unwrap_or(0.0),
self.channels[1].unwrap_or(0.0),
self.channels[2].unwrap_or(0.0),
];
let rgb01 = crate::builtins::hwb_to_srgb(hwb);
let hsl = crate::builtins::srgb_to_hsl(rgb01);
let hue = if hsl[1].abs() < 1e-11 { 0.0 } else { hsl[0] };
(
[rgb01[0] * 255.0, rgb01[1] * 255.0, rgb01[2] * 255.0],
hue,
hsl[1],
hsl[2],
)
}
fn legacy_hsl_triple(&self) -> (f64, f64, f64) {
match self.space {
ColorSpace::Hsl => (
self.channels[0].unwrap_or(0.0),
self.channels[1].unwrap_or(0.0),
self.channels[2].unwrap_or(0.0),
),
ColorSpace::Hwb => {
let (_, h, s, l) = self.hwb_rgb_and_hsl();
(h, s, l)
}
_ => (0.0, 0.0, 0.0),
}
}
}
pub(crate) fn fmt_num(n: f64, compressed: bool) -> String {
if n.is_nan() {
return "NaN".to_string();
}
if n.is_infinite() {
return if n > 0.0 {
"Infinity".to_string()
} else {
"-Infinity".to_string()
};
}
let mut s = if n.fract() == 0.0 {
let i = n as i64;
if i as f64 == n {
fmt_i64(i)
} else {
let mut s = String::with_capacity(24);
crate::ryu::format64(n, &mut s);
s
}
} else {
round_decimal_string(ecma_shortest(n))
};
if s == "-0" {
s = "0".to_string();
}
if compressed {
if let Some(rest) = s.strip_prefix("0.") {
s = format!(".{rest}");
} else if let Some(rest) = s.strip_prefix("-0.") {
s = format!("-.{rest}");
}
}
s
}
fn fmt_i64(v: i64) -> String {
let mut buf = [0u8; 20];
let neg = v < 0;
let mut u = v.unsigned_abs();
let mut at = buf.len();
loop {
at -= 1;
buf[at] = b'0' + (u % 10) as u8;
u /= 10;
if u == 0 {
break;
}
}
if neg {
at -= 1;
buf[at] = b'-';
}
std::str::from_utf8(&buf[at..]).expect("ascii").to_string()
}
fn ecma_shortest(n: f64) -> String {
let mut rust = String::with_capacity(24);
crate::ryu::format64(n, &mut rust);
let sig = rust
.chars()
.filter(|c| c.is_ascii_digit())
.skip_while(|&c| c == '0')
.count();
if sig == 0 {
return rust;
}
if !rust.contains('e') && n.abs() >= f64::MIN_POSITIVE {
if sig <= 15 {
return rust;
}
if let Some(dot) = rust.find('.') {
if rust.len() - dot - 1 > 11 {
return rust;
}
}
}
let sci = format!("{:.*e}", sig - 1, n);
if sci.parse::<f64>() != Ok(n) {
return rust;
}
let expanded = expand_exponent(&sci);
if expanded.parse::<f64>() == Ok(n) {
expanded
} else {
rust
}
}
fn expand_exponent(sci: &str) -> String {
let Some(epos) = sci.find('e') else {
return sci.to_string();
};
let exp: i32 = sci[epos + 1..].parse().unwrap_or(0);
let mantissa = &sci[..epos];
let neg = mantissa.starts_with('-');
let digits: String = mantissa.chars().filter(|c| c.is_ascii_digit()).collect();
let sign = if neg { "-" } else { "" };
let point = exp + 1;
if point <= 0 {
let zeros = "0".repeat((-point) as usize);
format!("{sign}0.{zeros}{digits}")
} else if (point as usize) >= digits.len() {
let zeros = "0".repeat(point as usize - digits.len());
format!("{sign}{digits}{zeros}")
} else {
format!(
"{sign}{}.{}",
&digits[..point as usize],
&digits[point as usize..]
)
}
}
fn round_decimal_string(text: String) -> String {
let Some(dot) = text.find('.') else {
return text;
};
if text.len() - dot - 1 <= 10 {
return trim_fraction(text);
}
let mut bytes: Vec<u8> = text.into_bytes();
let round_up = bytes[dot + 11] >= b'5';
bytes.truncate(dot + 11);
if round_up {
let mut i = bytes.len() - 1;
loop {
match bytes[i] {
b'.' => i -= 1,
b'9' => {
bytes[i] = b'0';
if i == 0 {
bytes.insert(0, b'1');
break;
}
i -= 1;
}
b'-' => {
bytes.insert(i + 1, b'1');
break;
}
_ => {
bytes[i] += 1;
break;
}
}
}
}
trim_fraction(String::from_utf8(bytes).expect("ascii decimal"))
}
fn trim_fraction(mut s: String) -> String {
if s.contains('.') {
while s.ends_with('0') {
s.pop();
}
if s.ends_with('.') {
s.pop();
}
}
if s == "-0" || s == "0" {
return "0".to_string();
}
s
}
pub(crate) fn named_color(name: &str) -> Option<Color> {
let (r, g, b, a) = match name.to_ascii_lowercase().as_str() {
"transparent" => (0, 0, 0, 0.0),
"aliceblue" => (240, 248, 255, 1.0),
"antiquewhite" => (250, 235, 215, 1.0),
"aqua" | "cyan" => (0, 255, 255, 1.0),
"aquamarine" => (127, 255, 212, 1.0),
"azure" => (240, 255, 255, 1.0),
"beige" => (245, 245, 220, 1.0),
"bisque" => (255, 228, 196, 1.0),
"black" => (0, 0, 0, 1.0),
"blanchedalmond" => (255, 235, 205, 1.0),
"blue" => (0, 0, 255, 1.0),
"blueviolet" => (138, 43, 226, 1.0),
"brown" => (165, 42, 42, 1.0),
"burlywood" => (222, 184, 135, 1.0),
"cadetblue" => (95, 158, 160, 1.0),
"chartreuse" => (127, 255, 0, 1.0),
"chocolate" => (210, 105, 30, 1.0),
"coral" => (255, 127, 80, 1.0),
"cornflowerblue" => (100, 149, 237, 1.0),
"cornsilk" => (255, 248, 220, 1.0),
"crimson" => (220, 20, 60, 1.0),
"darkblue" => (0, 0, 139, 1.0),
"darkcyan" => (0, 139, 139, 1.0),
"darkgoldenrod" => (184, 134, 11, 1.0),
"darkgray" | "darkgrey" => (169, 169, 169, 1.0),
"darkgreen" => (0, 100, 0, 1.0),
"darkkhaki" => (189, 183, 107, 1.0),
"darkmagenta" => (139, 0, 139, 1.0),
"darkolivegreen" => (85, 107, 47, 1.0),
"darkorange" => (255, 140, 0, 1.0),
"darkorchid" => (153, 50, 204, 1.0),
"darkred" => (139, 0, 0, 1.0),
"darksalmon" => (233, 150, 122, 1.0),
"darkseagreen" => (143, 188, 143, 1.0),
"darkslateblue" => (72, 61, 139, 1.0),
"darkslategray" | "darkslategrey" => (47, 79, 79, 1.0),
"darkturquoise" => (0, 206, 209, 1.0),
"darkviolet" => (148, 0, 211, 1.0),
"deeppink" => (255, 20, 147, 1.0),
"deepskyblue" => (0, 191, 255, 1.0),
"dimgray" | "dimgrey" => (105, 105, 105, 1.0),
"dodgerblue" => (30, 144, 255, 1.0),
"firebrick" => (178, 34, 34, 1.0),
"floralwhite" => (255, 250, 240, 1.0),
"forestgreen" => (34, 139, 34, 1.0),
"fuchsia" | "magenta" => (255, 0, 255, 1.0),
"gainsboro" => (220, 220, 220, 1.0),
"ghostwhite" => (248, 248, 255, 1.0),
"gold" => (255, 215, 0, 1.0),
"goldenrod" => (218, 165, 32, 1.0),
"gray" | "grey" => (128, 128, 128, 1.0),
"green" => (0, 128, 0, 1.0),
"greenyellow" => (173, 255, 47, 1.0),
"honeydew" => (240, 255, 240, 1.0),
"hotpink" => (255, 105, 180, 1.0),
"indianred" => (205, 92, 92, 1.0),
"indigo" => (75, 0, 130, 1.0),
"ivory" => (255, 255, 240, 1.0),
"khaki" => (240, 230, 140, 1.0),
"lavender" => (230, 230, 250, 1.0),
"lavenderblush" => (255, 240, 245, 1.0),
"lawngreen" => (124, 252, 0, 1.0),
"lemonchiffon" => (255, 250, 205, 1.0),
"lightblue" => (173, 216, 230, 1.0),
"lightcoral" => (240, 128, 128, 1.0),
"lightcyan" => (224, 255, 255, 1.0),
"lightgoldenrodyellow" => (250, 250, 210, 1.0),
"lightgray" | "lightgrey" => (211, 211, 211, 1.0),
"lightgreen" => (144, 238, 144, 1.0),
"lightpink" => (255, 182, 193, 1.0),
"lightsalmon" => (255, 160, 122, 1.0),
"lightseagreen" => (32, 178, 170, 1.0),
"lightskyblue" => (135, 206, 250, 1.0),
"lightslategray" | "lightslategrey" => (119, 136, 153, 1.0),
"lightsteelblue" => (176, 196, 222, 1.0),
"lightyellow" => (255, 255, 224, 1.0),
"lime" => (0, 255, 0, 1.0),
"limegreen" => (50, 205, 50, 1.0),
"linen" => (250, 240, 230, 1.0),
"maroon" => (128, 0, 0, 1.0),
"mediumaquamarine" => (102, 205, 170, 1.0),
"mediumblue" => (0, 0, 205, 1.0),
"mediumorchid" => (186, 85, 211, 1.0),
"mediumpurple" => (147, 112, 219, 1.0),
"mediumseagreen" => (60, 179, 113, 1.0),
"mediumslateblue" => (123, 104, 238, 1.0),
"mediumspringgreen" => (0, 250, 154, 1.0),
"mediumturquoise" => (72, 209, 204, 1.0),
"mediumvioletred" => (199, 21, 133, 1.0),
"midnightblue" => (25, 25, 112, 1.0),
"mintcream" => (245, 255, 250, 1.0),
"mistyrose" => (255, 228, 225, 1.0),
"moccasin" => (255, 228, 181, 1.0),
"navajowhite" => (255, 222, 173, 1.0),
"navy" => (0, 0, 128, 1.0),
"oldlace" => (253, 245, 230, 1.0),
"olive" => (128, 128, 0, 1.0),
"olivedrab" => (107, 142, 35, 1.0),
"orange" => (255, 165, 0, 1.0),
"orangered" => (255, 69, 0, 1.0),
"orchid" => (218, 112, 214, 1.0),
"palegoldenrod" => (238, 232, 170, 1.0),
"palegreen" => (152, 251, 152, 1.0),
"paleturquoise" => (175, 238, 238, 1.0),
"palevioletred" => (219, 112, 147, 1.0),
"papayawhip" => (255, 239, 213, 1.0),
"peachpuff" => (255, 218, 185, 1.0),
"peru" => (205, 133, 63, 1.0),
"pink" => (255, 192, 203, 1.0),
"plum" => (221, 160, 221, 1.0),
"powderblue" => (176, 224, 230, 1.0),
"purple" => (128, 0, 128, 1.0),
"rebeccapurple" => (102, 51, 153, 1.0),
"red" => (255, 0, 0, 1.0),
"rosybrown" => (188, 143, 143, 1.0),
"royalblue" => (65, 105, 225, 1.0),
"saddlebrown" => (139, 69, 19, 1.0),
"salmon" => (250, 128, 114, 1.0),
"sandybrown" => (244, 164, 96, 1.0),
"seagreen" => (46, 139, 87, 1.0),
"seashell" => (255, 245, 238, 1.0),
"sienna" => (160, 82, 45, 1.0),
"silver" => (192, 192, 192, 1.0),
"skyblue" => (135, 206, 235, 1.0),
"slateblue" => (106, 90, 205, 1.0),
"slategray" | "slategrey" => (112, 128, 144, 1.0),
"snow" => (255, 250, 250, 1.0),
"springgreen" => (0, 255, 127, 1.0),
"steelblue" => (70, 130, 180, 1.0),
"tan" => (210, 180, 140, 1.0),
"teal" => (0, 128, 128, 1.0),
"thistle" => (216, 191, 216, 1.0),
"tomato" => (255, 99, 71, 1.0),
"turquoise" => (64, 224, 208, 1.0),
"violet" => (238, 130, 238, 1.0),
"wheat" => (245, 222, 179, 1.0),
"white" => (255, 255, 255, 1.0),
"whitesmoke" => (245, 245, 245, 1.0),
"yellow" => (255, 255, 0, 1.0),
"yellowgreen" => (154, 205, 50, 1.0),
_ => return None,
};
Some(Color {
r: r as f64,
g: g as f64,
b: b as f64,
a,
repr: Some(name.to_string()),
modern: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fmt_num_trims_and_rounds() {
assert_eq!(fmt_num(153.0, false), "153");
assert_eq!(fmt_num(178.5, false), "178.5");
assert_eq!(fmt_num(0.5, false), "0.5");
assert_eq!(fmt_num(-0.0, false), "0");
assert_eq!(fmt_num(16.0, false), "16");
}
#[test]
fn fmt_num_large_values_stay_plain_decimals() {
assert_eq!(fmt_num(123456789012345.0, false), "123456789012345");
assert_eq!(fmt_num(-123456789012345.0, false), "-123456789012345");
assert_eq!(fmt_num(1e20, false), "100000000000000000000");
assert_eq!(fmt_num(1234567890123456789.0, false), "1234567890123456768");
assert_eq!(fmt_num(593644542057412224.0, false), "593644542057412224");
assert_eq!(fmt_num(9223372036854775808.0, false), "9223372036854775807");
assert_eq!(fmt_num(92233720368547758070.0, false), "92233720368547760000");
assert_eq!(
fmt_num(99999999999999999999999999999.0, false),
"100000000000000000000000000000"
);
}
#[test]
fn fmt_num_rounds_fractions_to_ten_places() {
assert_eq!(fmt_num(0.1 + 0.2, false), "0.3");
assert_eq!(fmt_num(1.0 / 3.0, false), "0.3333333333");
assert_eq!(fmt_num(2.0 / 3.0, false), "0.6666666667");
assert_eq!(fmt_num(123456.78901234567, false), "123456.7890123457");
assert_eq!(fmt_num(1e-11, false), "0");
assert_eq!(fmt_num(0.00000000015, false), "0.0000000002");
assert_eq!(fmt_num(0.00000000035, false), "0.0000000004");
assert_eq!(fmt_num(0.99999999995, false), "1");
assert_eq!(fmt_num(0.30000000005, false), "0.3000000001");
}
#[test]
fn fmt_num_compressed_drops_leading_zero() {
assert_eq!(fmt_num(0.5, true), ".5");
assert_eq!(fmt_num(-0.25, true), "-.25");
assert_eq!(fmt_num(2.0, true), "2");
}
fn num(value: f64, unit: &str) -> CalcNode {
CalcNode::Number(Number::with_unit(value, unit.to_string()))
}
#[test]
fn calc_serialization_drops_redundant_parens() {
let node = CalcNode::Op {
op: CalcOp::Add,
left: Box::new(num(1.0, "px")),
right: Box::new(CalcNode::Op {
op: CalcOp::Mul,
left: Box::new(num(2.0, "%")),
right: Box::new(CalcNode::Str("var(--c)".into())),
}),
};
assert_eq!(Value::Calc(node).to_css(false), "calc(1px + 2% * var(--c))");
}
#[test]
fn calc_serialization_keeps_required_parens_and_flips_sign() {
let node = CalcNode::Op {
op: CalcOp::Sub,
left: Box::new(num(1.0, "px")),
right: Box::new(CalcNode::Op {
op: CalcOp::Add,
left: Box::new(num(2.0, "%")),
right: Box::new(CalcNode::Str("var(--c)".into())),
}),
};
assert_eq!(Value::Calc(node).to_css(false), "calc(1px - (2% + var(--c)))");
let flip = CalcNode::Op {
op: CalcOp::Add,
left: Box::new(num(1.0, "%")),
right: Box::new(num(-1.0, "px")),
};
assert_eq!(Value::Calc(flip).to_css(false), "calc(1% - 1px)");
}
#[test]
fn hex_parsing_and_serialization() {
let c = Color::from_hex("336699").expect("valid hex");
assert_eq!(c.to_css(false), "#336699");
let short = Color::from_hex("369").expect("valid hex");
assert_eq!(short.to_css(false), "#369");
let upper = Color::from_hex("FFAA00").expect("valid hex");
assert_eq!(upper.to_css(false), "#FFAA00");
let upper_short = Color::from_hex("ABC").expect("valid hex");
assert_eq!(upper_short.to_css(false), "#ABC");
assert_eq!(upper.to_css(true), "#fa0");
let opaque4 = Color::from_hex("369f").expect("valid hex");
assert_eq!(opaque4.to_css(false), "#336699");
let partial = Color::from_hex("33669980").expect("valid hex");
assert!(partial.to_css(false).starts_with("rgba("));
}
#[test]
fn compressed_uses_shortest_of_name_or_hex() {
let name = |hex: &str| Color::from_hex(hex).expect("valid hex").to_css(true);
assert_eq!(name("ff0000"), "red"); assert_eq!(name("ffa500"), "orange"); assert_eq!(name("00ffff"), "aqua");
assert_eq!(name("0000ff"), "blue");
assert_eq!(name("00ff00"), "lime");
assert_eq!(name("ff00ff"), "#f0f"); assert_eq!(name("ffffff"), "#fff"); assert_eq!(name("3366cc"), "#36c");
assert_eq!(named_color("cyan").unwrap().to_css(true), "aqua");
assert_eq!(named_color("grey").unwrap().to_css(true), "gray");
assert_eq!(Color::rgb(0.0, 255.0, 255.0, 1.0).to_css(true), "aqua");
assert_eq!(Color::from_hex("ff0000").unwrap().to_css(false), "#ff0000");
assert_eq!(named_color("red").unwrap().to_css(false), "red");
}
#[test]
fn hsl_roundtrip_is_exact_for_integer_rgb() {
let c = Color::from_hex("336699").expect("valid hex");
let (h, s, l) = c.to_hsl();
assert!((h - 210.0).abs() < 1e-9);
assert!((s - 0.5).abs() < 1e-9);
assert!((l - 0.4).abs() < 1e-9);
let lit = Color::from_hsl(h, s, l + 0.1, 1.0);
assert_eq!(lit.to_css(false), "rgb(63.75, 127.5, 191.25)");
}
#[test]
fn computed_fractional_channels_serialize_as_rgb() {
let c = Color::rgb(153.0, 178.5, 204.0, 1.0);
assert_eq!(c.to_css(false), "rgb(153, 178.5, 204)");
}
#[test]
fn compressed_picks_shortest_of_rgb_and_hsl() {
let darkened = Color::from_hsl(210.0, 0.5, 0.3, 1.0); assert_eq!(darkened.to_css(true), "hsl(210,50%,30%)");
assert_eq!(darkened.to_css(false), "rgb(38.25, 76.5, 114.75)");
let saturated = Color::rgb(159.8, 112.2, 112.2, 1.0);
assert_eq!(saturated.to_css(true), "rgb(159.8,112.2,112.2)");
let translucent = Color::from_hsl(210.0, 0.5, 0.3, 0.5);
assert_eq!(translucent.to_css(true), "hsla(210,50%,30%,.5)");
let gray = Color::rgb(127.5, 127.5, 127.5, 1.0);
assert_eq!(gray.to_css(true), "hsl(0,0%,50%)");
let integer = Color::from_hsl(210.0, 0.5, 0.4, 1.0); assert_eq!(integer.to_css(true), "#369");
assert_eq!(Color::rgb(0.0, 0.0, 0.0, 0.5).to_css(true), "rgba(0,0,0,.5)");
}
#[test]
fn alpha_color_serializes_as_rgba() {
let c = Color::rgb(0.0, 0.0, 0.0, 0.5);
assert_eq!(c.to_css(false), "rgba(0, 0, 0, 0.5)");
}
#[test]
fn named_colors_resolve_and_preserve_spelling() {
let red = named_color("red").expect("named");
assert_eq!(red.to_css(false), "red");
assert!(named_color("definitely-not-a-color").is_none());
let plum = named_color("plum").expect("named");
assert_eq!((plum.r, plum.g, plum.b, plum.a), (221.0, 160.0, 221.0, 1.0));
let rebecca = named_color("rebeccapurple").expect("named");
assert_eq!((rebecca.r, rebecca.g, rebecca.b), (102.0, 51.0, 153.0));
let grey = named_color("darkgrey").expect("named");
let gray = named_color("darkgray").expect("named");
assert_eq!((grey.r, grey.g, grey.b), (gray.r, gray.g, gray.b));
}
#[test]
fn unit_dimensions_group_known_units() {
assert_eq!(unit_dimension("px"), Some(Dim::Length));
assert_eq!(unit_dimension("deg"), Some(Dim::Angle));
assert_eq!(unit_dimension("ms"), Some(Dim::Time));
assert_eq!(unit_dimension("kHz"), Some(Dim::Frequency));
assert_eq!(unit_dimension("dppx"), Some(Dim::Resolution));
assert_eq!(unit_dimension("PT"), None);
assert_eq!(unit_dimension("khz"), None);
assert_eq!(unit_dimension("%"), None);
assert_eq!(unit_dimension("vw"), None);
assert_eq!(unit_dimension(""), None);
}
#[test]
fn units_compatible_within_groups_only() {
assert!(units_compatible("in", "cm"));
assert!(units_compatible("deg", "turn"));
assert!(units_compatible("s", "ms"));
assert!(units_compatible("Hz", "kHz"));
assert!(units_compatible("dpi", "dppx"));
assert!(units_compatible("%", "%"));
assert!(units_compatible("PX", "PX"));
assert!(!units_compatible("px", "s"));
assert!(!units_compatible("px", "vw"));
assert!(!units_compatible("px", "PX"));
assert!(!units_compatible("khz", "hz"));
}
#[test]
fn convert_factor_matches_dart_sass() {
assert_eq!(convert_factor("in", "px"), Some(96.0));
assert_eq!(convert_factor("px", "px"), Some(1.0));
let cm_to_in = convert_factor("cm", "in").expect("compatible");
assert!((cm_to_in - (1.0 / 2.54)).abs() < 1e-12);
assert!((1.0 + cm_to_in - 1.393700787401575).abs() < 1e-12);
assert_eq!(convert_factor("ms", "s"), Some(0.001));
assert_eq!(convert_factor("turn", "deg"), Some(360.0));
assert_eq!(convert_factor("grad", "deg"), Some(0.9));
assert_eq!(convert_factor("dppx", "dpi"), Some(96.0));
assert_eq!(convert_factor("px", "s"), None);
assert_eq!(convert_factor("px", "vw"), None);
}
fn numval(value: f64, unit: &str) -> Value {
Value::Number(Number::with_unit(value, unit.to_string()))
}
#[test]
fn sass_eq_numbers_are_unit_aware_and_fuzzy() {
assert!(numval(2.0, "px").sass_eq(&numval(2.0, "px")));
assert!(numval(1.0, "in").sass_eq(&numval(96.0, "px")));
assert!(numval(96.0, "px").sass_eq(&numval(1.0, "in")));
assert!(numval(1.0, "cm").sass_eq(&numval(10.0, "mm")));
assert!(numval(100.0, "grad").sass_eq(&numval(90.0, "deg")));
assert!(numval(1.0, "s").sass_eq(&numval(1000.0, "ms")));
assert!(numval(1.000_000_000_000_1, "px").sass_eq(&numval(1.0, "px")));
assert!(!numval(1.000_000_001, "px").sass_eq(&numval(1.0, "px")));
assert!(!numval(1.0, "").sass_eq(&numval(1.0, "px")));
assert!(!numval(1.0, "px").sass_eq(&numval(1.0, "em")));
assert!(!numval(50.0, "%").sass_eq(&numval(50.0, "")));
assert!(!numval(1.0, "PX").sass_eq(&numval(1.0, "px")));
assert!(!numval(1.0, "IN").sass_eq(&numval(96.0, "px")));
}
#[test]
fn sass_eq_colors_compare_channels_fuzzily() {
let purple = Value::Color(Color::rgb(128.0, 0.0, 128.0, 1.0));
let computed = Value::Color(Color::rgb(127.999_999_999_998_6, 0.0, 127.999_999_999_998_6, 1.0));
assert!(purple.sass_eq(&computed));
assert!(!purple.sass_eq(&Value::Color(Color::rgb(255.0, 0.0, 0.0, 1.0))));
let frac = Value::Color(Color::rgb(0.4, 0.0, 0.0, 1.0));
assert!(!frac.sass_eq(&Value::Color(Color::rgb(0.0, 0.0, 0.0, 1.0))));
assert!(frac.sass_eq(&Value::Color(Color::rgb(0.4, 0.0, 0.0, 1.0))));
assert!(!purple.sass_eq(&Value::Color(Color::rgb(128.0, 0.0, 128.0, 0.5))));
}
}