summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLynn <lynn@foldr.moe>2023-07-05 11:26:50 +0200
committerGitHub <noreply@github.com>2023-07-05 11:26:50 +0200
commit9a9da806656fe70dde8827f8afc5dd9dac8f7cb0 (patch)
treec93422b364a759297771e9718745db935a8b14fa
parent5fdd62141ff1dd19917e93b60f4dff5884be2020 (diff)
Color mixing function (#1332)
Co-authored-by: Laurenz <laurmaedje@gmail.com>
-rw-r--r--Cargo.lock10
-rw-r--r--crates/typst-docs/src/lib.rs5
-rw-r--r--crates/typst-library/src/compute/construct.rs40
-rw-r--r--crates/typst-library/src/compute/mod.rs1
-rw-r--r--crates/typst/Cargo.toml1
-rw-r--r--crates/typst/src/geom/color.rs95
-rw-r--r--crates/typst/src/geom/mod.rs4
-rw-r--r--tests/typ/compute/construct.typ32
8 files changed, 186 insertions, 2 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 678007fe..628bbd24 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1312,6 +1312,15 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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 4f2c72b7..7ed6dcfe 100644
--- a/crates/typst-docs/src/lib.rs
+++ b/crates/typst-docs/src/lib.rs
@@ -40,6 +40,11 @@ static FONTS: Lazy<(Prehashed<FontBook>, Vec<Font>)> = Lazy::new(|| {
static LIBRARY: Lazy<Prehashed<Library>> = 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 956212ee..1ce676bb 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<WeightedColor>,
+ /// The color space to mix in. By default, this happens in a perceptual
+ /// color space (Oklab).
+ #[named]
+ #[default(ColorSpace::Oklab)]
+ space: ColorSpace,
+) -> StrResult<Color> {
+ 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 e9e4870c..15309f12 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 c9f3bb02..06c13562 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 c7676c2b..238c7e68 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<Item = WeightedColor>,
+ space: ColorSpace,
+ ) -> StrResult<Color> {
+ 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::<Weight>()?.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 b7a7ff40..922d25d7 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 f094b6b2..ddd4c591 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
@@ -41,6 +61,18 @@
#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(
"🖂",