summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--crates/typst-ide/src/complete.rs5
-rw-r--r--crates/typst-library/src/lib.rs1
-rw-r--r--crates/typst-pdf/src/color.rs10
-rw-r--r--crates/typst-svg/src/lib.rs18
-rw-r--r--crates/typst/src/geom/color.rs225
-rw-r--r--crates/typst/src/geom/gradient.rs2
-rw-r--r--tests/ref/compiler/color.pngbin21457 -> 1253 bytes
-rw-r--r--tests/ref/compiler/repr-color-gradient.pngbin101911 -> 50046 bytes
-rw-r--r--tests/typ/compiler/color.typ8
-rw-r--r--tests/typ/compiler/methods.typ5
-rw-r--r--tests/typ/compiler/repr-color-gradient.typ1
-rw-r--r--tests/typ/compute/construct.typ22
12 files changed, 270 insertions, 27 deletions
diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs
index c9a625d1..fd83b8c9 100644
--- a/crates/typst-ide/src/complete.rs
+++ b/crates/typst-ide/src/complete.rs
@@ -1224,6 +1224,11 @@ impl<'a> CompletionContext<'a> {
"A custom Oklab color.",
);
self.snippet_completion(
+ "oklch()",
+ "oklch(${l}, ${chroma}, ${hue}, ${alpha})",
+ "A custom Oklch color.",
+ );
+ self.snippet_completion(
"color.linear-rgb()",
"color.linear-rgb(${r}, ${g}, ${b}, ${a})",
"A custom linear RGBA color.",
diff --git a/crates/typst-library/src/lib.rs b/crates/typst-library/src/lib.rs
index 554e7bcb..212debb0 100644
--- a/crates/typst-library/src/lib.rs
+++ b/crates/typst-library/src/lib.rs
@@ -65,6 +65,7 @@ fn prelude(global: &mut Scope) {
global.define("lime", Color::LIME);
global.define("luma", Color::luma_data());
global.define("oklab", Color::oklab_data());
+ global.define("oklch", Color::oklch_data());
global.define("rgb", Color::rgb_data());
global.define("cmyk", Color::cmyk_data());
global.define("range", Array::range_data());
diff --git a/crates/typst-pdf/src/color.rs b/crates/typst-pdf/src/color.rs
index 999f604e..0de02307 100644
--- a/crates/typst-pdf/src/color.rs
+++ b/crates/typst-pdf/src/color.rs
@@ -107,6 +107,7 @@ impl ColorSpaces {
oklab.tint_ref(self.oklab(alloc));
oklab.attrs().subtype(DeviceNSubtype::DeviceN);
}
+ ColorSpace::Oklch => self.write(ColorSpace::Oklab, writer, alloc),
ColorSpace::Srgb => writer.icc_based(self.srgb(alloc)),
ColorSpace::D65Gray => writer.icc_based(self.d65_gray(alloc)),
ColorSpace::LinearRgb => {
@@ -266,6 +267,9 @@ impl ColorEncode for ColorSpace {
let [h, s, v, _] = color.to_hsv().to_vec4();
[h / 360.0, s, v, 0.0]
}
+ ColorSpace::Oklch => {
+ unimplemented!("Oklch is always converted to Oklab first")
+ }
_ => color.to_vec4(),
}
}
@@ -306,7 +310,8 @@ impl PaintEncode for Color {
let [l, _, _, _] = ColorSpace::D65Gray.encode(*self);
ctx.content.set_fill_color([l]);
}
- Color::Oklab(_) => {
+ // Oklch is converted to Oklab.
+ Color::Oklab(_) | Color::Oklch(_) => {
ctx.parent.colors.oklab(&mut ctx.parent.alloc);
ctx.set_fill_color_space(OKLAB);
@@ -359,7 +364,8 @@ impl PaintEncode for Color {
let [l, _, _, _] = ColorSpace::D65Gray.encode(*self);
ctx.content.set_stroke_color([l]);
}
- Color::Oklab(_) => {
+ // Oklch is converted to Oklab.
+ Color::Oklab(_) | Color::Oklch(_) => {
ctx.parent.colors.oklab(&mut ctx.parent.alloc);
ctx.set_stroke_color_space(OKLAB);
diff --git a/crates/typst-svg/src/lib.rs b/crates/typst-svg/src/lib.rs
index c057566d..6095bade 100644
--- a/crates/typst-svg/src/lib.rs
+++ b/crates/typst-svg/src/lib.rs
@@ -1275,6 +1275,24 @@ impl ColorEncode for Color {
)
}
}
+ Color::Oklch(oklch) => {
+ if oklch.alpha != 1.0 {
+ eco_format!(
+ "oklch({:.3}% {:.5} {:.3}deg / {:.3})",
+ oklch.l * 100.0,
+ oklch.chroma,
+ oklch.hue.into_degrees(),
+ oklch.alpha
+ )
+ } else {
+ eco_format!(
+ "oklch({:.3}% {:.5} {:.3}deg)",
+ oklch.l * 100.0,
+ oklch.chroma,
+ oklch.hue.into_degrees(),
+ )
+ }
+ }
Color::Hsl(hsl) => {
if hsl.alpha != 1.0 {
eco_format!(
diff --git a/crates/typst/src/geom/color.rs b/crates/typst/src/geom/color.rs
index 3a02a8c2..ab827c19 100644
--- a/crates/typst/src/geom/color.rs
+++ b/crates/typst/src/geom/color.rs
@@ -3,7 +3,9 @@ use std::str::FromStr;
use ecow::EcoVec;
use once_cell::sync::Lazy;
use palette::encoding::{self, Linear};
-use palette::{Darken, Desaturate, FromColor, Lighten, RgbHue, Saturate, ShiftHue};
+use palette::{
+ Darken, Desaturate, FromColor, Lighten, OklabHue, RgbHue, Saturate, ShiftHue,
+};
use super::*;
use crate::diag::{bail, error, At, SourceResult};
@@ -12,6 +14,7 @@ use crate::syntax::{Span, Spanned};
// Type aliases for `palette` internal types in f32.
pub type Oklab = palette::oklab::Oklaba<f32>;
+pub type Oklch = palette::oklch::Oklcha<f32>;
pub type LinearRgba = palette::rgb::Rgba<Linear<encoding::Srgb>, f32>;
pub type Rgba = palette::rgb::Rgba<encoding::Srgb, f32>;
pub type Hsl = palette::hsl::Hsla<encoding::Srgb, f32>;
@@ -28,6 +31,7 @@ const ANGLE_EPSILON: f32 = 1e-5;
/// - Device CMYK through [`cmyk` function]($color.cmyk)
/// - D65 Gray through the [`luma` function]($color.luma)
/// - Oklab through the [`oklab` function]($color.oklab)
+/// - Oklch through the [`oklch` function]($color.oklch)
/// - Linear RGB through the [`color.linear-rgb` function]($color.linear-rgb)
/// - HSL through the [`color.hsl` function]($color.hsl)
/// - HSV through the [`color.hsv` function]($color.hsv)
@@ -153,8 +157,10 @@ const ANGLE_EPSILON: f32 = 1e-5;
pub enum Color {
/// A 32-bit luma color.
Luma(Luma),
- /// A 32-bit L*a*b* color in the Oklab color space.
+ /// A 32-bit L\*a\*b\* color in the Oklab color space.
Oklab(Oklab),
+ /// A 32-bit LCh color in the Oklab color space.
+ Oklch(Oklch),
/// A 32-bit RGBA color.
Rgba(Rgba),
/// A 32-bit linear RGB color.
@@ -179,6 +185,12 @@ impl From<Oklab> for Color {
}
}
+impl From<Oklch> for Color {
+ fn from(c: Oklch) -> Self {
+ Self::Oklch(c)
+ }
+}
+
impl From<Rgba> for Color {
fn from(c: Rgba) -> Self {
Self::Rgba(c)
@@ -300,16 +312,16 @@ impl Color {
/// The real arguments (the other arguments are just for the docs, this
/// function is a bit involved, so we parse the arguments manually).
args: &mut Args,
- /// The cyan component.
+ /// The lightness component.
#[external]
lightness: RatioComponent,
- /// The magenta component.
+ /// The a ("green/red") component.
#[external]
a: ABComponent,
- /// The yellow component.
+ /// The b ("blue/yellow") component.
#[external]
b: ABComponent,
- /// The key component.
+ /// The alpha component.
#[external]
alpha: RatioComponent,
/// Alternatively: The color to convert to Oklab.
@@ -335,6 +347,68 @@ impl Color {
})
}
+ /// Create an [Oklch](https://bottosson.github.io/posts/oklab/) color.
+ ///
+ /// This color space is well suited for the following use cases:
+ /// - Color manipulation involving lightness, chroma, and hue
+ /// - Creating grayscale images with uniform perceived lightness
+ /// - Creating smooth and uniform color transition and gradients
+ ///
+ /// A linear Oklch color is represented internally by an array of four
+ /// components:
+ /// - lightness ([`ratio`]($ratio))
+ /// - chroma ([`float`]($float) in the range `[-0.4..0.4]`)
+ /// - hue ([`angle`]($angle))
+ /// - alpha ([`ratio`]($ratio))
+ ///
+ /// These components are also available using the
+ /// [`components`]($color.components) method.
+ ///
+ /// ```example
+ /// #square(
+ /// fill: oklch(40%, 0.2, 160deg, 50%)
+ /// )
+ /// ```
+ #[func]
+ pub fn oklch(
+ /// The real arguments (the other arguments are just for the docs, this
+ /// function is a bit involved, so we parse the arguments manually).
+ args: &mut Args,
+ /// The lightness component.
+ #[external]
+ lightness: RatioComponent,
+ /// The chroma component.
+ #[external]
+ chroma: ABComponent,
+ /// The hue component.
+ #[external]
+ hue: Angle,
+ /// The alpha component.
+ #[external]
+ alpha: RatioComponent,
+ /// Alternatively: The color to convert to Oklch.
+ ///
+ /// If this is given, the individual components should not be given.
+ #[external]
+ color: Color,
+ ) -> SourceResult<Color> {
+ Ok(if let Some(color) = args.find::<Color>()? {
+ color.to_oklch()
+ } else {
+ let RatioComponent(l) = args.expect("lightness component")?;
+ let ABComponent(c) = args.expect("chroma component")?;
+ let h: Angle = args.expect("hue component")?;
+ let RatioComponent(alpha) =
+ args.eat()?.unwrap_or(RatioComponent(Ratio::one()));
+ Self::Oklch(Oklch::new(
+ l.get() as f32,
+ c.get() as f32,
+ OklabHue::from_degrees(h.to_deg() as f32),
+ alpha.get() as f32,
+ ))
+ })
+ }
+
/// Create an RGB(A) color with linear luma.
///
/// This color space is similar to sRGB, but with the distinction that the
@@ -656,6 +730,7 @@ impl Color {
/// |-------------------------|-----------|------------|-----------|--------|
/// | [`luma`]($color.luma) | Lightness | | | |
/// | [`oklab`]($color.oklab) | Lightness | `a` | `b` | Alpha |
+ /// | [`oklch`]($color.oklch) | Lightness | Chroma | Hue | Alpha |
/// | [`linear-rgb`]($color.linear-rgb) | Red | Green | Blue | Alpha |
/// | [`rgb`]($color.rgb) | Red | Green | Blue | Alpha |
/// | [`cmyk`]($color.cmyk) | Cyan | Magenta | Yellow | Key |
@@ -697,6 +772,26 @@ impl Color {
]
}
}
+ Self::Oklch(c) => {
+ if alpha {
+ array![
+ Ratio::new(c.l as _),
+ (c.chroma as f64 * 1000.0).round() / 1000.0,
+ Angle::deg(
+ c.hue.into_degrees().rem_euclid(360.0 + ANGLE_EPSILON) as _
+ ),
+ Ratio::new(c.alpha as _),
+ ]
+ } else {
+ array![
+ Ratio::new(c.l as _),
+ (c.chroma as f64 * 1000.0).round() / 1000.0,
+ Angle::deg(
+ c.hue.into_degrees().rem_euclid(360.0 + ANGLE_EPSILON) as _
+ ),
+ ]
+ }
+ }
Self::LinearRgb(c) => {
if alpha {
array![
@@ -779,8 +874,9 @@ impl Color {
}
/// Returns the constructor function for this color's space:
- /// - [`oklab`]($color.oklab)
/// - [`luma`]($color.luma)
+ /// - [`oklab`]($color.oklab)
+ /// - [`oklch`]($color.oklch)
/// - [`linear-rgb`]($color.linear-rgb)
/// - [`rgb`]($color.rgb)
/// - [`cmyk`]($color.cmyk)
@@ -796,6 +892,7 @@ impl Color {
match self {
Self::Luma(_) => ColorSpace::D65Gray,
Self::Oklab(_) => ColorSpace::Oklab,
+ Self::Oklch(_) => ColorSpace::Oklch,
Self::LinearRgb(_) => ColorSpace::LinearRgb,
Self::Rgba(_) => ColorSpace::Srgb,
Self::Cmyk(_) => ColorSpace::Cmyk,
@@ -828,6 +925,7 @@ impl Color {
match self {
Self::Luma(c) => Self::Luma(c.lighten(factor)),
Self::Oklab(c) => Self::Oklab(c.lighten(factor)),
+ Self::Oklch(c) => Self::Oklch(c.lighten(factor)),
Self::LinearRgb(c) => Self::LinearRgb(c.lighten(factor)),
Self::Rgba(c) => Self::Rgba(c.lighten(factor)),
Self::Cmyk(c) => Self::Cmyk(c.lighten(factor)),
@@ -847,6 +945,7 @@ impl Color {
match self {
Self::Luma(c) => Self::Luma(c.darken(factor)),
Self::Oklab(c) => Self::Oklab(c.darken(factor)),
+ Self::Oklch(c) => Self::Oklch(c.darken(factor)),
Self::LinearRgb(c) => Self::LinearRgb(c.darken(factor)),
Self::Rgba(c) => Self::Rgba(c.darken(factor)),
Self::Cmyk(c) => Self::Cmyk(c.darken(factor)),
@@ -870,6 +969,7 @@ impl Color {
.with_hint("try converting your color to RGB first"));
}
Self::Oklab(_) => self.to_hsv().saturate(span, factor)?.to_oklab(),
+ Self::Oklch(_) => self.to_hsv().saturate(span, factor)?.to_oklch(),
Self::LinearRgb(_) => self.to_hsv().saturate(span, factor)?.to_linear_rgb(),
Self::Rgba(_) => self.to_hsv().saturate(span, factor)?.to_rgba(),
Self::Cmyk(_) => self.to_hsv().saturate(span, factor)?.to_cmyk(),
@@ -893,6 +993,7 @@ impl Color {
.with_hint("try converting your color to RGB first"));
}
Self::Oklab(_) => self.to_hsv().desaturate(span, factor)?.to_oklab(),
+ Self::Oklch(_) => self.to_hsv().desaturate(span, factor)?.to_oklch(),
Self::LinearRgb(_) => self.to_hsv().desaturate(span, factor)?.to_linear_rgb(),
Self::Rgba(_) => self.to_hsv().desaturate(span, factor)?.to_rgba(),
Self::Cmyk(_) => self.to_hsv().desaturate(span, factor)?.to_cmyk(),
@@ -907,6 +1008,12 @@ impl Color {
match self {
Self::Luma(c) => Self::Luma(Luma::new(1.0 - c.luma)),
Self::Oklab(c) => Self::Oklab(Oklab::new(c.l, -c.a, -c.b, c.alpha)),
+ Self::Oklch(c) => Self::Oklch(Oklch::new(
+ c.l,
+ -c.chroma,
+ OklabHue::from_degrees(360.0 - c.hue.into_degrees()),
+ c.alpha,
+ )),
Self::LinearRgb(c) => Self::LinearRgb(LinearRgba::new(
1.0 - c.red,
1.0 - c.green,
@@ -940,18 +1047,35 @@ impl Color {
span: Span,
/// The angle to rotate the hue by.
angle: Angle,
+ /// The color space used to rotate. By default, this happens in a perceptual
+ /// color space ([`oklch`]($color.oklch)).
+ #[named]
+ #[default(ColorSpace::Oklch)]
+ space: ColorSpace,
) -> SourceResult<Color> {
- Ok(match self {
- Self::Luma(_) => {
- bail!(error!(span, "cannot rotate grayscale color")
- .with_hint("try converting your color to RGB first"));
+ Ok(match space {
+ ColorSpace::Oklch => {
+ let Self::Oklch(oklch) = self.to_oklch() else {
+ unreachable!();
+ };
+ let rotated = oklch.shift_hue(angle.to_deg() as f32);
+ Self::Oklch(rotated).to_space(self.space())
+ }
+ ColorSpace::Hsl => {
+ let Self::Hsl(hsl) = self.to_hsl() else {
+ unreachable!();
+ };
+ let rotated = hsl.shift_hue(angle.to_deg() as f32);
+ Self::Hsl(rotated).to_space(self.space())
+ }
+ ColorSpace::Hsv => {
+ let Self::Hsv(hsv) = self.to_hsv() else {
+ unreachable!();
+ };
+ let rotated = hsv.shift_hue(angle.to_deg() as f32);
+ Self::Hsv(rotated).to_space(self.space())
}
- Self::Oklab(_) => self.to_hsv().rotate(span, angle)?.to_oklab(),
- Self::LinearRgb(_) => self.to_hsv().rotate(span, angle)?.to_linear_rgb(),
- Self::Rgba(_) => self.to_hsv().rotate(span, angle)?.to_rgba(),
- Self::Cmyk(_) => self.to_hsv().rotate(span, angle)?.to_cmyk(),
- Self::Hsl(c) => Self::Hsl(c.shift_hue(angle.to_deg() as f32)),
- Self::Hsv(c) => Self::Hsv(c.shift_hue(angle.to_deg() as f32)),
+ _ => bail!(span, "this colorspace does not support hue rotation"),
})
}
@@ -1009,6 +1133,7 @@ impl Color {
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])),
ColorSpace::Srgb => Color::Rgba(Rgba::new(m[0], m[1], m[2], m[3])),
ColorSpace::LinearRgb => {
Color::LinearRgb(LinearRgba::new(m[0], m[1], m[2], m[3]))
@@ -1050,6 +1175,7 @@ impl Color {
match self {
Color::Luma(_) | Color::Cmyk(_) => None,
Color::Oklab(c) => Some(c.alpha),
+ Color::Oklch(c) => Some(c.alpha),
Color::Rgba(c) => Some(c.alpha),
Color::LinearRgb(c) => Some(c.alpha),
Color::Hsl(c) => Some(c.alpha),
@@ -1062,6 +1188,7 @@ impl Color {
match &mut self {
Color::Luma(_) | Color::Cmyk(_) => {}
Color::Oklab(c) => c.alpha = alpha,
+ Color::Oklch(c) => c.alpha = alpha,
Color::Rgba(c) => c.alpha = alpha,
Color::LinearRgb(c) => c.alpha = alpha,
Color::Hsl(c) => c.alpha = alpha,
@@ -1076,6 +1203,12 @@ impl Color {
match self {
Color::Luma(c) => [c.luma; 4],
Color::Oklab(c) => [c.l, c.a, c.b, c.alpha],
+ Color::Oklch(c) => [
+ c.l,
+ c.chroma,
+ c.hue.into_degrees().rem_euclid(360.0 + ANGLE_EPSILON),
+ c.alpha,
+ ],
Color::Rgba(c) => [c.red, c.green, c.blue, c.alpha],
Color::LinearRgb(c) => [c.red, c.green, c.blue, c.alpha],
Color::Cmyk(c) => [c.c, c.m, c.y, c.k],
@@ -1102,6 +1235,7 @@ impl Color {
pub fn to_space(self, space: ColorSpace) -> Self {
match space {
ColorSpace::Oklab => self.to_oklab(),
+ ColorSpace::Oklch => self.to_oklch(),
ColorSpace::Srgb => self.to_rgba(),
ColorSpace::LinearRgb => self.to_linear_rgb(),
ColorSpace::Hsl => self.to_hsl(),
@@ -1115,6 +1249,7 @@ impl Color {
Self::Luma(match self {
Self::Luma(c) => c,
Self::Oklab(c) => Luma::from_color(c),
+ Self::Oklch(c) => Luma::from_color(c),
Self::Rgba(c) => Luma::from_color(c),
Self::LinearRgb(c) => Luma::from_color(c),
Self::Cmyk(c) => Luma::from_color(c.to_rgba()),
@@ -1127,6 +1262,7 @@ impl Color {
Self::Oklab(match self {
Self::Luma(c) => Oklab::from_color(c),
Self::Oklab(c) => c,
+ Self::Oklch(c) => Oklab::from_color(c),
Self::Rgba(c) => Oklab::from_color(c),
Self::LinearRgb(c) => Oklab::from_color(c),
Self::Cmyk(c) => Oklab::from_color(c.to_rgba()),
@@ -1135,10 +1271,24 @@ impl Color {
})
}
+ pub fn to_oklch(self) -> Self {
+ Self::Oklch(match self {
+ Self::Luma(c) => Oklch::from_color(c),
+ Self::Oklab(c) => Oklch::from_color(c),
+ Self::Oklch(c) => c,
+ Self::Rgba(c) => Oklch::from_color(c),
+ Self::LinearRgb(c) => Oklch::from_color(c),
+ Self::Cmyk(c) => Oklch::from_color(c.to_rgba()),
+ Self::Hsl(c) => Oklch::from_color(c),
+ Self::Hsv(c) => Oklch::from_color(c),
+ })
+ }
+
pub fn to_linear_rgb(self) -> Self {
Self::LinearRgb(match self {
Self::Luma(c) => LinearRgba::from_color(c),
Self::Oklab(c) => LinearRgba::from_color(c),
+ Self::Oklch(c) => LinearRgba::from_color(c),
Self::Rgba(c) => LinearRgba::from_color(c),
Self::LinearRgb(c) => c,
Self::Cmyk(c) => LinearRgba::from_color(c.to_rgba()),
@@ -1151,6 +1301,7 @@ impl Color {
Self::Rgba(match self {
Self::Luma(c) => Rgba::from_color(c),
Self::Oklab(c) => Rgba::from_color(c),
+ Self::Oklch(c) => Rgba::from_color(c),
Self::Rgba(c) => c,
Self::LinearRgb(c) => Rgba::from_linear(c),
Self::Cmyk(c) => c.to_rgba(),
@@ -1163,6 +1314,7 @@ impl Color {
Self::Cmyk(match self {
Self::Luma(c) => Cmyk::from_luma(c),
Self::Oklab(c) => Cmyk::from_rgba(Rgba::from_color(c)),
+ Self::Oklch(c) => Cmyk::from_rgba(Rgba::from_color(c)),
Self::Rgba(c) => Cmyk::from_rgba(c),
Self::LinearRgb(c) => Cmyk::from_rgba(Rgba::from_linear(c)),
Self::Cmyk(c) => c,
@@ -1175,6 +1327,7 @@ impl Color {
Self::Hsl(match self {
Self::Luma(c) => Hsl::from_color(c),
Self::Oklab(c) => Hsl::from_color(c),
+ Self::Oklch(c) => Hsl::from_color(c),
Self::Rgba(c) => Hsl::from_color(c),
Self::LinearRgb(c) => Hsl::from_color(Rgba::from_linear(c)),
Self::Cmyk(c) => Hsl::from_color(c.to_rgba()),
@@ -1187,6 +1340,7 @@ impl Color {
Self::Hsv(match self {
Self::Luma(c) => Hsv::from_color(c),
Self::Oklab(c) => Hsv::from_color(c),
+ Self::Oklch(c) => Hsv::from_color(c),
Self::Rgba(c) => Hsv::from_color(c),
Self::LinearRgb(c) => Hsv::from_color(Rgba::from_linear(c)),
Self::Cmyk(c) => Hsv::from_color(c.to_rgba()),
@@ -1246,6 +1400,30 @@ impl Repr for Color {
)
}
}
+ Self::Oklch(c) => {
+ if c.alpha == 1.0 {
+ eco_format!(
+ "oklch({}, {}, {})",
+ Ratio::new(c.l as _).repr(),
+ format_float(c.chroma as _, Some(3), ""),
+ Angle::deg(
+ c.hue.into_degrees().rem_euclid(360.0 + ANGLE_EPSILON) as _
+ )
+ .repr()
+ )
+ } else {
+ eco_format!(
+ "oklch({}, {}, {}, {})",
+ Ratio::new(c.l as _).repr(),
+ format_float(c.chroma as _, Some(3), ""),
+ Angle::deg(
+ c.hue.into_degrees().rem_euclid(360.0 + ANGLE_EPSILON) as _
+ )
+ .repr(),
+ Ratio::new(c.alpha as _).repr(),
+ )
+ }
+ }
Self::Hsl(c) => {
if c.alpha == 1.0 {
eco_format!(
@@ -1308,6 +1486,7 @@ impl PartialEq for Color {
(a.luma * 255.0).round() as u8 == (b.luma * 255.0).round() as u8
}
(Self::Oklab(a), Self::Oklab(b)) => a == b,
+ (Self::Oklch(a), Self::Oklch(b)) => a == b,
(Self::LinearRgb(a), Self::LinearRgb(b)) => a == b,
(Self::Cmyk(a), Self::Cmyk(b)) => a == b,
(Self::Hsl(a), Self::Hsl(b)) => a == b,
@@ -1468,12 +1647,15 @@ cast! {
v: Ratio => Self(v.get()),
}
-/// A color space for mixing.
+/// A color space for color manipulation.
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
pub enum ColorSpace {
- /// A perceptual color space.
+ /// The perceptual Oklab color space.
Oklab,
+ /// The perceptual Oklch color space.
+ Oklch,
+
/// The standard RGB color space.
Srgb,
@@ -1497,6 +1679,7 @@ cast! {
ColorSpace,
self => match self {
Self::Oklab => Color::oklab_data(),
+ Self::Oklch => Color::oklch_data(),
Self::Srgb => Color::rgb_data(),
Self::D65Gray => Color::luma_data(),
Self::LinearRgb => Color::linear_rgb_data(),
@@ -1505,7 +1688,7 @@ cast! {
Self::Cmyk => Color::cmyk_data(),
}.into_value(),
v: Value => {
- let expected = "expected `rgb`, `luma`, `cmyk`, `oklab`, `color.linear-rgb`, `color.hsl`, or `color.hsv`";
+ let expected = "expected `rgb`, `luma`, `cmyk`, `oklab`, `oklch`, `color.linear-rgb`, `color.hsl`, or `color.hsv`";
let Value::Func(func) = v else {
bail!("{expected}, found {}", v.ty());
};
@@ -1514,6 +1697,8 @@ cast! {
// whereas the `NativeFuncData` is not.
if func == Color::oklab_data() {
Self::Oklab
+ } else if func == Color::oklch_data() {
+ Self::Oklch
} else if func == Color::rgb_data() {
Self::Srgb
} else if func == Color::luma_data() {
diff --git a/crates/typst/src/geom/gradient.rs b/crates/typst/src/geom/gradient.rs
index 8a10ed2b..13ed25e3 100644
--- a/crates/typst/src/geom/gradient.rs
+++ b/crates/typst/src/geom/gradient.rs
@@ -101,6 +101,7 @@ use crate::syntax::{Span, Spanned};
/// | Color space | Perceptually uniform? |
/// | ------------------------------- |-----------------------|
/// | [Oklab]($color.oklab) | *Yes* |
+/// | [Oklch]($color.oklch) | *Yes* |
/// | [sRGB]($color.rgb) | *No* |
/// | [linear-RGB]($color.linear-rgb) | *Yes* |
/// | [CMYK]($color.cmyk) | *No* |
@@ -113,6 +114,7 @@ use crate::syntax::{Span, Spanned};
/// >>> #set block(spacing: 0pt)
/// #let spaces = (
/// ("Oklab", color.oklab),
+/// ("Oklch", color.oklch),
/// ("linear-RGB", color.linear-rgb),
/// ("sRGB", color.rgb),
/// ("CMYK", color.cmyk),
diff --git a/tests/ref/compiler/color.png b/tests/ref/compiler/color.png
index ff9514da..078a0b62 100644
--- a/tests/ref/compiler/color.png
+++ b/tests/ref/compiler/color.png
Binary files differ
diff --git a/tests/ref/compiler/repr-color-gradient.png b/tests/ref/compiler/repr-color-gradient.png
index 25aae2e3..36747d45 100644
--- a/tests/ref/compiler/repr-color-gradient.png
+++ b/tests/ref/compiler/repr-color-gradient.png
Binary files differ
diff --git a/tests/typ/compiler/color.typ b/tests/typ/compiler/color.typ
index 71fce1b1..86dd1542 100644
--- a/tests/typ/compiler/color.typ
+++ b/tests/typ/compiler/color.typ
@@ -24,6 +24,7 @@
#box(square(size: 9pt, fill: col))
#box(square(size: 9pt, fill: rgb(col)))
#box(square(size: 9pt, fill: oklab(col)))
+#box(square(size: 9pt, fill: oklch(col)))
#box(square(size: 9pt, fill: luma(col)))
#box(square(size: 9pt, fill: cmyk(col)))
#box(square(size: 9pt, fill: color.linear-rgb(col)))
@@ -34,16 +35,19 @@
// Test hue rotation
#let col = rgb(50%, 64%, 16%)
+// Oklch
#for x in range(0, 11) {
box(square(size: 9pt, fill: rgb(col).rotate(x * 36deg)))
}
+// HSL
#for x in range(0, 11) {
- box(square(size: 9pt, fill: color.hsv(col).rotate(x * 36deg)))
+ box(square(size: 9pt, fill: rgb(col).rotate(x * 36deg, space: color.hsl)))
}
+// HSV
#for x in range(0, 11) {
- box(square(size: 9pt, fill: color.hsl(col).rotate(x * 36deg)))
+ box(square(size: 9pt, fill: rgb(col).rotate(x * 36deg, space: color.hsv)))
}
---
diff --git a/tests/typ/compiler/methods.typ b/tests/typ/compiler/methods.typ
index f7e7ed8f..0ae17ae7 100644
--- a/tests/typ/compiler/methods.typ
+++ b/tests/typ/compiler/methods.typ
@@ -108,6 +108,7 @@
#test-repr(luma(40).components(), (15.69%, ))
#test-repr(cmyk(4%, 5%, 6%, 7%).components(), (4%, 5%, 6%, 7%))
#test-repr(oklab(10%, 0.2, 0.3).components(), (10%, 0.2, 0.3, 100%))
+#test-repr(oklch(10%, 0.2, 90deg).components(), (10%, 0.2, 90deg, 100%))
#test-repr(color.linear-rgb(10%, 20%, 30%).components(), (10%, 20%, 30%, 100%))
#test-repr(color.hsv(10deg, 20%, 30%).components(), (10deg, 20%, 30%, 100%))
#test-repr(color.hsl(10deg, 20%, 30%).components(), (10deg, 20%, 30%, 100%))
@@ -131,6 +132,9 @@
#test-repr(color.hsv(rgb(1, 2, 3)), color.hsv(-150deg, 66.67%, 1.18%))
#test-repr(oklab(luma(40)).components(), (27.68%, 0.0, 0.0, 100%))
#test-repr(oklab(rgb(1, 2, 3)).components(), (8.23%, -0.004, -0.007, 100%))
+#test-repr(oklch(oklab(40%, 0.2, 0.2)).components(), (40%, 0.283, 45deg, 100%))
+#test-repr(oklch(luma(40)).components(), (27.68%, 0.0, 72.49deg, 100%))
+#test-repr(oklch(rgb(1, 2, 3)).components(), (8.23%, 0.008, 240.75deg, 100%))
---
// Test gradient functions.
@@ -143,6 +147,7 @@
#test(gradient.linear(red, green, blue, space: rgb).sample(100%), blue)
#test(gradient.linear(red, green, space: rgb).space(), rgb)
#test(gradient.linear(red, green, space: oklab).space(), oklab)
+#test(gradient.linear(red, green, space: oklch).space(), oklch)
#test(gradient.linear(red, green, space: cmyk).space(), cmyk)
#test(gradient.linear(red, green, space: luma).space(), luma)
#test(gradient.linear(red, green, space: color.linear-rgb).space(), color.linear-rgb)
diff --git a/tests/typ/compiler/repr-color-gradient.typ b/tests/typ/compiler/repr-color-gradient.typ
index d1c639c0..ef158974 100644
--- a/tests/typ/compiler/repr-color-gradient.typ
+++ b/tests/typ/compiler/repr-color-gradient.typ
@@ -7,6 +7,7 @@
#blue \
#color.linear-rgb(blue) \
#oklab(blue) \
+#oklch(blue) \
#cmyk(blue) \
#color.hsl(blue) \
#color.hsv(blue) \
diff --git a/tests/typ/compute/construct.typ b/tests/typ/compute/construct.typ
index 58693a49..f6856c6f 100644
--- a/tests/typ/compute/construct.typ
+++ b/tests/typ/compute/construct.typ
@@ -37,6 +37,7 @@
#test(rgb(rgb(10, 20, 30)).space(), rgb)
#test(color.linear-rgb(rgb(10, 20, 30)).space(), color.linear-rgb)
#test(oklab(rgb(10, 20, 30)).space(), oklab)
+#test(oklch(rgb(10, 20, 30)).space(), oklch)
#test(color.hsl(rgb(10, 20, 30)).space(), color.hsl)
#test(color.hsv(rgb(10, 20, 30)).space(), color.hsv)
#test(cmyk(rgb(10, 20, 30)).space(), cmyk)
@@ -45,6 +46,7 @@
#test(rgb(color.linear-rgb(10, 20, 30)).space(), rgb)
#test(color.linear-rgb(color.linear-rgb(10, 20, 30)).space(), color.linear-rgb)
#test(oklab(color.linear-rgb(10, 20, 30)).space(), oklab)
+#test(oklch(color.linear-rgb(10, 20, 30)).space(), oklch)
#test(color.hsl(color.linear-rgb(10, 20, 30)).space(), color.hsl)
#test(color.hsv(color.linear-rgb(10, 20, 30)).space(), color.hsv)
#test(cmyk(color.linear-rgb(10, 20, 30)).space(), cmyk)
@@ -53,14 +55,25 @@
#test(rgb(oklab(10%, 20%, 30%)).space(), rgb)
#test(color.linear-rgb(oklab(10%, 20%, 30%)).space(), color.linear-rgb)
#test(oklab(oklab(10%, 20%, 30%)).space(), oklab)
+#test(oklch(oklab(10%, 20%, 30%)).space(), oklch)
#test(color.hsl(oklab(10%, 20%, 30%)).space(), color.hsl)
#test(color.hsv(oklab(10%, 20%, 30%)).space(), color.hsv)
#test(cmyk(oklab(10%, 20%, 30%)).space(), cmyk)
#test(luma(oklab(10%, 20%, 30%)).space(), luma)
+#test(rgb(oklch(60%, 40%, 0deg)).space(), rgb)
+#test(color.linear-rgb(oklch(60%, 40%, 0deg)).space(), color.linear-rgb)
+#test(oklab(oklch(60%, 40%, 0deg)).space(), oklab)
+#test(oklch(oklch(60%, 40%, 0deg)).space(), oklch)
+#test(color.hsl(oklch(60%, 40%, 0deg)).space(), color.hsl)
+#test(color.hsv(oklch(60%, 40%, 0deg)).space(), color.hsv)
+#test(cmyk(oklch(60%, 40%, 0deg)).space(), cmyk)
+#test(luma(oklch(60%, 40%, 0deg)).space(), luma)
+
#test(rgb(color.hsl(10deg, 20%, 30%)).space(), rgb)
#test(color.linear-rgb(color.hsl(10deg, 20%, 30%)).space(), color.linear-rgb)
#test(oklab(color.hsl(10deg, 20%, 30%)).space(), oklab)
+#test(oklch(color.hsl(10deg, 20%, 30%)).space(), oklch)
#test(color.hsl(color.hsl(10deg, 20%, 30%)).space(), color.hsl)
#test(color.hsv(color.hsl(10deg, 20%, 30%)).space(), color.hsv)
#test(cmyk(color.hsl(10deg, 20%, 30%)).space(), cmyk)
@@ -69,6 +82,7 @@
#test(rgb(color.hsv(10deg, 20%, 30%)).space(), rgb)
#test(color.linear-rgb(color.hsv(10deg, 20%, 30%)).space(), color.linear-rgb)
#test(oklab(color.hsv(10deg, 20%, 30%)).space(), oklab)
+#test(oklch(color.hsv(10deg, 20%, 30%)).space(), oklch)
#test(color.hsl(color.hsv(10deg, 20%, 30%)).space(), color.hsl)
#test(color.hsv(color.hsv(10deg, 20%, 30%)).space(), color.hsv)
#test(cmyk(color.hsv(10deg, 20%, 30%)).space(), cmyk)
@@ -77,6 +91,7 @@
#test(rgb(cmyk(10%, 20%, 30%, 40%)).space(), rgb)
#test(color.linear-rgb(cmyk(10%, 20%, 30%, 40%)).space(), color.linear-rgb)
#test(oklab(cmyk(10%, 20%, 30%, 40%)).space(), oklab)
+#test(oklch(cmyk(10%, 20%, 30%, 40%)).space(), oklch)
#test(color.hsl(cmyk(10%, 20%, 30%, 40%)).space(), color.hsl)
#test(color.hsv(cmyk(10%, 20%, 30%, 40%)).space(), color.hsv)
#test(cmyk(cmyk(10%, 20%, 30%, 40%)).space(), cmyk)
@@ -85,6 +100,7 @@
#test(rgb(luma(10%)).space(), rgb)
#test(color.linear-rgb(luma(10%)).space(), color.linear-rgb)
#test(oklab(luma(10%)).space(), oklab)
+#test(oklch(luma(10%)).space(), oklch)
#test(color.hsl(luma(10%)).space(), color.hsl)
#test(color.hsv(luma(10%)).space(), color.hsv)
#test(cmyk(luma(10%)).space(), cmyk)
@@ -130,15 +146,15 @@
#color.mix((red, 1, 2))
---
-// Error: 31-38 expected `rgb`, `luma`, `cmyk`, `oklab`, `color.linear-rgb`, `color.hsl`, or `color.hsv`, found string
+// Error: 31-38 expected `rgb`, `luma`, `cmyk`, `oklab`, `oklch`, `color.linear-rgb`, `color.hsl`, or `color.hsv`, found string
#color.mix(red, green, space: "cyber")
---
-// Error: 31-36 expected `rgb`, `luma`, `cmyk`, `oklab`, `color.linear-rgb`, `color.hsl`, or `color.hsv`
+// Error: 31-36 expected `rgb`, `luma`, `cmyk`, `oklab`, `oklch`, `color.linear-rgb`, `color.hsl`, or `color.hsv`
#color.mix(red, green, space: image)
---
-// Error: 31-41 expected `rgb`, `luma`, `cmyk`, `oklab`, `color.linear-rgb`, `color.hsl`, or `color.hsv`
+// Error: 31-41 expected `rgb`, `luma`, `cmyk`, `oklab`, `oklch`, `color.linear-rgb`, `color.hsl`, or `color.hsv`
#color.mix(red, green, space: calc.round)
---