From 83b417172547c1e37625ba5e0de2dc81563b6766 Mon Sep 17 00:00:00 2001 From: Evan Wallace Date: Sat, 9 Dec 2023 10:52:58 -0500 Subject: [PATCH] css: implement lowering of gradient syntax --- CHANGELOG.md | 93 ++- compat-table/src/index.ts | 2 + compat-table/src/mdn.ts | 22 +- internal/compat/css_table.go | 19 + internal/css_parser/css_color_spaces.go | 277 ++++++++ internal/css_parser/css_decls_color.go | 79 +-- internal/css_parser/css_decls_gradient.go | 788 +++++++++++++++++++++- internal/css_parser/css_parser_test.go | 107 ++- internal/css_printer/css_printer.go | 75 +- scripts/gradient-tests.css | 95 +++ scripts/gradient-tests.html | 301 +++++++++ 11 files changed, 1770 insertions(+), 88 deletions(-) create mode 100644 scripts/gradient-tests.css create mode 100644 scripts/gradient-tests.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fbcd4bbfae..5f7f4af6c51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,98 @@ ## Unreleased +* Add support for transforming new CSS gradient syntax for older browsers + + The specification called [CSS Images Module Level 4](https://www.w3.org/TR/css-images-4/) introduces new CSS gradient syntax for customizing how the browser interpolates colors in between color stops. You can now control the color space that the interpolation happens in as well as (for "polar" color spaces) control whether hue angle interpolation happens clockwise or counterclockwise. You can read more about this in [Mozilla's blog post about new CSS gradient features](https://developer.mozilla.org/en-US/blog/css-color-module-level-4/). + + With this release, esbuild will now automatically transform this syntax for older browsers in the `target` list. For example, here's a gradient that should appear as a rainbow in a browser that supports this new syntax: + + ```css + /* Original code */ + .rainbow-gradient { + width: 100px; + height: 100px; + background: linear-gradient(in hsl longer hue, #7ff, #77f); + } + + /* New output (with --target=chrome99) */ + .rainbow-gradient { + width: 100px; + height: 100px; + background: + linear-gradient( + #77ffff, + #77ffaa 12.5%, + #77ff80 18.75%, + #84ff77 21.88%, + #99ff77 25%, + #eeff77 37.5%, + #fffb77 40.62%, + #ffe577 43.75%, + #ffbb77 50%, + #ff9077 56.25%, + #ff7b77 59.38%, + #ff7788 62.5%, + #ff77dd 75%, + #ff77f2 78.12%, + #f777ff 81.25%, + #cc77ff 87.5%, + #7777ff); + } + ``` + + You can now use this syntax in your CSS source code and esbuild will automatically convert it to an equivalent gradient for older browsers. In addition, esbuild will now also transform "double position" and "transition hint" syntax for older browsers as appropriate: + + ```css + /* Original code */ + .stripes { + width: 100px; + height: 100px; + background: linear-gradient(#e65 33%, #ff2 33% 67%, #99e 67%); + } + .glow { + width: 100px; + height: 100px; + background: radial-gradient(white 10%, 20%, black); + } + + /* New output (with --target=chrome33) */ + .stripes { + width: 100px; + height: 100px; + background: + linear-gradient( + #e65 33%, + #ff2 33%, + #ff2 67%, + #99e 67%); + } + .glow { + width: 100px; + height: 100px; + background: + radial-gradient( + #ffffff 10%, + #aaaaaa 12.81%, + #959595 15.62%, + #7b7b7b 21.25%, + #5a5a5a 32.5%, + #444444 43.75%, + #323232 55%, + #161616 77.5%, + #000000); + } + ``` + + If necessary, esbuild will construct a new gradient that approximates the original gradient by recursively splitting the interval in between color stops until the approximation error is within a small threshold. That is why the above output CSS contains many more color stops than the input CSS. + + Note that esbuild deliberately _replaces_ the original gradient with the approximation instead of inserting the approximation before the original gradient as a fallback. The latest version of Firefox has multiple gradient rendering bugs (including incorrect interpolation of partially-transparent colors and interpolating non-sRGB colors using the incorrect color space). If esbuild didn't replace the original gradient, then Firefox would use the original gradient instead of the fallback the appearance would be incorrect in Firefox. In other words, the latest version of Firefox supports modern gradient syntax but interprets it incorrectly. + * 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 `color()`, `lab()`, `lch()`, `oklab()`, `oklch()`, or `hwb()` syntax for browsers that don't support it yet: + CSS has recently added lots of new ways of specifying colors. You can read more about this in [Chrome's blog post about CSS color spaces](https://developer.chrome.com/docs/css-ui/high-definition-css-color-guide). + + This release adds support for minifying colors that use the `color()`, `lab()`, `lch()`, `oklab()`, `oklch()`, or `hwb()` syntax and/or transforming these colors for browsers that don't support it yet: ```css /* Original code */ @@ -21,7 +110,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 new color syntax. You can enable or disable this behavior by setting `--supported:color-functions=` to `true` or `false`. + 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. * 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 b0dc08a5f93..d76e844dd0d 100644 --- a/compat-table/src/index.ts +++ b/compat-table/src/index.ts @@ -91,6 +91,8 @@ export type CSSFeature = keyof typeof cssFeatures export const cssFeatures = { ColorFunctions: true, GradientDoublePosition: true, + GradientInterpolation: true, + GradientMidpoints: true, HexRGBA: true, HWB: true, InlineStyle: true, diff --git a/compat-table/src/mdn.ts b/compat-table/src/mdn.ts index e3f29dfe9d1..16f4087c2c7 100644 --- a/compat-table/src/mdn.ts +++ b/compat-table/src/mdn.ts @@ -34,12 +34,32 @@ const cssFeatures: Partial> = { 'css.types.color.oklch', ], GradientDoublePosition: [ + 'css.types.image.gradient.conic-gradient.doubleposition', 'css.types.image.gradient.linear-gradient.doubleposition', 'css.types.image.gradient.radial-gradient.doubleposition', - 'css.types.image.gradient.conic-gradient.doubleposition', 'css.types.image.gradient.repeating-linear-gradient.doubleposition', 'css.types.image.gradient.repeating-radial-gradient.doubleposition', ], + GradientInterpolation: [ + 'css.types.image.gradient.conic-gradient.hue_interpolation_method', + 'css.types.image.gradient.conic-gradient.interpolation_color_space', + 'css.types.image.gradient.linear-gradient.hue_interpolation_method', + 'css.types.image.gradient.linear-gradient.interpolation_color_space', + 'css.types.image.gradient.radial-gradient.hue_interpolation_method', + 'css.types.image.gradient.radial-gradient.interpolation_color_space', + 'css.types.image.gradient.repeating-conic-gradient.hue_interpolation_method', + 'css.types.image.gradient.repeating-conic-gradient.interpolation_color_space', + 'css.types.image.gradient.repeating-linear-gradient.hue_interpolation_method', + 'css.types.image.gradient.repeating-linear-gradient.interpolation_color_space', + 'css.types.image.gradient.repeating-radial-gradient.hue_interpolation_method', + 'css.types.image.gradient.repeating-radial-gradient.interpolation_color_space', + ], + GradientMidpoints: [ + 'css.types.image.gradient.linear-gradient.interpolation_hints', + 'css.types.image.gradient.radial-gradient.interpolation_hints', + 'css.types.image.gradient.repeating-linear-gradient.interpolation_hints', + 'css.types.image.gradient.repeating-radial-gradient.interpolation_hints', + ], 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 737e4523f39..96a61aacfa2 100644 --- a/internal/compat/css_table.go +++ b/internal/compat/css_table.go @@ -11,6 +11,8 @@ type CSSFeature uint16 const ( ColorFunctions CSSFeature = 1 << iota GradientDoublePosition + GradientInterpolation + GradientMidpoints HWB HexRGBA InlineStyle @@ -24,6 +26,8 @@ const ( var StringToCSSFeature = map[string]CSSFeature{ "color-functions": ColorFunctions, "gradient-double-position": GradientDoublePosition, + "gradient-interpolation": GradientInterpolation, + "gradient-midpoints": GradientMidpoints, "hwb": HWB, "hex-rgba": HexRGBA, "inline-style": InlineStyle, @@ -59,6 +63,21 @@ var cssTable = map[CSSFeature]map[Engine][]versionRange{ Opera: {{start: v{60, 0, 0}}}, Safari: {{start: v{12, 1, 0}}}, }, + GradientInterpolation: { + Chrome: {{start: v{111, 0, 0}}}, + Edge: {{start: v{111, 0, 0}}}, + IOS: {{start: v{16, 2, 0}}}, + Opera: {{start: v{97, 0, 0}}}, + Safari: {{start: v{16, 2, 0}}}, + }, + GradientMidpoints: { + Chrome: {{start: v{40, 0, 0}}}, + Edge: {{start: v{79, 0, 0}}}, + Firefox: {{start: v{36, 0, 0}}}, + IOS: {{start: v{7, 0, 0}}}, + Opera: {{start: v{27, 0, 0}}}, + Safari: {{start: v{7, 0, 0}}}, + }, HWB: { Chrome: {{start: v{101, 0, 0}}}, Edge: {{start: v{101, 0, 0}}}, diff --git a/internal/css_parser/css_color_spaces.go b/internal/css_parser/css_color_spaces.go index adeece46f31..e9d37359015 100644 --- a/internal/css_parser/css_color_spaces.go +++ b/internal/css_parser/css_color_spaces.go @@ -4,6 +4,43 @@ import "math" // Reference: https://drafts.csswg.org/css-color/#color-conversion-code +type colorSpace uint8 + +const ( + colorSpace_a98_rgb colorSpace = iota + colorSpace_display_p3 + colorSpace_hsl + colorSpace_hwb + colorSpace_lab + colorSpace_lch + colorSpace_oklab + colorSpace_oklch + colorSpace_prophoto_rgb + colorSpace_rec2020 + colorSpace_srgb + colorSpace_srgb_linear + colorSpace_xyz + colorSpace_xyz_d50 + colorSpace_xyz_d65 +) + +func (colorSpace colorSpace) isPolar() bool { + switch colorSpace { + case colorSpace_hsl, colorSpace_hwb, colorSpace_lch, colorSpace_oklch: + return true + } + return false +} + +type hueMethod uint8 + +const ( + shorterHue hueMethod = iota + longerHue + increasingHue + decreasingHue +) + func lin_srgb(r float64, g float64, b float64) (float64, float64, float64) { f := func(val float64) float64 { if abs := math.Abs(val); abs < 0.04045 { @@ -48,6 +85,10 @@ func lin_p3(r float64, g float64, b float64) (float64, float64, float64) { return lin_srgb(r, g, b) } +func gam_p3(r float64, g float64, b float64) (float64, float64, float64) { + return gam_srgb(r, g, b) +} + func lin_p3_to_xyz(r float64, g float64, b float64) (float64, float64, float64) { M := [9]float64{ 608311.0 / 1250200, 189793.0 / 714400, 198249.0 / 1000160, @@ -57,6 +98,15 @@ func lin_p3_to_xyz(r float64, g float64, b float64) (float64, float64, float64) return multiplyMatrices(M, r, g, b) } +func xyz_to_lin_p3(x float64, y float64, z float64) (float64, float64, float64) { + M := [9]float64{ + 446124.0 / 178915, -333277.0 / 357830, -72051.0 / 178915, + -14852.0 / 17905, 63121.0 / 35810, 423.0 / 17905, + 11844.0 / 330415, -50337.0 / 660830, 316169.0 / 330415, + } + return multiplyMatrices(M, x, y, z) +} + func lin_prophoto(r float64, g float64, b float64) (float64, float64, float64) { f := func(val float64) float64 { const Et2 = 16.0 / 512 @@ -69,6 +119,18 @@ func lin_prophoto(r float64, g float64, b float64) (float64, float64, float64) { return f(r), f(g), f(b) } +func gam_prophoto(r float64, g float64, b float64) (float64, float64, float64) { + f := func(val float64) float64 { + const Et = 1.0 / 512 + if abs := math.Abs(val); abs >= Et { + return math.Copysign(math.Pow(abs, 1/1.8), val) + } else { + return 16 * val + } + } + return f(r), f(g), f(b) +} + func lin_prophoto_to_xyz(r float64, g float64, b float64) (float64, float64, float64) { M := [9]float64{ 0.7977604896723027, 0.13518583717574031, 0.0313493495815248, @@ -78,6 +140,15 @@ func lin_prophoto_to_xyz(r float64, g float64, b float64) (float64, float64, flo return multiplyMatrices(M, r, g, b) } +func xyz_to_lin_prophoto(x float64, y float64, z float64) (float64, float64, float64) { + M := [9]float64{ + 1.3457989731028281, -0.25558010007997534, -0.05110628506753401, + -0.5446224939028347, 1.5082327413132781, 0.02053603239147973, + 0.0, 0.0, 1.2119675456389454, + } + return multiplyMatrices(M, x, y, z) +} + func lin_a98rgb(r float64, g float64, b float64) (float64, float64, float64) { f := func(val float64) float64 { return math.Copysign(math.Pow(math.Abs(val), 563.0/256), val) @@ -85,6 +156,13 @@ func lin_a98rgb(r float64, g float64, b float64) (float64, float64, float64) { return f(r), f(g), f(b) } +func gam_a98rgb(r float64, g float64, b float64) (float64, float64, float64) { + f := func(val float64) float64 { + return math.Copysign(math.Pow(math.Abs(val), 256.0/563), val) + } + return f(r), f(g), f(b) +} + func lin_a98rgb_to_xyz(r float64, g float64, b float64) (float64, float64, float64) { M := [9]float64{ 573536.0 / 994567, 263643.0 / 1420810, 187206.0 / 994567, @@ -94,6 +172,15 @@ func lin_a98rgb_to_xyz(r float64, g float64, b float64) (float64, float64, float return multiplyMatrices(M, r, g, b) } +func xyz_to_lin_a98rgb(x float64, y float64, z float64) (float64, float64, float64) { + M := [9]float64{ + 1829569.0 / 896150, -506331.0 / 896150, -308931.0 / 896150, + -851781.0 / 878810, 1648619.0 / 878810, 36519.0 / 878810, + 16779.0 / 1248040, -147721.0 / 1248040, 1266979.0 / 1248040, + } + return multiplyMatrices(M, x, y, z) +} + func lin_2020(r float64, g float64, b float64) (float64, float64, float64) { f := func(val float64) float64 { const α = 1.09929682680944 @@ -107,6 +194,19 @@ func lin_2020(r float64, g float64, b float64) (float64, float64, float64) { return f(r), f(g), f(b) } +func gam_2020(r float64, g float64, b float64) (float64, float64, float64) { + f := func(val float64) float64 { + const α = 1.09929682680944 + const β = 0.018053968510807 + if abs := math.Abs(val); abs > β { + return math.Copysign(α*math.Pow(abs, 0.45)-(α-1), val) + } else { + return 4.5 * val + } + } + return f(r), f(g), f(b) +} + func lin_2020_to_xyz(r float64, g float64, b float64) (float64, float64, float64) { var M = [9]float64{ 63426534.0 / 99577255, 20160776.0 / 139408157, 47086771.0 / 278816314, @@ -116,6 +216,15 @@ func lin_2020_to_xyz(r float64, g float64, b float64) (float64, float64, float64 return multiplyMatrices(M, r, g, b) } +func xyz_to_lin_2020(x float64, y float64, z float64) (float64, float64, float64) { + M := [9]float64{ + 30757411.0 / 17917100, -6372589.0 / 17917100, -4539589.0 / 17917100, + -19765991.0 / 29648200, 47925759.0 / 29648200, 467509.0 / 29648200, + 792561.0 / 44930125, -1921689.0 / 44930125, 42328811.0 / 44930125, + } + return multiplyMatrices(M, x, y, z) +} + func d65_to_d50(x float64, y float64, z float64) (float64, float64, float64) { M := [9]float64{ 1.0479297925449969, 0.022946870601609652, -0.05019226628920524, @@ -331,3 +440,171 @@ func gamut_mapping_xyz_to_srgb(x float64, y float64, z float64) (float64, float6 return r, g, b } + +func hsl_to_rgb(hue float64, sat float64, light float64) (float64, float64, float64) { + hue /= 360 + hue -= math.Floor(hue) + hue *= 360 + + sat /= 100 + light /= 100 + + f := func(n float64) float64 { + k := n + hue/30 + k /= 12 + k -= math.Floor(k) + k *= 12 + a := sat * math.Min(light, 1-light) + return light - a*math.Max(-1, math.Min(math.Min(k-3, 9-k), 1)) + } + + return f(0), f(8), f(4) +} + +func rgb_to_hsl(red float64, green float64, blue float64) (float64, float64, float64) { + max := math.Max(math.Max(red, green), blue) + min := math.Min(math.Min(red, green), blue) + hue, sat, light := math.NaN(), 0.0, (min+max)/2 + d := max - min + + if d != 0 { + if div := math.Min(light, 1-light); div != 0 { + sat = (max - light) / div + } + + switch max { + case red: + hue = (green - blue) / d + if green < blue { + hue += 6 + } + case green: + hue = (blue-red)/d + 2 + case blue: + hue = (red-green)/d + 4 + } + + hue = hue * 60 + } + + return hue, sat * 100, light * 100 +} + +func hwb_to_rgb(hue float64, white float64, black float64) (float64, float64, float64) { + white /= 100 + black /= 100 + if white+black >= 1 { + gray := white / (white + black) + return gray, gray, gray + } + r, g, b := hsl_to_rgb(hue, 100, 50) + r = white + r*(1-white-black) + g = white + g*(1-white-black) + b = white + b*(1-white-black) + return r, g, b +} + +func rgb_to_hwb(red float64, green float64, blue float64) (float64, float64, float64) { + h, _, _ := rgb_to_hsl(red, green, blue) + white := math.Min(math.Min(red, green), blue) + black := 1 - math.Max(math.Max(red, green), blue) + return h, white * 100, black * 100 +} + +func xyz_to_colorSpace(x float64, y float64, z float64, colorSpace colorSpace) (float64, float64, float64) { + switch colorSpace { + case colorSpace_a98_rgb: + return gam_a98rgb(xyz_to_lin_a98rgb(x, y, z)) + + case colorSpace_display_p3: + return gam_p3(xyz_to_lin_p3(x, y, z)) + + case colorSpace_hsl: + return rgb_to_hsl(gam_srgb(xyz_to_lin_srgb(x, y, z))) + + case colorSpace_hwb: + return rgb_to_hwb(gam_srgb(xyz_to_lin_srgb(x, y, z))) + + case colorSpace_lab: + return xyz_to_lab(d65_to_d50(x, y, z)) + + case colorSpace_lch: + return lab_to_lch(xyz_to_lab(d65_to_d50(x, y, z))) + + case colorSpace_oklab: + return xyz_to_oklab(x, y, z) + + case colorSpace_oklch: + return oklab_to_oklch(xyz_to_oklab(x, y, z)) + + case colorSpace_prophoto_rgb: + return gam_prophoto(xyz_to_lin_prophoto(d65_to_d50(x, y, z))) + + case colorSpace_rec2020: + return gam_2020(xyz_to_lin_2020(x, y, z)) + + case colorSpace_srgb: + return gam_srgb(xyz_to_lin_srgb(x, y, z)) + + case colorSpace_srgb_linear: + return xyz_to_lin_srgb(x, y, z) + + case colorSpace_xyz, colorSpace_xyz_d65: + return x, y, z + + case colorSpace_xyz_d50: + return d65_to_d50(x, y, z) + + default: + panic("Internal error") + } +} + +func colorSpace_to_xyz(v0 float64, v1 float64, v2 float64, colorSpace colorSpace) (float64, float64, float64) { + switch colorSpace { + case colorSpace_a98_rgb: + return lin_a98rgb_to_xyz(lin_a98rgb(v0, v1, v2)) + + case colorSpace_display_p3: + return lin_p3_to_xyz(lin_p3(v0, v1, v2)) + + case colorSpace_hsl: + return lin_srgb_to_xyz(lin_srgb(hsl_to_rgb(v0, v1, v2))) + + case colorSpace_hwb: + return lin_srgb_to_xyz(lin_srgb(hwb_to_rgb(v0, v1, v2))) + + case colorSpace_lab: + return d50_to_d65(lab_to_xyz(v0, v1, v2)) + + case colorSpace_lch: + return d50_to_d65(lab_to_xyz(lch_to_lab(v0, v1, v2))) + + case colorSpace_oklab: + return oklab_to_xyz(v0, v1, v2) + + case colorSpace_oklch: + return oklab_to_xyz(oklch_to_oklab(v0, v1, v2)) + + case colorSpace_prophoto_rgb: + return d50_to_d65(lin_prophoto_to_xyz(lin_prophoto(v0, v1, v2))) + + case colorSpace_rec2020: + return lin_2020_to_xyz(lin_2020(v0, v1, v2)) + + case colorSpace_srgb: + return lin_srgb_to_xyz(lin_srgb(v0, v1, v2)) + + case colorSpace_srgb_linear: + return lin_srgb_to_xyz(v0, v1, v2) + + case colorSpace_xyz, colorSpace_xyz_d65: + return v0, v1, v2 + + case colorSpace_xyz_d50: + return d50_to_d65(v0, v1, v2) + + default: + panic("Internal error") + } +} diff --git a/internal/css_parser/css_decls_color.go b/internal/css_parser/css_decls_color.go index ca24b143b4b..71e11960b6c 100644 --- a/internal/css_parser/css_decls_color.go +++ b/internal/css_parser/css_decls_color.go @@ -287,13 +287,13 @@ func (p *parser) lowerAndMinifyColor(token css_ast.Token, wouldClipColor *bool) // "#1234" => "rgba(1, 2, 3, 0.004)" if hex, ok := parseHex(text); ok { hex = expandHex(hex) - return p.tryToGenerateColor(token, parsedColor{sRGB: true, hex: hex}, nil) + return p.tryToGenerateColor(token, parsedColor{hex: hex}, nil) } case 8: // "#12345678" => "rgba(18, 52, 86, 0.47)" if hex, ok := parseHex(text); ok { - return p.tryToGenerateColor(token, parsedColor{sRGB: true, hex: hex}, nil) + return p.tryToGenerateColor(token, parsedColor{hex: hex}, nil) } } } @@ -420,9 +420,9 @@ func (p *parser) lowerAndMinifyColor(token css_ast.Token, wouldClipColor *bool) } type parsedColor struct { - x, y, z float64 // color if sRGB == false - hex uint32 // color and alpha if sRGB == true, alpha if sRGB == false - sRGB bool + x, y, z float64 // color if hasColorSpace == true + hex uint32 // color and alpha if hasColorSpace == false, alpha if hasColorSpace == true + hasColorSpace bool } func looksLikeColor(token css_ast.Token) bool { @@ -467,7 +467,7 @@ func parseColor(token css_ast.Token) (parsedColor, bool) { switch token.Kind { case css_lexer.TIdent: if hex, ok := colorNameToHex[strings.ToLower(text)]; ok { - return parsedColor{sRGB: true, hex: hex}, true + return parsedColor{hex: hex}, true } case css_lexer.THash: @@ -475,25 +475,25 @@ func parseColor(token css_ast.Token) (parsedColor, bool) { case 3: // "#123" if hex, ok := parseHex(text); ok { - return parsedColor{sRGB: true, hex: (expandHex(hex) << 8) | 0xFF}, true + return parsedColor{hex: (expandHex(hex) << 8) | 0xFF}, true } case 4: // "#1234" if hex, ok := parseHex(text); ok { - return parsedColor{sRGB: true, hex: expandHex(hex)}, true + return parsedColor{hex: expandHex(hex)}, true } case 6: // "#112233" if hex, ok := parseHex(text); ok { - return parsedColor{sRGB: true, hex: (hex << 8) | 0xFF}, true + return parsedColor{hex: (hex << 8) | 0xFF}, true } case 8: // "#11223344" if hex, ok := parseHex(text); ok { - return parsedColor{sRGB: true, hex: hex}, true + return parsedColor{hex: hex}, true } } @@ -532,7 +532,7 @@ func parseColor(token css_ast.Token) (parsedColor, bool) { if g, ok := parseColorByte(g, 1); ok { if b, ok := parseColorByte(b, 1); ok { if a, ok := parseAlphaByte(a); ok { - return parsedColor{sRGB: true, hex: (r << 24) | (g << 16) | (b << 8) | a}, true + return parsedColor{hex: (r << 24) | (g << 16) | (b << 8) | a}, true } } } @@ -572,7 +572,7 @@ func parseColor(token css_ast.Token) (parsedColor, bool) { if l, ok := l.ClampedFractionForPercentage(); ok { if a, ok := parseAlphaByte(a); ok { r, g, b := hslToRgb(h, s, l) - return parsedColor{sRGB: true, hex: packRGBA(r, g, b, a)}, true + return parsedColor{hex: packRGBA(r, g, b, a)}, true } } } @@ -600,7 +600,7 @@ func parseColor(token css_ast.Token) (parsedColor, bool) { if black, ok := l.ClampedFractionForPercentage(); ok { if a, ok := parseAlphaByte(a); ok { r, g, b := hwbToRgb(h, white, black) - return parsedColor{sRGB: true, hex: packRGBA(r, g, b, a)}, true + return parsedColor{hex: packRGBA(r, g, b, a)}, true } } } @@ -631,42 +631,39 @@ func parseColor(token css_ast.Token) (parsedColor, bool) { case "a98-rgb": r, g, b := lin_a98rgb(v0, v1, v2) x, y, z := lin_a98rgb_to_xyz(r, g, b) - return parsedColor{x: x, y: y, z: z, hex: a}, true + return parsedColor{hasColorSpace: true, x: x, y: y, z: z, hex: a}, true case "display-p3": r, g, b := lin_p3(v0, v1, v2) x, y, z := lin_p3_to_xyz(r, g, b) - return parsedColor{x: x, y: y, z: z, hex: a}, true + return parsedColor{hasColorSpace: true, x: x, y: y, z: z, hex: a}, true case "prophoto-rgb": r, g, b := lin_prophoto(v0, v1, v2) x, y, z := lin_prophoto_to_xyz(r, g, b) x, y, z = d50_to_d65(x, y, z) - return parsedColor{x: x, y: y, z: z, hex: a}, true + return parsedColor{hasColorSpace: true, x: x, y: y, z: z, hex: a}, true case "rec2020": r, g, b := lin_2020(v0, v1, v2) x, y, z := lin_2020_to_xyz(r, g, b) - return parsedColor{x: x, y: y, z: z, hex: a}, true + return parsedColor{hasColorSpace: true, x: x, y: y, z: z, hex: a}, true case "srgb": r, g, b := lin_srgb(v0, v1, v2) x, y, z := lin_srgb_to_xyz(r, g, b) - return parsedColor{x: x, y: y, z: z, hex: a}, true + return parsedColor{hasColorSpace: true, x: x, y: y, z: z, hex: a}, true case "srgb-linear": x, y, z := lin_srgb_to_xyz(v0, v1, v2) - return parsedColor{x: x, y: y, z: z, hex: a}, true + return parsedColor{hasColorSpace: true, x: x, y: y, z: z, hex: a}, true - case "xyz": - return parsedColor{x: v0, y: v1, z: v2, hex: a}, true + case "xyz", "xyz-d65": + return parsedColor{hasColorSpace: true, x: v0, y: v1, z: v2, hex: a}, true case "xyz-d50": x, y, z := d50_to_d65(v0, v1, v2) - return parsedColor{x: x, y: y, z: z, hex: a}, true - - case "xyz-d65": - return parsedColor{x: v0, y: v1, z: v2, hex: a}, true + return parsedColor{hasColorSpace: true, x: x, y: y, z: z, hex: a}, true } } } @@ -699,7 +696,7 @@ func parseColor(token css_ast.Token) (parsedColor, bool) { 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 + return parsedColor{hasColorSpace: true, x: x, y: y, z: z, hex: alpha}, true } } } @@ -711,7 +708,7 @@ func parseColor(token css_ast.Token) (parsedColor, bool) { 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 + return parsedColor{hasColorSpace: true, x: x, y: y, z: z, hex: alpha}, true } } } @@ -721,7 +718,7 @@ func parseColor(token css_ast.Token) (parsedColor, bool) { 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 + return parsedColor{hasColorSpace: true, x: x, y: y, z: z, hex: alpha}, true } } } @@ -732,7 +729,7 @@ func parseColor(token css_ast.Token) (parsedColor, bool) { 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 + return parsedColor{hasColorSpace: true, x: x, y: y, z: z, hex: alpha}, true } } } @@ -841,6 +838,14 @@ func parseColorByte(token css_ast.Token, scale float64) (uint32, bool) { return uint32(i), ok } +func tryToConvertToHexWithoutClipping(x float64, y float64, z float64, a uint32) (uint32, bool) { + r, g, b := gam_srgb(xyz_to_lin_srgb(x, y, z)) + if r < -0.5/255 || r > 255.5/255 || g < -0.5/255 || g > 255.5/255 || b < -0.5/255 || b > 255.5/255 { + return 0, false + } + return packRGBA(r, g, b, a), true +} + func (p *parser) tryToGenerateColor(token css_ast.Token, color parsedColor, wouldClipColor *bool) css_ast.Token { // Note: Do NOT remove color information from fully transparent colors. // Safari behaves differently than other browsers for color interpolation: @@ -849,17 +854,15 @@ func (p *parser) tryToGenerateColor(token css_ast.Token, color parsedColor, woul // Attempt to convert other color spaces to sRGB, and only continue if the // result (rounded to the nearest byte) will be in the 0-to-1 sRGB range var hex uint32 - if color.sRGB { + if !color.hasColorSpace { hex = color.hex + } else if result, ok := tryToConvertToHexWithoutClipping(color.x, color.y, color.z, color.hex); ok { + hex = result + } else if wouldClipColor != nil { + *wouldClipColor = true + return token } else { - r, g, b := gam_srgb(xyz_to_lin_srgb(color.x, color.y, color.z)) - if r < -0.5/255 || r > 255.5/255 || g < -0.5/255 || g > 255.5/255 || b < -0.5/255 || b > 255.5/255 { - if wouldClipColor != nil { - *wouldClipColor = true - return token - } - r, g, b = gamut_mapping_xyz_to_srgb(color.x, color.y, color.z) - } + r, g, b := gamut_mapping_xyz_to_srgb(color.x, color.y, color.z) hex = packRGBA(r, g, b, color.hex) } diff --git a/internal/css_parser/css_decls_gradient.go b/internal/css_parser/css_decls_gradient.go index b1837917551..fe1b6095260 100644 --- a/internal/css_parser/css_decls_gradient.go +++ b/internal/css_parser/css_decls_gradient.go @@ -1,11 +1,15 @@ package css_parser import ( + "fmt" + "math" + "strconv" "strings" "github.com/evanw/esbuild/internal/compat" "github.com/evanw/esbuild/internal/css_ast" "github.com/evanw/esbuild/internal/css_lexer" + "github.com/evanw/esbuild/internal/logger" ) type gradientKind uint8 @@ -17,7 +21,7 @@ const ( ) type parsedGradient struct { - initialTokens []css_ast.Token + leadingTokens []css_ast.Token colorStops []colorStop kind gradientKind repeating bool @@ -26,7 +30,7 @@ type parsedGradient struct { type colorStop struct { positions []css_ast.Token color css_ast.Token - hint css_ast.Token // Absent if "hint.Kind == css_lexer.T(0)" + midpoint css_ast.Token // Absent if "midpoint.Kind == css_lexer.T(0)" } func parseGradient(token css_ast.Token) (gradient parsedGradient, success bool) { @@ -74,7 +78,7 @@ func parseGradient(token css_ast.Token) (gradient parsedGradient, success bool) for i < len(tokens) && tokens[i].Kind != css_lexer.TComma { i++ } - gradient.initialTokens = tokens[:i] + gradient.leadingTokens = tokens[:i] if i < len(tokens) { tokens = tokens[i+1:] } else { @@ -104,7 +108,7 @@ func parseGradient(token css_ast.Token) (gradient parsedGradient, success bool) } // Parse the comma - var hint css_ast.Token + var midpoint css_ast.Token if len(tokens) > 0 { if tokens[0].Kind != css_lexer.TComma { return @@ -114,9 +118,9 @@ func parseGradient(token css_ast.Token) (gradient parsedGradient, success bool) return } - // Parse the hint, if any + // Parse the midpoint, if any if len(tokens) > 0 && tokens[0].Kind.IsNumeric() { - hint = tokens[0] + midpoint = tokens[0] tokens = tokens[1:] // Followed by a mandatory comma @@ -131,7 +135,7 @@ func parseGradient(token css_ast.Token) (gradient parsedGradient, success bool) gradient.colorStops = append(gradient.colorStops, colorStop{ color: color, positions: positions, - hint: hint, + midpoint: midpoint, }) } @@ -143,15 +147,15 @@ func (p *parser) generateGradient(token css_ast.Token, gradient parsedGradient) var children []css_ast.Token commaToken := p.commaToken(token.Loc) - children = append(children, gradient.initialTokens...) + children = append(children, gradient.leadingTokens...) for _, stop := range gradient.colorStops { if len(children) > 0 { children = append(children, commaToken) } children = append(children, stop.color) children = append(children, stop.positions...) - if stop.hint.Kind != css_lexer.T(0) { - children = append(children, commaToken, stop.hint) + if stop.midpoint.Kind != css_lexer.T(0) { + children = append(children, commaToken, stop.midpoint) } } @@ -165,6 +169,54 @@ func (p *parser) lowerAndMinifyGradient(token css_ast.Token, wouldClipColor *boo return token } + lowerMidpoints := p.options.unsupportedCSSFeatures.Has(compat.GradientMidpoints) + lowerColorSpaces := p.options.unsupportedCSSFeatures.Has(compat.ColorFunctions) + lowerInterpolation := p.options.unsupportedCSSFeatures.Has(compat.GradientInterpolation) + + // Assume that if the browser doesn't support color spaces in gradients, then + // it doesn't correctly interpolate non-sRGB colors even when a color space + // is not specified. This is the case for Firefox 120, for example, which has + // support for the "color()" syntax but not for color spaces in gradients. + // There is no entry in our feature support matrix for this edge case so we + // make this assumption instead. + // + // Note that this edge case means we have to _replace_ the original gradient + // with the expanded one instead of inserting a fallback before it. Otherwise + // Firefox 120 would use the original gradient instead of the fallback because + // it supports the syntax, but just renders it incorrectly. + if lowerInterpolation { + lowerColorSpaces = true + } + + // Potentially expand the gradient to handle unsupported features + if lowerMidpoints || lowerColorSpaces || lowerInterpolation { + if colorStops, ok := tryToParseColorStops(gradient); ok { + hasColorSpace := false + hasMidpoint := false + for _, stop := range colorStops { + if stop.hasColorSpace { + hasColorSpace = true + } + if stop.midpoint != nil { + hasMidpoint = true + } + } + remaining, colorSpace, hueMethod, hasInterpolation := removeColorInterpolation(gradient.leadingTokens) + if (hasInterpolation && lowerInterpolation) || (hasColorSpace && lowerColorSpaces) || (hasMidpoint && lowerMidpoints) { + if hasInterpolation { + tryToExpandGradient(token.Loc, &gradient, colorStops, remaining, colorSpace, hueMethod) + } else { + if hasColorSpace { + colorSpace = colorSpace_oklab + } else { + colorSpace = colorSpace_srgb + } + tryToExpandGradient(token.Loc, &gradient, colorStops, gradient.leadingTokens, colorSpace, shorterHue) + } + } + } + } + // Lower all colors in the gradient stop for i, stop := range gradient.colorStops { gradient.colorStops[i].color = p.lowerAndMinifyColor(stop.color, wouldClipColor) @@ -182,7 +234,7 @@ func (p *parser) lowerAndMinifyGradient(token css_ast.Token, wouldClipColor *boo // Replace duplicated single positions with double positions for i, stop := range gradient.colorStops { if i > 0 && len(stop.positions) == 1 { - if prev := gradient.colorStops[i-1]; len(prev.positions) == 1 && prev.hint.Kind == css_lexer.T(0) && + if prev := gradient.colorStops[i-1]; len(prev.positions) == 1 && prev.midpoint.Kind == css_lexer.T(0) && css_ast.TokensEqual([]css_ast.Token{prev.color}, []css_ast.Token{stop.color}, nil) { gradient.colorStops = switchToDoublePositions(gradient.colorStops) break @@ -202,7 +254,7 @@ func switchToSinglePositions(double []colorStop) (single []colorStop) { for len(stop.positions) > 1 { clone := stop clone.positions = stop.positions[:1] - clone.hint = css_ast.Token{} + clone.midpoint = css_ast.Token{} single = append(single, clone) stop.positions = stop.positions[1:] } @@ -214,13 +266,13 @@ func switchToSinglePositions(double []colorStop) (single []colorStop) { func switchToDoublePositions(single []colorStop) (double []colorStop) { for i := 0; i < len(single); i++ { stop := single[i] - if i+1 < len(single) && len(stop.positions) == 1 && stop.hint.Kind == css_lexer.T(0) { + if i+1 < len(single) && len(stop.positions) == 1 && stop.midpoint.Kind == css_lexer.T(0) { if next := single[i+1]; len(next.positions) == 1 && css_ast.TokensEqual([]css_ast.Token{stop.color}, []css_ast.Token{next.color}, nil) { double = append(double, colorStop{ color: stop.color, positions: []css_ast.Token{stop.positions[0], next.positions[0]}, - hint: next.hint, + midpoint: next.midpoint, }) i++ continue @@ -230,3 +282,711 @@ func switchToDoublePositions(single []colorStop) (double []colorStop) { } return } + +func removeColorInterpolation(tokens []css_ast.Token) ([]css_ast.Token, colorSpace, hueMethod, bool) { + for i := 0; i+1 < len(tokens); i++ { + if in := tokens[i]; in.Kind == css_lexer.TIdent && strings.EqualFold(in.Text, "in") { + if space := tokens[i+1]; space.Kind == css_lexer.TIdent { + var colorSpace colorSpace + hueMethod := shorterHue + start := i + end := i + 2 + + // Parse the color space + switch strings.ToLower(space.Text) { + case "a98-rgb": + colorSpace = colorSpace_a98_rgb + case "display-p3": + colorSpace = colorSpace_display_p3 + case "hsl": + colorSpace = colorSpace_hsl + case "hwb": + colorSpace = colorSpace_hwb + case "lab": + colorSpace = colorSpace_lab + case "lch": + colorSpace = colorSpace_lch + case "oklab": + colorSpace = colorSpace_oklab + case "oklch": + colorSpace = colorSpace_oklch + case "prophoto-rgb": + colorSpace = colorSpace_prophoto_rgb + case "rec2020": + colorSpace = colorSpace_rec2020 + case "srgb": + colorSpace = colorSpace_srgb + case "srgb-linear": + colorSpace = colorSpace_srgb_linear + case "xyz": + colorSpace = colorSpace_xyz + case "xyz-d50": + colorSpace = colorSpace_xyz_d50 + case "xyz-d65": + colorSpace = colorSpace_xyz_d65 + default: + return nil, 0, 0, false + } + + // Parse the optional hue mode for polar color spaces + if colorSpace.isPolar() && i+3 < len(tokens) { + if hue := tokens[i+3]; hue.Kind == css_lexer.TIdent && strings.EqualFold(hue.Text, "hue") { + if method := tokens[i+2]; method.Kind == css_lexer.TIdent { + switch strings.ToLower(method.Text) { + case "shorter": + hueMethod = shorterHue + case "longer": + hueMethod = longerHue + case "increasing": + hueMethod = increasingHue + case "decreasing": + hueMethod = decreasingHue + default: + return nil, 0, 0, false + } + end = i + 4 + } + } + } + + // Remove all parsed tokens + remaining := append(append([]css_ast.Token{}, tokens[:start]...), tokens[end:]...) + if n := len(remaining); n > 0 { + remaining[0].Whitespace &= ^css_ast.WhitespaceBefore + remaining[n-1].Whitespace &= ^css_ast.WhitespaceAfter + } + return remaining, colorSpace, hueMethod, true + } + } + } + + return nil, 0, 0, false +} + +type valueWithUnit struct { + unit string + value float64 +} + +type parsedColorStop struct { + // Position information (may be a sum of two different units) + positionTerms []valueWithUnit + + // Color midpoint (a.k.a. transition hint) information + midpoint *valueWithUnit + + // Non-premultiplied color information in XYZ space + x, y, z, alpha float64 + + // Non-premultiplied color information in sRGB space + r, g, b float64 + + // Premultiplied color information in the interpolation color space + v0, v1, v2 float64 + + // True if the original color has a color space + hasColorSpace bool +} + +func tryToParseColorStops(gradient parsedGradient) ([]parsedColorStop, bool) { + var colorStops []parsedColorStop + + for _, stop := range gradient.colorStops { + color, ok := parseColor(stop.color) + if !ok { + return nil, false + } + var r, g, b float64 + if !color.hasColorSpace { + r = float64(hexR(color.hex)) / 255 + g = float64(hexG(color.hex)) / 255 + b = float64(hexB(color.hex)) / 255 + color.x, color.y, color.z = lin_srgb_to_xyz(lin_srgb(r, g, b)) + } else { + r, g, b = gam_srgb(xyz_to_lin_srgb(color.x, color.y, color.z)) + } + parsedStop := parsedColorStop{ + x: color.x, + y: color.y, + z: color.z, + r: r, + g: g, + b: b, + alpha: float64(hexA(color.hex)) / 255, + hasColorSpace: color.hasColorSpace, + } + + for i, position := range stop.positions { + if position, ok := tryToParseValue(position, gradient.kind); ok { + parsedStop.positionTerms = []valueWithUnit{position} + } else { + return nil, false + } + + // Expand double positions + if i+1 < len(stop.positions) { + colorStops = append(colorStops, parsedStop) + } + } + + if stop.midpoint.Kind != css_lexer.T(0) { + if midpoint, ok := tryToParseValue(stop.midpoint, gradient.kind); ok { + parsedStop.midpoint = &midpoint + } else { + return nil, false + } + } + + colorStops = append(colorStops, parsedStop) + } + + // Automatically fill in missing positions + if len(colorStops) > 0 { + type stopInfo struct { + fromPos valueWithUnit + toPos valueWithUnit + fromCount int32 + toCount int32 + } + + // Fill in missing positions for the endpoints first + if first := &colorStops[0]; len(first.positionTerms) == 0 { + first.positionTerms = []valueWithUnit{{value: 0, unit: "%"}} + } + if last := &colorStops[len(colorStops)-1]; len(last.positionTerms) == 0 { + last.positionTerms = []valueWithUnit{{value: 100, unit: "%"}} + } + + // Set all positions to be greater than the position before them + for i, stop := range colorStops { + var prevPos valueWithUnit + for j := i - 1; j >= 0; j-- { + prev := colorStops[j] + if prev.midpoint != nil { + prevPos = *prev.midpoint + break + } + if len(prev.positionTerms) == 1 { + prevPos = prev.positionTerms[0] + break + } + } + if len(stop.positionTerms) == 1 { + if prevPos.unit == stop.positionTerms[0].unit { + stop.positionTerms[0].value = math.Max(prevPos.value, stop.positionTerms[0].value) + } + prevPos = stop.positionTerms[0] + } + if stop.midpoint != nil && prevPos.unit == stop.midpoint.unit { + stop.midpoint.value = math.Max(prevPos.value, stop.midpoint.value) + } + } + + // Scan over all other stops with missing positions + infos := make([]stopInfo, len(colorStops)) + for i, stop := range colorStops { + if len(stop.positionTerms) == 1 { + continue + } + info := &infos[i] + + // Scan backward + for from := i - 1; from >= 0; from-- { + fromStop := colorStops[from] + info.fromCount++ + if fromStop.midpoint != nil { + info.fromPos = *fromStop.midpoint + break + } + if len(fromStop.positionTerms) == 1 { + info.fromPos = fromStop.positionTerms[0] + break + } + } + + // Scan forward + for to := i; to < len(colorStops); to++ { + info.toCount++ + if toStop := colorStops[to]; toStop.midpoint != nil { + info.toPos = *toStop.midpoint + break + } + if to+1 < len(colorStops) { + if toStop := colorStops[to+1]; len(toStop.positionTerms) == 1 { + info.toPos = toStop.positionTerms[0] + break + } + } + } + } + + // Then fill in all other missing positions + for i, stop := range colorStops { + if len(stop.positionTerms) != 1 { + info := infos[i] + t := float64(info.fromCount) / float64(info.fromCount+info.toCount) + if info.fromPos.unit == info.toPos.unit { + colorStops[i].positionTerms = []valueWithUnit{{ + value: info.fromPos.value + (info.toPos.value-info.fromPos.value)*t, + unit: info.fromPos.unit, + }} + } else { + colorStops[i].positionTerms = []valueWithUnit{{ + value: info.fromPos.value * (1 - t), + unit: info.fromPos.unit, + }, { + value: info.toPos.value * t, + unit: info.toPos.unit, + }} + } + } + } + + // Midpoints are only supported if they use the same units as their neighbors + for i, stop := range colorStops { + if stop.midpoint != nil { + next := colorStops[i+1] + if len(stop.positionTerms) != 1 || stop.midpoint.unit != stop.positionTerms[0].unit || + len(next.positionTerms) != 1 || stop.midpoint.unit != next.positionTerms[0].unit { + return nil, false + } + } + } + } + + return colorStops, true +} + +func tryToParseValue(token css_ast.Token, kind gradientKind) (result valueWithUnit, success bool) { + if kind == conicGradient { + // + switch token.Kind { + case css_lexer.TDimension: + degrees, ok := degreesForAngle(token) + if !ok { + return + } + result.value = degrees * (100.0 / 360) + result.unit = "%" + + case css_lexer.TPercentage: + percent, err := strconv.ParseFloat(token.PercentageValue(), 64) + if err != nil { + return + } + result.value = percent + result.unit = "%" + + default: + return + } + } else { + // + switch token.Kind { + case css_lexer.TNumber: + zero, err := strconv.ParseFloat(token.Text, 64) + if err != nil || zero != 0 { + return + } + result.value = 0 + result.unit = "%" + + case css_lexer.TDimension: + dimensionValue, err := strconv.ParseFloat(token.DimensionValue(), 64) + if err != nil { + return + } + result.value = dimensionValue + result.unit = token.DimensionUnit() + + case css_lexer.TPercentage: + percentageValue, err := strconv.ParseFloat(token.PercentageValue(), 64) + if err != nil { + return + } + result.value = percentageValue + result.unit = "%" + + default: + return + } + } + + success = true + return +} + +func tryToExpandGradient( + loc logger.Loc, + gradient *parsedGradient, + colorStops []parsedColorStop, + remaining []css_ast.Token, + colorSpace colorSpace, + hueMethod hueMethod, +) bool { + // Convert color stops into the interpolation color space + for i := range colorStops { + stop := &colorStops[i] + v0, v1, v2 := xyz_to_colorSpace(stop.x, stop.y, stop.z, colorSpace) + stop.v0, stop.v1, stop.v2 = premultiply(v0, v1, v2, stop.alpha, colorSpace) + } + + // Duplicate the endpoints if they should wrap around to themselves + if hueMethod == longerHue && colorSpace.isPolar() && len(colorStops) > 0 { + if first := colorStops[0]; len(first.positionTerms) == 1 { + if first.positionTerms[0].value < 0 { + colorStops[0].positionTerms[0].value = 0 + } else if first.positionTerms[0].value > 0 { + first.midpoint = nil + first.positionTerms = []valueWithUnit{{value: 0, unit: first.positionTerms[0].unit}} + colorStops = append([]parsedColorStop{first}, colorStops...) + } + } + if last := colorStops[len(colorStops)-1]; len(last.positionTerms) == 1 { + if last.positionTerms[0].unit != "%" || last.positionTerms[0].value < 100 { + last.positionTerms = []valueWithUnit{{value: 100, unit: "%"}} + colorStops = append(colorStops, last) + } + } + } + + var newColorStops []colorStop + var generateColorStops func( + int, parsedColorStop, parsedColorStop, + float64, float64, float64, float64, float64, float64, float64, float64, + float64, float64, float64, float64, float64, float64, float64, float64, + ) + + generateColorStops = func( + depth int, + from parsedColorStop, to parsedColorStop, + prevX, prevY, prevZ, prevR, prevG, prevB, prevA, prevT float64, + nextX, nextY, nextZ, nextR, nextG, nextB, nextA, nextT float64, + ) { + if depth > 4 { + return + } + + t := (prevT + nextT) / 2 + positionT := t + + // Handle midpoints (which we have already checked uses the same units) + if from.midpoint != nil { + fromPos := from.positionTerms[0].value + toPos := to.positionTerms[0].value + stopPos := fromPos + (toPos-fromPos)*t + H := (from.midpoint.value - fromPos) / (toPos - fromPos) + P := (stopPos - fromPos) / (toPos - fromPos) + if H <= 0 { + positionT = 1 + } else if H >= 1 { + positionT = 0 + } else { + positionT = math.Pow(P, -1/math.Log2(H)) + } + } + + v0, v1, v2 := interpolateColors(from.v0, from.v1, from.v2, to.v0, to.v1, to.v2, colorSpace, hueMethod, positionT) + a := from.alpha + (to.alpha-from.alpha)*positionT + v0, v1, v2 = unpremultiply(v0, v1, v2, a, colorSpace) + x, y, z := colorSpace_to_xyz(v0, v1, v2, colorSpace) + + // Stop when the color is similar enough to the sRGB midpoint + const epsilon = 4.0 / 255 + r, g, b := gam_srgb(xyz_to_lin_srgb(x, y, z)) + dr := r*a - (prevR*prevA+nextR*nextA)/2 + dg := g*a - (prevG*prevA+nextG*nextA)/2 + db := b*a - (prevB*prevA+nextB*nextA)/2 + if d := dr*dr + dg*dg + db*db; d < epsilon*epsilon { + return + } + + // Recursive split before this stop + generateColorStops(depth+1, from, to, + prevX, prevY, prevZ, prevR, prevG, prevB, prevA, prevT, + x, y, z, r, g, b, a, t) + + // Generate this stop + color := makeColorToken(loc, x, y, z, a) + positionTerms := interpolatePositions(from.positionTerms, to.positionTerms, t) + position := makePositionToken(loc, positionTerms) + position.Whitespace = css_ast.WhitespaceBefore + newColorStops = append(newColorStops, colorStop{ + color: color, + positions: []css_ast.Token{position}, + }) + + // Recursive split after this stop + generateColorStops(depth+1, from, to, + x, y, z, r, g, b, a, t, + nextX, nextY, nextZ, nextR, nextG, nextB, nextA, nextT) + } + + for i, stop := range colorStops { + color := makeColorToken(loc, stop.x, stop.y, stop.z, stop.alpha) + position := makePositionToken(loc, stop.positionTerms) + position.Whitespace = css_ast.WhitespaceBefore + newColorStops = append(newColorStops, colorStop{ + color: color, + positions: []css_ast.Token{position}, + }) + + // Generate new color stops in between as needed + if i+1 < len(colorStops) { + next := colorStops[i+1] + generateColorStops(0, stop, next, + stop.x, stop.y, stop.z, stop.r, stop.g, stop.b, stop.alpha, 0, + next.x, next.y, next.z, next.r, next.g, next.b, next.alpha, 1) + } + } + + // Remove implied positions for neatness + if len(newColorStops) > 0 { + first := newColorStops[0].positions[0] + if (first.Kind == css_lexer.TPercentage && first.PercentageValue() == "0") || + (first.Kind == css_lexer.TDimension && first.DimensionValue() == "0") { + newColorStops[0].positions = nil + } + last := newColorStops[len(newColorStops)-1].positions[0] + if last.Kind == css_lexer.TPercentage && last.PercentageValue() == "100" { + newColorStops[len(newColorStops)-1].positions = nil + } + } + + gradient.leadingTokens = remaining + gradient.colorStops = newColorStops + return true +} + +func formatFloat(value float64, decimals int) string { + return strings.TrimSuffix(strings.TrimRight(strconv.FormatFloat(value, 'f', decimals, 64), "0"), ".") +} + +func makeDimensionOrPercentToken(loc logger.Loc, value float64, unit string) (token css_ast.Token) { + token.Loc = loc + token.Text = formatFloat(value, 2) + if unit == "%" { + token.Kind = css_lexer.TPercentage + } else { + token.Kind = css_lexer.TDimension + token.UnitOffset = uint16(len(token.Text)) + } + token.Text += unit + return +} + +func makePositionToken(loc logger.Loc, positionTerms []valueWithUnit) css_ast.Token { + if len(positionTerms) == 1 { + return makeDimensionOrPercentToken(loc, positionTerms[0].value, positionTerms[0].unit) + } + + children := make([]css_ast.Token, 0, 1+2*len(positionTerms)) + for i, term := range positionTerms { + if i > 0 { + children = append(children, css_ast.Token{ + Loc: loc, + Kind: css_lexer.TDelimPlus, + Text: "+", + Whitespace: css_ast.WhitespaceBefore | css_ast.WhitespaceAfter, + }) + } + children = append(children, makeDimensionOrPercentToken(loc, term.value, term.unit)) + } + + return css_ast.Token{ + Loc: loc, + Kind: css_lexer.TFunction, + Text: "calc", + Children: &children, + } +} + +func makeColorToken(loc logger.Loc, x float64, y float64, z float64, a float64) (color css_ast.Token) { + color.Loc = loc + alpha := uint32(math.Round(a * 255)) + if hex, ok := tryToConvertToHexWithoutClipping(x, y, z, alpha); ok { + color.Kind = css_lexer.THash + if alpha == 255 { + color.Text = fmt.Sprintf("%06x", hex>>8) + } else { + color.Text = fmt.Sprintf("%08x", hex) + } + } else { + children := []css_ast.Token{ + { + Loc: loc, + Kind: css_lexer.TIdent, + Text: "xyz", + Whitespace: css_ast.WhitespaceAfter, + }, + { + Loc: loc, + Kind: css_lexer.TNumber, + Text: formatFloat(x, 3), + Whitespace: css_ast.WhitespaceBefore | css_ast.WhitespaceAfter, + }, + { + Loc: loc, + Kind: css_lexer.TNumber, + Text: formatFloat(y, 3), + Whitespace: css_ast.WhitespaceBefore | css_ast.WhitespaceAfter, + }, + { + Loc: loc, + Kind: css_lexer.TNumber, + Text: formatFloat(z, 3), + Whitespace: css_ast.WhitespaceBefore, + }, + } + if a < 1 { + children = append(children, + css_ast.Token{ + Loc: loc, + Kind: css_lexer.TDelimSlash, + Text: "/", + Whitespace: css_ast.WhitespaceBefore | css_ast.WhitespaceAfter, + }, + css_ast.Token{ + Loc: loc, + Kind: css_lexer.TNumber, + Text: formatFloat(a, 3), + Whitespace: css_ast.WhitespaceBefore, + }, + ) + } + color.Kind = css_lexer.TFunction + color.Text = "color" + color.Children = &children + } + return +} + +func interpolateHues(a, b, t float64, hueMethod hueMethod) float64 { + a /= 360 + b /= 360 + a -= math.Floor(a) + b -= math.Floor(b) + + switch hueMethod { + case shorterHue: + delta := b - a + if delta > 0.5 { + a++ + } + if delta < -0.5 { + b++ + } + + case longerHue: + delta := b - a + if delta > 0 && delta < 0.5 { + a++ + } + if delta > -0.5 && delta <= 0 { + b++ + } + + case increasingHue: + if b < a { + b++ + } + + case decreasingHue: + if a < b { + a++ + } + } + + return (a + (b-a)*t) * 360 +} + +func interpolateColors( + a0, a1, a2 float64, b0, b1, b2 float64, + colorSpace colorSpace, hueMethod hueMethod, t float64, +) (v0 float64, v1 float64, v2 float64) { + v1 = a1 + (b1-a1)*t + + switch colorSpace { + case colorSpace_hsl, colorSpace_hwb: + v2 = a2 + (b2-a2)*t + v0 = interpolateHues(a0, b0, t, hueMethod) + + case colorSpace_lch, colorSpace_oklch: + v0 = a0 + (b0-a0)*t + v2 = interpolateHues(a2, b2, t, hueMethod) + + default: + v0 = a0 + (b0-a0)*t + v2 = a2 + (b2-a2)*t + } + + return v0, v1, v2 +} + +func interpolatePositions(a []valueWithUnit, b []valueWithUnit, t float64) (result []valueWithUnit) { + findUnit := func(unit string) int { + for i, x := range result { + if x.unit == unit { + return i + } + } + result = append(result, valueWithUnit{unit: unit}) + return len(result) - 1 + } + + // "result += a * (1 - t)" + for _, term := range a { + result[findUnit(term.unit)].value += term.value * (1 - t) + } + + // "result += b * t" + for _, term := range b { + result[findUnit(term.unit)].value += term.value * t + } + + // Remove an extra zero value for neatness. We don't remove all + // of them because it may be important to retain a single zero. + if len(result) > 1 { + for i, term := range result { + if term.value == 0 { + copy(result[i:], result[i+1:]) + result = result[:len(result)-1] + break + } + } + } + + return +} + +func premultiply(v0, v1, v2, alpha float64, colorSpace colorSpace) (float64, float64, float64) { + if alpha < 1 { + switch colorSpace { + case colorSpace_hsl, colorSpace_hwb: + v2 *= alpha + case colorSpace_lch, colorSpace_oklch: + v0 *= alpha + default: + v0 *= alpha + v2 *= alpha + } + v1 *= alpha + } + return v0, v1, v2 +} + +func unpremultiply(v0, v1, v2, alpha float64, colorSpace colorSpace) (float64, float64, float64) { + if alpha > 0 && alpha < 1 { + switch colorSpace { + case colorSpace_hsl, colorSpace_hwb: + v2 /= alpha + case colorSpace_lch, colorSpace_oklch: + v0 /= alpha + default: + v0 /= alpha + v2 /= alpha + } + v1 /= alpha + } + return v0, v1, v2 +} diff --git a/internal/css_parser/css_parser_test.go b/internal/css_parser/css_parser_test.go index 3eb7481625f..08f0578a081 100644 --- a/internal/css_parser/css_parser_test.go +++ b/internal/css_parser/css_parser_test.go @@ -761,50 +761,74 @@ func TestGradient(t *testing.T) { expectPrinted(t, code, "a {\n background: "+gradient+"(yellow, #11223344);\n}\n", "") expectPrintedMangle(t, code, "a {\n background: "+gradient+"(#ff0, #1234);\n}\n", "") expectPrintedMinify(t, code, "a{background:"+gradient+"(yellow,#11223344)}", "") - expectPrintedLower(t, code, "a {\n background: "+gradient+"(yellow, rgba(17, 34, 51, .267));\n}\n", "") + expectPrintedLowerUnsupported(t, compat.HexRGBA, code, + "a {\n background: "+gradient+"(yellow, rgba(17, 34, 51, .267));\n}\n", "") // Basic with positions code = "a { background: " + gradient + "(yellow 10%, #11223344 90%) }" expectPrinted(t, code, "a {\n background: "+gradient+"(yellow 10%, #11223344 90%);\n}\n", "") expectPrintedMangle(t, code, "a {\n background: "+gradient+"(#ff0 10%, #1234 90%);\n}\n", "") expectPrintedMinify(t, code, "a{background:"+gradient+"(yellow 10%,#11223344 90%)}", "") - expectPrintedLower(t, code, "a {\n background: "+gradient+"(yellow 10%, rgba(17, 34, 51, .267) 90%);\n}\n", "") + expectPrintedLowerUnsupported(t, compat.HexRGBA, code, + "a {\n background: "+gradient+"(yellow 10%, rgba(17, 34, 51, .267) 90%);\n}\n", "") // Basic with hints code = "a { background: " + gradient + "(yellow, 25%, #11223344) }" - expectPrinted(t, code, "a {\n background: "+gradient+"(yellow, 25%, #11223344);\n}\n", "") - expectPrintedMangle(t, code, "a {\n background: "+gradient+"(#ff0, 25%, #1234);\n}\n", "") + expectPrinted(t, code, "a {\n background:\n "+gradient+"(\n yellow,\n 25%,\n #11223344);\n}\n", "") + expectPrintedMangle(t, code, "a {\n background:\n "+gradient+"(\n #ff0,\n 25%,\n #1234);\n}\n", "") expectPrintedMinify(t, code, "a{background:"+gradient+"(yellow,25%,#11223344)}", "") - expectPrintedLower(t, code, "a {\n background: "+gradient+"(yellow, 25%, rgba(17, 34, 51, .267));\n}\n", "") + expectPrintedLowerUnsupported(t, compat.HexRGBA, code, + "a {\n background:\n "+gradient+"(\n yellow,\n 25%,\n rgba(17, 34, 51, .267));\n}\n", "") + expectPrintedLowerUnsupported(t, compat.GradientMidpoints, code, + "a {\n background:\n "+gradient+"(\n #ffff00,\n #f2f303de 3.12%,\n #eced04d0 6.25%,\n "+ + "#e1e306bd 12.5%,\n #cdd00ba2 25%,\n #a2a8147b 50%,\n #6873205d 75%,\n #11223344);\n}\n", "") // Double positions code = "a { background: " + gradient + "(green, red 10%, red 20%, yellow 70% 80%, black) }" - expectPrinted(t, code, "a {\n background: "+gradient+"(green, red 10%, red 20%, yellow 70% 80%, black);\n}\n", "") - expectPrintedMangle(t, code, "a {\n background: "+gradient+"(green, red 10% 20%, #ff0 70% 80%, #000);\n}\n", "") + expectPrinted(t, code, "a {\n background:\n "+gradient+"(\n green,\n red 10%,\n "+ + "red 20%,\n yellow 70% 80%,\n black);\n}\n", "") + expectPrintedMangle(t, code, "a {\n background:\n "+gradient+"(\n green,\n "+ + "red 10% 20%,\n #ff0 70% 80%,\n #000);\n}\n", "") expectPrintedMinify(t, code, "a{background:"+gradient+"(green,red 10%,red 20%,yellow 70% 80%,black)}", "") - expectPrintedLower(t, code, "a {\n background: "+gradient+"(green, red 10%, red 20%, yellow 70%, yellow 80%, black);\n}\n", "") + expectPrintedLowerUnsupported(t, compat.GradientDoublePosition, code, + "a {\n background:\n "+gradient+"(\n green,\n red 10%,\n red 20%,\n "+ + "yellow 70%,\n yellow 80%,\n black);\n}\n", "") // Double positions with hints code = "a { background: " + gradient + "(green, red 10%, red 20%, 30%, yellow 70% 80%, 85%, black) }" - expectPrinted(t, code, "a {\n background: "+gradient+"(green, red 10%, red 20%, 30%, yellow 70% 80%, 85%, black);\n}\n", "") - expectPrintedMangle(t, code, "a {\n background: "+gradient+"(green, red 10% 20%, 30%, #ff0 70% 80%, 85%, #000);\n}\n", "") + expectPrinted(t, code, "a {\n background:\n "+gradient+"(\n green,\n red 10%,\n red 20%,\n "+ + "30%,\n yellow 70% 80%,\n 85%,\n black);\n}\n", "") + expectPrintedMangle(t, code, "a {\n background:\n "+gradient+"(\n green,\n red 10% 20%,\n "+ + "30%,\n #ff0 70% 80%,\n 85%,\n #000);\n}\n", "") expectPrintedMinify(t, code, "a{background:"+gradient+"(green,red 10%,red 20%,30%,yellow 70% 80%,85%,black)}", "") - expectPrintedLower(t, code, "a {\n background: "+gradient+"(green, red 10%, red 20%, 30%, yellow 70%, yellow 80%, 85%, black);\n}\n", "") + expectPrintedLowerUnsupported(t, compat.GradientDoublePosition, code, + "a {\n background:\n "+gradient+"(\n green,\n red 10%,\n red 20%,\n 30%,\n "+ + "yellow 70%,\n yellow 80%,\n 85%,\n black);\n}\n", "") // Non-double positions with hints code = "a { background: " + gradient + "(green, red 10%, 1%, red 20%, black) }" - expectPrinted(t, code, "a {\n background: "+gradient+"(green, red 10%, 1%, red 20%, black);\n}\n", "") - expectPrintedMangle(t, code, "a {\n background: "+gradient+"(green, red 10%, 1%, red 20%, #000);\n}\n", "") + expectPrinted(t, code, "a {\n background:\n "+gradient+"(\n green,\n red 10%,\n 1%,\n "+ + "red 20%,\n black);\n}\n", "") + expectPrintedMangle(t, code, "a {\n background:\n "+gradient+"(\n green,\n red 10%,\n "+ + "1%,\n red 20%,\n #000);\n}\n", "") expectPrintedMinify(t, code, "a{background:"+gradient+"(green,red 10%,1%,red 20%,black)}", "") - expectPrintedLower(t, code, "a {\n background: "+gradient+"(green, red 10%, 1%, red 20%, black);\n}\n", "") + expectPrintedLowerUnsupported(t, compat.GradientDoublePosition, code, + "a {\n background:\n "+gradient+"(\n green,\n red 10%,\n 1%,\n red 20%,\n black);\n}\n", "") // Out-of-gamut colors code = "a { background: " + gradient + "(yellow, color(display-p3 1 0 0)) }" expectPrinted(t, code, "a {\n background: "+gradient+"(yellow, color(display-p3 1 0 0));\n}\n", "") expectPrintedMangle(t, code, "a {\n background: "+gradient+"(#ff0, color(display-p3 1 0 0));\n}\n", "") expectPrintedMinify(t, code, "a{background:"+gradient+"(yellow,color(display-p3 1 0 0))}", "") - expectPrintedLower(t, code, "a {\n background: "+gradient+"(yellow, #ff0f0e);\n "+ - "background: "+gradient+"(yellow, color(display-p3 1 0 0));\n}\n", "") + expectPrintedLowerUnsupported(t, compat.ColorFunctions, code, + "a {\n background:\n "+gradient+"(\n #ffff00,\n #ffe971 12.5%,\n #ffd472 25%,\n "+ + "#ffab5f 50%,\n #ff7b45 75%,\n #ff5e38 87.5%,\n #ff5534 90.62%,\n #ff4c30 93.75%,\n "+ + "#ff412c 96.88%,\n #ff0e0e);\n "+ + "background:\n "+gradient+"(\n #ffff00,\n color(xyz 0.734 0.805 0.111) 12.5%,\n "+ + "color(xyz 0.699 0.693 0.087) 25%,\n color(xyz 0.627 0.501 0.048) 50%,\n "+ + "color(xyz 0.556 0.348 0.019) 75%,\n color(xyz 0.521 0.284 0.009) 87.5%,\n "+ + "color(xyz 0.512 0.27 0.006) 90.62%,\n color(xyz 0.504 0.256 0.004) 93.75%,\n "+ + "color(xyz 0.495 0.242 0.002) 96.88%,\n color(xyz 0.487 0.229 0));\n}\n", "") // Whitespace code = "a { background: " + gradient + "(color-mix(in lab,red,green)calc(1px)calc(2px),color-mix(in lab,blue,red)calc(98%)calc(99%)) }" @@ -814,12 +838,40 @@ func TestGradient(t *testing.T) { "(color-mix(in lab, red, green) 1px 2px, color-mix(in lab, blue, red) 98% 99%);\n}\n", "") expectPrintedMinify(t, code, "a{background:"+gradient+ "(color-mix(in lab,red,green)calc(1px)calc(2px),color-mix(in lab,blue,red)calc(98%)calc(99%))}", "") - expectPrintedLower(t, code, "a {\n background: "+gradient+ - "(color-mix(in lab, red, green) calc(1px), color-mix(in lab, red, green) calc(2px),"+ - " color-mix(in lab, blue, red) calc(98%), color-mix(in lab, blue, red) calc(99%));\n}\n", "") - expectPrintedLowerMangle(t, code, "a {\n background: "+gradient+ - "(color-mix(in lab, red, green) 1px, color-mix(in lab, red, green) 2px,"+ - " color-mix(in lab, blue, red) 98%, color-mix(in lab, blue, red) 99%);\n}\n", "") + expectPrintedLowerUnsupported(t, compat.GradientDoublePosition, code, "a {\n background:\n "+gradient+ + "(\n color-mix(in lab, red, green) calc(1px),\n color-mix(in lab, red, green) calc(2px),"+ + "\n color-mix(in lab, blue, red) calc(98%),\n color-mix(in lab, blue, red) calc(99%));\n}\n", "") + expectPrintedLowerMangle(t, code, "a {\n background:\n "+gradient+ + "(\n color-mix(in lab, red, green) 1px,\n color-mix(in lab, red, green) 2px,"+ + "\n color-mix(in lab, blue, red) 98%,\n color-mix(in lab, blue, red) 99%);\n}\n", "") + + // Color space interpolation + expectPrintedLowerUnsupported(t, compat.GradientInterpolation, + "a { background: "+gradient+"(in srgb, red, green) }", + "a {\n background: "+gradient+"(#ff0000, #008000);\n}\n", "") + expectPrintedLowerUnsupported(t, compat.GradientInterpolation, + "a { background: "+gradient+"(in srgb-linear, red, green) }", + "a {\n background:\n "+gradient+"(\n #ff0000,\n #fb1300 3.12%,\n #f81f00 6.25%,\n "+ + "#f02e00 12.5%,\n #e14200 25%,\n #bc5c00 50%,\n #897000 75%,\n #637800 87.5%,\n "+ + "#477c00 93.75%,\n #317e00 96.88%,\n #008000);\n}\n", "") + expectPrintedLowerUnsupported(t, compat.GradientInterpolation, + "a { background: "+gradient+"(in lab, red, green) }", + "a {\n background:\n "+gradient+"(\n #ff0000,\n color(xyz 0.396 0.211 0.019) 3.12%,\n "+ + "color(xyz 0.38 0.209 0.02) 6.25%,\n color(xyz 0.35 0.205 0.02) 12.5%,\n "+ + "color(xyz 0.294 0.198 0.02) 25%,\n color(xyz 0.2 0.183 0.022) 50%,\n "+ + "color(xyz 0.129 0.168 0.024) 75%,\n color(xyz 0.101 0.161 0.025) 87.5%,\n "+ + "color(xyz 0.089 0.158 0.025) 93.75%,\n color(xyz 0.083 0.156 0.025) 96.88%,\n #008000);\n}\n", "") + + // Hue interpolation + expectPrintedLowerUnsupported(t, compat.GradientInterpolation, + "a { background: "+gradient+"(in hsl shorter hue, red, green) }", + "a {\n background:\n "+gradient+"(\n #ff0000,\n #df7000 25%,\n "+ + "#bfbf00 50%,\n #50a000 75%,\n #008000);\n}\n", "") + expectPrintedLowerUnsupported(t, compat.GradientInterpolation, + "a { background: "+gradient+"(in hsl longer hue, red, green) }", + "a {\n background:\n "+gradient+"(\n #ff0000,\n #ef0078 12.5%,\n "+ + "#df00df 25%,\n #6800cf 37.5%,\n #0000c0 50%,\n #0058b0 62.5%,\n "+ + "#00a0a0 75%,\n #009048 87.5%,\n #008000);\n}\n", "") } } @@ -2145,7 +2197,8 @@ func TestTransform(t *testing.T) { expectPrintedMangle(t, "a { transform: matrix(1, 0, 0, 2, 0, 0) }", "a {\n transform: scaleY(2);\n}\n", "") expectPrintedMangle(t, "a { transform: matrix(2, 0, 0, 3, 0, 0) }", "a {\n transform: scale(2, 3);\n}\n", "") expectPrintedMangle(t, "a { transform: matrix(2, 0, 0, 2, 0, 0) }", "a {\n transform: scale(2);\n}\n", "") - expectPrintedMangle(t, "a { transform: matrix(1, 0, 0, 1, 1, 2) }", "a {\n transform: matrix(1, 0, 0, 1, 1, 2);\n}\n", "") + expectPrintedMangle(t, "a { transform: matrix(1, 0, 0, 1, 1, 2) }", + "a {\n transform:\n matrix(\n 1, 0,\n 0, 1,\n 1, 2);\n}\n", "") expectPrintedMangle(t, "a { transform: translate(0, 0) }", "a {\n transform: translate(0);\n}\n", "") expectPrintedMangle(t, "a { transform: translate(0px, 0px) }", "a {\n transform: translate(0);\n}\n", "") @@ -2218,13 +2271,13 @@ func TestTransform(t *testing.T) { expectPrintedMangle(t, "a { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 2) }", - "a {\n transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 2);\n}\n", "") + "a {\n transform:\n matrix3d(\n 1, 0, 0, 0,\n 0, 1, 0, 0,\n 0, 0, 1, 0,\n 0, 0, 0, 2);\n}\n", "") expectPrintedMangle(t, "a { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 2, 3, 4, 1) }", - "a {\n transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 2, 3, 4, 1);\n}\n", "") + "a {\n transform:\n matrix3d(\n 1, 0, 0, 0,\n 0, 1, 0, 0,\n 0, 0, 1, 0,\n 2, 3, 4, 1);\n}\n", "") expectPrintedMangle(t, "a { transform: matrix3d(1, 0, 1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 0, 0, 1) }", - "a {\n transform: matrix3d(1, 0, 1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 0, 0, 1);\n}\n", "") + "a {\n transform:\n matrix3d(\n 1, 0, 1, 0,\n 0, 1, 0, 0,\n 1, 0, 1, 0,\n 0, 0, 0, 1);\n}\n", "") expectPrintedMangle(t, "a { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) }", "a {\n transform: scaleZ(1);\n}\n", "") @@ -2248,7 +2301,7 @@ func TestTransform(t *testing.T) { "a {\n transform: scale3d(1, 2, 3);\n}\n", "") expectPrintedMangle(t, "a { transform: matrix3d(2, 3, 0, 0, 4, 5, 0, 0, 0, 0, 1, 0, 6, 7, 0, 1) }", - "a {\n transform: matrix3d(2, 3, 0, 0, 4, 5, 0, 0, 0, 0, 1, 0, 6, 7, 0, 1);\n}\n", "") + "a {\n transform:\n matrix3d(\n 2, 3, 0, 0,\n 4, 5, 0, 0,\n 0, 0, 1, 0,\n 6, 7, 0, 1);\n}\n", "") expectPrintedMangle(t, "a { transform: translate3d(0, 0, 0) }", "a {\n transform: translateZ(0);\n}\n", "") expectPrintedMangle(t, "a { transform: translate3d(0%, 0%, 0) }", "a {\n transform: translateZ(0);\n}\n", "") diff --git a/internal/css_printer/css_printer.go b/internal/css_printer/css_printer.go index 2f63184ce04..c010074c6a1 100644 --- a/internal/css_printer/css_printer.go +++ b/internal/css_printer/css_printer.go @@ -942,32 +942,74 @@ func (p *printer) printIndent(indent int32) { } type printTokensOpts struct { - indent int32 - isDeclaration bool + indent int32 + multiLineCommaPeriod uint8 + isDeclaration bool +} + +func functionMultiLineCommaPeriod(token css_ast.Token) uint8 { + if token.Kind == css_lexer.TFunction { + commaCount := 0 + for _, t := range *token.Children { + if t.Kind == css_lexer.TComma { + commaCount++ + } + } + + switch strings.ToLower(token.Text) { + case "linear-gradient", "radial-gradient", "conic-gradient", + "repeating-linear-gradient", "repeating-radial-gradient", "repeating-conic-gradient": + if commaCount >= 2 { + return 1 + } + + case "matrix": + if commaCount == 5 { + return 2 + } + + case "matrix3d": + if commaCount == 15 { + return 4 + } + } + } + return 0 } func (p *printer) printTokens(tokens []css_ast.Token, opts printTokensOpts) bool { hasWhitespaceAfter := len(tokens) > 0 && (tokens[0].Whitespace&css_ast.WhitespaceBefore) != 0 // Pretty-print long comma-separated declarations of 3 or more items - isMultiLineValue := false + commaPeriod := int(opts.multiLineCommaPeriod) if !p.options.MinifyWhitespace && opts.isDeclaration { commaCount := 0 for _, t := range tokens { if t.Kind == css_lexer.TComma { commaCount++ + if commaCount >= 2 { + commaPeriod = 1 + break + } + } + if t.Kind == css_lexer.TFunction && functionMultiLineCommaPeriod(t) > 0 { + commaPeriod = 1 + break } } - isMultiLineValue = commaCount >= 2 } + commaCount := 0 for i, t := range tokens { + if t.Kind == css_lexer.TComma { + commaCount++ + } if t.Kind == css_lexer.TWhitespace { hasWhitespaceAfter = true continue } if hasWhitespaceAfter { - if isMultiLineValue && (i == 0 || tokens[i-1].Kind == css_lexer.TComma) { + if commaPeriod > 0 && (i == 0 || (tokens[i-1].Kind == css_lexer.TComma && commaCount%commaPeriod == 0)) { p.print("\n") p.printIndent(opts.indent + 1) } else if p.options.LineLimit <= 0 || !p.printNewlinePastLineLimit(opts.indent+1) { @@ -1054,7 +1096,28 @@ func (p *printer) printTokens(tokens []css_ast.Token, opts printTokensOpts) bool } if t.Children != nil { - p.printTokens(*t.Children, printTokensOpts{indent: opts.indent}) + childCommaPeriod := uint8(0) + + if commaPeriod > 0 && opts.isDeclaration { + childCommaPeriod = functionMultiLineCommaPeriod(t) + } + + if childCommaPeriod > 0 { + opts.indent++ + if !p.options.MinifyWhitespace { + p.print("\n") + p.printIndent(opts.indent + 1) + } + } + + p.printTokens(*t.Children, printTokensOpts{ + indent: opts.indent, + multiLineCommaPeriod: childCommaPeriod, + }) + + if childCommaPeriod > 0 { + opts.indent-- + } switch t.Kind { case css_lexer.TFunction: diff --git a/scripts/gradient-tests.css b/scripts/gradient-tests.css new file mode 100644 index 00000000000..91c8f718163 --- /dev/null +++ b/scripts/gradient-tests.css @@ -0,0 +1,95 @@ +.linear.red_to_green-esbuild { + background: linear-gradient(to right, color(display-p3 1 0 0), color(display-p3 0 0.6 0)); +} + +.radial.red_to_green-esbuild { + background: radial-gradient(color(display-p3 1 0 0), color(display-p3 0 0.6 0)); +} + +.conic.red_to_green-esbuild { + background: conic-gradient(color(display-p3 1 0 0), color(display-p3 0 0.6 0)); +} + +.linear.rainbow_shorter-esbuild { + background: linear-gradient(to right in hwb shorter hue, hsl(180deg 100% 75%) 10%, hsl(240deg 100% 75%) 90%); +} + +.radial.rainbow_shorter-esbuild { + background: radial-gradient(in hwb shorter hue, hsl(180deg 100% 75%) 10%, hsl(240deg 100% 75%) 90%); +} + +.conic.rainbow_shorter-esbuild { + background: conic-gradient(in hwb shorter hue, hsl(180deg 100% 75%) 10%, hsl(240deg 100% 75%) 90%); +} + +.linear.rainbow_longer-esbuild { + background: linear-gradient(to right in hsl longer hue, hsl(180deg 100% 75%) 10%, hsl(240deg 100% 75%) 90%); +} + +.radial.rainbow_longer-esbuild { + background: radial-gradient(in hsl longer hue, hsl(180deg 100% 75%) 10%, hsl(240deg 100% 75%) 90%); +} + +.conic.rainbow_longer-esbuild { + background: conic-gradient(in hsl longer hue, hsl(180deg 100% 75%) 10%, hsl(240deg 100% 75%) 90%); +} + +.linear.rainbow_increasing-esbuild { + background: linear-gradient(to right in lch increasing hue, hsl(240deg 100% 75%) 10%, hsl(180deg 100% 75%) 90%); +} + +.radial.rainbow_increasing-esbuild { + background: radial-gradient(in lch increasing hue, hsl(240deg 100% 75%) 10%, hsl(180deg 100% 75%) 90%); +} + +.conic.rainbow_increasing-esbuild { + background: conic-gradient(in lch increasing hue, hsl(240deg 100% 75%) 10%, hsl(180deg 100% 75%) 90%); +} + +.linear.rainbow_decreasing-esbuild { + background: linear-gradient(to right in oklch decreasing hue, hsl(180deg 100% 75%) 10%, hsl(240deg 100% 75%) 90%); +} + +.radial.rainbow_decreasing-esbuild { + background: radial-gradient(in oklch decreasing hue, hsl(180deg 100% 75%) 10%, hsl(240deg 100% 75%) 90%); +} + +.conic.rainbow_decreasing-esbuild { + background: conic-gradient(in oklch decreasing hue, hsl(180deg 100% 75%) 10%, hsl(240deg 100% 75%) 90%); +} + +.linear.midpoint_hint-esbuild { + background: linear-gradient(to right, #f00, #ff0, 75%, #0ff, #00f); +} + +.radial.midpoint_hint-esbuild { + background: radial-gradient(#f00, #ff0, 75%, #0ff, #00f); +} + +.conic.midpoint_hint-esbuild { + background: conic-gradient(#f00, #ff0, 75%, #0ff, #00f); +} + +.linear.premultiplied_alpha-esbuild { + background: linear-gradient(to right, #f00f, 10%, #00f1, 90%, #0f0f); +} + +.radial.premultiplied_alpha-esbuild { + background: radial-gradient(#f00f, 10%, #00f1, 90%, #0f0f); +} + +.conic.premultiplied_alpha-esbuild { + background: conic-gradient(#f00f, 10%, #00f1, 90%, #0f0f); +} + +.linear.mixed_units-esbuild { + background: linear-gradient(to right, color(display-p3 0.4 0 1) 30px, color(display-p3 0.96 0.75 0.4) 60%); +} + +.radial.mixed_units-esbuild { + background: radial-gradient(color(display-p3 0.4 0 1) 30px, color(display-p3 1 0.75 0.4) 60%); +} + +.conic.mixed_units-esbuild { + background: conic-gradient(color(display-p3 0.4 0 1) 30deg, color(display-p3 1 0.75 0.4) 60%); +} diff --git a/scripts/gradient-tests.html b/scripts/gradient-tests.html new file mode 100644 index 00000000000..13e91ae80a5 --- /dev/null +++ b/scripts/gradient-tests.html @@ -0,0 +1,301 @@ + + + + + + + + + + +

