From b0e81d4b3fd2bd525955f1d95145cf62d64ac096 Mon Sep 17 00:00:00 2001 From: frozolotl <44589151+frozolotl@users.noreply.github.com> Date: Fri, 17 Nov 2023 10:41:45 +0100 Subject: [PATCH] Remove restrictions to chroma and improve clamping (#2690) This PR does a few small things: - Oklab's a*/b* and Oklch's chroma components can be as large as desired. - In PDF, when encoding Oklab, the range is widened from [-0.4,0.4] to [-0.5,0.5]. - In PDF, clamping is now performed on Oklch's chroma instead of a* and b*. This causes hue not to be distorted when clamping. SVG and PNG export remain unchanged: - SVG itself never had any restrictions on chroma. We directly use the `oklab` and `oklch` CSS colors, which should work fine for the most part. In the future, embedded ICC profiles might be nice. Further research is likely necessary. - While PNG does not support color spaces like Oklab or Oklch, certain useful features exist. One can define gamma (gAMA) and chromacities&whitepoint (cHRM) chunks and even embed ICC profiles. While `image` crate does not support these features for encoding, its backend crate `png` does support gAMA and cHRM. It does not allow embedding ICC profiles yet, though. As it stands, to fully support wide gamuts and more accurate colors, more work is necessary. This PR should help a bit though. --- crates/typst-pdf/src/color.rs | 13 ++++--- crates/typst-pdf/src/postscript/oklab.ps | 8 ++--- crates/typst/src/geom/color.rs | 41 ++++++++--------------- tests/ref/compiler/color.png | Bin 1253 -> 1344 bytes tests/typ/compiler/color.typ | 5 +++ 5 files changed, 32 insertions(+), 35 deletions(-) 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 faf6a9b2b1e2fa286c49ea2e1f00ba63cc425d75..a585bf30edb1795bad3e4d25e261d7d64934a620 100644 GIT binary patch delta 697 zcmaFLd4OwzINu=-W(EcZ{{C1VAa!!0suDB9tN#<-E13T?bWGf@>-KU5#}Wnx79&p= z$B>F!Z|~mpyTZWWmUzd3MNm}S%(c*wh2==?_rLO|j(GbSO;VenbbsfYqduQvegFI2 z*7TgD@^VS+t^~))fy}~!3=h8u-s5?D**MP0Hc215 zYsx>lkVRzj21XW<9;Sy1d;R(^A1*PD^}l}UYKdyB-{eL{VMjKF8P{`q1QZ+^7#NvY zIDia^^GkpBzx4h!Yv0oMFWrC5tXq0NQ_B%94QEI+B*bktbOO1Ir+|xNl8Wc0BY$$r z-c3$q6cIc$Q$c6BXM6aiC5N|PYFPg!aq=4`RjHmAmu$dxzzu;go|#T=U=-D;xZA1X zIcd+kBRiR~sd_H{(Ct_CkK87p4Z_R^_ppI{$IWs+WFk;k`YMZZkW&m99?B@JSt9<@ zvuf76Ww&4Y?V2vPdsU=yL_j~?6gZIK%*#c0Y<8b+#Jp(V33p?`p1y{KJE1U_!1#t P+GOx_^>bP0l+XkKnfn5l delta 634 zcmX@W^^|jhIA0wHGXn!dQhOg0kUBh3RcWGE#l)k!uDr89`7$st-}Q8H45_&F_U=u; zD+~;7iFX_vT#mM;iX3GXoN%=E```Jes{M>6srhu(J>Mj5e6rm1uXXNo70*daK9@bU z=bl{8D9SJ1|Dh}+-R^lsLb~0vuNNj4GU`swXXcn(z{tx{@KWH8!Mk;plN%W&L=r_f zj=%J+nk2V0`eoX#iR+eUzs#E4!>sCms6y{-y-)n5>tA$h{p~NE|DyfZub7jGg+oBW zp@D%B&N#%t@_AOTAe@h2v~W920J>_~v-Hiy>_E#z1k;)}%v&aV&*L)ReLv=Xo7xp8 zuV+zVF<9jNd9ovmFtfsp>p+K!az7|lpQPgXX_xSGK9IP$LQGss?-wIm-}9IDmZ;zL z{eJoI7lm9Eppn9pYng;5_cHS6CvM^2Qa?3r$@7=NzvkRq`u(MI)tqa>7_Mb%VdQvb zDy=-Zfl~`Df3`0v#%%H-qz#O77CLTc(9w z_KNcJ&NS3r@{In>S zBN4aXJ=flDnOt@`?v_9MO9R=ZlS^g+tr4DlkVR2w&a%lRQ}`}B-tw=`G=IC~t%)Zn uXxRUCJ&;^$<^~E~*|a7F&q*qCH`^O=X>;hazW54ETnwJBelF{r5}E*OUFjqM 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%)