From a389c52567c8401e7862ffea06ffcd71cddf8c98 Mon Sep 17 00:00:00 2001 From: Evan Wallace Date: Fri, 8 Dec 2023 17:21:25 -0500 Subject: [PATCH] css: add `lab()` + `lch()` + `oklab()` + `oklch()` --- CHANGELOG.md | 6 +- compat-table/src/index.ts | 2 +- compat-table/src/mdn.ts | 8 ++- internal/compat/css_table.go | 10 +-- internal/css_ast/css_ast.go | 18 +++-- internal/css_parser/css_color_spaces.go | 91 ++++++++++++++++++++++--- internal/css_parser/css_decls.go | 2 +- internal/css_parser/css_decls_color.go | 79 +++++++++++++++++++-- internal/css_parser/css_parser_test.go | 24 ++++++- 9 files changed, 207 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee6f6a08e35..8fbcd4bbfae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 */ @@ -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)) diff --git a/compat-table/src/index.ts b/compat-table/src/index.ts index 9bf877fca0a..ce18be39009 100644 --- a/compat-table/src/index.ts +++ b/compat-table/src/index.ts @@ -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, diff --git a/compat-table/src/mdn.ts b/compat-table/src/mdn.ts index 954ec09867b..dec41438f81 100644 --- a/compat-table/src/mdn.ts +++ b/compat-table/src/mdn.ts @@ -26,7 +26,13 @@ const jsFeatures: Partial> = { } const cssFeatures: Partial> = { - 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', diff --git a/internal/compat/css_table.go b/internal/compat/css_table.go index 05affacb4d2..2697644158b 100644 --- a/internal/compat/css_table.go +++ b/internal/compat/css_table.go @@ -9,7 +9,7 @@ import ( type CSSFeature uint16 const ( - ColorFunction CSSFeature = 1 << iota + ColorFunctions CSSFeature = 1 << iota HWB HexRGBA InlineStyle @@ -21,7 +21,7 @@ const ( ) var StringToCSSFeature = map[string]CSSFeature{ - "color-function": ColorFunction, + "color-functions": ColorFunctions, "hwb": HWB, "hex-rgba": HexRGBA, "inline-style": InlineStyle, @@ -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}}}, diff --git a/internal/css_ast/css_ast.go b/internal/css_ast/css_ast.go index 4eeb424a4fa..b7a17316210 100644 --- a/internal/css_ast/css_ast.go +++ b/internal/css_ast/css_ast.go @@ -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 { @@ -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 } } diff --git a/internal/css_parser/css_color_spaces.go b/internal/css_parser/css_color_spaces.go index d97de1c7e2b..adeece46f31 100644 --- a/internal/css_parser/css_color_spaces.go +++ b/internal/css_parser/css_color_spaces.go @@ -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, @@ -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) { diff --git a/internal/css_parser/css_decls.go b/internal/css_parser/css_decls.go index fe64723788b..d5fa04e55a8 100644 --- a/internal/css_parser/css_decls.go +++ b/internal/css_parser/css_decls.go @@ -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-- { diff --git a/internal/css_parser/css_decls_color.go b/internal/css_parser/css_decls_color.go index 16c6f34cb4e..46bbc38fa51 100644 --- a/internal/css_parser/css_decls_color.go +++ b/internal/css_parser/css_decls_color.go @@ -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) } @@ -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 @@ -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": @@ -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 + } + } + } + } + } + } } } diff --git a/internal/css_parser/css_parser_test.go b/internal/css_parser/css_parser_test.go index 7fb3926d8a8..acf1cc5cd4c 100644 --- a/internal/css_parser/css_parser_test.go +++ b/internal/css_parser/css_parser_test.go @@ -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", "") @@ -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) {