This page is a visual test of esbuild's CSS gradient transformation. Run it like this:

+
./esbuild --servedir=scripts --outfile=scripts/out.css scripts/gradient-tests.css --target=firefox30
+

Then open this page as http://localhost:8000/gradient-tests.html.

+ +

1. Red to green in P3

+
gradient(
+  color(display-p3 1 0 0),
+  color(display-p3 0 0.6 0))
+
native
+
esbuild
+
+
native
+
esbuild
+
native
+
esbuild
+
+ +

2. Rainbow using shorter hue

+
gradient(
+  in hwb shorter hue,
+  hsl(180deg 100% 75%) 10%,
+  hsl(240deg 100% 75%) 90%)
+
native
+
esbuild
+
+
native
+
esbuild
+
native
+
esbuild
+
+ +

3. Rainbow using longer hue

+
gradient(
+  in hsl longer hue,
+  hsl(180deg 100% 75%) 10%,
+  hsl(240deg 100% 75%) 90%)
+
native
+
esbuild
+
+
native
+
esbuild
+
native
+
esbuild
+
+ +

4. Rainbow using increasing hue

+
gradient(
+  in lch increasing hue,
+  hsl(240deg 100% 75%) 10%,
+  hsl(180deg 100% 75%) 90%)
+
native
+
esbuild
+
+
native
+
esbuild
+
native
+
esbuild
+
+ +

5. Rainbow using decreasing hue

+
gradient(
+  in oklch decreasing hue,
+  hsl(180deg 100% 75%) 10%,
+  hsl(240deg 100% 75%) 90%)
+
native
+
esbuild
+
+
native
+
esbuild
+
native
+
esbuild
+
+ +

6. Transition hint / midpoint

+
gradient(#f00, #ff0, 75%, #0ff, #00f)
+
native
+
esbuild
+
+
native
+
esbuild
+
native
+
esbuild
+
+ +

7. Premultiplied alpha

+
gradient(#f00f, 10%, #00f1, 90%, #0f0f)
+
native
+
esbuild
+
+
native
+
esbuild
+
native
+
esbuild
+
+ +

8. Mixed units

+
gradient(
+  color(display-p3 0.4 0 1) 30px,
+  color(display-p3 1 0.75 0.4) 60%)
+
native
+
esbuild
+
+
native
+
esbuild
+
native
+
esbuild
+
+ + +