diff --git a/crates/typst/src/visualize/color.rs b/crates/typst/src/visualize/color.rs index fe66cc1a7..cb998160f 100644 --- a/crates/typst/src/visualize/color.rs +++ b/crates/typst/src/visualize/color.rs @@ -1047,6 +1047,10 @@ impl Color { /// Create a color by mixing two or more colors. /// + /// In color spaces with a hue component (hsl, hsv, oklch), only two colors + /// can be mixed at once. Mixing more than two colors in such a space will + /// result in an error! + /// /// ```example /// #set block(height: 20pt, width: 100%) /// #block(fill: red.mix(blue)) @@ -1076,27 +1080,70 @@ impl Color { impl Color { /// Same as [`Color::mix`], but takes an iterator instead of a vector. pub fn mix_iter( - colors: impl IntoIterator, + colors: impl IntoIterator< + Item = WeightedColor, + IntoIter = impl ExactSizeIterator, + >, space: ColorSpace, ) -> StrResult { - let mut total = 0.0; - let mut acc = [0.0; 4]; - - for WeightedColor { color, weight } in colors { - let weight = weight as f32; - let v = color.to_space(space).to_vec4(); - acc[0] += weight * v[0]; - acc[1] += weight * v[1]; - acc[2] += weight * v[2]; - acc[3] += weight * v[3]; - total += weight; + let mut colors = colors.into_iter(); + if space.hue_index().is_some() && colors.len() > 2 { + bail!("cannot mix more than two colors in a hue-based space"); } - if total <= 0.0 { - bail!("sum of weights must be positive"); - } + let m = if space.hue_index().is_some() && colors.len() == 2 { + let mut m = [0.0; 4]; + + let WeightedColor { color: c0, weight: w0 } = colors.next().unwrap(); + let WeightedColor { color: c1, weight: w1 } = colors.next().unwrap(); + + let c0 = c0.to_space(space).to_vec4(); + let c1 = c1.to_space(space).to_vec4(); + let w0 = w0 as f32; + let w1 = w1 as f32; + + if w0 + w1 <= 0.0 { + bail!("sum of weights must be positive"); + } + + for i in 0..4 { + m[i] = (w0 * c0[i] + w1 * c1[i]) / (w0 + w1); + } + + // Ensure that the hue circle is traversed in the short direction. + if let Some(index) = space.hue_index() { + if (c0[index] - c1[index]).abs() > 180.0 { + let (h0, h1) = if c0[index] < c1[index] { + (c0[index] + 360.0, c1[index]) + } else { + (c0[index], c1[index] + 360.0) + }; + m[index] = (w0 * h0 + w1 * h1) / (w0 + w1); + } + } + + m + } else { + let mut total = 0.0; + let mut acc = [0.0; 4]; + + for WeightedColor { color, weight } in colors { + let weight = weight as f32; + let v = color.to_space(space).to_vec4(); + acc[0] += weight * v[0]; + acc[1] += weight * v[1]; + acc[2] += weight * v[2]; + acc[3] += weight * v[3]; + total += weight; + } + + if total <= 0.0 { + bail!("sum of weights must be positive"); + } + + acc.map(|v| v / total) + }; - let m = acc.map(|v| v / total); Ok(match space { ColorSpace::Oklab => Color::Oklab(Oklab::new(m[0], m[1], m[2], m[3])), ColorSpace::Oklch => Color::Oklch(Oklch::new(m[0], m[1], m[2], m[3])), @@ -1740,6 +1787,18 @@ pub enum ColorSpace { Cmyk, } +impl ColorSpace { + /// Returns the index of the hue component in this color space, if it has + /// one. + pub fn hue_index(&self) -> Option { + match self { + Self::Hsl | Self::Hsv => Some(0), + Self::Oklch => Some(2), + _ => None, + } + } +} + cast! { ColorSpace, self => match self { diff --git a/crates/typst/src/visualize/gradient.rs b/crates/typst/src/visualize/gradient.rs index e4878527d..3848b4994 100644 --- a/crates/typst/src/visualize/gradient.rs +++ b/crates/typst/src/visualize/gradient.rs @@ -12,7 +12,6 @@ use crate::foundations::{ }; use crate::layout::{Angle, Axes, Dir, Quadrant, Ratio}; use crate::syntax::{Span, Spanned}; -use crate::visualize::color::{Hsl, Hsv}; use crate::visualize::{Color, ColorSpace, WeightedColor}; /// A color gradient. @@ -1234,37 +1233,14 @@ fn sample_stops(stops: &[(Color, Ratio)], mixing_space: ColorSpace, t: f64) -> C if low == 0 { low = 1; } + let (col_0, pos_0) = stops[low - 1]; let (col_1, pos_1) = stops[low]; let t = (t - pos_0.get()) / (pos_1.get() - pos_0.get()); - let out = Color::mix_iter( + Color::mix_iter( [WeightedColor::new(col_0, 1.0 - t), WeightedColor::new(col_1, t)], mixing_space, ) - .unwrap(); - - // Special case for handling multi-turn hue interpolation. - if mixing_space == ColorSpace::Hsl || mixing_space == ColorSpace::Hsv { - let hue_0 = col_0.to_space(mixing_space).to_vec4()[0]; - let hue_1 = col_1.to_space(mixing_space).to_vec4()[0]; - - // Check if we need to interpolate over the 360° boundary. - if (hue_0 - hue_1).abs() > 180.0 { - let hue_0 = if hue_0 < hue_1 { hue_0 + 360.0 } else { hue_0 }; - let hue_1 = if hue_1 < hue_0 { hue_1 + 360.0 } else { hue_1 }; - - let hue = hue_0 * (1.0 - t as f32) + hue_1 * t as f32; - - if mixing_space == ColorSpace::Hsl { - let [_, saturation, lightness, alpha] = out.to_hsl().to_vec4(); - return Color::Hsl(Hsl::new(hue, saturation, lightness, alpha)); - } else if mixing_space == ColorSpace::Hsv { - let [_, saturation, value, alpha] = out.to_hsv().to_vec4(); - return Color::Hsv(Hsv::new(hue, saturation, value, alpha)); - } - } - } - - out + .unwrap() } diff --git a/tests/typ/compute/construct.typ b/tests/typ/compute/construct.typ index f6856c6f2..f37669b5f 100644 --- a/tests/typ/compute/construct.typ +++ b/tests/typ/compute/construct.typ @@ -32,6 +32,12 @@ #test(color.mix((rgb("#aaff00"), 50%), (rgb("#aa00ff"), 50%), space: rgb), rgb("#aa8080")) #test(color.mix((rgb("#aaff00"), 75%), (rgb("#aa00ff"), 25%), space: rgb), rgb("#aabf40")) +// Mix in hue-based space. +#test(rgb(color.mix(red, blue, space: color.hsl)), rgb("#c408ff")) +#test(rgb(color.mix((red, 50%), (blue, 100%), space: color.hsl)), rgb("#5100f8")) +// Error: 15-51 cannot mix more than two colors in a hue-based space +#rgb(color.mix(red, blue, white, space: color.hsl)) + --- // Test color conversion method kinds #test(rgb(rgb(10, 20, 30)).space(), rgb)