Skip to content

Commit

Permalink
css: add lab() + lch() + oklab() + oklch()
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Dec 8, 2023
1 parent b837f21 commit a389c52
Show file tree
Hide file tree
Showing 9 changed files with 207 additions and 33 deletions.
6 changes: 3 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

## Unreleased

* Add support for `hwb()` and `color()` in CSS
* Add support for `color()`, `lab()`, `lch()`, `oklab()`, `oklch()`, and `hwb()` in CSS

CSS has recently added lots of new ways of specifying colors. This release adds support for lowering and/or minifying colors that use the `hwb()` or `color()` syntax for browsers that don't support it yet:
CSS has recently added lots of new ways of specifying colors. This release adds support for lowering and/or minifying colors that use the `color()`, `lab()`, `lch()`, `oklab()`, `oklch()`, or `hwb()` syntax for browsers that don't support it yet:

```css
/* Original code */
Expand All @@ -21,7 +21,7 @@
}
```

As you can see, colors outside of the sRGB color space such as `color(display-p3 1 0 0)` are mapped back into the sRGB gamut and inserted as a fallback for browsers that don't support the `color()` syntax.
As you can see, colors outside of the sRGB color space such as `color(display-p3 1 0 0)` are mapped back into the sRGB gamut and inserted as a fallback for browsers that don't support the new color syntax. You can enable or disable this behavior by setting `--supported:color-functions=` to `true` or `false`.

