diff --git a/crates/typst-pdf/src/color.rs b/crates/typst-pdf/src/color.rs index 4c2f6183f..a9ad01008 100644 --- a/crates/typst-pdf/src/color.rs +++ b/crates/typst-pdf/src/color.rs @@ -240,10 +240,10 @@ fn minify(source: &str) -> String { /// Ensures that the values are in the range [0.0, 1.0]. /// /// # Why? -/// - Oklab: The a and b components are in the range [-0.4, 0.4] and the PDF +/// - Oklab: The a and b components are in the range [-0.5, 0.5] and the PDF /// specifies (and some readers enforce) that all color values be in the range /// [0.0, 1.0]. This means that the PostScript function and the encoded color -/// must be offset by 0.4. +/// must be offset by 0.5. /// - HSV/HSL: The hue component is in the range [0.0, 360.0] and the PDF format /// specifies that it must be in the range [0.0, 1.0]. This means that the /// PostScript function and the encoded color must be divided by 360.0. @@ -256,8 +256,13 @@ impl ColorEncode for ColorSpace { fn encode(&self, color: Color) -> [f32; 4] { match self { ColorSpace::Oklab => { - let [l, a, b, alpha] = color.to_oklab().to_vec4(); - [l, (a + 0.4).clamp(0.0, 1.0), (b + 0.4).clamp(0.0, 1.0), alpha] + let [l, c, h, alpha] = color.to_oklch().to_vec4(); + // Clamp on Oklch's chroma, not Oklab's a\* and b\* as to not distort hue. + let c = c.clamp(0.0, 0.5); + // Convert cylindrical coordinates back to rectangular ones. + let a = c * h.to_radians().cos(); + let b = c * h.to_radians().sin(); + [l, a + 0.5, b + 0.5, alpha] } ColorSpace::Hsl => { let [h, s, l, _] = color.to_hsl().to_vec4(); diff --git a/crates/typst-pdf/src/postscript/oklab.ps b/crates/typst-pdf/src/postscript/oklab.ps index 4d6e9ad57..e766bbd84 100644 --- a/crates/typst-pdf/src/postscript/oklab.ps +++ b/crates/typst-pdf/src/postscript/oklab.ps @@ -3,12 +3,12 @@ % /!\ WARNING: The A and B components **MUST** be encoded % in the range [0, 1] before calling this function. % This is because the function assumes that the - % A and B components are offset by a factor of 0.4 + % A and B components are offset by a factor of 0.5 % in order to meet the range requirements of the % PDF specification. - exch 0.4 sub - exch 0.4 sub + exch 0.5 sub + exch 0.5 sub % Load L a and b into the stack 2 index @@ -75,4 +75,4 @@ % Discard f1, f2, and f3 by rolling the stack and popping 6 3 roll pop pop pop -} \ No newline at end of file +} diff --git a/crates/typst/src/geom/color.rs b/crates/typst/src/geom/color.rs index 3801dbab8..379aca732 100644 --- a/crates/typst/src/geom/color.rs +++ b/crates/typst/src/geom/color.rs @@ -296,10 +296,10 @@ impl Color { /// A linear Oklab color is represented internally by an array of four /// components: /// - lightness ([`ratio`]($ratio)) - /// - a ([`float`]($float) in the range `[-0.4..0.4]` - /// or [`ratio`]($ratio) in the range `[-100%..100%]`) - /// - b ([`float`]($float) in the range `[-0.4..0.4]` - /// or [`ratio`]($ratio) in the range `[-100%..100%]`) + /// - a ([`float`]($float) or [`ratio`]($ratio). + /// Ratios are relative to `{0.4}`; meaning `{50%}` is equal to `{0.2}`) + /// - b ([`float`]($float) or [`ratio`]($ratio). + /// Ratios are relative to `{0.4}`; meaning `{50%}` is equal to `{0.2}`) /// - alpha ([`ratio`]($ratio)) /// /// These components are also available using the @@ -341,12 +341,7 @@ impl Color { let ChromaComponent(b) = args.expect("B component")?; let RatioComponent(alpha) = args.eat()?.unwrap_or(RatioComponent(Ratio::one())); - Self::Oklab(Oklab::new( - l.get() as f32, - a.get() as f32, - b.get() as f32, - alpha.get() as f32, - )) + Self::Oklab(Oklab::new(l.get() as f32, a, b, alpha.get() as f32)) }) } @@ -360,8 +355,8 @@ impl Color { /// A linear Oklch color is represented internally by an array of four /// components: /// - lightness ([`ratio`]($ratio)) - /// - chroma ([`float`]($float) in the range `[0.0..0.4]` - /// or [`ratio`]($ratio) in the range `[0%..100%]`) + /// - chroma ([`float`]($float) or [`ratio`]($ratio). + /// Ratios are relative to `{0.4}`; meaning `{50%}` is equal to `{0.2}`) /// - hue ([`angle`]($angle)) /// - alpha ([`ratio`]($ratio)) /// @@ -406,7 +401,7 @@ impl Color { args.eat()?.unwrap_or(RatioComponent(Ratio::one())); Self::Oklch(Oklch::new( l.get() as f32, - c.get() as f32, + c, OklabHue::from_degrees(h.to_deg() as f32), alpha.get() as f32, )) @@ -1764,23 +1759,15 @@ cast! { }, } -/// A component that must either be a value between: -/// - -100% and 100%, in which case it is relative to 0.4. -/// - -0.4 and 0.4, in which case it is taken literally. -pub struct ChromaComponent(Ratio); +/// A component that must either be: +/// - a ratio, in which case it is relative to 0.4. +/// - a float, in which case it is taken literally. +pub struct ChromaComponent(f32); cast! { ChromaComponent, - v: Ratio => if (-1.0 ..= 1.0).contains(&v.get()) { - Self(v * 0.4) - } else { - bail!("ratio must be between -100% and 100%"); - }, - v: f64 => if (-0.4 ..= 0.4).contains(&v) { - Self(Ratio::new(v)) - } else { - bail!("ratio must be between -0.4 and 0.4"); - }, + v: Ratio => Self((v.get() * 0.4) as f32), + v: f64 => Self(v as f32), } /// An integer or ratio component. diff --git a/tests/ref/compiler/color.png b/tests/ref/compiler/color.png index faf6a9b2b..a585bf30e 100644 Binary files a/tests/ref/compiler/color.png and b/tests/ref/compiler/color.png differ diff --git a/tests/typ/compiler/color.typ b/tests/typ/compiler/color.typ index 86dd15424..ec1f99024 100644 --- a/tests/typ/compiler/color.typ +++ b/tests/typ/compiler/color.typ @@ -31,6 +31,11 @@ #box(square(size: 9pt, fill: color.hsl(col))) #box(square(size: 9pt, fill: color.hsv(col))) +--- +// Colors outside the sRGB gamut. +#box(square(size: 9pt, fill: oklab(90%, -0.2, -0.1))) +#box(square(size: 9pt, fill: oklch(50%, 0.5, 0deg))) + --- // Test hue rotation #let col = rgb(50%, 64%, 16%)