Color mixing function (#1332)

Co-authored-by: Laurenz <laurmaedje@gmail.com>
This commit is contained in:
Lynn 2023-07-05 11:26:50 +02:00 committed by GitHub
parent 5fdd62141f
commit 9a9da80665
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 186 additions and 2 deletions

10
Cargo.lock generated
View File

@ -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",

View File

@ -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));

View File

@ -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 }

View File

@ -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());

View File

@ -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"

View File

@ -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);

View File

@ -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;

View File

@ -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(