From 9a9da806656fe70dde8827f8afc5dd9dac8f7cb0 Mon Sep 17 00:00:00 2001 From: Lynn Date: Wed, 5 Jul 2023 11:26:50 +0200 Subject: [PATCH] Color mixing function (#1332) Co-authored-by: Laurenz --- Cargo.lock | 10 ++ crates/typst-docs/src/lib.rs | 5 + crates/typst-library/src/compute/construct.rs | 40 +++++++- crates/typst-library/src/compute/mod.rs | 1 + crates/typst/Cargo.toml | 1 + crates/typst/src/geom/color.rs | 95 +++++++++++++++++++ crates/typst/src/geom/mod.rs | 4 +- tests/typ/compute/construct.typ | 32 +++++++ 8 files changed, 186 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 678007fe7..628bbd244 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1311,6 +1311,15 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e25be21376a772d15f97ae789845340a9651d3c4246ff5ebb6a2b35f9c37bd31" +[[package]] +name = "oklab" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "467e40ada50d13bab19019e3707862b5076ca15841f31ee1474c40397c1b9f11" +dependencies = [ + "rgb", +] + [[package]] name = "once_cell" version = "1.17.1" @@ -2340,6 +2349,7 @@ dependencies = [ "indexmap", "log", "miniz_oxide", + "oklab", "once_cell", "pdf-writer", "pixglyph", diff --git a/crates/typst-docs/src/lib.rs b/crates/typst-docs/src/lib.rs index 4f2c72b7d..7ed6dcfe9 100644 --- a/crates/typst-docs/src/lib.rs +++ b/crates/typst-docs/src/lib.rs @@ -40,6 +40,11 @@ static FONTS: Lazy<(Prehashed, Vec)> = Lazy::new(|| { static LIBRARY: Lazy> = Lazy::new(|| { let mut lib = typst_library::build(); + // Hack for documenting the `mix` function in the color module. + // Will be superseded by proper associated functions. + lib.global + .scope_mut() + .define("mix", typst_library::compute::mix_func()); lib.styles .set(PageElem::set_width(Smart::Custom(Abs::pt(240.0).into()))); lib.styles.set(PageElem::set_height(Smart::Auto)); diff --git a/crates/typst-library/src/compute/construct.rs b/crates/typst-library/src/compute/construct.rs index 956212ee9..1ce676bb4 100644 --- a/crates/typst-library/src/compute/construct.rs +++ b/crates/typst-library/src/compute/construct.rs @@ -3,7 +3,7 @@ use std::str::FromStr; use time::{Month, PrimitiveDateTime}; -use typst::eval::{Datetime, Regex}; +use typst::eval::{Datetime, Module, Regex}; use crate::prelude::*; @@ -379,6 +379,44 @@ cast! { }, } +/// A module with functions operating on colors. +pub fn color_module() -> Module { + let mut scope = Scope::new(); + scope.define("mix", mix_func()); + Module::new("color").with_scope(scope) +} + +/// Create a color by mixing two or more colors. +/// +/// ## Example +/// ```example +/// #color.mix(red, green) +/// #color.mix(red, green, white) +/// #color.mix(red, green, space: "srgb") +/// #color.mix((red, 30%), (green, 70%)) +/// ```` +/// +/// _Note:_ This function must be specified as `color.mix`, not just `mix`. +/// Currently, `color` is a module, but it is designed to be forward compatible +/// with a future `color` type. +/// +/// Display: Mix +/// Category: construct +#[func] +pub fn mix( + /// The colors, optionally with weights, specified as a pair (array of + /// length two) of color and weight (float or ratio). + #[variadic] + colors: Vec, + /// The color space to mix in. By default, this happens in a perceptual + /// color space (Oklab). + #[named] + #[default(ColorSpace::Oklab)] + space: ColorSpace, +) -> StrResult { + Color::mix(colors, space) +} + /// Creates a custom symbol with modifiers. /// /// ## Example { #example } diff --git a/crates/typst-library/src/compute/mod.rs b/crates/typst-library/src/compute/mod.rs index e9e4870c7..15309f129 100644 --- a/crates/typst-library/src/compute/mod.rs +++ b/crates/typst-library/src/compute/mod.rs @@ -23,6 +23,7 @@ pub(super) fn define(global: &mut Scope) { global.define("luma", luma_func()); global.define("rgb", rgb_func()); global.define("cmyk", cmyk_func()); + global.define("color", color_module()); global.define("datetime", datetime_func()); global.define("symbol", symbol_func()); global.define("str", str_func()); diff --git a/crates/typst/Cargo.toml b/crates/typst/Cargo.toml index c9f3bb029..06c135627 100644 --- a/crates/typst/Cargo.toml +++ b/crates/typst/Cargo.toml @@ -28,6 +28,7 @@ image = { version = "0.24", default-features = false, features = ["png", "jpeg", indexmap = "1.9.3" log = "0.4" miniz_oxide = "0.7" +oklab = "1" once_cell = "1" pdf-writer = "0.7.1" pixglyph = "0.1" diff --git a/crates/typst/src/geom/color.rs b/crates/typst/src/geom/color.rs index c7676c2b9..238c7e681 100644 --- a/crates/typst/src/geom/color.rs +++ b/crates/typst/src/geom/color.rs @@ -1,6 +1,8 @@ use std::str::FromStr; use super::*; +use crate::diag::bail; +use crate::eval::{cast, Array, Cast}; /// A color in a dynamic format. #[derive(Copy, Clone, Eq, PartialEq, Hash)] @@ -68,6 +70,31 @@ impl Color { Self::Cmyk(cmyk) => Self::Cmyk(cmyk.negate()), } } + + /// Mixes multiple colors through weight. + pub fn mix( + colors: impl IntoIterator, + space: ColorSpace, + ) -> StrResult { + let mut total = 0.0; + let mut acc = [0.0; 4]; + + for WeightedColor(color, weight) in colors.into_iter() { + let v = rgba_to_vec4(color.to_rgba(), space); + 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"); + } + + let mixed = acc.map(|v| v / total); + Ok(vec4_to_rgba(mixed, space).into()) + } } impl Debug for Color { @@ -80,6 +107,74 @@ impl Debug for Color { } } +/// A color with a weight. +pub struct WeightedColor(Color, f32); + +cast! { + WeightedColor, + v: Color => Self(v, 1.0), + v: Array => { + let mut iter = v.into_iter(); + match (iter.next(), iter.next(), iter.next()) { + (Some(c), Some(w), None) => Self(c.cast()?, w.cast::()?.0), + _ => bail!("expected a color or color-weight pair"), + } + } +} + +/// A weight for color mixing. +struct Weight(f32); + +cast! { + Weight, + v: f64 => Self(v as f32), + v: Ratio => Self(v.get() as f32), +} + +/// Convert an RGBA color to four components in the given color space. +fn rgba_to_vec4(color: RgbaColor, space: ColorSpace) -> [f32; 4] { + match space { + ColorSpace::Oklab => { + let RgbaColor { r, g, b, a } = color; + let oklab = oklab::srgb_to_oklab(oklab::RGB { r, g, b }); + [oklab.l, oklab.a, oklab.b, a as f32 / 255.0] + } + ColorSpace::Srgb => { + let RgbaColor { r, g, b, a } = color; + [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, a as f32 / 255.0] + } + } +} + +/// Convert four components in the given color space to RGBA. +fn vec4_to_rgba(vec: [f32; 4], space: ColorSpace) -> RgbaColor { + match space { + ColorSpace::Oklab => { + let [l, a, b, alpha] = vec; + let oklab::RGB { r, g, b } = oklab::oklab_to_srgb(oklab::Oklab { l, a, b }); + RgbaColor { r, g, b, a: (alpha * 255.0).round() as u8 } + } + ColorSpace::Srgb => { + let [r, g, b, a] = vec; + RgbaColor { + r: (r * 255.0).round() as u8, + g: (g * 255.0).round() as u8, + b: (b * 255.0).round() as u8, + a: (a * 255.0).round() as u8, + } + } + } +} + +/// A color space for mixing. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum ColorSpace { + /// A perceptual color space. + Oklab, + /// The standard RGB color space. + Srgb, +} + /// An 8-bit grayscale color. #[derive(Copy, Clone, Eq, PartialEq, Hash)] pub struct LumaColor(pub u8); diff --git a/crates/typst/src/geom/mod.rs b/crates/typst/src/geom/mod.rs index b7a7ff409..922d25d7a 100644 --- a/crates/typst/src/geom/mod.rs +++ b/crates/typst/src/geom/mod.rs @@ -31,7 +31,9 @@ pub use self::abs::{Abs, AbsUnit}; pub use self::align::{Align, GenAlign, HorizontalAlign, VerticalAlign}; pub use self::angle::{Angle, AngleUnit}; pub use self::axes::{Axes, Axis}; -pub use self::color::{CmykColor, Color, LumaColor, RgbaColor}; +pub use self::color::{ + CmykColor, Color, ColorSpace, LumaColor, RgbaColor, WeightedColor, +}; pub use self::corners::{Corner, Corners}; pub use self::dir::Dir; pub use self::ellipse::ellipse; diff --git a/tests/typ/compute/construct.typ b/tests/typ/compute/construct.typ index f094b6b2f..ddd4c5912 100644 --- a/tests/typ/compute/construct.typ +++ b/tests/typ/compute/construct.typ @@ -14,6 +14,26 @@ #test(rgb("#133337").negate(), rgb(236, 204, 200)) #test(white.lighten(100%), white) +// Color mixing, in Oklab space by default. +#test(color.mix(rgb("#ff0000"), rgb("#00ff00")), rgb("#d0a800")) +#test(color.mix(rgb("#ff0000"), rgb("#00ff00"), space: "oklab"), rgb("#d0a800")) +#test(color.mix(rgb("#ff0000"), rgb("#00ff00"), space: "srgb"), rgb("#808000")) + +#test(color.mix(red, green, blue), rgb("#909282")) +#test(color.mix(red, blue, green), rgb("#909282")) +#test(color.mix(blue, red, green), rgb("#909282")) + +// Mix with weights. +#test(color.mix((red, 50%), (green, 50%)), rgb("#c0983b")) +#test(color.mix((red, 0.5), (green, 0.5)), rgb("#c0983b")) +#test(color.mix((red, 5), (green, 5)), rgb("#c0983b")) +#test(color.mix((green, 5), (white, 0), (red, 5)), rgb("#c0983b")) +#test(color.mix((red, 100%), (green, 0%)), red) +#test(color.mix((red, 0%), (green, 100%)), green) +#test(color.mix((rgb("#aaff00"), 25%), (rgb("#aa00ff"), 75%), space: "srgb"), rgb("#aa40bf")) +#test(color.mix((rgb("#aaff00"), 50%), (rgb("#aa00ff"), 50%), space: "srgb"), rgb("#aa8080")) +#test(color.mix((rgb("#aaff00"), 75%), (rgb("#aa00ff"), 25%), space: "srgb"), rgb("#aabf40")) + --- // Test gray color conversion. // Ref: true @@ -40,6 +60,18 @@ // Error: 21-26 expected integer or ratio, found boolean #rgb(10%, 20%, 30%, false) +--- +// Error: 12-24 expected float or ratio, found string +#color.mix((red, "yes"), (green, "no")) + +--- +// Error: 12-23 expected a color or color-weight pair +#color.mix((red, 1, 2)) + +--- +// Error: 31-38 expected "oklab" or "srgb" +#color.mix(red, green, space: "cyber") + --- // Ref: true #let envelope = symbol(