* Allow empty type parameter lists in certain cases ([#3512](https://github.com/evanw/esbuild/issues/3512))

Expand Down
2 changes: 1 addition & 1 deletion compat-table/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export const jsFeatures = {

export type CSSFeature = keyof typeof cssFeatures
export const cssFeatures = {
ColorFunction: true,
ColorFunctions: true,
HexRGBA: true,
HWB: true,
InlineStyle: true,
Expand Down
8 changes: 7 additions & 1 deletion compat-table/src/mdn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@ const jsFeatures: Partial<Record<JSFeature, string>> = {
}

const cssFeatures: Partial<Record<CSSFeature, string | string[]>> = {
ColorFunction: 'css.types.color.color',
ColorFunctions: [
'css.types.color.color',
'css.types.color.lab',
'css.types.color.lch',
'css.types.color.oklab',
'css.types.color.oklch',
],
HexRGBA: 'css.types.color.rgb_hexadecimal_notation.alpha_hexadecimal_notation',
HWB: 'css.types.color.hwb',
InsetProperty: 'css.properties.inset',
Expand Down
10 changes: 5 additions & 5 deletions internal/compat/css_table.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
type CSSFeature uint16

const (
ColorFunction CSSFeature = 1 << iota
ColorFunctions CSSFeature = 1 << iota
HWB
HexRGBA
InlineStyle
Expand All @@ -21,7 +21,7 @@ const (
)

var StringToCSSFeature = map[string]CSSFeature{
"color-function": ColorFunction,
"color-functions": ColorFunctions,
"hwb": HWB,
"hex-rgba": HexRGBA,
"inline-style": InlineStyle,
Expand All @@ -41,13 +41,13 @@ func (features CSSFeature) ApplyOverrides(overrides CSSFeature, mask CSSFeature)
}

var cssTable = map[CSSFeature]map[Engine][]versionRange{
ColorFunction: {
ColorFunctions: {
Chrome: {{start: v{111, 0, 0}}},
Edge: {{start: v{111, 0, 0}}},
Firefox: {{start: v{113, 0, 0}}},
IOS: {{start: v{15, 0, 0}}},
IOS: {{start: v{15, 4, 0}}},
Opera: {{start: v{97, 0, 0}}},
Safari: {{start: v{15, 0, 0}}},
Safari: {{start: v{15, 4, 0}}},
},
HWB: {
Chrome: {{start: v{101, 0, 0}}},
Expand Down
18 changes: 13 additions & 5 deletions internal/css_ast/css_ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,15 @@ func TokensAreCommaSeparated(tokens []Token) bool {
return false
}

func (t Token) NumberOrFractionForPercentage() (float64, bool) {
type PercentageFlags uint8

const (
AllowPercentageBelow0 PercentageFlags = 1 << iota
AllowPercentageAbove100
AllowAnyPercentage = AllowPercentageBelow0 | AllowPercentageAbove100
)

func (t Token) NumberOrFractionForPercentage(percentReferenceRange float64, flags PercentageFlags) (float64, bool) {
switch t.Kind {
case css_lexer.TNumber:
if f, err := strconv.ParseFloat(t.Text, 64); err == nil {
Expand All @@ -290,13 +298,13 @@ func (t Token) NumberOrFractionForPercentage() (float64, bool) {

case css_lexer.TPercentage:
if f, err := strconv.ParseFloat(t.PercentageValue(), 64); err == nil {
if f < 0 {
if (flags&AllowPercentageBelow0) == 0 && f < 0 {
return 0, true
}
if f > 100 {
return 1, true
if (flags&AllowPercentageAbove100) == 0 && f > 100 {
return percentReferenceRange, true
}
return f / 100, true
return f / 100 * percentReferenceRange, true
}
}

Expand Down
91 changes: 81 additions & 10 deletions internal/css_parser/css_color_spaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,85 @@ func d50_to_d65(x float64, y float64, z float64) (float64, float64, float64) {
return multiplyMatrices(M, x, y, z)
}

const d50_x = 0.3457 / 0.3585
const d50_z = (1.0 - 0.3457 - 0.3585) / 0.3585

func xyz_to_lab(x float64, y float64, z float64) (float64, float64, float64) {
const ε = 216.0 / 24389
const κ = 24389.0 / 27

x /= d50_x
z /= d50_z

var f0, f1, f2 float64
if x > ε {
f0 = math.Cbrt(x)
} else {
f0 = (κ*x + 16) / 116
}
if y > ε {
f1 = math.Cbrt(y)
} else {
f1 = (κ*y + 16) / 116
}
if z > ε {
f2 = math.Cbrt(z)
} else {
f2 = (κ*z + 16) / 116
}

return (116 * f1) - 16,
500 * (f0 - f1),
200 * (f1 - f2)
}

func lab_to_xyz(l float64, a float64, b float64) (x float64, y float64, z float64) {
const κ = 24389.0 / 27
const ε = 216.0 / 24389

f1 := (l + 16) / 116
f0 := a/500 + f1
f2 := f1 - b/200

f0_3 := f0 * f0 * f0
f2_3 := f2 * f2 * f2

if f0_3 > ε {
x = f0_3
} else {
x = (116*f0 - 16) / κ
}
if l > κ*ε {
y = (l + 16) / 116
y = y * y * y
} else {
y = l / κ
}
if f2_3 > ε {
z = f2_3
} else {
z = (116*f2 - 16) / κ
}

return x * d50_x, y, z * d50_z
}

func lab_to_lch(l float64, a float64, b float64) (float64, float64, float64) {
hue := math.Atan2(b, a) * (180 / math.Pi)
if hue < 0 {
hue += 360
}
return l,
math.Sqrt(a*a + b*b),
hue
}

func lch_to_lab(l float64, c float64, h float64) (float64, float64, float64) {
return l,
c * math.Cos(h*math.Pi/180),
c * math.Sin(h*math.Pi/180)
}

func xyz_to_oklab(x float64, y float64, z float64) (float64, float64, float64) {
XYZtoLMS := [9]float64{
0.8190224432164319, 0.3619062562801221, -0.12887378261216414,
Expand Down Expand Up @@ -165,19 +244,11 @@ func oklab_to_xyz(l float64, a float64, b float64) (float64, float64, float64) {
}

func oklab_to_oklch(l float64, a float64, b float64) (float64, float64, float64) {
hue := math.Atan2(b, a) * (180 / math.Pi)
if hue < 0 {
hue += 360
}
return l,
math.Sqrt(a*a + b*b),
hue
return lab_to_lch(l, a, b)
}

func oklch_to_oklab(l float64, c float64, h float64) (float64, float64, float64) {
return l,
c * math.Cos(h*(math.Pi/180)),
c * math.Sin(h*(math.Pi/180))
return lch_to_lab(l, c, h)
}

func multiplyMatrices(A [9]float64, b0 float64, b1 float64, b2 float64) (float64, float64, float64) {
Expand Down
2 changes: 1 addition & 1 deletion internal/css_parser/css_decls.go
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ func (p *parser) processDeclarations(rules []css_ast.Rule, composesContext *comp
// next iteration of the loop to duplicate this rule and process it again
// with color clipping enabled.
if wouldClipColorFlag {
if p.options.unsupportedCSSFeatures.Has(compat.ColorFunction) {
if p.options.unsupportedCSSFeatures.Has(compat.ColorFunctions) {
// Only do this if there was no previous instance of that property so
// we avoid overwriting any manually-specified fallback values
for j := len(rewrittenRules) - 2; j >= 0; j-- {
Expand Down
79 changes: 73 additions & 6 deletions internal/css_parser/css_decls_color.go
Original file line number Diff line number Diff line change
Expand Up @@ -400,8 +400,8 @@ func (p *parser) lowerAndMinifyColor(token css_ast.Token, wouldClipColor *bool)
}
}

case "color":
if p.options.unsupportedCSSFeatures.Has(compat.ColorFunction) {
case "color", "lab", "lch", "oklab", "oklch":
if p.options.unsupportedCSSFeatures.Has(compat.ColorFunctions) {
if color, ok := parseColor(token); ok {
return p.tryToGenerateColor(token, color, wouldClipColor)
}
Expand Down Expand Up @@ -463,7 +463,8 @@ func parseColor(token css_ast.Token) (parsedColor, bool) {
}

case css_lexer.TFunction:
switch strings.ToLower(text) {
lowerText := strings.ToLower(text)
switch lowerText {
case "rgb", "rgba":
args := *token.Children
var r, g, b, a css_ast.Token
Expand Down Expand Up @@ -587,9 +588,9 @@ func parseColor(token css_ast.Token) (parsedColor, bool) {
}

if colorSpace.Kind == css_lexer.TIdent {
if v0, ok := args[1].NumberOrFractionForPercentage(); ok {
if v1, ok := args[2].NumberOrFractionForPercentage(); ok {
if v2, ok := args[3].NumberOrFractionForPercentage(); ok {
if v0, ok := args[1].NumberOrFractionForPercentage(1, 0); ok {
if v1, ok := args[2].NumberOrFractionForPercentage(1, 0); ok {
if v2, ok := args[3].NumberOrFractionForPercentage(1, 0); ok {
if a, ok := parseAlphaByte(alpha); ok {
switch strings.ToLower(colorSpace.Text) {
case "a98-rgb":
Expand Down Expand Up @@ -638,6 +639,72 @@ func parseColor(token css_ast.Token) (parsedColor, bool) {
}
}
}

case "lab", "lch", "oklab", "oklch":
args := *token.Children
var v0, v1, v2, alpha css_ast.Token

switch len(args) {
case 3:
// "lab(1 2 3)"
v0, v1, v2 = args[0], args[1], args[2]

case 5:
// "lab(1 2 3 / 50%)"
if args[3].Kind == css_lexer.TDelimSlash {
v0, v1, v2, alpha = args[0], args[1], args[2], args[4]
}
}

if v0.Kind != css_lexer.T(0) {
if alpha, ok := parseAlphaByte(alpha); ok {
switch lowerText {
case "lab":
if v0, ok := v0.NumberOrFractionForPercentage(100, 0); ok {
if v1, ok := v1.NumberOrFractionForPercentage(125, css_ast.AllowAnyPercentage); ok {
if v2, ok := v2.NumberOrFractionForPercentage(125, css_ast.AllowAnyPercentage); ok {
x, y, z := lab_to_xyz(v0, v1, v2)
x, y, z = d50_to_d65(x, y, z)
return parsedColor{x: x, y: y, z: z, hex: alpha}, true
}
}
}

case "lch":
if v0, ok := v0.NumberOrFractionForPercentage(100, 0); ok {
if v1, ok := v1.NumberOrFractionForPercentage(125, css_ast.AllowPercentageAbove100); ok {
if v2, ok := degreesForAngle(v2); ok {
l, a, b := lch_to_lab(v0, v1, v2)
x, y, z := lab_to_xyz(l, a, b)
x, y, z = d50_to_d65(x, y, z)
return parsedColor{x: x, y: y, z: z, hex: alpha}, true
}
}
}

case "oklab":
if v0, ok := v0.NumberOrFractionForPercentage(1, 0); ok {
if v1, ok := v1.NumberOrFractionForPercentage(0.4, css_ast.AllowAnyPercentage); ok {
if v2, ok := v2.NumberOrFractionForPercentage(0.4, css_ast.AllowAnyPercentage); ok {
x, y, z := oklab_to_xyz(v0, v1, v2)
return parsedColor{x: x, y: y, z: z, hex: alpha}, true
}
}
}

case "oklch":
if v0, ok := v0.NumberOrFractionForPercentage(1, 0); ok {
if v1, ok := v1.NumberOrFractionForPercentage(0.4, css_ast.AllowPercentageAbove100); ok {
if v2, ok := degreesForAngle(v2); ok {
l, a, b := oklch_to_oklab(v0, v1, v2)
x, y, z := oklab_to_xyz(l, a, b)
return parsedColor{x: x, y: y, z: z, hex: alpha}, true
}
}
}
}
}
}
}
}

Expand Down
24 changes: 23 additions & 1 deletion internal/css_parser/css_parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,7 @@ func TestHexColor(t *testing.T) {
expectPrintedMangle(t, "a { color: #AABBCCEF }", "a {\n color: #aabbccef;\n}\n", "")
}

func TestColorFunction(t *testing.T) {
func TestColorFunctions(t *testing.T) {
expectPrinted(t, "a { color: color(display-p3 0.5 0.0 0.0%) }", "a {\n color: color(display-p3 0.5 0.0 0.0%);\n}\n", "")
expectPrinted(t, "a { color: color(display-p3 0.5 0.0 0.0% / 0.5) }", "a {\n color: color(display-p3 0.5 0.0 0.0% / 0.5);\n}\n", "")

Expand Down Expand Up @@ -587,8 +587,30 @@ func TestColorFunction(t *testing.T) {
expectPrintedLower(t, "a { color: color(xyz-d65 0.754 0.883 0.715) }", "a {\n color: #deface;\n}\n", "")
expectPrintedLower(t, "a { color: color(xyz-d65 75.4% 88.3% 71.5%) }", "a {\n color: #deface;\n}\n", "")

// Check color functions with unusual percent reference ranges
expectPrintedLower(t, "a { color: lab(95.38 -15 18) }", "a {\n color: #deface;\n}\n", "")
expectPrintedLower(t, "a { color: lab(95.38% -15 18) }", "a {\n color: #deface;\n}\n", "")
expectPrintedLower(t, "a { color: lab(95.38 -12% 18) }", "a {\n color: #deface;\n}\n", "")
expectPrintedLower(t, "a { color: lab(95.38% -15 14.4%) }", "a {\n color: #deface;\n}\n", "")
expectPrintedLower(t, "a { color: lch(95.38 23.57 130.22) }", "a {\n color: #deface;\n}\n", "")
expectPrintedLower(t, "a { color: lch(95.38% 23.57 130.22) }", "a {\n color: #deface;\n}\n", "")
expectPrintedLower(t, "a { color: lch(95.38 19% 130.22) }", "a {\n color: #deface;\n}\n", "")
expectPrintedLower(t, "a { color: lch(95.38 23.57 0.362turn) }", "a {\n color: #deface;\n}\n", "")
expectPrintedLower(t, "a { color: oklab(0.953 -0.045 0.046) }", "a {\n color: #deface;\n}\n", "")
expectPrintedLower(t, "a { color: oklab(95.3% -0.045 0.046) }", "a {\n color: #deface;\n}\n", "")
expectPrintedLower(t, "a { color: oklab(0.953 -11.2% 0.046) }", "a {\n color: #deface;\n}\n", "")
expectPrintedLower(t, "a { color: oklab(0.953 -0.045 11.5%) }", "a {\n color: #deface;\n}\n", "")
expectPrintedLower(t, "a { color: oklch(0.953 0.064 134) }", "a {\n color: #deface;\n}\n", "")
expectPrintedLower(t, "a { color: oklch(95.3% 0.064 134) }", "a {\n color: #deface;\n}\n", "")
expectPrintedLower(t, "a { color: oklch(0.953 16% 134) }", "a {\n color: #deface;\n}\n", "")
expectPrintedLower(t, "a { color: oklch(0.953 0.064 0.372turn) }", "a {\n color: #deface;\n}\n", "")

// Test alpha
expectPrintedLower(t, "a { color: color(srgb 0.87 0.98 0.807 / 0.5) }", "a {\n color: rgba(222, 250, 206, .5);\n}\n", "")
expectPrintedLower(t, "a { color: lab(95.38 -15 18 / 0.5) }", "a {\n color: rgba(222, 250, 206, .5);\n}\n", "")
expectPrintedLower(t, "a { color: lch(95.38 23.57 130.22 / 0.5) }", "a {\n color: rgba(222, 250, 206, .5);\n}\n", "")
expectPrintedLower(t, "a { color: oklab(0.953 -0.045 0.046 / 0.5) }", "a {\n color: rgba(222, 250, 206, .5);\n}\n", "")
expectPrintedLower(t, "a { color: oklch(0.953 0.064 134 / 0.5) }", "a {\n color: rgba(222, 250, 206, .5);\n}\n", "")
}

func TestColorNames(t *testing.T) {
Expand Down

0 comments on commit a389c52

Please sign in to comment.