mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-08 19:42:05 +00:00
Merge pull request #1773 from TrevorBrown-dev/fix/css-color-none-and-units
M8: CSS colours take none and every hue unit, refuse nan and inf, and to_css keeps the 8-bit alpha
This commit is contained in:
5 files changed
+256
-38
No files matched your search
@@ -73,7 +73,10 @@ fn css_hue_units_and_none() {
|
||||
assert_eq!(Color::parse_css(&format!("oklch(0.6 0.2 {h})")).unwrap().to_hex(), blue.to_hex(), "{h}");
|
||||
}
|
||||
assert_eq!(Color::parse_css("lch(50 none none)").unwrap().to_hex(), Color::parse_css("lch(50 0 0)").unwrap().to_hex());
|
||||
assert_eq!(Color::parse_css("oklch(none 0 0 / none)").unwrap().to_rgba8(), [0, 0, 0, 255]);
|
||||
// A missing alpha is zero like any other missing component (CSS Color 4 §4.4), so it is
|
||||
// transparent; only an *omitted* alpha defaults to opaque.
|
||||
assert_eq!(Color::parse_css("oklch(none 0 0 / none)").unwrap().to_rgba8(), [0, 0, 0, 0]);
|
||||
assert_eq!(Color::parse_css("oklch(none 0 0)").unwrap().to_rgba8(), [0, 0, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+55
-24
@@ -158,14 +158,9 @@ impl Color {
|
||||
}
|
||||
if let Some(inner) = t.strip_prefix("rgba(").or_else(|| t.strip_prefix("rgb(")).and_then(|r| r.strip_suffix(')')) {
|
||||
let parts = css_args(inner)?;
|
||||
let num = |i: usize| -> Option<f32> {
|
||||
let p = parts.get(i)?;
|
||||
if let Some(pc) = p.strip_suffix('%') {
|
||||
pc.trim().parse::<f32>().ok().map(|v| v / 100.0)
|
||||
} else {
|
||||
p.parse::<f32>().ok().map(|v| v / 255.0)
|
||||
}
|
||||
};
|
||||
// A channel is a number of 255, a percentage or `none`; out-of-range values (a number
|
||||
// too big for f32 included) clamp in `Color::new`, as CSS Color 4 §5.1 asks.
|
||||
let num = |i: usize| css_fraction(parts.get(i)?, 255.0);
|
||||
let alpha = match parts.get(3) {
|
||||
Some(a) => parse_alpha(a)?,
|
||||
None => 1.0,
|
||||
@@ -177,10 +172,12 @@ impl Color {
|
||||
}
|
||||
if let Some(inner) = t.strip_prefix("hsla(").or_else(|| t.strip_prefix("hsl(")).and_then(|r| r.strip_suffix(')')) {
|
||||
let parts = css_args(inner)?;
|
||||
let h = parts.first()?.trim_end_matches("deg").parse::<f32>().ok()?;
|
||||
let s = parts.get(1)?.trim_end_matches('%').parse::<f32>().ok()? / 100.0;
|
||||
let l = parts.get(2)?.trim_end_matches('%').parse::<f32>().ok()? / 100.0;
|
||||
if !(h.is_finite() && s.is_finite() && l.is_finite()) {
|
||||
// The hue takes every CSS angle unit; saturation and lightness are percentages, numbers
|
||||
// read as percentages (CSS Color 4 §7) or `none`.
|
||||
let h = css_hue(parts.first()?)?;
|
||||
let s = css_fraction(parts.get(1)?, 100.0)?;
|
||||
let l = css_fraction(parts.get(2)?, 100.0)?;
|
||||
if !(s.is_finite() && l.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let alpha = match parts.get(3) {
|
||||
@@ -209,14 +206,23 @@ impl Color {
|
||||
|
||||
/// CSS `rgba(...)` / hex text for copy-as-CSS.
|
||||
pub fn to_css(self) -> String {
|
||||
let [r, g, b, _] = self.to_rgba8();
|
||||
if self.a >= 0.999 {
|
||||
let [r, g, b, a8] = self.to_rgba8();
|
||||
let a = clamp01(self.a);
|
||||
if a >= 0.999 {
|
||||
return self.to_css_hex();
|
||||
}
|
||||
// Two decimals when they keep the 8-bit alpha, three otherwise (near-transparent colours).
|
||||
let a8 = |a: f32| (a.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
let short = format!("{:.2}", self.a);
|
||||
let alpha = if short.parse::<f32>().is_ok_and(|v| a8(v) == a8(self.a)) { short } else { format!("{:.3}", self.a) };
|
||||
// The fewest decimals that read back as the same 8-bit alpha: two, else three, else three
|
||||
// of the 8-bit value itself (within half a step of it, so it always reads back).
|
||||
let keeps = |s: &str| s.parse::<f32>().is_ok_and(|v| (v * 255.0).round() as u8 == a8);
|
||||
let two = format!("{a:.2}");
|
||||
let three = format!("{a:.3}");
|
||||
let alpha = if keeps(&two) {
|
||||
two
|
||||
} else if keeps(&three) {
|
||||
three
|
||||
} else {
|
||||
format!("{:.3}", f32::from(a8) / 255.0)
|
||||
};
|
||||
format!("rgba({r}, {g}, {b}, {alpha})")
|
||||
}
|
||||
|
||||
@@ -268,19 +274,42 @@ fn css_polar(inner: &str, l_full: f32, c_full: f32) -> Option<([f32; 3], f32)> {
|
||||
let l = css_number(parts.first()?, l_full)?;
|
||||
let c = css_number(parts.get(1)?, c_full)?;
|
||||
let h = css_hue(parts.get(2)?)?;
|
||||
let alpha = match parts.get(3).map(String::as_str) {
|
||||
None | Some("none") => 1.0,
|
||||
let alpha = match parts.get(3) {
|
||||
None => 1.0,
|
||||
Some(a) => parse_alpha(a)?,
|
||||
};
|
||||
Some(([l, c, h], alpha))
|
||||
}
|
||||
|
||||
/// A CSS `<number>` as f32: a sign, digits, a decimal point and an exponent only. Rust's float
|
||||
/// parser also reads `inf`, `infinity` and `nan`, which are identifiers in CSS, not numbers. A
|
||||
/// value too big for f32 comes back as an infinity for the caller to clamp or refuse.
|
||||
fn css_f32(s: &str) -> Option<f32> {
|
||||
if !s.bytes().all(|b| b.is_ascii_digit() || matches!(b, b'+' | b'-' | b'.' | b'e' | b'E')) {
|
||||
return None;
|
||||
}
|
||||
s.parse::<f32>().ok()
|
||||
}
|
||||
|
||||
/// A CSS number, percentage or `none` as a fraction of one: a percentage is of 100, a bare number
|
||||
/// of `number_full` (255 for an rgb() channel, 100 for hsl() saturation and lightness, 1 for
|
||||
/// alpha) and `none` is 0 (CSS Color 4 §4.4).
|
||||
fn css_fraction(p: &str, number_full: f32) -> Option<f32> {
|
||||
if p == "none" {
|
||||
return Some(0.0);
|
||||
}
|
||||
match p.strip_suffix('%') {
|
||||
Some(pc) => Some(css_f32(pc)? / 100.0),
|
||||
None => Some(css_f32(p)? / number_full),
|
||||
}
|
||||
}
|
||||
|
||||
/// A CSS number, a percentage of `full` or `none` (0); finite only.
|
||||
fn css_number(p: &str, full: f32) -> Option<f32> {
|
||||
let v = match p.strip_suffix('%') {
|
||||
_ if p == "none" => 0.0,
|
||||
Some(pc) => pc.trim().parse::<f32>().ok()? / 100.0 * full,
|
||||
None => p.parse::<f32>().ok()?,
|
||||
Some(pc) => css_f32(pc)? / 100.0 * full,
|
||||
None => css_f32(p)?,
|
||||
};
|
||||
v.is_finite().then_some(v)
|
||||
}
|
||||
@@ -294,12 +323,14 @@ fn css_hue(p: &str) -> Option<f32> {
|
||||
.into_iter()
|
||||
.find_map(|(unit, k)| p.strip_suffix(unit).map(|n| (n, k)))
|
||||
.unwrap_or((p, 1.0));
|
||||
let v = num.parse::<f32>().ok()? / per_degree;
|
||||
let v = css_f32(num)? / per_degree;
|
||||
v.is_finite().then_some(v)
|
||||
}
|
||||
|
||||
/// A CSS `<alpha-value>`: a number, a percentage or `none` (0: a missing alpha is transparent, CSS
|
||||
/// Color 4 §4.4); finite only. An *omitted* alpha is the caller's default of 1.
|
||||
fn parse_alpha(a: &str) -> Option<f32> {
|
||||
let v = if let Some(pc) = a.strip_suffix('%') { pc.parse::<f32>().ok().map(|v| v / 100.0) } else { a.parse::<f32>().ok() }?;
|
||||
let v = css_fraction(a, 1.0)?;
|
||||
v.is_finite().then_some(v)
|
||||
}
|
||||
|
||||
|
||||
@@ -195,3 +195,102 @@ fn css_rejects_empty_arguments() {
|
||||
assert!(Color::parse_css("rgb(255 0 0 / 50%)").is_some());
|
||||
assert!(Color::parse_css("hsl(120deg 50% 50%)").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_rgb_rejects_non_numeric_channels() {
|
||||
// `nan`, `inf` and `infinity` are identifiers, not CSS numbers; the hsl(), lch() and oklch()
|
||||
// branches and the alpha already refuse them, and so must rgb() channels (which previously read
|
||||
// `rgb(nan, 0, 0)` as black and `rgb(inf, 0, 0)` as red).
|
||||
for s in ["rgb(nan, 0, 0)", "rgb(inf, 0, 0)", "rgb(0, 0, infinity)", "rgb(-inf, 0, 0)", "rgb(nan%, 0, 0)", "rgb(NaN 0 0)", "rgba(0, 0, 0, inf)"] {
|
||||
assert_eq!(Color::parse_css(s), None, "{s}");
|
||||
}
|
||||
// A genuine number that overflows f32 is still a number and clamps (CSS Color 4 §5.1).
|
||||
assert_eq!(Color::parse_css("rgb(1e40, -1e40, 0)").unwrap().to_rgba8(), [255, 0, 0, 255]);
|
||||
assert_eq!(Color::parse_css("rgb(+255, .5%, 1E2)").unwrap().to_rgba8(), [255, 1, 100, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_hsl_takes_every_hue_unit() {
|
||||
// CSS Color 4 §4.3: `<hue> = <number> | <angle>`, so turn, grad and rad are as valid as deg.
|
||||
let cyan = Color::parse_css("hsl(180, 100%, 50%)").unwrap();
|
||||
assert_eq!(cyan.to_hex(), "00FFFF");
|
||||
let units =
|
||||
["hsl(0.5turn, 100%, 50%)", "hsl(200grad 100% 50%)", "hsl(3.1415927rad 100% 50%)", "hsl(180deg 100% 50%)", "hsla(-0.5turn, 100%, 50%, 1)"];
|
||||
for s in units {
|
||||
assert_eq!(Color::parse_css(s).map(|c| c.to_hex()), Some(cyan.to_hex()), "{s}");
|
||||
}
|
||||
// One unit only: a repeated or stray unit is not a dimension.
|
||||
for s in ["hsl(120degdeg, 100%, 50%)", "hsl(120, 100%%, 50%)", "hsl(0.5turndeg 100% 50%)", "hsl(deg 100% 50%)"] {
|
||||
assert_eq!(Color::parse_css(s), None, "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_none_is_zero_in_every_function() {
|
||||
// CSS Color 4 §4.4: every modern-syntax component may be `none`, and outside interpolation a
|
||||
// missing component "behaves as a zero value" -- including a missing alpha, which renders
|
||||
// fully transparent.
|
||||
assert_eq!(Color::parse_css("rgb(none 0 0)").unwrap().to_rgba8(), [0, 0, 0, 255]);
|
||||
assert_eq!(Color::parse_css("rgb(255 none none)").unwrap().to_rgba8(), [255, 0, 0, 255]);
|
||||
assert_eq!(Color::parse_css("rgb(255 0 0 / none)").unwrap().to_rgba8(), [255, 0, 0, 0]);
|
||||
assert_eq!(Color::parse_css("hsl(none 100% 50%)").unwrap().to_hex(), "FF0000");
|
||||
assert_eq!(Color::parse_css("hsl(120 none 50%)").unwrap().to_hex(), "808080");
|
||||
assert_eq!(Color::parse_css("hsl(120 100% none)").unwrap().to_hex(), "000000");
|
||||
assert_eq!(Color::parse_css("hsl(120 100% 50% / none)").unwrap().to_rgba8(), [0, 255, 0, 0]);
|
||||
assert_eq!(Color::parse_css("oklch(0.5 0.1 20 / none)").unwrap().a, 0.0);
|
||||
assert_eq!(Color::parse_css("lch(50 50 50 / none)").unwrap().a, 0.0);
|
||||
// An omitted alpha still defaults to opaque.
|
||||
assert_eq!(Color::parse_css("rgb(none none none)").unwrap().a, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_css_keeps_the_8_bit_alpha_for_any_alpha() {
|
||||
// Three decimals of the alpha can still round across an 8-bit step (0.0055 -> "0.006" reads
|
||||
// back as 2/255, not 1/255); the text must always read back to the same 8-bit alpha.
|
||||
for i in 0..=10_000u32 {
|
||||
let a = i as f32 / 10_000.0;
|
||||
let c = Color::new(0.1, 0.2, 0.3, a);
|
||||
let css = c.to_css();
|
||||
assert_eq!(Color::parse_css(&css).map(|b| b.to_rgba8()), Some(c.to_rgba8()), "alpha {a}: {css}");
|
||||
}
|
||||
let c = Color::new(0.1, 0.2, 0.3, 0.0055);
|
||||
assert_eq!(Color::parse_css(&c.to_css()).unwrap().to_rgba8()[3], 1, "{}", c.to_css());
|
||||
// Still the short forms where they are exact.
|
||||
assert_eq!(Color::new(1.0, 0.0, 0.0, 0.5).to_css(), "rgba(255, 0, 0, 0.50)");
|
||||
assert_eq!(Color::from_rgba8(255, 0, 0, 85).to_css(), "rgba(255, 0, 0, 0.333)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_css_sanitizes_an_unclamped_alpha() {
|
||||
// The RGB channels go through `to_rgba8` and are sanitized; the alpha must be too, or a colour
|
||||
// built field-by-field prints `rgba(0, 0, 0, NaN)` / `rgba(0, 0, 0, -0.50)`, which is not CSS.
|
||||
for a in [f32::NAN, -0.5, f32::NEG_INFINITY] {
|
||||
let c = Color { r: 0.0, g: 0.0, b: 0.0, a };
|
||||
let css = c.to_css();
|
||||
assert_eq!(Color::parse_css(&css).map(|b| b.to_rgba8()), Some([0, 0, 0, 0]), "alpha {a}: {css}");
|
||||
}
|
||||
assert_eq!(Color { r: 1.0, g: 0.0, b: 0.0, a: f32::INFINITY }.to_css(), "#FF0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_hue_wraps_a_whole_turn_and_calc_is_not_a_number() {
|
||||
// A hue is an angle (CSS Color 4 §4.3): a whole turn (360, 1turn, 400grad, 2 pi rad) is 0 and a
|
||||
// negative hue counts back from it, in hsl() and in the polar spaces alike. `calc()` is not
|
||||
// read, so `calc(NaN)` and `calc(infinity)` are refused like any other non-number rather than
|
||||
// read as 0 or clamped.
|
||||
let red = Color::parse_css("hsl(0 100% 50%)").unwrap().to_rgba8();
|
||||
for s in ["hsl(360 100% 50%)", "hsl(1turn 100% 50%)", "hsl(400grad 100% 50%)", "hsl(6.2831855rad 100% 50%)", "hsl(-1turn 100% 50%)"] {
|
||||
assert_eq!(Color::parse_css(s).map(|c| c.to_rgba8()), Some(red), "{s}");
|
||||
}
|
||||
let violet = Color::parse_css("hsl(270 100% 50%)").unwrap().to_rgba8();
|
||||
for s in ["hsl(-90deg 100% 50%)", "hsl(-0.25turn 100% 50%)", "hsl(-100grad 100% 50%)", "hsl(630 100% 50%)"] {
|
||||
assert_eq!(Color::parse_css(s).map(|c| c.to_rgba8()), Some(violet), "{s}");
|
||||
}
|
||||
assert_eq!(Color::parse_css("oklch(0.5 0.1 1turn)").map(|c| c.to_rgba8()), Color::parse_css("oklch(0.5 0.1 0)").map(|c| c.to_rgba8()));
|
||||
assert_eq!(Color::parse_css("lch(50 50 400grad)").map(|c| c.to_rgba8()), Color::parse_css("lch(50 50 0)").map(|c| c.to_rgba8()));
|
||||
assert_eq!(Color::parse_css("oklch(0.5 0.1 -90deg)").map(|c| c.to_rgba8()), Color::parse_css("oklch(0.5 0.1 270)").map(|c| c.to_rgba8()));
|
||||
for s in ["hsl(calc(NaN) 100% 50%)", "rgb(calc(infinity) 0 0)", "rgb(0 0 0 / calc(1))", "oklch(0.5 0.1 calc(20deg))", "hsl(calc(1turn) 100% 50%)"]
|
||||
{
|
||||
assert_eq!(Color::parse_css(s), None, "{s}");
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,50 @@ const GREY_CHROMA: f32 = 0.000_2;
|
||||
/// The attributes that can hold a colour (with `style`, which can hold any property).
|
||||
const COLOR_ATTRS: [&str; 8] = ["fill", "stroke", "stop-color", "flood-color", "lighting-color", "color", "solid-color", "style"];
|
||||
|
||||
/// A colour read from a call, with why it is only an approximation; or why it can't be read.
|
||||
type Eval = Result<(Color, Option<&'static str>), &'static str>;
|
||||
/// A colour read from a call, with why it is only an approximation (or why it can't be read) and
|
||||
/// which of its components a mix should treat as missing.
|
||||
type Eval = Result<(Color, Option<&'static str>, Missing), &'static str>;
|
||||
|
||||
/// Which components a colour gave as `none`: red, green, blue and alpha. A mix takes a missing
|
||||
/// component from its other colour before interpolating (CSS Color 5 section 4.1): red, green and
|
||||
/// blue only in an sRGB mix (in another space they are converted first, and a missing one is 0),
|
||||
/// the alpha in every mix. Missing in both colours, a component stays missing (and 0).
|
||||
#[derive(Clone, Copy)]
|
||||
struct Missing {
|
||||
r: bool,
|
||||
g: bool,
|
||||
b: bool,
|
||||
a: bool,
|
||||
}
|
||||
|
||||
impl Missing {
|
||||
const NONE: Self = Self { r: false, g: false, b: false, a: false };
|
||||
|
||||
/// What a colour function's text (lower case) gives as `none`: the four values of `rgb()` or
|
||||
/// `rgba()`, else only an alpha after a `/`.
|
||||
fn of(color: &str) -> Self {
|
||||
let rgb = color.strip_prefix("rgba(").or_else(|| color.strip_prefix("rgb(")).and_then(|r| r.strip_suffix(')'));
|
||||
match rgb {
|
||||
Some(inner) => {
|
||||
let parts = split_top(inner, |c| c == ',' || c == '/' || c.is_whitespace());
|
||||
let at = |i: usize| parts.get(i).is_some_and(|p| *p == "none");
|
||||
Self { r: at(0), g: at(1), b: at(2), a: at(3) }
|
||||
}
|
||||
None => Self { a: alpha_is_none(color), ..Self::NONE },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill a component missing on one side from the other; true when it is missing on both.
|
||||
fn carry(a: &mut f32, b: &mut f32, a_missing: bool, b_missing: bool) -> bool {
|
||||
match (a_missing, b_missing) {
|
||||
(true, false) => *a = *b,
|
||||
(false, true) => *b = *a,
|
||||
(true, true) => return true,
|
||||
(false, false) => {}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// `data` with its `color()` and `color-mix()` calls replaced by colours usvg reads, and notes on
|
||||
/// those it read only approximately or not at all; `None` when there are none (or `data` isn't
|
||||
@@ -90,8 +132,8 @@ fn rewrite_calls(text: &str, range: Range<usize>, profile: ColorProfile, edits:
|
||||
};
|
||||
let call = s.get(start..end).unwrap_or_default();
|
||||
let color = match eval(call, profile, 0) {
|
||||
Ok((c, None)) => c,
|
||||
Ok((c, Some(why))) => {
|
||||
Ok((c, None, _)) => c,
|
||||
Ok((c, Some(why), _)) => {
|
||||
notes.push(note(call, why));
|
||||
c
|
||||
}
|
||||
@@ -176,12 +218,15 @@ fn color_fn(args: &str, profile: ColorProfile) -> Eval {
|
||||
None => 1.0,
|
||||
};
|
||||
let c = Color::new(value(r)?, value(g)?, value(b)?, a);
|
||||
// A `none` is 0 here and missing in a mix: reds, greens and blues are analogous across the RGB
|
||||
// spaces (CSS Color 4 section 12.2), so a missing channel stays missing through the conversion.
|
||||
let missing = Missing { r: *r == "none", g: *g == "none", b: *b == "none", a: alpha == Some("none") };
|
||||
let (from, why) = match *space {
|
||||
"srgb" => (ColorProfile::Srgb, None),
|
||||
"display-p3" => (ColorProfile::DisplayP3, None),
|
||||
_ => (ColorProfile::Srgb, Some("only the srgb and display-p3 spaces are read: its values were taken as sRGB")),
|
||||
};
|
||||
Ok((from.conversion_to(profile).apply(c), why))
|
||||
Ok((from.conversion_to(profile).apply(c), why, missing))
|
||||
}
|
||||
|
||||
/// A CSS number, a percentage (100% is 1) or `none` (0); finite only.
|
||||
@@ -219,9 +264,18 @@ fn mix(args: &str, profile: ColorProfile, depth: usize) -> Eval {
|
||||
["in", ..] => (Space::Srgb, Some("only srgb, oklab and oklch mixes are read: it was mixed in sRGB")),
|
||||
_ => return Err("color-mix() starts with `in` and a colour space"),
|
||||
};
|
||||
let (a, pa, why_a) = operand(first, profile, depth)?;
|
||||
let (b, pb, why_b) = operand(second, profile, depth)?;
|
||||
let (mut a, pa, why_a, ma) = operand(first, profile, depth)?;
|
||||
let (mut b, pb, why_b, mb) = operand(second, profile, depth)?;
|
||||
why = why.or(why_a).or(why_b);
|
||||
// Missing components are carried over from the other colour before the mix (see `Missing`);
|
||||
// rendered on its own, the same colour has them at 0.
|
||||
let srgb = matches!(space, Space::Srgb);
|
||||
let missing = Missing {
|
||||
r: srgb && carry(&mut a.r, &mut b.r, ma.r, mb.r),
|
||||
g: srgb && carry(&mut a.g, &mut b.g, ma.g, mb.g),
|
||||
b: srgb && carry(&mut a.b, &mut b.b, ma.b, mb.b),
|
||||
a: carry(&mut a.a, &mut b.a, ma.a, mb.a),
|
||||
};
|
||||
if [pa, pb].into_iter().flatten().any(|p| !(0.0..=100.0).contains(&p)) {
|
||||
return Err("mix percentages are between 0% and 100%");
|
||||
}
|
||||
@@ -236,11 +290,14 @@ fn mix(args: &str, profile: ColorProfile, depth: usize) -> Eval {
|
||||
return Err("mix percentages add up to 0%");
|
||||
}
|
||||
let mixed = interpolate(space, a, b, pb / sum);
|
||||
Ok((mixed.with_alpha(mixed.a * (sum / 100.0).min(1.0)), why))
|
||||
Ok((mixed.with_alpha(mixed.a * (sum / 100.0).min(1.0)), why, missing))
|
||||
}
|
||||
|
||||
/// One colour of a mix and its percentage (before or after it).
|
||||
fn operand(s: &str, profile: ColorProfile, depth: usize) -> Result<(Color, Option<f32>, Option<&'static str>), &'static str> {
|
||||
/// One colour of a mix: the colour, its percentage (before or after it), a note, and the
|
||||
/// components it gave as `none` (see [`Missing`]).
|
||||
type Operand = (Color, Option<f32>, Option<&'static str>, Missing);
|
||||
|
||||
fn operand(s: &str, profile: ColorProfile, depth: usize) -> Result<Operand, &'static str> {
|
||||
let words = split_top(s, char::is_whitespace);
|
||||
let pct = |w: &str| w.strip_suffix('%').and_then(|p| p.parse::<f32>().ok()).filter(|p| p.is_finite());
|
||||
let (color, p) = match words.as_slice() {
|
||||
@@ -249,12 +306,18 @@ fn operand(s: &str, profile: ColorProfile, depth: usize) -> Result<(Color, Optio
|
||||
[p, c] if pct(p).is_some() => (*c, pct(p)),
|
||||
_ => return Err("a mix colour is a colour and an optional percentage"),
|
||||
};
|
||||
let (c, why) = if color.starts_with("color(") || color.starts_with("color-mix(") {
|
||||
let (c, why, missing) = if color.starts_with("color(") || color.starts_with("color-mix(") {
|
||||
eval(color, profile, depth + 1)?
|
||||
} else {
|
||||
(Color::parse_css(color).ok_or("a mix colour isn't read")?, None)
|
||||
(Color::parse_css(color).ok_or("a mix colour isn't read")?, None, Missing::of(color))
|
||||
};
|
||||
Ok((c, p, why))
|
||||
Ok((c, p, why, missing))
|
||||
}
|
||||
|
||||
/// Whether a colour function's text (already lower case) gives its alpha as `none`: what follows
|
||||
/// its last `/`.
|
||||
fn alpha_is_none(color: &str) -> bool {
|
||||
color.rsplit_once('/').is_some_and(|(_, rest)| rest.trim().trim_end_matches(')').trim_end() == "none")
|
||||
}
|
||||
|
||||
/// `a` and `b` mixed `t` of the way to `b`, premultiplied by their alpha.
|
||||
|
||||
@@ -73,6 +73,28 @@ fn color_mix_in_srgb_oklab_and_oklch() {
|
||||
// Percentages under 100% lower the alpha; alphas mix premultiplied.
|
||||
assert_eq!(rgba8(fill("color-mix(in srgb, red 20%, blue 20%)").0), Some([128, 0, 128, 102]));
|
||||
assert_eq!(rgba8(fill("color-mix(in srgb, red, transparent)").0), Some([255, 0, 0, 128]));
|
||||
// A missing alpha (`/ none`) takes the other colour's before the mix (CSS Color 5 section 4.1);
|
||||
// on its own the same colour renders transparent.
|
||||
assert_eq!(rgba8(fill("color-mix(in srgb, rgb(255 0 0 / none), blue)").0), Some([128, 0, 128, 255]));
|
||||
assert_eq!(rgba8(fill("color-mix(in srgb, red, rgb(0 0 255 / none) 50%)").0), Some([128, 0, 128, 255]));
|
||||
assert_eq!(rgba8(fill("color-mix(in srgb, rgb(255 0 0 / none), rgb(0 0 255 / 50%))").0), Some([128, 0, 128, 128]));
|
||||
assert_eq!(rgba8(fill("color-mix(in srgb, rgb(255 0 0 / none), rgb(0 0 255 / none))").0), Some([128, 0, 128, 0]), "both missing: zero");
|
||||
// A missing red, green or blue is carried over as well in an sRGB mix; in another space the
|
||||
// converted channels are plain zeros (only analogous components carry, CSS Color 5 section 4.1).
|
||||
assert_eq!(rgba8(fill("color-mix(in srgb, rgb(none 0 0), red)").0), Some([255, 0, 0, 255]));
|
||||
assert_eq!(rgba8(fill("color-mix(in srgb, rgb(none 0 0), rgb(255 none 0))").0), Some([255, 0, 0, 255]));
|
||||
assert_eq!(rgba8(fill("color-mix(in oklab, rgb(none 0 0), red)").0), rgba8(fill("color-mix(in oklab, black, red)").0));
|
||||
// Missing in both stays missing through a nested mix and is carried in the outer one.
|
||||
assert_eq!(rgba8(fill("color-mix(in srgb, color-mix(in srgb, rgb(0 255 0 / none), rgb(0 255 0 / none)), red)").0), Some([128, 128, 0, 255]));
|
||||
// `color()` gives its `none` values the same way, in either of its spaces (reds, greens and
|
||||
// blues are analogous across RGB spaces, CSS Color 4 section 12.2).
|
||||
assert_eq!(rgba8(fill("color-mix(in srgb, color(srgb 1 0 0 / none), blue)").0), Some([128, 0, 128, 255]));
|
||||
assert_eq!(rgba8(fill("color-mix(in srgb, color(srgb none 0 0), red)").0), Some([255, 0, 0, 255]));
|
||||
assert_eq!(rgba8(fill("color-mix(in srgb, color(display-p3 none 0 0), red)").0), Some([255, 0, 0, 255]));
|
||||
assert_eq!(
|
||||
rgba8(fill("color-mix(in srgb, color-mix(in srgb, color(srgb 0 1 0 / none), color(srgb 0 1 0 / none)), red)").0),
|
||||
Some([128, 128, 0, 255])
|
||||
);
|
||||
// Nested, with color() inside.
|
||||
assert_eq!(rgba8(fill("color-mix(in srgb, color-mix(in srgb, red, blue), color(srgb 0 1 0) 0%)").0), Some([128, 0, 128, 255]));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user