Repository: latex3/latex3
Pull request #1500: Support Oklab and Oklch as interface color models
Author: login: mgkurtz
Created: 2024-03-05T10:54:50Z | Merged: 2025-05-18T20:47:41Z

As wished in https://github.com/latex3/xcolor/issues/26, I wrote a function converting from some LCh color space (name Oklch based on [Oklab](https://bottosson.github.io/posts/oklab)) to the standard RGB color space. That seemed easier to do with l3color, so I did it here.

IMHO it would make much sense to allow mixing colors in Oklab, and thus provide it as a core color model. Though, that seems better suited in a package like xcolor outside the kernel. So, likely I will also port the code to xcolor after all.


Diff:
diff --git a/l3kernel/l3color.dtx b/l3kernel/l3color.dtx
index 4a2158d96b..a3c285c4fd 100644
--- a/l3kernel/l3color.dtx
+++ b/l3kernel/l3color.dtx
@@ -114,6 +114,14 @@
 %     single six-digit hexadecimal number
 %   \item \texttt{RGB} Red-green-blue color, with three axes, one for each of
 %     the components, values as integers from $0$ to $255$
+%   \item \texttt{oklch} Lightness-chromacity-hue color in the Oklab color space
+%     (\url{https://bottosson.github.io/posts/oklab}), which models human
+%     perception, with three axes, real values in the range $[0,1]$ for
+%     lightness, real values in the range $[0, 0.4]$ for chromacity and real
+%     values in the range $[0, 360]$ for hue.
+%   \item \texttt{oklab} Oklab color, with three axes, real values in the range
+%     $[0,1]$ for lightness and real values in the range $[-0.4, 0.4]$ for the
+%     position on the green/red- and yellow/blue-axes.
 %   \item \texttt{wave} Light wavelength, a real number in the range
 %     $380$ to $780$ (nanometres)
 % \end{itemize}
@@ -1384,6 +1392,121 @@
 %    \end{macrocode}
 % \end{macro}
 %
+% \begin{macro}[EXP]
+%   { \@@_parse_model_linearrgb:nnn, \@@_parse_model_linearrgb_aux:n }
+%   Encode the tristimulus values of the sRGB primaries according to sRGB.
+%   That is, take the actual physical light intensities of the primaries
+%   defined by the default RGB color space sRGB
+%   (\href{https://webstore.iec.ch/en/publication/6168}{IEC 61966-2-1}) and
+%   transform them with the gamma function defined in the same
+%   standard\footnote{For background information see
+%   \url{https://www.w3.org/Graphics/Color/sRGB}.}:
+%   \[ x_{\text{standard}} = \left\{\begin{array}{ll}
+%   12.92 \cdot x & x<0.0031308 \\
+%   1.055 \cdot x^{1/2.4} - 0.055 & x\ge0.0031308
+%   \end{array}\right. \]
+%   Additionally clip values to the range $[0,1]$.
+%    \begin{macrocode}
+\cs_new:Nn \@@_parse_model_linearrgb:nnn
+  {
+    { rgb }
+    {
+      \@@_parse_model_linearrgb_aux:n { #1 } ~
+      \@@_parse_model_linearrgb_aux:n { #2 } ~
+      \@@_parse_model_linearrgb_aux:n { #3 }
+    }
+  }
+\cs_new:Nn \@@_parse_model_linearrgb_aux:n
+  {
+    \fp_compare:nNnTF { #1 } < { 0.0031308 }
+      { \fp_eval:n { 12.92 * max ( 0 , #1 ) } }
+      { \fp_eval:n { 1.055 * min ( 1 , #1 ) ^ 0.4166666666666667 - 0.055 } }
+  }
+%    \end{macrocode}
+% \end{macro}
+%
+% \begin{macro}[EXP] {
+%     \@@_parse_model_lms:nnn, \@@_parse_model_cbrtlms:nnn,
+%     \@@_parse_model_oklab:nnn, \@@_parse_model_oklab:w
+%   }
+%   The \href{https://bottosson.github.io/posts/oklab}{Oklab color space} is
+%   defined by first transforming the physical light intensity to the
+%   biophysical responses of the L, M and S cones in a typical human eye, then
+%   taking those responses to the power of $\frac{1}{3}$ (which approximately
+%   fits human lightness perception), and finally applying one more matrix
+%   transformation $M_2$ to obtain handy $L$, $a$, and $b$ values. Here, we
+%   just go the other way round:
+%
+%   First, transform $L$, $a$ and $b$ into the cube roots of $l$, $m$ and $s$
+%   cone response intensities using the inverse of Oklab's defining matrix
+%   $M_2$:
+%   \[ \left(\begin{array}{c} l' \\ m' \\ s' \end{array}\right)
+%   = \left(\begin{array}{ccc}
+%     1 &   0.3963377774 &   0.2158037573 \\
+%     1 & - 0.1055613458 & - 0.0638541728 \\
+%     1 & - 0.0894841775 & - 1.2914855480
+%   \end{array}\right) \left(\begin{array}{c} L \\ a \\ b \end{array}\right) \]
+%    \begin{macrocode}
+\cs_new:Nn \@@_parse_model_oklab:nnn
+  {
+    \exp_args:Neee \@@_parse_model_cbrtlms:nnn
+      { \fp_eval:n { #1 + 0.3963377774 * #2 + 0.2158037573 * #3 } }
+      { \fp_eval:n { #1 - 0.1055613458 * #2 - 0.0638541728 * #3 } }
+      { \fp_eval:n { #1 - 0.0894841775 * #2 - 1.2914855480 * #3 } }
+  }
+%    \end{macrocode}
+%   Next, take those cube roots to the power of $3$ to obtain the actual cone responses:
+%   \[ l = l'^3, \quad m = m'^3, \quad s = s'^3 \]
+%    \begin{macrocode}
+\cs_new:Nn \@@_parse_model_cbrtlms:nnn
+  {
+    \exp_args:Neee \@@_parse_model_lms:nnn
+      { \fp_eval:n { #1 ^ 3 } }
+      { \fp_eval:n { #2 ^ 3 } }
+      { \fp_eval:n { #3 ^ 3 } }
+  }
+%    \end{macrocode}
+%
+%   Finally, transform $l$, $m$ and $s$ cone responses to linear sRGB
+%   intensity.
+%   \[ \left(\begin{array}{c} r \\ g \\ b \end{array}\right)
+%   = \left(\begin{array}{ccc}
+%     4.0767416621 & - 3.3077115913 &   0.2309699292 \\
+%    -1.2684380046 &   2.6097574011 & - 0.3413193965 \\
+%    -0.0041960863 & - 0.7034186147 &   1.7076147010
+%   \end{array}\right) \left(\begin{array}{c} l \\ m \\ s \end{array}\right) \]
+%    \begin{macrocode}
+\cs_new:Nn \@@_parse_model_lms:nnn
+  {
+    \exp_args:Neee \@@_parse_model_linearrgb:nnn
+      { \fp_eval:n {  4.0767416621 * #1 - 3.3077115913 * #2 + 0.2309699292 * #3 } }
+      { \fp_eval:n { -1.2684380046 * #1 + 2.6097574011 * #2 - 0.3413193965 * #3 } }
+      { \fp_eval:n { -0.0041960863 * #1 - 0.7034186147 * #2 + 1.7076147010 * #3 } }
+  }
+%    \end{macrocode}
+%   The actual parse macro with \emph{weird} signature.
+%    \begin{macrocode}
+\cs_new:Npn \@@_parse_model_oklab:w #1 , #2 , #3 , #4 \s_@@_stop
+  { \@@_parse_model_oklab:nnn { #1 } { #2 } { #3 } }
+%    \end{macrocode}
+% \end{macro}
+% \begin{macro}[EXP] { \@@_parse_model_oklch:w }
+%   Oklch is Oklab in cylinder coordinates with lightness
+%   $L\in\left[0,1\right]$, chroma $c$ giving the color intensity in values up
+%   to around $0.35$ and hue $h$ given in degrees (typically given as value
+%   from $0$ to $360$):
+%   \[ L = L, \quad a = h \cdot \cos(c), \quad b = h \cdot \sin(c) \]
+%    \begin{macrocode}
+\cs_new:Npn \@@_parse_model_oklch:w #1 , #2 , #3 , #4 \s_@@_stop
+  {
+    \exp_args:Nnee \@@_parse_model_oklab:nnn
+      { #1 }
+      { \fp_eval:n { #2 * cosd(#3) } }
+      { \fp_eval:n { #2 * sind(#3) } }
+  }
+%    \end{macrocode}
+% \end{macro}
+%
 % \subsection{Selecting colors (and color models)}
 %
 % \begin{variable}{\l_color_fixed_model_tl}
diff --git a/l3kernel/testfiles/m3color003.lvt b/l3kernel/testfiles/m3color003.lvt
new file mode 100644
index 0000000000..6760512b5e
--- /dev/null
+++ b/l3kernel/testfiles/m3color003.lvt
@@ -0,0 +1,104 @@
+%
+% Copyright (C) The LaTeX Project
+%
+
+\documentclass{minimal}
+
+\input{regression-test}
+
+\ExplSyntaxOn
+\debug_on:n { check-declarations , deprecation , log-functions }
+
+\cs_new:Npn \__color_parse_model_lms:w #1 , #2 , #3 , #4 \s__color_stop
+  { \__color_parse_model_lms:nnn { #1 } { #2 } { #3 } }
+\cs_new:Npn \__color_parse_model_cbrtlms:w #1 , #2 , #3 , #4 \s__color_stop
+  { \__color_parse_model_cbrtlms:nnn { #1 } { #2 } { #3 } }
+\cs_new:Npn \__color_parse_model_linearrgb:w #1 , #2 , #3 , #4 \s__color_stop
+  { \__color_parse_model_linearrgb:nnn { #1 } { #2 } { #3 } }
+
+\START
+
+\AUTHOR{Markus~Kurtz}
+
+\TEST { linearrgb~model }
+  {
+    \color_set:nnn { foo1 } { linearrgb }
+      { 0.0007739938080495357 , 0.07323895587840545 , 0.2140411404822325 }
+    \color_log:n { foo1 }
+    \color_set:nnn { foo1 } { linearrgb } { 0 , 0.003130799 , 0.00313081 }
+    \color_log:n { foo1 }
+    \color_set:nnn { foo1 } { linearrgb } { -0.1 , 1.1 , 1 }
+    \color_log:n { foo1 }
+    \color_set:nnn { foo1 } { linearrgb } { 0.2 , 0.6 , 0.9 }
+    \color_log:n { foo1 }
+  }
+
+\TEST { lms~model }
+  {
+    \color_set:nnn { foo1 } { lms } { 1, 1, 1 }
+    \color_log:n { foo1 }
+    \color_set:nnn { foo1 } { lms } { .7, .7, .7 }
+    \color_log:n { foo1 }
+    \color_set:nnn { foo1 } { lms } { 0 , 0 , 0.5 }
+    \color_log:n { foo1 }
+    \color_set:nnn { foo1 } { lms } { 0.3 , 0.4 , 0.2 }
+    \color_log:n { foo1 }
+    \color_set:nnn { foo1 } { lms } { 0.7 , 0.7 , 0.5 }
+    \color_log:n { foo1 }
+  }
+
+\TEST { cbrtlms~model }
+  {
+    \color_set:nnn { foo1 } { cbrtlms } { 1, 1, 1 }
+    \color_log:n { foo1 }
+    \color_set:nnn { foo1 } { cbrtlms } { .7, .7, .7 }
+    \color_log:n { foo1 }
+    \color_set:nnn { foo1 } { cbrtlms } { 0 , 0 , 0.5 }
+    \color_log:n { foo1 }
+    \color_set:nnn { foo1 } { cbrtlms } { 0.3 , 0.4 , 0.2 }
+    \color_log:n { foo1 }
+    \color_set:nnn { foo1 } { cbrtlms } { 0.7 , 0.7 , 0.5 }
+    \color_log:n { foo1 }
+  }
+
+\TEST { oklab~model }
+  {
+    \color_set:nnn { foo1 } { oklab } { 0 , 0 , 0 }
+    \color_log:n { foo1 }
+    \color_set:nnn { foo1 } { oklab } { 1 , 0 , 0 }
+    \color_log:n { foo1 }
+    \color_set:nnn { foo1 } { oklab } { .7 , 0 , 0 }
+    \color_log:n { foo1 }
+    \color_set:nnn { foo1 } { oklab } { 0.37, 0.13, 0.07 }
+    \color_log:n { foo1 }
+    \color_set:nnn { foo1 } { oklab } { 0.45, -0.032, -0.31 }
+    \color_log:n { foo1 }
+    \color_set:nnn { foo1 } { oklab } { 0.32 , 0 , -0.06 }
+    \color_log:n { foo1 }
+    \color_set:nnn { foo1 } { oklab } { 0.7 , -0.1 , 0.2 }
+    \color_log:n { foo1 }
+    \color_set:nnn { foo1 } { oklab } { 1.1 , 0 , 0 }
+    \color_log:n { foo1 }
+  }
+
+\TEST { oklch~model }
+  {
+    \color_set:nnn { foo1 } { oklch } { 0 , 0 , 0 }
+    \color_log:n { foo1 }
+    \color_set:nnn { foo1 } { oklch } { 1 , 0 , 0 }
+    \color_log:n { foo1 }
+    \color_set:nnn { foo1 } { oklch } { .7 , 0 , 0 }
+    \color_log:n { foo1 }
+    \color_set:nnn { foo1 } { oklch } { 0.6, 0.4, 0 }
+    \color_log:n { foo1 }
+    \color_set:nnn { foo1 } { oklch } { 0.452 , 0.31 , 264.1 }
+    \color_log:n { foo1 }
+    \color_set:nnn { foo1 } { oklch } { 0.8 , 0.1, 90 }
+    \color_log:n { foo1 }
+    \color_set:nnn { foo1 } { oklch } { 0.1 , 0.03 , 15 }
+    \color_log:n { foo1 }
+    \color_set:nnn { foo1 } { oklch } { 0.6 , 0.2 , 180 }
+    \color_log:n { foo1 }
+  }
+
+\END
diff --git a/l3kernel/testfiles/m3color003.tlg b/l3kernel/testfiles/m3color003.tlg
new file mode 100644
index 0000000000..e422741eda
--- /dev/null
+++ b/l3kernel/testfiles/m3color003.tlg
@@ -0,0 +1,115 @@
+This is a generated file for the LaTeX (2e + expl3) validation system.
+Don't change this file in any respect.
+Author: Markus Kurtz
+============================================================
+TEST 1: linearrgb model
+============================================================
+Defining \l__color_named_foo1_tl on line ...
+Defining \l__color_named_foo1_prop on line ...
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  0.01 0.3 0.5
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  0 0.04044992308 0.04045003451334598
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  0 1 1
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  0.4845292044817069 0.79773773303126 0.954687171885866
+============================================================
+============================================================
+TEST 2: lms model
+============================================================
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  1 1 1
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  0.8543058315449401 0.8543058315449401 0.8543058315449401
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  0.3741786031699747 0 0.9327622252715925
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  0 0.7948336840226253 0.2691830197643374
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  0.8288044270221876 0.8902538737698393 0.633036318401439
+============================================================
+============================================================
+TEST 3: cbrtlms model
+============================================================
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  1 1 1
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  0.6204994654003908 0.6204994654003908 0.6204994654003908
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  0.1858683470023627 0 0.4993628053104879
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  0 0.3959478675515466 0
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  0.5772615717173346 0.6780821058614707 0
+============================================================
+============================================================
+TEST 4: oklab model
+============================================================
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  0 0 0
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  1 1 1
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  0.6204994654003908 0.6204994654003908 0.6204994654003908
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  0.4842738090432652 0.01913857747061253 0.02426429457184269
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  0.003179872312245272 0.0000259847232831 0.99379117131677
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  0.1525473096988617 0.1913362061546487 0.3194347438290483
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  0.5985815757223127 0.6754026826189243 0
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  1 1 1
+============================================================
+============================================================
+TEST 5: oklch model
+============================================================
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  0 0 0
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  1 1 1
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  0.6204994654003908 0.6204994654003908 0.6204994654003908
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  1 0 0.4646834491735871
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  0.000646966654132396 0.02772663050089872 0.993174110239439
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  0.8418570616286571 0.734024402153969 0.4387023732211911
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  0.03997663126521621 0.003559099498593614 0.006487309387513319
+The color foo1 has the properties:
+>  model  =>  rgb
+>  rgb  =>  0 0.6387818589565522 0.5207007185748967
+============================================================


Reviews:
user: login: josephwright
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-13T07:01:12Z
user: login: mgkurtz
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T19:57:39Z
user: login: mgkurtz
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T19:58:37Z

Review comments:
diff_hunk: @@ -1377,6 +1385,75 @@
 %    \end{macrocode}
 % \end{macro}
 %
+% \begin{macro}[EXP] {
+%     \__color_parse_model_lms:nnn, \__color_parse_model_cbrtlms:nnn,
+%     \__color_parse_model_oklab:nnn, \__color_parse_model_oklab:w
path: l3kernel/l3color.dtx
user: login: josephwright
body: Here and elsewhere we should have `@@` for internal code, not `\__color`.
created_at: 2025-05-13T06:57:30Z
author_association: MEMBER
diff_hunk: @@ -1377,6 +1385,75 @@
 %    \end{macrocode}
 % \end{macro}
 %
+% \begin{macro}[EXP] {
+%     \__color_parse_model_lms:nnn, \__color_parse_model_cbrtlms:nnn,
+%     \__color_parse_model_oklab:nnn, \__color_parse_model_oklab:w
+%   }
+%   The Oklab color space is defined by first transforming the color values to
+%   the responses of the L, M and S cones of a standard human observer, then
+%   taking those responses to the power of $\frac{1}{3}$, and finally applying
+%   one more matrix transformation to obtain the $l$, $a$, and $b$ values.
+%   Here, we just go the other way round.
+%    \begin{macrocode}
+\cs_new:Nn \__color_parse_model_lms:nnn
+  {
+    \__color_parse_model_linearrgb:nnn
+      { \fp_eval:n {  4.0767416621 * #1 - 3.3077115913 * #2 + 0.2309699292 * #3 } }
+      { \fp_eval:n { -1.2684380046 * #1 + 2.6097574011 * #2 - 0.3413193965 * #3 } }
+      { \fp_eval:n { -0.0041960863 * #1 - 0.7034186147 * #2 + 1.7076147010 * #3 } }
+  }
path: l3kernel/l3color.dtx
user: login: josephwright
body: The code here should be expanded so the next internal in the chain receives the values not the expressions.
created_at: 2025-05-13T06:58:18Z
author_association: MEMBER
diff_hunk: @@ -1377,6 +1385,75 @@
 %    \end{macrocode}
 % \end{macro}
 %
+% \begin{macro}[EXP] {
+%     \__color_parse_model_lms:nnn, \__color_parse_model_cbrtlms:nnn,
+%     \__color_parse_model_oklab:nnn, \__color_parse_model_oklab:w
+%   }
+%   The Oklab color space is defined by first transforming the color values to
+%   the responses of the L, M and S cones of a standard human observer, then
+%   taking those responses to the power of $\frac{1}{3}$, and finally applying
+%   one more matrix transformation to obtain the $l$, $a$, and $b$ values.
+%   Here, we just go the other way round.
+%    \begin{macrocode}
+\cs_new:Nn \__color_parse_model_lms:nnn
+  {
+    \__color_parse_model_linearrgb:nnn
+      { \fp_eval:n {  4.0767416621 * #1 - 3.3077115913 * #2 + 0.2309699292 * #3 } }
+      { \fp_eval:n { -1.2684380046 * #1 + 2.6097574011 * #2 - 0.3413193965 * #3 } }
+      { \fp_eval:n { -0.0041960863 * #1 - 0.7034186147 * #2 + 1.7076147010 * #3 } }
+  }
path: l3kernel/l3color.dtx
user: login: josephwright
body: (Or the expressions should build-up to use `\fp_eval:n` only once, if appropriate)
created_at: 2025-05-13T06:58:58Z
author_association: MEMBER
diff_hunk: @@ -1377,6 +1385,75 @@
 %    \end{macrocode}
 % \end{macro}
 %
+% \begin{macro}[EXP] {
+%     \__color_parse_model_lms:nnn, \__color_parse_model_cbrtlms:nnn,
+%     \__color_parse_model_oklab:nnn, \__color_parse_model_oklab:w
+%   }
+%   The Oklab color space is defined by first transforming the color values to
+%   the responses of the L, M and S cones of a standard human observer, then
+%   taking those responses to the power of $\frac{1}{3}$, and finally applying
+%   one more matrix transformation to obtain the $l$, $a$, and $b$ values.
+%   Here, we just go the other way round.
+%    \begin{macrocode}
+\cs_new:Nn \__color_parse_model_lms:nnn
+  {
+    \__color_parse_model_linearrgb:nnn
+      { \fp_eval:n {  4.0767416621 * #1 - 3.3077115913 * #2 + 0.2309699292 * #3 } }
+      { \fp_eval:n { -1.2684380046 * #1 + 2.6097574011 * #2 - 0.3413193965 * #3 } }
+      { \fp_eval:n { -0.0041960863 * #1 - 0.7034186147 * #2 + 1.7076147010 * #3 } }
+  }
+\cs_new:Nn \__color_parse_model_cbrtlms:nnn
+  {
+    \__color_parse_model_lms:nnn
+      { \fp_eval:n { #1 ^ 3 } }
+      { \fp_eval:n { #2 ^ 3 } }
+      { \fp_eval:n { #3 ^ 3 } }
+  }
+\cs_new:Nn \__color_parse_model_oklab:nnn
+  {
+    \__color_parse_model_cbrtlms:nnn
+      { \fp_eval:n { #1 + 0.3963377774 * #2 + 0.2158037573 * #3 } }
+      { \fp_eval:n { #1 - 0.1055613458 * #2 - 0.0638541728 * #3 } }
+      { \fp_eval:n { #1 - 0.0894841775 * #2 - 1.2914855480 * #3 } }
+  }
+\cs_new:Npn \__color_parse_model_oklab:w #1 , #2 , #3 , #4 \s__color_stop
+  { \__color_parse_model_oklab:nnn { #1 } { #2 } { #3 } }
+%    \end{macrocode}
+% \end{macro}
+% \begin{macro}[EXP]
+%   { \__color_parse_model_linearrgb:nnn, \__color_parse_model_linearrgb_aux:n }
+%   Encode the tristimulus values of the sRGB primaries according to sRGB.
+%    \begin{macrocode}
+\cs_new:Nn \__color_parse_model_linearrgb:nnn
+  {
+    { rgb }
+    {
+      \__color_parse_model_linearrgb_aux:n { #1 } ~
+      \__color_parse_model_linearrgb_aux:n { #2 } ~
+      \__color_parse_model_linearrgb_aux:n { #3 }
+    }
+  }
+\cs_new:Nn \__color_parse_model_linearrgb_aux:n
+  {
+    \fp_compare:nNnTF { #1 } < { 0.0031308 }
+      { \fp_eval:n { 12.92 * max ( 0 , #1 ) } }
+      { \fp_eval:n { 1.055 * min ( 1 , #1 ) ^ (1/2.4) - 0.055 } }
path: l3kernel/l3color.dtx
user: login: josephwright
body: We don't need `\fp_eval:n` here as `\fp_compare:nNnTF` will apply automaticall
created_at: 2025-05-13T06:59:43Z
author_association: MEMBER
diff_hunk: @@ -1377,6 +1385,75 @@
 %    \end{macrocode}
 % \end{macro}
 %
+% \begin{macro}[EXP] {
+%     \__color_parse_model_lms:nnn, \__color_parse_model_cbrtlms:nnn,
+%     \__color_parse_model_oklab:nnn, \__color_parse_model_oklab:w
+%   }
+%   The Oklab color space is defined by first transforming the color values to
+%   the responses of the L, M and S cones of a standard human observer, then
+%   taking those responses to the power of $\frac{1}{3}$, and finally applying
+%   one more matrix transformation to obtain the $l$, $a$, and $b$ values.
+%   Here, we just go the other way round.
+%    \begin{macrocode}
+\cs_new:Nn \__color_parse_model_lms:nnn
+  {
+    \__color_parse_model_linearrgb:nnn
+      { \fp_eval:n {  4.0767416621 * #1 - 3.3077115913 * #2 + 0.2309699292 * #3 } }
+      { \fp_eval:n { -1.2684380046 * #1 + 2.6097574011 * #2 - 0.3413193965 * #3 } }
+      { \fp_eval:n { -0.0041960863 * #1 - 0.7034186147 * #2 + 1.7076147010 * #3 } }
+  }
+\cs_new:Nn \__color_parse_model_cbrtlms:nnn
+  {
+    \__color_parse_model_lms:nnn
+      { \fp_eval:n { #1 ^ 3 } }
+      { \fp_eval:n { #2 ^ 3 } }
+      { \fp_eval:n { #3 ^ 3 } }
+  }
+\cs_new:Nn \__color_parse_model_oklab:nnn
+  {
+    \__color_parse_model_cbrtlms:nnn
+      { \fp_eval:n { #1 + 0.3963377774 * #2 + 0.2158037573 * #3 } }
+      { \fp_eval:n { #1 - 0.1055613458 * #2 - 0.0638541728 * #3 } }
+      { \fp_eval:n { #1 - 0.0894841775 * #2 - 1.2914855480 * #3 } }
+  }
+\cs_new:Npn \__color_parse_model_oklab:w #1 , #2 , #3 , #4 \s__color_stop
+  { \__color_parse_model_oklab:nnn { #1 } { #2 } { #3 } }
+%    \end{macrocode}
+% \end{macro}
+% \begin{macro}[EXP]
+%   { \__color_parse_model_linearrgb:nnn, \__color_parse_model_linearrgb_aux:n }
+%   Encode the tristimulus values of the sRGB primaries according to sRGB.
+%    \begin{macrocode}
+\cs_new:Nn \__color_parse_model_linearrgb:nnn
+  {
+    { rgb }
+    {
+      \__color_parse_model_linearrgb_aux:n { #1 } ~
+      \__color_parse_model_linearrgb_aux:n { #2 } ~
+      \__color_parse_model_linearrgb_aux:n { #3 }
+    }
+  }
+\cs_new:Nn \__color_parse_model_linearrgb_aux:n
+  {
+    \fp_compare:nNnTF { #1 } < { 0.0031308 }
+      { \fp_eval:n { 12.92 * max ( 0 , #1 ) } }
+      { \fp_eval:n { 1.055 * min ( 1 , #1 ) ^ (1/2.4) - 0.055 } }
path: l3kernel/l3color.dtx
user: login: josephwright
body: Division is 'expensive' - can we pre-calculate `1/2.4`?
created_at: 2025-05-13T07:00:06Z
author_association: MEMBER
diff_hunk: @@ -1377,6 +1385,75 @@
 %    \end{macrocode}
 % \end{macro}
 %
+% \begin{macro}[EXP] {
+%     \__color_parse_model_lms:nnn, \__color_parse_model_cbrtlms:nnn,
+%     \__color_parse_model_oklab:nnn, \__color_parse_model_oklab:w
+%   }
+%   The Oklab color space is defined by first transforming the color values to
+%   the responses of the L, M and S cones of a standard human observer, then
+%   taking those responses to the power of $\frac{1}{3}$, and finally applying
+%   one more matrix transformation to obtain the $l$, $a$, and $b$ values.
+%   Here, we just go the other way round.
+%    \begin{macrocode}
+\cs_new:Nn \__color_parse_model_lms:nnn
+  {
+    \__color_parse_model_linearrgb:nnn
+      { \fp_eval:n {  4.0767416621 * #1 - 3.3077115913 * #2 + 0.2309699292 * #3 } }
+      { \fp_eval:n { -1.2684380046 * #1 + 2.6097574011 * #2 - 0.3413193965 * #3 } }
+      { \fp_eval:n { -0.0041960863 * #1 - 0.7034186147 * #2 + 1.7076147010 * #3 } }
+  }
path: l3kernel/l3color.dtx
user: login: josephwright
body: Can we have the formulae for the expression in the source please?
created_at: 2025-05-13T07:00:30Z
author_association: MEMBER
diff_hunk: @@ -1377,6 +1385,75 @@
 %    \end{macrocode}
 % \end{macro}
 %
+% \begin{macro}[EXP] {
+%     \__color_parse_model_lms:nnn, \__color_parse_model_cbrtlms:nnn,
+%     \__color_parse_model_oklab:nnn, \__color_parse_model_oklab:w
+%   }
+%   The Oklab color space is defined by first transforming the color values to
+%   the responses of the L, M and S cones of a standard human observer, then
+%   taking those responses to the power of $\frac{1}{3}$, and finally applying
+%   one more matrix transformation to obtain the $l$, $a$, and $b$ values.
+%   Here, we just go the other way round.
+%    \begin{macrocode}
+\cs_new:Nn \__color_parse_model_lms:nnn
+  {
+    \__color_parse_model_linearrgb:nnn
+      { \fp_eval:n {  4.0767416621 * #1 - 3.3077115913 * #2 + 0.2309699292 * #3 } }
+      { \fp_eval:n { -1.2684380046 * #1 + 2.6097574011 * #2 - 0.3413193965 * #3 } }
+      { \fp_eval:n { -0.0041960863 * #1 - 0.7034186147 * #2 + 1.7076147010 * #3 } }
+  }
path: l3kernel/l3color.dtx
user: login: josephwright
body: (As in in the comments part, typeset correctly)
created_at: 2025-05-13T07:00:44Z
author_association: MEMBER
diff_hunk: @@ -1377,6 +1385,75 @@
 %    \end{macrocode}
 % \end{macro}
 %
+% \begin{macro}[EXP] {
+%     \__color_parse_model_lms:nnn, \__color_parse_model_cbrtlms:nnn,
+%     \__color_parse_model_oklab:nnn, \__color_parse_model_oklab:w
+%   }
+%   The Oklab color space is defined by first transforming the color values to
+%   the responses of the L, M and S cones of a standard human observer, then
+%   taking those responses to the power of $\frac{1}{3}$, and finally applying
+%   one more matrix transformation to obtain the $l$, $a$, and $b$ values.
+%   Here, we just go the other way round.
+%    \begin{macrocode}
+\cs_new:Nn \__color_parse_model_lms:nnn
+  {
+    \__color_parse_model_linearrgb:nnn
+      { \fp_eval:n {  4.0767416621 * #1 - 3.3077115913 * #2 + 0.2309699292 * #3 } }
+      { \fp_eval:n { -1.2684380046 * #1 + 2.6097574011 * #2 - 0.3413193965 * #3 } }
+      { \fp_eval:n { -0.0041960863 * #1 - 0.7034186147 * #2 + 1.7076147010 * #3 } }
+  }
+\cs_new:Nn \__color_parse_model_cbrtlms:nnn
+  {
+    \__color_parse_model_lms:nnn
+      { \fp_eval:n { #1 ^ 3 } }
+      { \fp_eval:n { #2 ^ 3 } }
+      { \fp_eval:n { #3 ^ 3 } }
+  }
+\cs_new:Nn \__color_parse_model_oklab:nnn
+  {
+    \__color_parse_model_cbrtlms:nnn
+      { \fp_eval:n { #1 + 0.3963377774 * #2 + 0.2158037573 * #3 } }
+      { \fp_eval:n { #1 - 0.1055613458 * #2 - 0.0638541728 * #3 } }
+      { \fp_eval:n { #1 - 0.0894841775 * #2 - 1.2914855480 * #3 } }
+  }
+\cs_new:Npn \__color_parse_model_oklab:w #1 , #2 , #3 , #4 \s__color_stop
+  { \__color_parse_model_oklab:nnn { #1 } { #2 } { #3 } }
+%    \end{macrocode}
+% \end{macro}
+% \begin{macro}[EXP]
+%   { \__color_parse_model_linearrgb:nnn, \__color_parse_model_linearrgb_aux:n }
+%   Encode the tristimulus values of the sRGB primaries according to sRGB.
+%    \begin{macrocode}
+\cs_new:Nn \__color_parse_model_linearrgb:nnn
+  {
+    { rgb }
+    {
+      \__color_parse_model_linearrgb_aux:n { #1 } ~
+      \__color_parse_model_linearrgb_aux:n { #2 } ~
+      \__color_parse_model_linearrgb_aux:n { #3 }
+    }
+  }
+\cs_new:Nn \__color_parse_model_linearrgb_aux:n
+  {
+    \fp_compare:nNnTF { #1 } < { 0.0031308 }
+      { \fp_eval:n { 12.92 * max ( 0 , #1 ) } }
+      { \fp_eval:n { 1.055 * min ( 1 , #1 ) ^ (1/2.4) - 0.055 } }
path: l3kernel/l3color.dtx
user: login: mgkurtz
body: The `\fp_eval:n` are in the T/F branches. I hope that `\fp_compare:nNnTF` keeps them as is.
created_at: 2025-05-14T19:57:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1377,6 +1385,75 @@
 %    \end{macrocode}
 % \end{macro}
 %
+% \begin{macro}[EXP] {
+%     \__color_parse_model_lms:nnn, \__color_parse_model_cbrtlms:nnn,
+%     \__color_parse_model_oklab:nnn, \__color_parse_model_oklab:w
+%   }
+%   The Oklab color space is defined by first transforming the color values to
+%   the responses of the L, M and S cones of a standard human observer, then
+%   taking those responses to the power of $\frac{1}{3}$, and finally applying
+%   one more matrix transformation to obtain the $l$, $a$, and $b$ values.
+%   Here, we just go the other way round.
+%    \begin{macrocode}
+\cs_new:Nn \__color_parse_model_lms:nnn
+  {
+    \__color_parse_model_linearrgb:nnn
+      { \fp_eval:n {  4.0767416621 * #1 - 3.3077115913 * #2 + 0.2309699292 * #3 } }
+      { \fp_eval:n { -1.2684380046 * #1 + 2.6097574011 * #2 - 0.3413193965 * #3 } }
+      { \fp_eval:n { -0.0041960863 * #1 - 0.7034186147 * #2 + 1.7076147010 * #3 } }
+  }
+\cs_new:Nn \__color_parse_model_cbrtlms:nnn
+  {
+    \__color_parse_model_lms:nnn
+      { \fp_eval:n { #1 ^ 3 } }
+      { \fp_eval:n { #2 ^ 3 } }
+      { \fp_eval:n { #3 ^ 3 } }
+  }
+\cs_new:Nn \__color_parse_model_oklab:nnn
+  {
+    \__color_parse_model_cbrtlms:nnn
+      { \fp_eval:n { #1 + 0.3963377774 * #2 + 0.2158037573 * #3 } }
+      { \fp_eval:n { #1 - 0.1055613458 * #2 - 0.0638541728 * #3 } }
+      { \fp_eval:n { #1 - 0.0894841775 * #2 - 1.2914855480 * #3 } }
+  }
+\cs_new:Npn \__color_parse_model_oklab:w #1 , #2 , #3 , #4 \s__color_stop
+  { \__color_parse_model_oklab:nnn { #1 } { #2 } { #3 } }
+%    \end{macrocode}
+% \end{macro}
+% \begin{macro}[EXP]
+%   { \__color_parse_model_linearrgb:nnn, \__color_parse_model_linearrgb_aux:n }
+%   Encode the tristimulus values of the sRGB primaries according to sRGB.
+%    \begin{macrocode}
+\cs_new:Nn \__color_parse_model_linearrgb:nnn
+  {
+    { rgb }
+    {
+      \__color_parse_model_linearrgb_aux:n { #1 } ~
+      \__color_parse_model_linearrgb_aux:n { #2 } ~
+      \__color_parse_model_linearrgb_aux:n { #3 }
+    }
+  }
+\cs_new:Nn \__color_parse_model_linearrgb_aux:n
+  {
+    \fp_compare:nNnTF { #1 } < { 0.0031308 }
+      { \fp_eval:n { 12.92 * max ( 0 , #1 ) } }
+      { \fp_eval:n { 1.055 * min ( 1 , #1 ) ^ (1/2.4) - 0.055 } }
path: l3kernel/l3color.dtx
user: login: mgkurtz
body: I am going with 0.4166666666666667 now.
created_at: 2025-05-14T19:58:37Z
author_association: CONTRIBUTOR

PR comments:
user: login: josephwright
created_at: 2024-03-28T14:45:42Z
body: Can I ask where this comes up: I'm no expert but have never seen it 'in the wild' (Cf. L*a*b*)
author_association: MEMBER
user: login: mgkurtz
created_at: 2024-04-26T16:52:16Z
body: Most prominently, Oklab is part of [CSS Color Module Level 4](https://www.w3.org/TR/css-color-4/#ok-lab). Since Oklab is relatively new, it did not go much into the wilds besides CSS. I just thought that when Oklab is qualitatively better than CIE Lab, then it makes more sense to support Oklab. Actually, I could easily add some more color models, it is just a matter of adapting this code and copy pasting some matrices mostly.

Anyway, rethinking this PR, I am more in favor of adding additional color models to xcolor only instead of bloating the kernel. Though, we may still discuss here, whether the concept of xcolor providing more color models than the kernel is wanted at all.
author_association: CONTRIBUTOR
user: login: josephwright
created_at: 2024-04-27T07:34:35Z
body: @mgkurtz  Longer-term plans are to move colour support into the kernel, so don't worry about 'bloat' here. The key question for me is whether we need this approach given that PDF doesn't support it.
author_association: MEMBER
user: login: mgkurtz
created_at: 2024-04-27T13:12:44Z
body: Indeed, my point is that we do not necessarily need Oklab, CIE Lab or whatever in the kernel. Still some people would definitely like to use it. So, even if xcolor should mostly or totally merge with l3color, I see reason to have packages allowing more color models than those, which are generally needed and provided in the kernel.

Thus, what I believe, we need more than kernel support for Oklab is a simple officially documented macro like `\color_new_input_model:nnnN`, where `\color_new_input_model:nnnN { oklab } { 3 } { rgb } \mypackage_oklab:nnn` creates code such as
```
\cs_new:Npn \__color_parse_model_oklch:w #1 , #2 , #3 , #4 \s__color_stop {
    { rgb } { \mypackage_oklab:nnn {#1} {#2} {#3} }
}
```

Then, I can write `mypackage` with support for Oklab, CIE Lab, XYZ or who knows what and be happy.
author_association: CONTRIBUTOR
user: login: josephwright
created_at: 2024-05-27T14:19:22Z
body: I'm not keen on supporting generic model adding as it's _so_ rare: what I'd rather do is be sure that adding an extra model is a good idea. What I suppose that needs is some sense of likely take-up of such features.
author_association: MEMBER
user: login: josephwright
created_at: 2024-08-01T12:51:25Z
body: I'm taking another look here. I can see the point about CCC4-like colours - but I can also see that we might want to know if they actually take off. @davidcarlisle you are the HTML expert - thoughts?
author_association: MEMBER
user: login: davidcarlisle
created_at: 2024-08-01T13:30:05Z
body: @josephwright  hmmm

https://caniuse.com/?search=oklab

suggests the browser implementers are actively working on adding this, eg firefox goes green for "relative oklab colors" at 128 (the current stable release) and basic support seems to be in the major browsers in any release after 2023

I (presume?) this model isn't going to be supported in pdf readers any time soon so in practice it will be an input syntax only and always be rendered via conversion to something else, at least in traditional pdf generating uses of the tex source.  This means the actual gains from using a different color model are possibly slight compared with the amount of code required for conversions and mixing. But supporting the syntax would allow svg (or html) backends to use it directly without passing through some potentially lossy conversion via rgb.

There is also the general issue of policy as more models arrive. You comment above that possibly a "new model" declaration isn't needed and I don't necessarily disagree but I wonder if as a first step there should (just) be a documented way for a contrib package to add a model (to l3color and/or xcolor)  to allow actual experimentation and test user take up, before we commit to supporting a new color model forever....


author_association: MEMBER
user: login: car222222
created_at: 2024-08-01T13:39:51Z
body: I agree with David (where I can understand him:), especially the general idea of making it easy for us and others to add new colour models as they come along, possibly in extension packages.  Why restrict such possibilities? 
author_association: NONE
user: login: car222222
created_at: 2024-08-03T09:46:20Z
body: @josephwright wrote --

"I'm not keen on supporting generic model adding as it's so rare"

OK, but it would be good to at least provide some "hooks" (in the non-latex sense) to help people add such extra support via new packages.

In other words, set things up so that it is straightforward to provide an interface to any (reasonable) particular "colour space".

Note: it is a long time since I looked at the colour stuff so I am perhaps asking for something that would involve a complete rewrite! 

But we already provide a range of colour models of several distinct types, so I hope that providing a generic interface would not be so difficult. 


author_association: NONE
user: login: josephwright
created_at: 2025-05-13T06:56:45Z
body: I've been thinking about the PR and probably we should take, as this model is clearly useful in the browser sphere. To do that, however, the code needs a bit of a tidy up - I'll add some comments.
author_association: MEMBER
user: login: mgkurtz
created_at: 2025-05-14T20:05:16Z
body: Thanks for coming back to this PR. I applied your changes now. Though, I did not take the time to actually look at the build. If there is anything awry, I will revisit this on the weekend.
author_association: CONTRIBUTOR
user: login: josephwright
created_at: 2025-05-14T20:30:18Z
body: There are a few minor things but I can fix post-merge once the stray `a` is removed :)
author_association: MEMBER
user: login: mgkurtz
created_at: 2025-05-18T15:03:33Z
body: I fixed the stray `a`s, corrected the math presentation and rebased on `develop` now.
author_association: CONTRIBUTOR
user: login: mgkurtz
created_at: 2025-05-21T05:34:47Z
body: A caveat to note about my code: Colors out of the sRGB gamut just get clipped to sRGB. This leads to possibly big hue distortions. Adding proper gamut clipping would require about a dozen lines of code more, when I just naïvely bisect the chroma.
author_association: CONTRIBUTOR
user: login: josephwright
created_at: 2025-05-21T06:55:12Z
body: @mgkurtz  Feel free to make a PR for the docs - I think that could readily be merged.
author_association: MEMBER