From 401abdd928c92212cfaeb82adcf735a51727f81f Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Thu, 8 Dec 2022 16:25:43 -0800 Subject: [PATCH 01/56] [Color 4] Add initial implementation of Color Level 4 (#1837) This is still missing some planned deprecations and doubtlessly has bugs in the new features, but it's enough to provide a baseline to begin iterating on in smaller chunks. See #1805 See sass/sass#2831 --- CHANGELOG.md | 137 ++ lib/sass.dart | 7 +- lib/src/functions/color.dart | 1073 +++++++++++----- lib/src/functions/meta.dart | 8 +- lib/src/node/value/color.dart | 38 +- lib/src/util/fuzzy_equality.dart | 17 + lib/src/util/number.dart | 15 +- lib/src/utils.dart | 3 + lib/src/value.dart | 27 + lib/src/value/color.dart | 1138 +++++++++++++---- lib/src/value/color/channel.dart | 89 ++ lib/src/value/color/conversions.dart | 464 +++++++ lib/src/value/color/interpolation_method.dart | 160 +++ lib/src/value/color/space.dart | 336 +++++ lib/src/value/color/space/a98_rgb.dart | 58 + lib/src/value/color/space/display_p3.dart | 53 + lib/src/value/color/space/hsl.dart | 50 + lib/src/value/color/space/hwb.dart | 50 + lib/src/value/color/space/lab.dart | 65 + lib/src/value/color/space/lch.dart | 37 + lib/src/value/color/space/lms.dart | 115 ++ lib/src/value/color/space/oklab.dart | 60 + lib/src/value/color/space/oklch.dart | 37 + lib/src/value/color/space/prophoto_rgb.dart | 64 + lib/src/value/color/space/rec2020.dart | 72 ++ lib/src/value/color/space/rgb.dart | 37 + lib/src/value/color/space/srgb.dart | 130 ++ lib/src/value/color/space/srgb_linear.dart | 72 ++ lib/src/value/color/space/utils.dart | 85 ++ lib/src/value/color/space/xyz_d50.dart | 80 ++ lib/src/value/color/space/xyz_d65.dart | 53 + lib/src/value/list.dart | 12 + lib/src/value/number.dart | 7 +- lib/src/value/number/single_unit.dart | 5 + lib/src/value/string.dart | 24 + lib/src/visitor/async_evaluate.dart | 6 +- lib/src/visitor/evaluate.dart | 8 +- lib/src/visitor/serialize.dart | 319 ++++- pkg/sass_api/CHANGELOG.md | 53 +- pkg/sass_api/pubspec.yaml | 4 +- precompute_matrices.dart | 310 +++++ pubspec.yaml | 3 +- test/dart_api/value/color_test.dart | 2 + 43 files changed, 4704 insertions(+), 679 deletions(-) create mode 100644 lib/src/util/fuzzy_equality.dart create mode 100644 lib/src/value/color/channel.dart create mode 100644 lib/src/value/color/conversions.dart create mode 100644 lib/src/value/color/interpolation_method.dart create mode 100644 lib/src/value/color/space.dart create mode 100644 lib/src/value/color/space/a98_rgb.dart create mode 100644 lib/src/value/color/space/display_p3.dart create mode 100644 lib/src/value/color/space/hsl.dart create mode 100644 lib/src/value/color/space/hwb.dart create mode 100644 lib/src/value/color/space/lab.dart create mode 100644 lib/src/value/color/space/lch.dart create mode 100644 lib/src/value/color/space/lms.dart create mode 100644 lib/src/value/color/space/oklab.dart create mode 100644 lib/src/value/color/space/oklch.dart create mode 100644 lib/src/value/color/space/prophoto_rgb.dart create mode 100644 lib/src/value/color/space/rec2020.dart create mode 100644 lib/src/value/color/space/rgb.dart create mode 100644 lib/src/value/color/space/srgb.dart create mode 100644 lib/src/value/color/space/srgb_linear.dart create mode 100644 lib/src/value/color/space/utils.dart create mode 100644 lib/src/value/color/space/xyz_d50.dart create mode 100644 lib/src/value/color/space/xyz_d65.dart create mode 100644 precompute_matrices.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index c7e3a4a2a..d588295b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,140 @@ +## 1.57.0 + +* Add support for CSS Color Level 4 [color spaces]. Each color value now tracks + its color space along with the values of each channel in that color space. + There are two general principles to keep in mind when dealing with new color + spaces: + + 1. With the exception of legacy color spaces (`rgb`, `hsl`, and `hwb`), colors + will always be emitted in the color space they were defined in unless + they're explicitly converted. + + 2. The `color.to-space()` function is the only way to convert a color to + another color space. Some built-in functions may do operations in a + different color space, but they'll always convert back to the original space + afterwards. + +* `rgb` colors can now have non-integer channels and channels outside the normal + gamut of 0-255. These colors are always emitted using the `rgb()` syntax so + that modern browsers that are being displayed on wide-gamut devices can + display the most accurate color possible. + +* Add support for all the new color syntax defined in Color Level 4, including: + + * `oklab()`, `oklch()`, `lab()`, and `lch()` functions; + * a top-level `hwb()` function that matches the space-separated CSS syntax; + * and a `color()` function that supports the `srgb`, `srgb-linear`, + `display-p3`, `a98-rgb`, `prophoto-rgb`, `rec2020`, `xyz`, `xyz-d50`, and + `xyz-d65` color spaces. + +* Add new functions for working with color spaces: + + * `color.to-space($color, $space)` converts `$color` to the given `$space`. In + most cases this conversion is lossless—the color may end up out-of-gamut for + the destination color space, but browsers will generally display it as best + they can regardless. However, the `hsl` and `hwb` spaces can't represent + out-of-gamut colors and so will be clamped. + + * `color.channel($color, $channel, $space: null)` returns the value of the + given `$channel` in `$color`, after converting it to `$space` if necessary. + It should be used instead of the old channel-specific functions such as + `color.red()` and `color.hue()`. + + * `color.same($color1, $color2)` returns whether two colors represent the same + color even across color spaces. It differs from `$color1 == $color2` because + `==` never consider colors in different (non-legacy) spaces as equal. + + * `color.is-in-gamut($color, $space: null)` returns whether `$color` is + in-gamut for its color space (or `$space` if it's passed). + + * `color.to-gamut($color, $space: null)` returns `$color` constrained to its + space's gamut (or to `$space`'s gamut, if passed). This is generally not + recommended since even older browsers will display out-of-gamut colors as + best they can, but it may be necessary in some cases. + + * `color.space($color)`: Returns the name of `$color`'s color space. + + * `color.is-legacy($color)`: Returns whether `$color` is in a legacy color + space (`rgb`, `hsl`, or `hwb`). + + * `color.is-powerless($color, $channel, $space: null)`: Returns whether the + given `$channel` of `$color` is powerless in `$space` (or its own color + space). A channel is "powerless" if its value doesn't affect the way the + color is displayed, such as hue for a color with 0 chroma. + + * `color.is-missing($color, $channel)`: Returns whether `$channel`'s value is + missing in `$color`. Missing channels can be explicitly specified using the + special value `none` and can appear automatically when `color.to-space()` + returns a color with a powerless channel. Missing channels are usually + treated as 0, except when interpolating between two colors and in + `color.mix()` where they're treated as the same value as the other color. + +* Update existing functions to support color spaces: + + * `hsl()` and `color.hwb()` no longer forbid out-of-bounds values. Instead, + they follow the CSS spec by clamping them to within the allowed range. + + * `color.change()`, `color.adjust()`, and `color.scale()` now support all + channels of all color spaces. However, if you want to modify a channel + that's not in `$color`'s own color space, you have to explicitly specify the + space with the `$space` parameter. (For backwards-compatibility, this + doesn't apply to legacy channels of legacy colors—for example, you can still + adjust an `rgb` color's saturation without passing `$space: hsl`). + + * `color.mix()` and `color.invert()` now support the standard CSS algorithm + for interpolating between two colors (the same one that's used for gradients + and animations). To use this, pass the color space to use for interpolation + to the `$method` parameter. For polar color spaces like `hsl` and `oklch`, + this parameter also allows you to specify how hue interpolation is handled. + + * `color.complement()` now supports a `$space` parameter that indicates which + color space should be used to take the complement. + + * `color.grayscale()` now operates in the `oklch` space for non-legacy colors. + + * `color.ie-hex-str()` now automatically converts its color to the `rgb` space + and gamut-maps it so that it can continue to take colors from any color + space. + +[color spaces]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value + +### Dart API + +* Added a `ColorSpace` class which represents the various color spaces defined + in the CSS spec. + +* Added `SassColor.space` which returns a color's color space. + +* Added `SassColor.channels` and `.channelsOrNull` which returns a list + of channel values, with missing channels converted to 0 or exposed as null, + respectively. + +* Added `SassColor.isLegacy`, `.isInGamut`, `.channel()`, `.isChannelMissing()`, + `.isChannelPowerless()`, `.toSpace()`, `.toGamut()`, `.changeChannels()`, and + `.interpolate()` which do the same thing as the Sass functions of the + corresponding names. + +* `SassColor.rgb()` now allows out-of-bounds and non-integer arguments. + +* `SassColor.hsl()` and `.hwb()` now allow out-of-bounds arguments. + +* Added `SassColor.hwb()`, `.srgb()`, `.srgbLinear()`, `.displayP3()`, + `.a98Rgb()`, `.prophotoRgb()`, `.rec2020()`, `.xyzD50()`, `.xyzD65()`, + `.lab()`, `.lch()`, `.oklab()`, `.oklch()`, and `.forSpace()` constructors. + +* Deprecated `SassColor.red`, `.green`, `.blue`, `.hue`, `.saturation`, + `.lightness`, `.whiteness`, and `.blackness` in favor of + `SassColor.channel()`. + +* Deprecated `SassColor.changeRgb()`, `.changeHsl()`, and `.changeHwb()` in + favor of `SassColor.changeChannels()`. + +* Added `SassNumber.convertValueToUnit()` as a shorthand for + `SassNumber.convertValue()` with a single numerator. + +* Added `InterpolationMethod` and `HueInterpolationMethod` which collectively + represent the method to use to interpolate two colors. + ## 1.56.2 ### Embedded Sass diff --git a/lib/sass.dart b/lib/sass.dart index d29e53824..157383dee 100644 --- a/lib/sass.dart +++ b/lib/sass.dart @@ -29,7 +29,12 @@ export 'src/importer.dart'; export 'src/logger.dart'; export 'src/syntax.dart'; export 'src/value.dart' - hide ColorFormat, SassApiColor, SassApiValue, SpanColorFormat; + hide + ColorChannel, + ColorFormat, + LinearChannel, + SassApiColorSpace, + SpanColorFormat; export 'src/visitor/serialize.dart' show OutputStyle; export 'src/evaluation_context.dart' show warn; diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index 5ff3bad1b..30409fab3 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -19,6 +19,11 @@ import '../value.dart'; /// filter declaration. final _microsoftFilterStart = RegExp(r'^[a-zA-Z]+\s*='); +/// If a special number string is detected in these color spaces, even if they +/// were using the one-argument function syntax, we convert it to the three- or +/// four- argument comma-separated syntax for broader browser compatibility. +const _specialCommaSpaces = {ColorSpace.rgb, ColorSpace.hsl}; + /// The global definitions of Sass color functions. final global = UnmodifiableListView([ // ### RGB @@ -28,46 +33,22 @@ final global = UnmodifiableListView([ r"$red, $green, $blue, $alpha": (arguments) => _rgb("rgb", arguments), r"$red, $green, $blue": (arguments) => _rgb("rgb", arguments), r"$color, $alpha": (arguments) => _rgbTwoArg("rgb", arguments), - r"$channels": (arguments) { - var parsed = _parseChannels( - "rgb", [r"$red", r"$green", r"$blue"], arguments.first); - return parsed is SassString ? parsed : _rgb("rgb", parsed as List); - } + r"$channels": (arguments) => _parseChannels("rgb", arguments[0], + space: ColorSpace.rgb, name: 'channels') }), BuiltInCallable.overloadedFunction("rgba", { r"$red, $green, $blue, $alpha": (arguments) => _rgb("rgba", arguments), r"$red, $green, $blue": (arguments) => _rgb("rgba", arguments), r"$color, $alpha": (arguments) => _rgbTwoArg("rgba", arguments), - r"$channels": (arguments) { - var parsed = _parseChannels( - "rgba", [r"$red", r"$green", r"$blue"], arguments.first); - return parsed is SassString - ? parsed - : _rgb("rgba", parsed as List); - } + r"$channels": (arguments) => _parseChannels('rgba', arguments[0], + space: ColorSpace.rgb, name: 'channels') }), - _function("invert", r"$color, $weight: 100%", (arguments) { - var weight = arguments[1].assertNumber("weight"); - if (arguments[0] is SassNumber) { - if (weight.value != 100 || !weight.hasUnit("%")) { - throw "Only one argument may be passed to the plain-CSS invert() " - "function."; - } - - return _functionString("invert", arguments.take(1)); - } - - var color = arguments[0].assertColor("color"); - var inverse = color.changeRgb( - red: 255 - color.red, green: 255 - color.green, blue: 255 - color.blue); - - return _mixColors(inverse, color, weight); - }), + _function("invert", r"$color, $weight: 100%, $space: null", _invert), // ### HSL - _hue, _saturation, _lightness, _complement, + _hue, _saturation, _lightness, BuiltInCallable.overloadedFunction("hsl", { r"$hue, $saturation, $lightness, $alpha": (arguments) => @@ -82,11 +63,8 @@ final global = UnmodifiableListView([ throw SassScriptException(r"Missing argument $lightness."); } }, - r"$channels": (arguments) { - var parsed = _parseChannels( - "hsl", [r"$hue", r"$saturation", r"$lightness"], arguments.first); - return parsed is SassString ? parsed : _hsl("hsl", parsed as List); - } + r"$channels": (arguments) => _parseChannels('hsl', arguments[0], + space: ColorSpace.hsl, name: 'channels') }), BuiltInCallable.overloadedFunction("hsla", { @@ -100,23 +78,16 @@ final global = UnmodifiableListView([ throw SassScriptException(r"Missing argument $lightness."); } }, - r"$channels": (arguments) { - var parsed = _parseChannels( - "hsla", [r"$hue", r"$saturation", r"$lightness"], arguments.first); - return parsed is SassString - ? parsed - : _hsl("hsla", parsed as List); - } + r"$channels": (arguments) => _parseChannels('hsla', arguments[0], + space: ColorSpace.hsl, name: 'channels') }), - _function("grayscale", r"$color", (arguments) { - if (arguments[0] is SassNumber) { - return _functionString('grayscale', arguments); - } - - var color = arguments[0].assertColor("color"); - return color.changeHsl(saturation: 0); - }), + _function( + "grayscale", + r"$color", + (arguments) => arguments[0] is SassNumber + ? _functionString('grayscale', arguments) + : _grayscale(arguments[0])), _function("adjust-hue", r"$color, $degrees", (arguments) { var color = arguments[0].assertColor("color"); @@ -211,6 +182,46 @@ final global = UnmodifiableListView([ return SassNumber(color.alpha); }), + // ### Color Spaces + + _function( + "color", + r"$description", + (arguments) => + _parseChannels("color", arguments[0], name: 'description')), + + _function( + "hwb", + r"$channels", + (arguments) => _parseChannels("hwb", arguments[0], + space: ColorSpace.hwb, name: 'channels')), + + _function( + "lab", + r"$channels", + (arguments) => _parseChannels("lab", arguments[0], + space: ColorSpace.lab, name: 'channels')), + + _function( + "lch", + r"$channels", + (arguments) => _parseChannels("lch", arguments[0], + space: ColorSpace.lch, name: 'channels')), + + _function( + "oklab", + r"$channels", + (arguments) => _parseChannels("oklab", arguments[0], + space: ColorSpace.oklab, name: 'channels')), + + _function( + "oklch", + r"$channels", + (arguments) => _parseChannels("oklch", arguments[0], + space: ColorSpace.oklch, name: 'channels')), + + _complement, + // ### Miscellaneous _ieHexStr, _adjust.withName("adjust-color"), @@ -223,33 +234,21 @@ final module = BuiltInModule("color", functions: [ // ### RGB _red, _green, _blue, _mix, - _function("invert", r"$color, $weight: 100%", (arguments) { - var weight = arguments[1].assertNumber("weight"); - if (arguments[0] is SassNumber) { - if (weight.value != 100 || !weight.hasUnit("%")) { - throw "Only one argument may be passed to the plain-CSS invert() " - "function."; - } - - var result = _functionString("invert", arguments.take(1)); + _function("invert", r"$color, $weight: 100%, $space: null", (arguments) { + var result = _invert(arguments); + if (result is SassString) { warn( "Passing a number (${arguments[0]}) to color.invert() is " "deprecated.\n" "\n" "Recommendation: $result", deprecation: true); - return result; } - - var color = arguments[0].assertColor("color"); - var inverse = color.changeRgb( - red: 255 - color.red, green: 255 - color.green, blue: 255 - color.blue); - - return _mixColors(inverse, color, weight); + return result; }), // ### HSL - _hue, _saturation, _lightness, _complement, + _hue, _saturation, _lightness, _removedColorFunction("adjust-hue", "hue"), _removedColorFunction("lighten", "lightness"), _removedColorFunction("darken", "lightness", negative: true), @@ -268,24 +267,21 @@ final module = BuiltInModule("color", functions: [ return result; } - var color = arguments[0].assertColor("color"); - return color.changeHsl(saturation: 0); + return _grayscale(arguments[0]); }), // ### HWB BuiltInCallable.overloadedFunction("hwb", { - r"$hue, $whiteness, $blackness, $alpha: 1": (arguments) => _hwb(arguments), - r"$channels": (arguments) { - var parsed = _parseChannels( - "hwb", [r"$hue", r"$whiteness", r"$blackness"], arguments.first); - - // `hwb()` doesn't (currently) support special number or variable strings. - if (parsed is SassString) { - throw SassScriptException('Expected numeric channels, got "$parsed".'); - } else { - return _hwb(parsed as List); - } - } + r"$hue, $whiteness, $blackness, $alpha: 1": (arguments) => _parseChannels( + 'hwb', + SassList([ + SassList( + [arguments[0], arguments[1], arguments[2]], ListSeparator.space), + arguments[3] + ], ListSeparator.slash), + space: ColorSpace.hwb), + r"$channels": (arguments) => _parseChannels('hwb', arguments[0], + space: ColorSpace.hwb, name: 'channels') }), _function( @@ -361,6 +357,85 @@ final module = BuiltInModule("color", functions: [ return SassNumber(color.alpha); }), + // ### Color Spaces + _function( + "space", + r"$color", + (arguments) => SassString(arguments.first.assertColor("color").space.name, + quotes: false)), + + _function( + "to-space", + r"$color, $space", + (arguments) => + _colorInSpace(arguments[0], arguments[1].assertString("space"))), + + _function("is-legacy", r"$color", + (arguments) => SassBoolean(arguments[0].assertColor("color").isLegacy)), + + _function( + "is-in-gamut", + r"$color, $space: null", + (arguments) => + SassBoolean(_colorInSpace(arguments[0], arguments[1]).isInGamut)), + + _function("to-gamut", r"$color, $space: null", (arguments) { + var color = arguments[0].assertColor("color"); + var space = _spaceOrDefault(color, arguments[1], "space"); + if (!space.isBounded) return color; + + return color + .toSpace(space == ColorSpace.hsl || space == ColorSpace.hwb + ? ColorSpace.srgb + : space) + .toGamut() + .toSpace(space); + }), + + _function("channel", r"$color, $channel, $space: null", (arguments) { + var color = _colorInSpace(arguments[0], arguments[2]); + var channelName = arguments[1].assertString("channel").text.toLowerCase(); + if (channelName == "alpha") return SassNumber(color.alpha); + + var channelIndex = color.space.channels + .indexWhere((channel) => channel.name == channelName); + if (channelIndex == -1) { + throw SassScriptException( + "Color $color has no channel named $channelName.", "channel"); + } + + var channelInfo = color.space.channels[channelIndex]; + var channelValue = color.channels[channelIndex]; + + return channelInfo is LinearChannel + ? SassNumber(channelValue, + channelInfo.min == 0 && channelInfo.max == 100 ? '%' : null) + : SassNumber(channelValue, 'deg'); + }), + + _function("same", r"$color1, $color2", (arguments) { + var color1 = arguments[0].assertColor('color1'); + var color2 = arguments[1].assertColor('color2'); + + // Convert both colors into the same space to compare them. Usually we + // just use color1's space, but since HSL and HWB can't represent + // out-of-gamut colors we use RGB for all legacy color spaces. + var targetSpace = color1.isLegacy ? ColorSpace.rgb : color1.space; + return SassBoolean( + color1.toSpace(targetSpace) == color2.toSpace(targetSpace)); + }), + + _function( + "is-powerless", + r"$color, $channel, $space: null", + (arguments) => SassBoolean(_colorInSpace(arguments[0], arguments[2]) + .isChannelPowerless( + arguments[1].assertString("channel").text.toLowerCase(), + colorName: "color", + channelName: "channel"))), + + _complement, + // Miscellaneous _adjust, _scale, _change, _ieHexStr ]); @@ -379,11 +454,32 @@ final _blue = _function("blue", r"$color", (arguments) { return SassNumber(arguments.first.assertColor("color").blue); }); -final _mix = _function("mix", r"$color1, $color2, $weight: 50%", (arguments) { +final _mix = _function("mix", r"$color1, $color2, $weight: 50%, $method: null", + (arguments) { var color1 = arguments[0].assertColor("color1"); var color2 = arguments[1].assertColor("color2"); var weight = arguments[2].assertNumber("weight"); - return _mixColors(color1, color2, weight); + + if (arguments[3] != sassNull) { + return color1.interpolate( + color2, InterpolationMethod.fromValue(arguments[3], "method"), + weight: weight.valueInRangeWithUnit(0, 100, "weight", "%") / 100); + } + + _checkPercent(weight, "weight"); + if (!color1.isLegacy) { + throw SassScriptException( + "To use color.mix() with non-legacy color $color1, you must provide a " + "\$method.", + "color1"); + } else if (!color2.isLegacy) { + throw SassScriptException( + "To use color.mix() with non-legacy color $color1, you must provide a " + "\$method.", + "color1"); + } + + return _mixLegacy(color1, color2, weight); }); // ### HSL @@ -403,11 +499,125 @@ final _lightness = _function( (arguments) => SassNumber(arguments.first.assertColor("color").lightness, "%")); -final _complement = _function("complement", r"$color", (arguments) { +// ### Color Spaces + +final _complement = + _function("complement", r"$color, $space: null", (arguments) { var color = arguments[0].assertColor("color"); - return color.changeHsl(hue: color.hue + 180); + var space = arguments[1] == sassNull + ? ColorSpace.hsl + : ColorSpace.fromName( + (arguments[1].assertString("space")..assertUnquoted("space")).text, + "space"); + + var inSpace = color.toSpace(space); + return inSpace.changeChannels({'hue': inSpace.channel('hue') + 180}).toSpace( + color.space); }); +/// The implementation of the `invert()` function. +Value _invert(List arguments) { + var weightNumber = arguments[1].assertNumber("weight"); + if (arguments[0] is SassNumber) { + if (weightNumber.value != 100 || !weightNumber.hasUnit("%")) { + throw "Only one argument may be passed to the plain-CSS invert() " + "function."; + } + + var result = _functionString("invert", arguments.take(1)); + return result; + } + + var color = arguments[0].assertColor("color"); + if (arguments[2] == sassNull) { + if (!color.isLegacy) { + throw SassScriptException( + "To use color.invert() with non-legacy color $color, you must provide " + "a \$space.", + "color"); + } + + _checkPercent(weightNumber, "weight"); + var rgb = color.toSpace(ColorSpace.rgb); + return _mixLegacy( + SassColor.rgb(255.0 - rgb.channel0, 255.0 - rgb.channel1, + 255.0 - rgb.channel2, color.alpha), + color, + weightNumber); + } + + var space = ColorSpace.fromName( + (arguments[2].assertString('space')..assertUnquoted('space')).text, + 'space'); + var weight = weightNumber.valueInRangeWithUnit(0, 100, 'weight', '%') / 100; + if (fuzzyEquals(weight, 0)) return color; + + var inSpace = color.toSpace(space); + SassColor inverted; + switch (space) { + case ColorSpace.hwb: + inverted = SassColor.hwb((inSpace.channel0 + 180) % 360, inSpace.channel2, + inSpace.channel1, inSpace.alpha); + break; + + case ColorSpace.hsl: + inverted = SassColor.hsl((inSpace.channel0 + 180) % 360, inSpace.channel1, + 100 - inSpace.channel2, inSpace.alpha); + break; + + case ColorSpace.lch: + inverted = SassColor.lch(100 - inSpace.channel0, inSpace.channel1, + (inSpace.channel2 + 180) % 360, inSpace.alpha); + break; + + case ColorSpace.oklch: + inverted = SassColor.oklch(1 - inSpace.channel0, inSpace.channel1, + (inSpace.channel2 + 180) % 360, inSpace.alpha); + break; + + default: + var channel0 = space.channels[0] as LinearChannel; + var channel1 = space.channels[1] as LinearChannel; + var channel2 = space.channels[2] as LinearChannel; + inverted = SassColor.forSpaceInternal( + space, + _invertChannel(channel0, inSpace.channel0), + _invertChannel(channel1, inSpace.channel1), + _invertChannel(channel2, inSpace.channel2), + inSpace.alpha); + break; + } + + if (fuzzyEquals(weight, 1)) return inverted; + if (!InterpolationMethod.supportedSpaces.contains(space)) { + throw SassScriptException( + "Color space $space can't be used for interpolation.", "space"); + } + + return color.interpolate(inverted, InterpolationMethod(space), + weight: 1 - weight); +} + +/// Returns the inverse of the given [value] in a linear color channel. +double _invertChannel(LinearChannel channel, double value) => + channel.min < 0 ? -value : channel.max - value; + +/// The implementation of the `grayscale()` function, without any logic for the +/// plain-CSS `grayscale()` syntax. +Value _grayscale(Value colorArg) { + var color = colorArg.assertColor("color"); + + if (color.isLegacy) { + var hsl = color.toSpace(ColorSpace.hsl); + return SassColor.hsl(hsl.channel0, 0, hsl.channel2, hsl.alpha) + .toSpace(color.space); + } else { + var oklch = color.toSpace(ColorSpace.oklch); + return SassColor.oklch(oklch.channel0, 0, oklch.channel2, oklch.alpha) + .toSpace(color.space); + } +} + // Miscellaneous final _adjust = _function("adjust", r"$color, $kwargs...", @@ -420,12 +630,13 @@ final _change = _function("change", r"$color, $kwargs...", (arguments) => _updateComponents(arguments, change: true)); final _ieHexStr = _function("ie-hex-str", r"$color", (arguments) { - var color = arguments[0].assertColor("color"); - String hexString(int component) => - component.toRadixString(16).padLeft(2, '0').toUpperCase(); + var color = + arguments[0].assertColor("color").toSpace(ColorSpace.rgb).toGamut(); + String hexString(double component) => + fuzzyRound(component).toRadixString(16).padLeft(2, '0').toUpperCase(); return SassString( - "#${hexString(fuzzyRound(color.alpha * 255))}${hexString(color.red)}" - "${hexString(color.green)}${hexString(color.blue)}", + "#${hexString(color.alpha * 255)}${hexString(color.channel0)}" + "${hexString(color.channel1)}${hexString(color.channel2)}", quotes: false); }); @@ -437,7 +648,6 @@ SassColor _updateComponents(List arguments, {bool change = false, bool adjust = false, bool scale = false}) { assert([change, adjust, scale].where((x) => x).length == 1); - var color = arguments[0].assertColor("color"); var argumentList = arguments[1] as SassArgumentList; if (argumentList.asList.isNotEmpty) { throw SassScriptException( @@ -446,106 +656,181 @@ SassColor _updateComponents(List arguments, } var keywords = Map.of(argumentList.keywords); - - /// Gets and validates the parameter with [name] from keywords. - /// - /// [max] should be 255 for RGB channels, 1 for the alpha channel, and 100 - /// for saturation, lightness, whiteness, and blackness. - double? getParam(String name, num max, - {bool checkPercent = false, - bool assertPercent = false, - bool checkUnitless = false}) { - var number = keywords.remove(name)?.assertNumber(name); - if (number == null) return null; - if (!scale && checkUnitless) { - if (number.hasUnits) { - warn( - "\$$name: Passing a number with unit ${number.unitString} is " - "deprecated.\n" - "\n" - "To preserve current behavior: ${number.unitSuggestion(name)}\n" - "\n" - "More info: https://sass-lang.com/d/function-units", - deprecation: true); - } + var originalColor = arguments[0].assertColor("color"); + var spaceKeyword = keywords.remove("space")?.assertString("space") + ?..assertUnquoted("space"); + + var alphaArg = keywords.remove('alpha')?.assertNumber('alpha'); + + // For backwards-compatibility, we allow legacy colors to modify channels in + // any legacy color space. + var color = + spaceKeyword == null && originalColor.isLegacy && keywords.isNotEmpty + ? _sniffLegacyColorSpace(keywords).andThen(originalColor.toSpace) ?? + originalColor + : _colorInSpace(originalColor, spaceKeyword ?? sassNull); + + var oldChannels = color.channels; + var channelArgs = List.filled(oldChannels.length, null); + var channelInfo = color.space.channels; + for (var entry in keywords.entries) { + var channelIndex = channelInfo.indexWhere((info) => entry.key == info.name); + if (channelIndex == -1) { + throw SassScriptException( + "Color space ${color.space} doesn't have a channel with this name.", + entry.key); } - if (!scale && checkPercent) _checkPercent(number, name); - if (scale || assertPercent) number.assertUnit("%", name); - if (scale) max = 100; - return scale || assertPercent - ? number.valueInRange(change ? 0 : -max, max, name) - : number.valueInRangeWithUnit( - change ? 0 : -max, max, name, checkPercent ? '%' : ''); + + channelArgs[channelIndex] = entry.value.assertNumber(entry.key); } - var alpha = getParam("alpha", 1, checkUnitless: true); - var red = getParam("red", 255); - var green = getParam("green", 255); - var blue = getParam("blue", 255); + var result = change + ? _changeColor(color, channelArgs, alphaArg) + : scale + ? _scaleColor(color, channelArgs, alphaArg) + : _adjustColor(color, channelArgs, alphaArg); - var hue = scale - ? null - : keywords.remove("hue").andThen((hue) => _angleValue(hue, "hue")); + return result.toSpace(originalColor.space); +} - var saturation = getParam("saturation", 100, checkPercent: true); - var lightness = getParam("lightness", 100, checkPercent: true); - var whiteness = getParam("whiteness", 100, assertPercent: true); - var blackness = getParam("blackness", 100, assertPercent: true); +/// Returns a copy of [color] with its channel values replaced by those in +/// [channelArgs] and [alphaArg], if specified. +SassColor _changeColor( + SassColor color, List channelArgs, SassNumber? alphaArg) { + var latterUnits = + color.space == ColorSpace.hsl || color.space == ColorSpace.hwb + ? '%' + : null; + return _colorFromChannels( + color.space, + channelArgs[0] ?? SassNumber(color.channel0), + channelArgs[1] ?? SassNumber(color.channel1, latterUnits), + channelArgs[2] ?? SassNumber(color.channel2, latterUnits), + alphaArg.andThen( + (alphaArg) => _percentageOrUnitless(alphaArg, 1, 'alpha')) ?? + color.alpha); +} - if (keywords.isNotEmpty) { - throw SassScriptException( - "No ${pluralize('argument', keywords.length)} named " - "${toSentence(keywords.keys.map((name) => '\$$name'), 'or')}."); +/// Returns a copy of [color] with its channel values scaled by the values in +/// [channelArgs] and [alphaArg], if specified. +SassColor _scaleColor( + SassColor color, List channelArgs, SassNumber? alphaArg) => + SassColor.forSpaceInternal( + color.space, + _scaleChannel(color.space.channels[0], color.channel0, channelArgs[0]), + _scaleChannel(color.space.channels[1], color.channel1, channelArgs[1]), + _scaleChannel(color.space.channels[2], color.channel2, channelArgs[2]), + _scaleChannel(ColorChannel.alpha, color.alpha, alphaArg)); + +/// Returns [oldValue] scaled by [factorArg] according to the definition in +/// [channel]. +double _scaleChannel( + ColorChannel channel, double oldValue, SassNumber? factorArg) { + if (factorArg == null) return oldValue; + if (channel is! LinearChannel) { + throw SassScriptException("Channel isn't scalable.", channel.name); } - var hasRgb = red != null || green != null || blue != null; - var hasSL = saturation != null || lightness != null; - var hasWB = whiteness != null || blackness != null; - - if (hasRgb && (hasSL || hasWB || hue != null)) { - throw SassScriptException("RGB parameters may not be passed along with " - "${hasWB ? 'HWB' : 'HSL'} parameters."); + var factor = (factorArg..assertUnit('%', channel.name)) + .valueInRangeWithUnit(-100, 100, channel.name, '%') / + 100; + if (factor == 0) { + return oldValue; + } else if (factor > 0) { + return oldValue >= channel.max + ? oldValue + : oldValue + (channel.max - oldValue) * factor; + } else { + return oldValue <= channel.min + ? oldValue + : oldValue + (oldValue - channel.min) * factor; } +} - if (hasSL && hasWB) { - throw SassScriptException( - "HSL parameters may not be passed along with HWB parameters."); +/// Returns a copy of [color] with its channel values adjusted by the values in +/// [channelArgs] and [alphaArg], if specified. +SassColor _adjustColor( + SassColor color, List channelArgs, SassNumber? alphaArg) => + SassColor.forSpaceInternal( + color.space, + _adjustChannel(color.space, color.space.channels[0], color.channel0, + channelArgs[0]), + _adjustChannel(color.space, color.space.channels[1], color.channel1, + channelArgs[1]), + _adjustChannel(color.space, color.space.channels[2], color.channel2, + channelArgs[2]), + // The color space doesn't matter for alpha, as long as it's not + // strictly bounded. + fuzzyClamp( + _adjustChannel( + ColorSpace.lab, ColorChannel.alpha, color.alpha, alphaArg), + 0, + 1)); + +/// Returns [oldValue] adjusted by [adjustmentArg] according to the definition +/// in [space]'s [channel]. +double _adjustChannel(ColorSpace space, ColorChannel channel, double oldValue, + SassNumber? adjustmentArg) { + if (adjustmentArg == null) return oldValue; + + if ((space == ColorSpace.hsl || space == ColorSpace.hwb) && + channel is! LinearChannel) { + // `_channelFromValue` expects all hue values to be compatible with `deg`, + // but we're still in the deprecation period where we allow non-`deg` values + // for HSL and HWB so we have to handle that ahead-of-time. + adjustmentArg = SassNumber(_angleValue(adjustmentArg, 'hue')); + } else if (space == ColorSpace.hsl && channel is LinearChannel) { + // `_channelFromValue` expects lightness/saturation to be `%`, but we're + // still in the deprecation period where we allow non-`%` values so we have + // to handle that ahead-of-time. + _checkPercent(adjustmentArg, channel.name); + adjustmentArg = SassNumber(adjustmentArg.value, '%'); + } else if (channel == ColorChannel.alpha && adjustmentArg.hasUnits) { + // `_channelFromValue` expects alpha to be unitless or `%`, but we're still + // in the deprecation period where we allow other values (and interpret `%` + // as unitless) so we have to handle that ahead-of-time. + warn( + "\$alpha: Passing a number with unit ${adjustmentArg.unitString} is " + "deprecated.\n" + "\n" + "To preserve current behavior: " + "${adjustmentArg.unitSuggestion('alpha')}\n" + "\n" + "More info: https://sass-lang.com/d/function-units", + deprecation: true); + adjustmentArg = SassNumber(adjustmentArg.value); } - /// Updates [current] based on [param], clamped within [max]. - double updateValue(double current, double? param, num max) { - if (param == null) return current; - if (change) return param; - if (adjust) return (current + param).clamp(0, max).toDouble(); - return current + (param > 0 ? max - current : current) * (param / 100); - } + var result = oldValue + _channelFromValue(channel, adjustmentArg)!; + return space.isStrictlyBounded && channel is LinearChannel + ? fuzzyClamp(result, channel.min, channel.max) + : result; +} - int updateRgb(int current, double? param) => - fuzzyRound(updateValue(current.toDouble(), param, 255)); - - if (hasRgb) { - return color.changeRgb( - red: updateRgb(color.red, red), - green: updateRgb(color.green, green), - blue: updateRgb(color.blue, blue), - alpha: updateValue(color.alpha, alpha, 1)); - } else if (hasWB) { - return color.changeHwb( - hue: change ? hue : color.hue + (hue ?? 0), - whiteness: updateValue(color.whiteness, whiteness, 100), - blackness: updateValue(color.blackness, blackness, 100), - alpha: updateValue(color.alpha, alpha, 1)); - } else if (hue != null || hasSL) { - return color.changeHsl( - hue: change ? hue : color.hue + (hue ?? 0), - saturation: updateValue(color.saturation, saturation, 100), - lightness: updateValue(color.lightness, lightness, 100), - alpha: updateValue(color.alpha, alpha, 1)); - } else if (alpha != null) { - return color.changeAlpha(updateValue(color.alpha, alpha, 1)); - } else { - return color; +/// Given a map of arguments passed to [_updateComponents] for a legacy color, +/// determines whether it's updating the color as RGB, HSL, or HWB. +/// +/// Returns `null` if [keywords] contains no keywords for any of the legacy +/// color spaces. +ColorSpace? _sniffLegacyColorSpace(Map keywords) { + for (var key in keywords.keys) { + switch (key) { + case "red": + case "green": + case "blue": + return ColorSpace.rgb; + + case "saturation": + case "lightness": + return ColorSpace.hsl; + + case "whiteness": + case "blackness": + return ColorSpace.hwb; + } } + + return keywords.containsKey("hue") ? ColorSpace.hsl : null; } /// Returns a string representation of [name] called with [arguments], as though @@ -574,6 +859,8 @@ BuiltInCallable _removedColorFunction(String name, String argument, "More info: https://sass-lang.com/documentation/functions/color#$name"); }); +/// The implementation of the three- and four-argument `rgb()` and `rgba()` +/// functions. Value _rgb(String name, List arguments) { var alpha = arguments.length > 3 ? arguments[3] : null; if (arguments[0].isSpecialNumber || @@ -583,47 +870,47 @@ Value _rgb(String name, List arguments) { return _functionString(name, arguments); } - var red = arguments[0].assertNumber("red"); - var green = arguments[1].assertNumber("green"); - var blue = arguments[2].assertNumber("blue"); - - return SassColor.rgbInternal( - fuzzyRound(_percentageOrUnitless(red, 255, "red")), - fuzzyRound(_percentageOrUnitless(green, 255, "green")), - fuzzyRound(_percentageOrUnitless(blue, 255, "blue")), - alpha.andThen((alpha) => - _percentageOrUnitless(alpha.assertNumber("alpha"), 1, "alpha")), - ColorFormat.rgbFunction); + return _colorFromChannels( + ColorSpace.rgb, + arguments[0].assertNumber("red"), + arguments[1].assertNumber("green"), + arguments[2].assertNumber("blue"), + alpha == null + ? 1.0 + : _percentageOrUnitless(alpha.assertNumber("alpha"), 1, "alpha") + .clamp(0, 1), + fromRgbFunction: true); } +/// The implementation of the two-argument `rgb()` and `rgba()` functions. Value _rgbTwoArg(String name, List arguments) { // rgba(var(--foo), 0.5) is valid CSS because --foo might be `123, 456, 789` // and functions are parsed after variable substitution. - if (arguments[0].isVar) { + var first = arguments[0]; + var second = arguments[1]; + if (first.isVar || (first is! SassColor && second.isVar)) { return _functionString(name, arguments); - } else if (arguments[1].isVar) { - var first = arguments[0]; - if (first is SassColor) { - return SassString( - "$name(${first.red}, ${first.green}, ${first.blue}, " - "${arguments[1].toCssString()})", - quotes: false); - } else { - return _functionString(name, arguments); - } - } else if (arguments[1].isSpecialNumber) { - var color = arguments[0].assertColor("color"); - return SassString( - "$name(${color.red}, ${color.green}, ${color.blue}, " - "${arguments[1].toCssString()})", - quotes: false); } - var color = arguments[0].assertColor("color"); + var color = first.assertColor("color"); + color.assertLegacy("color"); + color = color.toSpace(ColorSpace.rgb); + if (second.isSpecialNumber) { + return _functionString(name, [ + SassNumber(color.channel('red')), + SassNumber(color.channel('green')), + SassNumber(color.channel('blue')), + arguments[1] + ]); + } + var alpha = arguments[1].assertNumber("alpha"); - return color.changeAlpha(_percentageOrUnitless(alpha, 1, "alpha")); + return color + .changeAlpha(_percentageOrUnitless(alpha, 1, "alpha").clamp(0, 1)); } +/// The implementation of the three- and four-argument `hsl()` and `hsla()` +/// functions. Value _hsl(String name, List arguments) { var alpha = arguments.length > 3 ? arguments[3] : null; if (arguments[0].isSpecialNumber || @@ -633,20 +920,15 @@ Value _hsl(String name, List arguments) { return _functionString(name, arguments); } - var hue = _angleValue(arguments[0], "hue"); - var saturation = arguments[1].assertNumber("saturation"); - var lightness = arguments[2].assertNumber("lightness"); - - _checkPercent(saturation, "saturation"); - _checkPercent(lightness, "lightness"); - - return SassColor.hslInternal( - hue, - saturation.value.clamp(0, 100), - lightness.value.clamp(0, 100), - alpha.andThen((alpha) => - _percentageOrUnitless(alpha.assertNumber("alpha"), 1, "alpha")), - ColorFormat.hslFunction); + return _colorFromChannels( + ColorSpace.hsl, + arguments[0].assertNumber("hue"), + arguments[1].assertNumber("saturation"), + arguments[2].assertNumber("lightness"), + alpha == null + ? 1.0 + : _percentageOrUnitless(alpha.assertNumber("alpha"), 1, "alpha") + .clamp(0, 1)); } /// Asserts that [angle] is a number and returns its value in degrees. @@ -679,107 +961,15 @@ void _checkPercent(SassNumber number, String name) { deprecation: true); } -/// Create an HWB color from the given [arguments]. -Value _hwb(List arguments) { - var alpha = arguments.length > 3 ? arguments[3] : null; - var hue = _angleValue(arguments[0], "hue"); - var whiteness = arguments[1].assertNumber("whiteness"); - var blackness = arguments[2].assertNumber("blackness"); - - whiteness.assertUnit("%", "whiteness"); - blackness.assertUnit("%", "blackness"); - - return SassColor.hwb( - hue, - whiteness.valueInRange(0, 100, "whiteness"), - blackness.valueInRange(0, 100, "blackness"), - alpha.andThen((alpha) => - _percentageOrUnitless(alpha.assertNumber("alpha"), 1, "alpha"))); -} - -Object /* SassString | List */ _parseChannels( - String name, List argumentNames, Value channels) { - if (channels.isVar) return _functionString(name, [channels]); - - var originalChannels = channels; - Value? alphaFromSlashList; - if (channels.separator == ListSeparator.slash) { - var list = channels.asList; - if (list.length != 2) { - throw SassScriptException( - "Only 2 slash-separated elements allowed, but ${list.length} " - "${pluralize('was', list.length, plural: 'were')} passed."); - } - - channels = list[0]; - - alphaFromSlashList = list[1]; - if (!alphaFromSlashList.isSpecialNumber) { - alphaFromSlashList.assertNumber("alpha"); - } - if (list[0].isVar) return _functionString(name, [originalChannels]); - } - - var isCommaSeparated = channels.separator == ListSeparator.comma; - var isBracketed = channels.hasBrackets; - if (isCommaSeparated || isBracketed) { - var buffer = StringBuffer(r"$channels must be"); - if (isBracketed) buffer.write(" an unbracketed"); - if (isCommaSeparated) { - buffer.write(isBracketed ? "," : " a"); - buffer.write(" space-separated"); - } - buffer.write(" list."); - throw SassScriptException(buffer.toString()); - } - - var list = channels.asList; - if (list.length > 3) { - throw SassScriptException("Only 3 elements allowed, but ${list.length} " - "were passed."); - } else if (list.length < 3) { - if (list.any((value) => value.isVar) || - (list.isNotEmpty && _isVarSlash(list.last))) { - return _functionString(name, [originalChannels]); - } else { - var argument = argumentNames[list.length]; - throw SassScriptException("Missing element $argument."); - } - } - - if (alphaFromSlashList != null) return [...list, alphaFromSlashList]; - - var maybeSlashSeparated = list[2]; - if (maybeSlashSeparated is SassNumber) { - var slash = maybeSlashSeparated.asSlash; - if (slash == null) return list; - return [list[0], list[1], slash.item1, slash.item2]; - } else if (maybeSlashSeparated is SassString && - !maybeSlashSeparated.hasQuotes && - maybeSlashSeparated.text.contains("/")) { - return _functionString(name, [channels]); - } else { - return list; - } -} - -/// Returns whether [value] is an unquoted string that start with `var(` and -/// contains `/`. -bool _isVarSlash(Value value) => - value is SassString && - value.hasQuotes && - startsWithIgnoreCase(value.text, "var(") && - value.text.contains("/"); - /// Asserts that [number] is a percentage or has no units, and normalizes the /// value. /// -/// If [number] has no units, its value is clamped to be greater than `0` or -/// less than [max] and returned. If [number] is a percentage, it's scaled to be -/// within `0` and [max]. Otherwise, this throws a [SassScriptException]. +/// If [number] has no units, it's returned as-id. If it's a percentage, it's +/// scaled so that `0%` is `0` and `100%` is [max]. Otherwise, this throws a +/// [SassScriptException]. /// /// [name] is used to identify the argument in the error message. -double _percentageOrUnitless(SassNumber number, num max, String name) { +double _percentageOrUnitless(SassNumber number, double max, [String? name]) { double value; if (!number.hasUnits) { value = number.value; @@ -787,15 +977,20 @@ double _percentageOrUnitless(SassNumber number, num max, String name) { value = max * number.value / 100; } else { throw SassScriptException( - '\$$name: Expected $number to have no units or "%".'); + 'Expected $number to have no units or "%".', name); } - return value.clamp(0, max).toDouble(); + return value; } -/// Returns [color1] and [color2], mixed together and weighted by [weight]. -SassColor _mixColors(SassColor color1, SassColor color2, SassNumber weight) { - _checkPercent(weight, 'weight'); +/// Returns [color1] and [color2], mixed together and weighted by [weight] using +/// Sass's legacy color-mixing algorithm. +SassColor _mixLegacy(SassColor color1, SassColor color2, SassNumber weight) { + assert(color1.isLegacy, "[BUG] $color1 should be a legacy color."); + assert(color2.isLegacy, "[BUG] $color2 should be a legacy color."); + + var rgb1 = color1.toSpace(ColorSpace.rgb); + var rgb2 = color2.toSpace(ColorSpace.rgb); // This algorithm factors in both the user-provided weight (w) and the // difference between the alpha values of the two colors (a) to decide how @@ -829,7 +1024,7 @@ SassColor _mixColors(SassColor color1, SassColor color2, SassNumber weight) { var weight2 = 1 - weight1; return SassColor.rgb( - fuzzyRound(color1.red * weight1 + color2.red * weight2), + fuzzyRound(rgb1.channel0 * weight1 + rgb2.channel0 * weight2), fuzzyRound(color1.green * weight1 + color2.green * weight2), fuzzyRound(color1.blue * weight1 + color2.blue * weight2), color1.alpha * weightScale + color2.alpha * (1 - weightScale)); @@ -855,6 +1050,238 @@ SassColor _transparentize(List arguments) { .clamp(0, 1)); } +/// Returns the [colorUntyped] as a [SassColor] in the color space specified by +/// [spaceUntyped]. +/// +/// Throws a [SassScriptException] if either argument isn't the expected type or +/// if [spaceUntyped] isn't the name of a color space. If [spaceUntyped] is +/// `sassNull`, it defaults to the color's existing space. +SassColor _colorInSpace(Value colorUntyped, Value spaceUntyped) { + var color = colorUntyped.assertColor("color"); + if (spaceUntyped == sassNull) return color; + + var space = ColorSpace.fromName( + (spaceUntyped.assertString("space")..assertUnquoted("space")).text, + "space"); + return color.space == space ? color : color.toSpace(space); +} + +/// Returns the color space named by [space], or throws a [SassScriptException] +/// if [space] isn't the name of a color space. +/// +/// If [space] is `sassNull`, this returns [color]'s space instead. +/// +/// If [space] came from a function argument, [name] is the argument name +/// (without the `$`). It's used for error reporting. +ColorSpace _spaceOrDefault(SassColor color, Value space, [String? name]) => + space == sassNull + ? color.space + : ColorSpace.fromName( + (space.assertString(name)..assertUnquoted(name)).text, name); + +/// Parses the color components specified by [input] into a [SassColor], or +/// returns an unquoted [SassString] representing the plain CSS function call if +/// they contain a construct that can only be resolved at browse time. +/// +/// If [space] is passed, it's used as the color space to parse. Otherwise, this +/// expects the color space to be specified in [input] as for the `color()` +/// function. +/// +/// Throws a [SassScriptException] if [input] is invalid. If [input] came from a +/// function argument, [name] is the argument name (without the `$`). It's used +/// for error reporting. +Value _parseChannels(String functionName, Value input, + {ColorSpace? space, String? name}) { + if (input.isVar) return _functionString(functionName, [input]); + var inputList = input.assertCommonListStyle(name, allowSlash: true); + + Value components; + Value? alphaValue; + if (input.separator == ListSeparator.slash) { + if (inputList.length != 2) { + throw SassScriptException( + "Only 2 slash-separated elements allowed, but ${inputList.length} " + "${pluralize('was', inputList.length, plural: 'were')} passed."); + } else { + components = inputList[0]; + alphaValue = inputList[1]; + } + } else if (inputList.isEmpty) { + components = input; + } else { + components = input; + var last = inputList.last; + if (last is SassString && !last.hasQuotes && last.text.contains('/')) { + return _functionString(functionName, [input]); + } else if (last is SassNumber) { + var slash = last.asSlash; + if (slash != null) { + components = SassList( + [...inputList.take(inputList.length - 1), slash.item1], + ListSeparator.space); + alphaValue = slash.item2; + } + } + } + + List channels; + SassString? spaceName; + var componentList = components.assertCommonListStyle(name, allowSlash: false); + if (componentList.isEmpty) { + throw SassScriptException('Color component list may not be empty.', name); + } else if (components.isVar) { + channels = [components]; + } else { + if (space == null) { + spaceName = componentList.first.assertString(name)..assertUnquoted(name); + space = + spaceName.isVar ? null : ColorSpace.fromName(spaceName.text, name); + channels = [...componentList.skip(1)]; + + if (const { + ColorSpace.rgb, + ColorSpace.hsl, + ColorSpace.hwb, + ColorSpace.lab, + ColorSpace.lch, + ColorSpace.oklab, + ColorSpace.oklch + }.contains(space)) { + throw SassScriptException( + "The color() function doesn't support the color space $space. Use " + "the $space() function instead.", + name); + } + } else { + channels = componentList; + } + + for (var channel in channels) { + if (!channel.isSpecialNumber && + channel is! SassNumber && + !_isNone(channel)) { + var channelName = + space?.channels[channels.indexOf(channel)].name ?? 'channel'; + throw SassScriptException( + 'Expected $channelName $channel to be a number.', name); + } + } + } + + if (alphaValue?.isSpecialNumber ?? false) { + return channels.length == 3 && _specialCommaSpaces.contains(space) + ? _functionString(functionName, [...channels, alphaValue!]) + : _functionString(functionName, [input]); + } + + var alpha = alphaValue == null + ? 1.0 + : _percentageOrUnitless(alphaValue.assertNumber(name), 1, 'alpha') + .clamp(0, 1) + .toDouble(); + + // `space` will be null if either `components` or `spaceName` is a `var()`. + // Again, we check this here rather than returning early in those cases so + // that we can verify `alphaValue` even for colors we can't fully parse. + if (space == null) return _functionString(functionName, [input]); + if (channels.any((channel) => channel.isSpecialNumber)) { + return channels.length == 3 && _specialCommaSpaces.contains(space) + ? _functionString( + functionName, [...channels, if (alphaValue != null) alphaValue]) + : _functionString(functionName, [input]); + } + + if (channels.length != 3) { + throw SassScriptException( + 'The $space color space has 3 channels but $input has ' + '${channels.length}.', + name); + } + + return _colorFromChannels( + space, + // If a channel isn't a number, it must be `none`. + castOrNull(channels[0]), + castOrNull(channels[1]), + castOrNull(channels[2]), + alpha, + fromRgbFunction: space == ColorSpace.rgb); +} + +/// Creates a [SassColor] for the given [space] from the given channel values, +/// or throws a [SassScriptException] if the channel values are invalid. +SassColor _colorFromChannels(ColorSpace space, SassNumber? channel0, + SassNumber? channel1, SassNumber? channel2, double alpha, + {bool fromRgbFunction = false}) { + switch (space) { + case ColorSpace.hsl: + if (channel1 != null) _checkPercent(channel1, 'saturation'); + if (channel2 != null) _checkPercent(channel2, 'lightness'); + return SassColor.hsl( + channel0.andThen((channel0) => _angleValue(channel0, 'hue')), + channel1?.value.clamp(0, 100).toDouble(), + channel2?.value.clamp(0, 100).toDouble(), + alpha); + + case ColorSpace.hwb: + channel1?.assertUnit('%', 'whiteness'); + channel2?.assertUnit('%', 'blackness'); + var whiteness = channel1?.value.clamp(0, 100).toDouble(); + var blackness = channel2?.value.clamp(0, 100).toDouble(); + + if (whiteness != null && + blackness != null && + whiteness + blackness > 100) { + var oldWhiteness = whiteness; + whiteness = whiteness / (whiteness + blackness) * 100; + blackness = blackness / (oldWhiteness + blackness) * 100; + } + + return SassColor.hwb( + channel0.andThen((channel0) => _angleValue(channel0, 'hue')), + whiteness, + blackness, + alpha); + + case ColorSpace.rgb: + return SassColor.rgbInternal( + _channelFromValue(space.channels[0], channel0), + _channelFromValue(space.channels[1], channel1), + _channelFromValue(space.channels[2], channel2), + alpha, + fromRgbFunction ? ColorFormat.rgbFunction : null); + + default: + return SassColor.forSpaceInternal( + space, + _channelFromValue(space.channels[0], channel0), + _channelFromValue(space.channels[1], channel1), + _channelFromValue(space.channels[2], channel2), + alpha); + } +} + +/// Converts a channel value from a [SassNumber] into a [double] according to +/// [channel]. +double? _channelFromValue(ColorChannel channel, SassNumber? value) => + value.andThen((value) { + if (channel is! LinearChannel) { + return value.coerceValueToUnit('deg', channel.name); + } else if (channel.requiresPercent && !value.hasUnit('%')) { + throw SassScriptException( + 'Expected $value to have unit "%".', channel.name); + } else { + return _percentageOrUnitless(value, channel.max, channel.name); + } + }); + +/// Returns whether [value] is an unquoted string case-insensitively equal to +/// "none". +bool _isNone(Value value) => + value is SassString && + !value.hasQuotes && + value.text.toLowerCase() == 'none'; + /// Like [BuiltInCallable.function], but always sets the URL to /// `sass:color`. BuiltInCallable _function( diff --git a/lib/src/functions/meta.dart b/lib/src/functions/meta.dart index 4ed71f8bf..91c7cf398 100644 --- a/lib/src/functions/meta.dart +++ b/lib/src/functions/meta.dart @@ -8,6 +8,7 @@ import 'package:collection/collection.dart'; import '../callable.dart'; import '../value.dart'; +import '../visitor/serialize.dart'; /// Feature names supported by Dart sass. final _features = { @@ -28,8 +29,11 @@ final global = UnmodifiableListView([ return SassBoolean(_features.contains(feature.text)); }), - _function("inspect", r"$value", - (arguments) => SassString(arguments.first.toString(), quotes: false)), + _function( + "inspect", + r"$value", + (arguments) => SassString(serializeValue(arguments.first, inspect: true), + quotes: false)), _function("type-of", r"$value", (arguments) { var value = arguments[0]; diff --git a/lib/src/node/value/color.dart b/lib/src/node/value/color.dart index fb639a6a8..c326ee36c 100644 --- a/lib/src/node/value/color.dart +++ b/lib/src/node/value/color.dart @@ -4,8 +4,6 @@ import 'package:js/js.dart'; -import '../../util/nullable.dart'; -import '../../util/number.dart'; import '../../value.dart'; import '../reflection.dart'; @@ -13,8 +11,7 @@ import '../reflection.dart'; final JSClass colorClass = () { var jsClass = createJSClass('sass.SassColor', (Object self, _Channels color) { if (color.red != null) { - return SassColor.rgb(fuzzyRound(color.red!), fuzzyRound(color.green!), - fuzzyRound(color.blue!), color.alpha); + return SassColor.rgb(color.red!, color.green!, color.blue!, color.alpha); } else if (color.saturation != null) { return SassColor.hsl( color.hue!, color.saturation!, color.lightness!, color.alpha); @@ -42,11 +39,16 @@ final JSClass colorClass = () { } else if (options.red != null || options.green != null || options.blue != null) { - return self.changeRgb( - red: options.red.andThen(fuzzyRound) ?? self.red, - green: options.green.andThen(fuzzyRound) ?? self.green, - blue: options.blue.andThen(fuzzyRound) ?? self.blue, - alpha: options.alpha ?? self.alpha); + var red = options.red; + var green = options.green; + var blue = options.blue; + var alpha = options.alpha; + return self.changeChannels({ + if (red != null) "red": red, + if (green != null) "green": green, + if (blue != null) "blue": blue, + if (alpha != null) "alpha": alpha + }); } else { return self.changeAlpha(options.alpha ?? self.alpha); } @@ -71,13 +73,13 @@ final JSClass colorClass = () { @JS() @anonymous class _Channels { - external num? get red; - external num? get green; - external num? get blue; - external num? get hue; - external num? get saturation; - external num? get lightness; - external num? get whiteness; - external num? get blackness; - external num? get alpha; + external double? get red; + external double? get green; + external double? get blue; + external double? get hue; + external double? get saturation; + external double? get lightness; + external double? get whiteness; + external double? get blackness; + external double? get alpha; } diff --git a/lib/src/util/fuzzy_equality.dart b/lib/src/util/fuzzy_equality.dart new file mode 100644 index 000000000..9d8a78e95 --- /dev/null +++ b/lib/src/util/fuzzy_equality.dart @@ -0,0 +1,17 @@ +// Copyright 2022 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'package:collection/collection.dart'; + +import 'number.dart'; + +class FuzzyEquality implements Equality { + const FuzzyEquality(); + + bool equals(double e1, double e2) => fuzzyEquals(e1, e2); + + int hash(double e1) => fuzzyHashCode(e1); + + bool isValidKey(Object? o) => o is double; +} diff --git a/lib/src/util/number.dart b/lib/src/util/number.dart index 80fd3aaa2..cec845879 100644 --- a/lib/src/util/number.dart +++ b/lib/src/util/number.dart @@ -83,6 +83,16 @@ int fuzzyRound(num number) { } } +/// Returns [number], clamped to be within [min] and [max]. +/// +/// If [number] is [fuzzyEquals] to [min] or [max], it's clamped to the +/// appropriate value. +double fuzzyClamp(double number, double min, double max) { + if (fuzzyLessThanOrEquals(number, min)) return min; + if (fuzzyGreaterThanOrEquals(number, max)) return max; + return number; +} + /// Returns [number] if it's within [min] and [max], or `null` if it's not. /// /// If [number] is [fuzzyEquals] to [min] or [max], it's clamped to the @@ -98,11 +108,10 @@ double? fuzzyCheckRange(double number, num min, num max) { /// /// If [number] is [fuzzyEquals] to [min] or [max], it's clamped to the /// appropriate value. [name] is used in error reporting. -double fuzzyAssertRange(double number, int min, int max, [String? name]) { +double fuzzyAssertRange(double number, double min, double max, [String? name]) { var result = fuzzyCheckRange(number, min, max); if (result != null) return result; - throw RangeError.range( - number, min, max, name, "must be between $min and $max"); + throw RangeError.value(number, name, "must be between $min and $max"); } /// Return [num1] modulo [num2], using Sass's [floored division] modulo diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 295f753db..f08f6e5de 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -160,6 +160,9 @@ T? firstOrNull(Iterable iterable) { return iterator.moveNext() ? iterator.current : null; } +/// Returns [value] if it's a [T] or null otherwise. +T? castOrNull(Object? value) => value is T ? value : null; + /// Converts [codepointIndex] to a code unit index, relative to [string]. /// /// A codepoint index is the index in pure Unicode codepoints; a code unit index diff --git a/lib/src/value.dart b/lib/src/value.dart index 22105eb2f..8bcb71957 100644 --- a/lib/src/value.dart +++ b/lib/src/value.dart @@ -201,6 +201,33 @@ abstract class Value { SassString assertString([String? name]) => throw SassScriptException("$this is not a string.", name); + /// Throws a [SassScriptException] if [this] isn't a list of the sort commonly + /// used in plain CSS expression syntax: space-separated and unbracketed. + /// + /// If [allowSlash] is `true`, this allows slash-separated lists as well. + /// + /// If this came from a function argument, [name] is the argument name + /// (without the `$`). It's used for error reporting. + /// + /// @nodoc + @internal + List assertCommonListStyle(String? name, {required bool allowSlash}) { + var invalidSeparator = separator == ListSeparator.comma || + (!allowSlash && separator == ListSeparator.slash); + if (!invalidSeparator && !hasBrackets) return asList; + + var buffer = StringBuffer(r"Expected"); + if (hasBrackets) buffer.write(" an unbracketed"); + if (invalidSeparator) { + buffer.write(hasBrackets ? "," : " a"); + buffer.write(" space-"); + if (allowSlash) buffer.write(" or slash-"); + buffer.write("separated"); + } + buffer.write(" list, was $this"); + throw SassScriptException(buffer.toString(), name); + } + /// Converts a `selector-parse()`-style input into a string that can be /// parsed. /// diff --git a/lib/src/value/color.dart b/lib/src/value/color.dart index 3d3db20f2..85e2bad85 100644 --- a/lib/src/value/color.dart +++ b/lib/src/value/color.dart @@ -4,176 +4,450 @@ import 'dart:math' as math; +import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; import '../exception.dart'; +import '../util/nullable.dart'; import '../util/number.dart'; import '../value.dart'; import '../visitor/interface/value.dart'; +export 'color/interpolation_method.dart'; +export 'color/channel.dart'; +export 'color/space.dart'; + /// A SassScript color. /// /// {@category Value} @sealed class SassColor extends Value { - /// This color's red channel, between `0` and `255`. - int get red { - if (_red == null) _hslToRgb(); - return _red!; - } + // We don't use public fields because they'd be overridden by the getters of + // the same name in the JS API. - int? _red; + /// This color's space. + ColorSpace get space => _space; + final ColorSpace _space; - /// This color's green channel, between `0` and `255`. - int get green { - if (_green == null) _hslToRgb(); - return _green!; - } + /// The values of this color's channels (excluding the alpha channel). + /// + /// Note that the semantics of each of these channels varies significantly + /// based on the value of [space]. + List get channels => + List.unmodifiable([channel0, channel1, channel2]); - int? _green; + /// The values of this color's channels (excluding the alpha channel), or + /// `null` for [missing] channels. + /// + /// [missing]: https://www.w3.org/TR/css-color-4/#missing + /// + /// Note that the semantics of each of these channels varies significantly + /// based on the value of [space]. + List get channelsOrNull => + List.unmodifiable([channel0OrNull, channel1OrNull, channel2OrNull]); - /// This color's blue channel, between `0` and `255`. - int get blue { - if (_blue == null) _hslToRgb(); - return _blue!; - } + /// This color's first channel. + /// + /// The semantics of this depend on the color space. Returns 0 for a missing + /// channel. + /// + /// @nodoc + @internal + double get channel0 => channel0OrNull ?? 0; - int? _blue; + /// Returns whether this color's first channel is [missing]. + /// + /// [missing]: https://www.w3.org/TR/css-color-4/#missing + /// + /// @nodoc + @internal + bool get isChannel0Missing => channel0OrNull == null; - /// This color's hue, between `0` and `360`. - double get hue { - if (_hue == null) _rgbToHsl(); - return _hue!; - } + /// Returns whether this color's first channel is [powerless]. + /// + /// [powerless]: https://www.w3.org/TR/css-color-4/#powerless + /// + /// @nodoc + @internal + bool get isChannel0Powerless { + switch (space) { + case ColorSpace.hsl: + return fuzzyEquals(channel1, 0) || fuzzyEquals(channel2, 0); - double? _hue; + case ColorSpace.hwb: + return fuzzyEquals(channel1 + channel2, 100); - /// This color's saturation, a percentage between `0` and `100`. - double get saturation { - if (_saturation == null) _rgbToHsl(); - return _saturation!; + default: + return false; + } } - double? _saturation; + /// This color's first channel. + /// + /// The semantics of this depend on the color space. If this is `null`, that + /// indicates a [missing] component. + /// + /// [missing]: https://www.w3.org/TR/css-color-4/#missing + final double? channel0OrNull; - /// This color's lightness, a percentage between `0` and `100`. - double get lightness { - if (_lightness == null) _rgbToHsl(); - return _lightness!; - } + /// This color's second channel. + /// + /// The semantics of this depend on the color space. Returns 0 for a missing + /// channel. + /// + /// @nodoc + @internal + double get channel1 => channel1OrNull ?? 0; - double? _lightness; + /// Returns whether this color's second channel is [missing]. + /// + /// [missing]: https://www.w3.org/TR/css-color-4/#missing + /// + /// @nodoc + @internal + bool get isChannel1Missing => channel1OrNull == null; - /// This color's whiteness, a percentage between `0` and `100`. - double get whiteness { - // Because HWB is (currently) used much less frequently than HSL or RGB, we - // don't cache its values because we expect the memory overhead of doing so - // to outweigh the cost of recalculating it on access. - return math.min(math.min(red, green), blue) / 255 * 100; + /// Returns whether this color's second channel is [powerless]. + /// + /// [powerless]: https://www.w3.org/TR/css-color-4/#powerless + /// + /// @nodoc + @internal + bool get isChannel1Powerless { + switch (space) { + case ColorSpace.hsl: + return fuzzyEquals(channel2, 0); + + case ColorSpace.lab: + case ColorSpace.oklab: + case ColorSpace.lch: + case ColorSpace.oklch: + return fuzzyEquals(channel0, 0); + + default: + return false; + } } - /// This color's blackness, a percentage between `0` and `100`. - double get blackness { - // Because HWB is (currently) used much less frequently than HSL or RGB, we - // don't cache its values because we expect the memory overhead of doing so - // to outweigh the cost of recalculating it on access. - return 100 - math.max(math.max(red, green), blue) / 255 * 100; + /// This color's second channel. + /// + /// The semantics of this depend on the color space. If this is `null`, that + /// indicates a [missing] component. + /// + /// [missing]: https://www.w3.org/TR/css-color-4/#missing + final double? channel1OrNull; + + /// Returns whether this color's third channel is [missing]. + /// + /// [missing]: https://www.w3.org/TR/css-color-4/#missing + /// + /// @nodoc + @internal + bool get isChannel2Missing => channel2OrNull == null; + + /// Returns whether this color's third channel is [powerless]. + /// + /// [powerless]: https://www.w3.org/TR/css-color-4/#powerless + /// + /// @nodoc + @internal + bool get isChannel2Powerless { + switch (space) { + case ColorSpace.lab: + case ColorSpace.oklab: + return fuzzyEquals(channel0, 0); + + case ColorSpace.lch: + case ColorSpace.oklch: + return fuzzyEquals(channel0, 0) || fuzzyEquals(channel1, 0); + + default: + return false; + } } - // We don't use public fields because they'd be overridden by the getters of - // the same name in the JS API. + /// This color's third channel. + /// + /// The semantics of this depend on the color space. Returns 0 for a missing + /// channel. + /// + /// @nodoc + @internal + double get channel2 => channel2OrNull ?? 0; - /// This color's alpha channel, between `0` and `1`. - double get alpha => _alpha; - final double _alpha; + /// This color's third channel. + /// + /// The semantics of this depend on the color space. If this is `null`, that + /// indicates a [missing] component. + /// + /// [missing]: https://www.w3.org/TR/css-color-4/#missing + final double? channel2OrNull; /// The format in which this color was originally written and should be /// serialized in expanded mode, or `null` if the color wasn't written in a /// supported format. /// + /// This is only set if `space` is `"rgb"`. + /// /// @nodoc @internal final ColorFormat? format; - /// Creates an RGB color. + /// This color's alpha channel, between `0` and `1`. + double get alpha => _alpha; + final double _alpha; + + /// Whether this is a legacy color—that is, a color defined using + /// pre-color-spaces syntax that preserves comaptibility with old color + /// behavior and semantics. + bool get isLegacy => space.isLegacy; + + /// Whether this color is in-gamut for its color space. + bool get isInGamut { + // Strictly-bounded spaces can't even represent out-of-gamut colors, so + // any color that exists must be bounded. + if (!space.isBounded || space.isStrictlyBounded) return true; + + // There aren't (currently) any color spaces that are bounded but not + // STRICTLY bounded, and have polar-angle channels. + var channel0Info = space.channels[0] as LinearChannel; + var channel1Info = space.channels[1] as LinearChannel; + var channel2Info = space.channels[2] as LinearChannel; + return fuzzyLessThanOrEquals(channel0, channel0Info.max) && + fuzzyGreaterThanOrEquals(channel0, channel0Info.min) && + fuzzyLessThanOrEquals(channel1, channel1Info.max) && + fuzzyGreaterThanOrEquals(channel1, channel1Info.min) && + fuzzyLessThanOrEquals(channel2, channel2Info.max) && + fuzzyGreaterThanOrEquals(channel2, channel2Info.min); + } + + /// This color's red channel, between `0` and `255`. + /// + /// **Note:** This is rounded to the nearest integer, which may be lossy. Use + /// [channel] instead to get the true red value. + @Deprecated('Use channel() instead.') + int get red => _legacyChannel(ColorSpace.rgb, 'red').round(); + + /// This color's green channel, between `0` and `255`. + /// + /// **Note:** This is rounded to the nearest integer, which may be lossy. Use + /// [channel] instead to get the true red value. + @Deprecated('Use channel() instead.') + int get green => _legacyChannel(ColorSpace.rgb, 'green').round(); + + /// This color's blue channel, between `0` and `255`. + /// + /// **Note:** This is rounded to the nearest integer, which may be lossy. Use + /// [channel] instead to get the true red value. + @Deprecated('Use channel() instead.') + int get blue => _legacyChannel(ColorSpace.rgb, 'blue').round(); + + /// This color's hue, between `0` and `360`. + @Deprecated('Use channel() instead.') + double get hue => _legacyChannel(ColorSpace.hsl, 'hue') % 360; + + /// This color's saturation, a percentage between `0` and `100`. + @Deprecated('Use channel() instead.') + double get saturation => _legacyChannel(ColorSpace.hsl, 'saturation'); + + /// This color's lightness, a percentage between `0` and `100`. + @Deprecated('Use channel() instead.') + double get lightness => _legacyChannel(ColorSpace.hsl, 'lightness'); + + /// This color's whiteness, a percentage between `0` and `100`. + @Deprecated('Use channel() instead.') + double get whiteness => _legacyChannel(ColorSpace.hwb, 'whiteness'); + + /// This color's blackness, a percentage between `0` and `100`. + @Deprecated('Use channel() instead.') + double get blackness => _legacyChannel(ColorSpace.hwb, 'blackness'); + + /// Creates a color in [ColorSpace.rgb]. /// - /// Throws a [RangeError] if [red], [green], and [blue] aren't between `0` and - /// `255`, or if [alpha] isn't between `0` and `1`. - SassColor.rgb(int red, int green, int blue, [num? alpha]) - : this.rgbInternal(red, green, blue, alpha); + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.rgb(num? red, num? green, num? blue, [num? alpha]) => + SassColor.rgbInternal(red, green, blue, alpha); /// Like [SassColor.rgb], but also takes a [format] parameter. /// /// @nodoc @internal - SassColor.rgbInternal(this._red, this._green, this._blue, - [num? alpha, this.format]) - : _alpha = alpha == null - ? 1 - : fuzzyAssertRange(alpha.toDouble(), 0, 1, "alpha") { - RangeError.checkValueInInterval(red, 0, 255, "red"); - RangeError.checkValueInInterval(green, 0, 255, "green"); - RangeError.checkValueInInterval(blue, 0, 255, "blue"); - } + factory SassColor.rgbInternal(num? red, num? green, num? blue, + [num? alpha, ColorFormat? format]) => + SassColor.forSpaceInternal(ColorSpace.rgb, red?.toDouble(), + green?.toDouble(), blue?.toDouble(), alpha?.toDouble(), format); - /// Creates an HSL color. + /// Creates a color in [ColorSpace.hsl]. /// - /// Throws a [RangeError] if [saturation] or [lightness] aren't between `0` - /// and `100`, or if [alpha] isn't between `0` and `1`. - SassColor.hsl(num hue, num saturation, num lightness, [num? alpha]) - : this.hslInternal(hue, saturation, lightness, alpha); + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.hsl(num? hue, num? saturation, num? lightness, + [num? alpha]) => + SassColor.forSpaceInternal( + ColorSpace.hsl, + hue?.toDouble(), + saturation.andThen((saturation) => + fuzzyAssertRange(saturation.toDouble(), 0, 100, "saturation")), + lightness.andThen((lightness) => + fuzzyAssertRange(lightness.toDouble(), 0, 100, "lightness")), + alpha?.toDouble()); - /// Like [SassColor.hsl], but also takes a [format] parameter. + /// Creates a color in [ColorSpace.hwb]. /// - /// @nodoc - @internal - SassColor.hslInternal(num hue, num saturation, num lightness, - [num? alpha, this.format]) - : _hue = hue % 360, - _saturation = - fuzzyAssertRange(saturation.toDouble(), 0, 100, "saturation"), - _lightness = - fuzzyAssertRange(lightness.toDouble(), 0, 100, "lightness"), - _alpha = alpha == null - ? 1 - : fuzzyAssertRange(alpha.toDouble(), 0, 1, "alpha"); - - /// Creates an HWB color. - /// - /// Throws a [RangeError] if [whiteness] or [blackness] aren't between `0` and - /// `100`, or if [alpha] isn't between `0` and `1`. - factory SassColor.hwb(num hue, num whiteness, num blackness, [num? alpha]) { - // From https://www.w3.org/TR/css-color-4/#hwb-to-rgb - var scaledHue = hue % 360 / 360; - var scaledWhiteness = - fuzzyAssertRange(whiteness.toDouble(), 0, 100, "whiteness") / 100; - var scaledBlackness = - fuzzyAssertRange(blackness.toDouble(), 0, 100, "blackness") / 100; - - var sum = scaledWhiteness + scaledBlackness; - if (sum > 1) { - scaledWhiteness /= sum; - scaledBlackness /= sum; - } + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.hwb(num? hue, num? whiteness, num? blackness, + [num? alpha]) => + SassColor.forSpaceInternal( + ColorSpace.hwb, + hue?.toDouble(), + whiteness.andThen((whiteness) => + fuzzyAssertRange(whiteness.toDouble(), 0, 100, "whiteness")), + blackness.andThen((blackness) => + fuzzyAssertRange(blackness.toDouble(), 0, 100, "blackness")), + alpha?.toDouble()); - var factor = 1 - scaledWhiteness - scaledBlackness; - int toRgb(double hue) { - var channel = _hueToRgb(0, 1, hue) * factor + scaledWhiteness; - return fuzzyRound(channel * 255); + /// Creates a color in [ColorSpace.srgb]. + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.srgb(double? red, double? green, double? blue, + [double? alpha]) => + SassColor.forSpaceInternal(ColorSpace.srgb, red, green, blue, alpha); + + /// Creates a color in [ColorSpace.srgbLinear]. + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.srgbLinear(double? red, double? green, double? blue, + [double? alpha]) => + SassColor.forSpaceInternal( + ColorSpace.srgbLinear, red, green, blue, alpha); + + /// Creates a color in [ColorSpace.displayP3]. + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.displayP3(double? red, double? green, double? blue, + [double? alpha]) => + SassColor.forSpaceInternal(ColorSpace.displayP3, red, green, blue, alpha); + + /// Creates a color in [ColorSpace.a98Rgb]. + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.a98Rgb(double? red, double? green, double? blue, + [double? alpha]) => + SassColor.forSpaceInternal(ColorSpace.a98Rgb, red, green, blue, alpha); + + /// Creates a color in [ColorSpace.prophotoRgb]. + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.prophotoRgb(double? red, double? green, double? blue, + [double? alpha]) => + SassColor.forSpaceInternal( + ColorSpace.prophotoRgb, red, green, blue, alpha); + + /// Creates a color in [ColorSpace.rec2020]. + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.rec2020(double? red, double? green, double? blue, + [double? alpha]) => + SassColor.forSpaceInternal(ColorSpace.rec2020, red, green, blue, alpha); + + /// Creates a color in [ColorSpace.xyzD50]. + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.xyzD50(double? x, double? y, double? z, [double? alpha]) => + SassColor.forSpaceInternal(ColorSpace.xyzD50, x, y, z, alpha); + + /// Creates a color in [ColorSpace.xyzD65]. + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.xyzD65(double? x, double? y, double? z, [double? alpha]) => + SassColor.forSpaceInternal(ColorSpace.xyzD65, x, y, z, alpha); + + /// Creates a color in [ColorSpace.lab]. + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.lab(double? lightness, double? a, double? b, + [double? alpha]) => + SassColor.forSpaceInternal(ColorSpace.lab, lightness, a, b, alpha); + + /// Creates a color in [ColorSpace.lch]. + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.lch(double? lightness, double? chroma, double? hue, + [double? alpha]) => + SassColor.forSpaceInternal(ColorSpace.lch, lightness, chroma, hue, alpha); + + /// Creates a color in [ColorSpace.oklab]. + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.oklab(double? lightness, double? a, double? b, + [double? alpha]) => + SassColor.forSpaceInternal(ColorSpace.oklab, lightness, a, b, alpha); + + /// Creates a color in [ColorSpace.oklch]. + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.oklch(double? lightness, double? chroma, double? hue, + [double? alpha]) => + SassColor.forSpaceInternal( + ColorSpace.oklch, lightness, chroma, hue, alpha); + + /// Creates a color in the color space named [space]. + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1` or if + /// [channels] is the wrong length for [space]. + factory SassColor.forSpace(ColorSpace space, List channels, + [double? alpha]) { + if (channels.length != space.channels.length) { + throw RangeError.value(channels.length, "channels.length", + 'must be exactly ${space.channels.length} for color space "$space"'); + } else { + var clampChannels = space == ColorSpace.hsl || space == ColorSpace.hwb; + return SassColor.forSpaceInternal( + space, + channels[0], + clampChannels + ? channels[1].andThen((value) => fuzzyClamp(value, 0, 100)) + : channels[1], + clampChannels + ? channels[2].andThen((value) => fuzzyClamp(value, 0, 100)) + : channels[2], + alpha); } + } - // Because HWB is (currently) used much less frequently than HSL or RGB, we - // don't cache its values because we expect the memory overhead of doing so - // to outweigh the cost of recalculating it on access. Instead, we eagerly - // convert it to RGB and then convert back if necessary. - return SassColor.rgb(toRgb(scaledHue + 1 / 3), toRgb(scaledHue), - toRgb(scaledHue - 1 / 3), alpha); + /// Like [forSpace], but takes three channels explicitly rather than wrapping + /// and unwrapping them in an array. + /// + /// @nodoc + @internal + SassColor.forSpaceInternal(this._space, this.channel0OrNull, + this.channel1OrNull, this.channel2OrNull, double? alpha, + [this.format]) + : _alpha = alpha == null ? 1 : fuzzyAssertRange(alpha, 0, 1, "alpha") { + assert(format == null || _space == ColorSpace.rgb); + assert( + !(space == ColorSpace.hsl || space == ColorSpace.hwb) || + (fuzzyCheckRange(channel1, 0, 100) != null && + fuzzyCheckRange(channel2, 0, 100) != null), + "[BUG] Tried to create " + "$_space(${channel0OrNull ?? 'none'}, ${channel1OrNull ?? 'none'}, " + "${channel2OrNull ?? 'none'})"); + assert(space != ColorSpace.lms); + + _checkChannel(channel0OrNull, space.channels[0].name); + _checkChannel(channel1OrNull, space.channels[1].name); + _checkChannel(channel2OrNull, space.channels[2].name); } - SassColor._(this._red, this._green, this._blue, this._hue, this._saturation, - this._lightness, this._alpha) - : format = null; + /// Throws a [RangeError] if [channel] isn't a finite number. + void _checkChannel(double? channel, String name) { + if (channel == null) return; + if (channel.isNaN) { + throw RangeError.value(channel, name, 'must be a number.'); + } else if (!channel.isFinite) { + throw RangeError.value(channel, name, 'must be finite.'); + } + } /// @nodoc @internal @@ -181,31 +455,484 @@ class SassColor extends Value { SassColor assertColor([String? name]) => this; + /// Throws a [SassScriptException] if this isn't in a legacy color space. + /// + /// If this came from a function argument, [name] is the argument name + /// (without the `$`). This is used for error reporting. + /// + /// @nodoc + @internal + void assertLegacy([String? name]) { + if (isLegacy) return; + throw SassScriptException( + 'Expected $this to be in the legacy RGB, HSL, or HWB color space.', + name); + } + + /// Returns the value of the given [channel] in this color, or throws a + /// [SassScriptException] if it doesn't exist. + /// + /// If this came from a function argument, [colorName] is the argument name + /// for this color and [channelName] is the argument name for [channel] + /// (without the `$`). These are used for error reporting. + double channel(String channel, {String? colorName, String? channelName}) { + channel = channel.toLowerCase(); + var channels = space.channels; + if (channel == channels[0].name) return channel0; + if (channel == channels[1].name) return channel1; + if (channel == channels[2].name) return channel2; + + throw SassScriptException( + "Color $this doesn't have a channel named \"$channel\".", channelName); + } + + /// Returns whether the given [channel] in this color is [missing]. + /// + /// [missing]: https://www.w3.org/TR/css-color-4/#missing + /// + /// If this came from a function argument, [colorName] is the argument name + /// for this color and [channelName] is the argument name for [channel] + /// (without the `$`). These are used for error reporting. + bool isChannelMissing(String channel, + {String? colorName, String? channelName}) { + channel = channel.toLowerCase(); + var channels = space.channels; + if (channel == channels[0].name) return isChannel0Missing; + if (channel == channels[1].name) return isChannel1Missing; + if (channel == channels[2].name) return isChannel2Missing; + + throw SassScriptException( + "Color $this doesn't have a channel named \"$channel\".", channelName); + } + + /// Returns whether the given [channel] in this color is [powerless]. + /// + /// [powerless]: https://www.w3.org/TR/css-color-4/#powerless + /// + /// If this came from a function argument, [colorName] is the argument name + /// for this color and [channelName] is the argument name for [channel] + /// (without the `$`). These are used for error reporting. + bool isChannelPowerless(String channel, + {String? colorName, String? channelName}) { + channel = channel.toLowerCase(); + var channels = space.channels; + if (channel == channels[0].name) return isChannel0Powerless; + if (channel == channels[1].name) return isChannel1Powerless; + if (channel == channels[2].name) return isChannel2Powerless; + + throw SassScriptException( + "Color $this doesn't have a channel named \"$channel\".", channelName); + } + + /// If this is a legacy color, converts it to the given [space] and then + /// returns the given [channel]. + /// + /// Otherwise, throws an exception. + double _legacyChannel(ColorSpace space, String channel) { + if (!isLegacy) { + throw SassScriptException( + "color.$channel() is only supported for legacy colors. Please use " + "color.channel() instead with an explicit \$space argument."); + } + + return toSpace(space).channel(channel); + } + + /// Converts this color to [space]. + /// + /// If this came from a function argument, [name] is the argument name for + /// this color (without the `$`). It's used for error reporting. + /// + /// This currently can't produce an error, but it will likely do so in the + /// future when Sass adds support for color spaces that don't support + /// automatic conversions. + SassColor toSpace(ColorSpace space) => this.space == space + ? this + : this.space.convert(space, channel0, channel1, channel2, alpha); + + /// Returns a copy of this color that's in-gamut in the current color space. + SassColor toGamut() { + if (isInGamut) return this; + + // Algorithm from https://www.w3.org/TR/css-color-4/#css-gamut-mapping-algorithm + var originOklch = toSpace(ColorSpace.oklch); + + if (fuzzyGreaterThanOrEquals(originOklch.channel0, 1)) { + return space == ColorSpace.rgb + ? SassColor.rgb(255, 255, 255, alpha) + : SassColor.forSpaceInternal(space, 1, 1, 1, alpha); + } else if (fuzzyLessThanOrEquals(originOklch.channel0, 0)) { + return SassColor.forSpaceInternal(space, 0, 0, 0, alpha); + } + + // Always target RGB for legacy colors because HSL and HWB can't even + // represent out-of-gamut colors. + var targetSpace = isLegacy ? ColorSpace.rgb : space; + + var min = 0.0; + var max = originOklch.channel1; + while (true) { + var chroma = (min + max) / 2; + // Never null because [targetSpace] can't be HSL or HWB. + var current = ColorSpace.oklch.convert(targetSpace, originOklch.channel0, + chroma, originOklch.channel2, originOklch.alpha); + if (current.isInGamut) { + min = chroma; + continue; + } + + var clipped = _clip(current); + if (_deltaEOK(clipped, current) < 0.02) return clipped; + max = chroma; + } + } + + /// Returns [current] clipped into its space's gamut. + SassColor _clip(SassColor current) { + assert(!current.isInGamut); + assert(current.space == space); + + if (space == ColorSpace.rgb) { + return SassColor.rgb( + fuzzyClamp(current.channel0, 0, 255), + fuzzyClamp(current.channel1, 0, 255), + fuzzyClamp(current.channel2, 0, 255), + current.alpha); + } else { + return SassColor.forSpaceInternal( + space, + fuzzyClamp(current.channel0, 0, 1), + fuzzyClamp(current.channel1, 0, 1), + fuzzyClamp(current.channel2, 0, 1), + current.alpha); + } + } + + /// Returns the ΔEOK measure between [color1] and [color2]. + double _deltaEOK(SassColor color1, SassColor color2) { + // Algorithm from https://www.w3.org/TR/css-color-4/#color-difference-OK + var lab1 = color1.toSpace(ColorSpace.oklab); + var lab2 = color2.toSpace(ColorSpace.oklab); + + return math.sqrt(math.pow(lab1.channel0 - lab2.channel0, 2) + + math.pow(lab1.channel1 - lab2.channel1, 2) + + math.pow(lab1.channel2 - lab2.channel2, 2)); + } + /// Changes one or more of this color's RGB channels and returns the result. - SassColor changeRgb({int? red, int? green, int? blue, num? alpha}) => - SassColor.rgb(red ?? this.red, green ?? this.green, blue ?? this.blue, - alpha ?? this.alpha); + @Deprecated('Use changeChannels() instead.') + SassColor changeRgb({int? red, int? green, int? blue, num? alpha}) { + if (!isLegacy) { + throw SassScriptException( + "color.changeRgb() is only supported for legacy colors. Please use " + "color.changeChannels() instead with an explicit \$space argument."); + } + + return SassColor.rgb( + red?.toDouble() ?? channel('red'), + green?.toDouble() ?? channel('green'), + blue?.toDouble() ?? channel('blue'), + alpha?.toDouble() ?? this.alpha); + } /// Changes one or more of this color's HSL channels and returns the result. - SassColor changeHsl( - {num? hue, num? saturation, num? lightness, num? alpha}) => - SassColor.hsl(hue ?? this.hue, saturation ?? this.saturation, - lightness ?? this.lightness, alpha ?? this.alpha); + @Deprecated('Use changeChannels() instead.') + SassColor changeHsl({num? hue, num? saturation, num? lightness, num? alpha}) { + if (!isLegacy) { + throw SassScriptException( + "color.changeHsl() is only supported for legacy colors. Please use " + "color.changeChannels() instead with an explicit \$space argument."); + } + + return SassColor.hsl( + hue?.toDouble() ?? this.hue, + saturation?.toDouble() ?? this.saturation, + lightness?.toDouble() ?? this.lightness, + alpha?.toDouble() ?? this.alpha) + .toSpace(space); + } /// Changes one or more of this color's HWB channels and returns the result. - SassColor changeHwb({num? hue, num? whiteness, num? blackness, num? alpha}) => - SassColor.hwb(hue ?? this.hue, whiteness ?? this.whiteness, - blackness ?? this.blackness, alpha ?? this.alpha); + @Deprecated('Use changeChannels() instead.') + SassColor changeHwb({num? hue, num? whiteness, num? blackness, num? alpha}) { + if (!isLegacy) { + throw SassScriptException( + "color.changeHsl() is only supported for legacy colors. Please use " + "color.changeChannels() instead with an explicit \$space argument."); + } + + return SassColor.hwb( + hue?.toDouble() ?? this.hue, + whiteness?.toDouble() ?? this.whiteness, + blackness?.toDouble() ?? this.blackness, + alpha?.toDouble() ?? this.alpha + 0.0) + .toSpace(space); + } /// Returns a new copy of this color with the alpha channel set to [alpha]. - SassColor changeAlpha(num alpha) => SassColor._( - _red, - _green, - _blue, - _hue, - _saturation, - _lightness, - fuzzyAssertRange(alpha.toDouble(), 0, 1, "alpha")); + SassColor changeAlpha(num alpha) => SassColor.forSpaceInternal( + space, channel0, channel1, channel2, alpha.toDouble()); + + /// Changes one or more of this color's channels and returns the result. + /// + /// The keys of [newValues] are channel names and the values are the new + /// values of those channels. + /// + /// If [space] is passed, this converts this color to [space], sets the + /// channels, then converts the result back to its original color space. + /// + /// Throws a [SassScriptException] if any of the keys aren't valid channel + /// names for this color, or if the same channel is set multiple times. + /// + /// If this color came from a function argument, [colorName] is the argument + /// name (without the `$`). This is used for error reporting. + SassColor changeChannels(Map newValues, + {ColorSpace? space, String? colorName}) { + if (newValues.isEmpty) { + // If space conversion produces an error, we still want to expose that + // error even if there's nothing to change. + if (space != null && space != this.space) toSpace(space); + return this; + } + + if (space != null && space != this.space) { + return toSpace(space) + .changeChannels(newValues, colorName: colorName) + .toSpace(space); + } + + double? new0; + double? new1; + double? new2; + double? alpha; + var channels = this.space.channels; + + void setChannel0(double value) { + if (new0 != null) { + throw SassScriptException( + 'Multiple values supplied for "${channels[0]}": $new0 and ' + '$value.', + colorName); + } + new0 = value; + } + + void setChannel1(double value) { + if (new1 != null) { + throw SassScriptException( + 'Multiple values supplied for "${channels[1]}": $new1 and ' + '$value.', + colorName); + } + new1 = value; + } + + void setChannel2(double value) { + if (new2 != null) { + throw SassScriptException( + 'Multiple values supplied for "${channels[2]}": $new2 and ' + '$value.', + colorName); + } + new2 = value; + } + + for (var entry in newValues.entries) { + var channel = entry.key.toLowerCase(); + if (channel == channels[0].name) { + setChannel0(entry.value); + } else if (channel == channels[1].name) { + setChannel1(entry.value); + } else if (channel == channels[2].name) { + setChannel2(entry.value); + } else if (channel == 'alpha') { + if (alpha != null) { + throw SassScriptException( + 'Multiple values supplied for "alpha": $alpha and ' + '${entry.value}.', + colorName); + } + alpha = entry.value; + } else { + throw SassScriptException( + "Color $this doesn't have a channel named \"$channel\".", + colorName); + } + } + + return SassColor.forSpaceInternal( + this.space, + _clampChannelIfNecessary(new0, this.space, 0) ?? channel0, + _clampChannelIfNecessary(new1, this.space, 1) ?? channel1, + _clampChannelIfNecessary(new2, this.space, 2) ?? channel2, + alpha ?? this.alpha); + } + + /// If [space] is strictly bounded and its [index]th channel isn't polar, + /// clamps [value] between its minimum and maximum. + double? _clampChannelIfNecessary(double? value, ColorSpace space, int index) { + if (value == null) return value; + if (!space.isStrictlyBounded) return value; + var channel = space.channels[index]; + if (channel is! LinearChannel) return value; + return fuzzyClamp(value, channel.min, channel.max); + } + + /// Returns a color partway between [this] and [other] according to [method], + /// as defined by the CSS Color 4 [color interpolation] procedure. + /// + /// [color interpolation]: https://www.w3.org/TR/css-color-4/#interpolation + /// + /// The [weight] is a number between 0 and 1 that indicates how much of [this] + /// should be in the resulting color. It defaults to 0.5. + SassColor interpolate(SassColor other, InterpolationMethod method, + {double? weight}) { + weight ??= 0.5; + + if (fuzzyEquals(weight, 0)) return other; + if (fuzzyEquals(weight, 1)) return this; + + var color1 = toSpace(method.space); + var color2 = other.toSpace(method.space); + + if (weight < 0 || weight > 1) { + throw RangeError.range(weight, 0, 1, 'weight'); + } + + // If either color is missing a channel _and_ that channel is analogous with + // one in the output space, then the output channel should take on the other + // color's value. + var missing1_0 = _isAnalogousChannelMissing(this, color1, 0); + var missing1_1 = _isAnalogousChannelMissing(this, color1, 1); + var missing1_2 = _isAnalogousChannelMissing(this, color1, 2); + var missing2_0 = _isAnalogousChannelMissing(other, color2, 0); + var missing2_1 = _isAnalogousChannelMissing(other, color2, 1); + var missing2_2 = _isAnalogousChannelMissing(other, color2, 2); + var channel1_0 = (missing1_0 ? color2 : color1).channel0; + var channel1_1 = (missing1_1 ? color2 : color1).channel1; + var channel1_2 = (missing1_2 ? color2 : color1).channel2; + var channel2_0 = (missing2_0 ? color1 : color2).channel0; + var channel2_1 = (missing2_1 ? color1 : color2).channel1; + var channel2_2 = (missing2_2 ? color1 : color2).channel2; + + // TODO: handle missing channels + var thisMultiplier = alpha * weight; + var otherMultiplier = other.alpha * (1 - weight); + var mixedAlpha = alpha * weight + other.alpha * (1 - weight); + var mixed0 = missing1_0 && missing2_0 + ? null + : (channel1_0 * thisMultiplier + channel2_0 * otherMultiplier) / + mixedAlpha; + var mixed1 = missing1_1 && missing2_1 + ? null + : (channel1_1 * thisMultiplier + channel2_1 * otherMultiplier) / + mixedAlpha; + var mixed2 = missing1_2 && missing2_2 + ? null + : (channel1_2 * thisMultiplier + channel2_2 * otherMultiplier) / + mixedAlpha; + + SassColor mixed; + switch (method.space) { + case ColorSpace.hsl: + case ColorSpace.hwb: + mixed = SassColor.forSpaceInternal( + method.space, + missing1_0 && missing2_0 + ? null + : _interpolateHues(channel1_0, channel2_0, method.hue!, weight), + mixed1, + mixed2, + mixedAlpha); + break; + + case ColorSpace.lch: + case ColorSpace.oklch: + mixed = SassColor.forSpaceInternal( + method.space, + mixed0, + mixed1, + missing1_2 && missing2_2 + ? null + : _interpolateHues(channel1_2, channel2_2, method.hue!, weight), + mixedAlpha); + break; + + default: + assert(!space.isPolar); + mixed = SassColor.forSpaceInternal( + method.space, mixed0, mixed1, mixed2, mixedAlpha); + break; + } + + return mixed.toSpace(space); + } + + /// Returns whether [output], which was converted to its color space from + /// [original], should be considered to have a missing channel at + /// [outputChannelIndex]. + /// + /// This includes channels that are analogous to missing channels in + /// [original]. + bool _isAnalogousChannelMissing( + SassColor original, SassColor output, int outputChannelIndex) { + if (output.channelsOrNull[outputChannelIndex] == null) return true; + if (identical(original, output)) return false; + + var outputChannel = output.space.channels[outputChannelIndex]; + var originalChannel = + original.space.channels.firstWhereOrNull(outputChannel.isAnalogous); + if (originalChannel == null) return false; + + return original.isChannelMissing(originalChannel.name); + } + + /// Returns a hue partway between [hue1] and [hue2] according to [method]. + /// + /// The [weight] is a number between 0 and 1 that indicates how much of [hue1] + /// should be in the resulting hue. + double _interpolateHues( + double hue1, double hue2, HueInterpolationMethod method, double weight) { + // Algorithms from https://www.w3.org/TR/css-color-4/#hue-interpolation + if (method != HueInterpolationMethod.specified) { + hue1 = (hue1 % 360 + 360) % 360; + hue2 = (hue2 % 360 + 360) % 360; + } + + switch (method) { + case HueInterpolationMethod.shorter: + var difference = hue2 - hue1; + if (difference > 180) { + hue1 += 360; + } else if (difference < -180) { + hue2 += 360; + } + break; + + case HueInterpolationMethod.longer: + var difference = hue2 - hue1; + if (difference > 0 && difference < 180) { + hue2 += 360; + } else if (difference > -180 && difference <= 0) { + hue1 += 360; + } + break; + + case HueInterpolationMethod.increasing: + if (hue2 < hue1) hue2 += 360; + break; + + case HueInterpolationMethod.decreasing: + if (hue1 < hue2) hue1 += 360; + break; + + case HueInterpolationMethod.specified: + // Use the hues as-is. + break; + } + + return hue1 * weight + hue2 * (1 - weight); + } /// @nodoc @internal @@ -230,129 +957,45 @@ class SassColor extends Value { throw SassScriptException('Undefined operation "$this / $other".'); } - bool operator ==(Object other) => - other is SassColor && - other.red == red && - other.green == green && - other.blue == blue && - other.alpha == alpha; - - int get hashCode => - red.hashCode ^ green.hashCode ^ blue.hashCode ^ alpha.hashCode; - - /// Computes [_hue], [_saturation], and [_value] based on [red], [green], and - /// [blue]. - void _rgbToHsl() { - // Algorithm from https://en.wikipedia.org/wiki/HSL_and_HSV#RGB_to_HSL_and_HSV - var scaledRed = red / 255; - var scaledGreen = green / 255; - var scaledBlue = blue / 255; - - var max = math.max(math.max(scaledRed, scaledGreen), scaledBlue); - var min = math.min(math.min(scaledRed, scaledGreen), scaledBlue); - var delta = max - min; - - if (max == min) { - _hue = 0; - } else if (max == scaledRed) { - _hue = (60 * (scaledGreen - scaledBlue) / delta) % 360; - } else if (max == scaledGreen) { - _hue = (120 + 60 * (scaledBlue - scaledRed) / delta) % 360; - } else if (max == scaledBlue) { - _hue = (240 + 60 * (scaledRed - scaledGreen) / delta) % 360; - } - - var lightness = _lightness = 50 * (max + min); + operator ==(Object other) { + if (other is! SassColor) return false; - if (max == min) { - _saturation = 0; - } else if (lightness < 50) { - _saturation = 100 * delta / (max + min); - } else { - _saturation = 100 * delta / (2 - max - min); + if (isLegacy) { + if (!other.isLegacy) return false; + if (!fuzzyEquals(alpha, other.alpha)) return false; + if (space == ColorSpace.rgb && other.space == ColorSpace.rgb) { + return fuzzyEquals(channel0, other.channel0) && + fuzzyEquals(channel1, other.channel1) && + fuzzyEquals(channel2, other.channel2); + } else { + return toSpace(ColorSpace.rgb) == other.toSpace(ColorSpace.rgb); + } } - } - /// Computes [_red], [_green], and [_blue] based on [hue], [saturation], and - /// [value]. - void _hslToRgb() { - // Algorithm from the CSS3 spec: https://www.w3.org/TR/css3-color/#hsl-color. - var scaledHue = hue / 360; - var scaledSaturation = saturation / 100; - var scaledLightness = lightness / 100; - - var m2 = scaledLightness <= 0.5 - ? scaledLightness * (scaledSaturation + 1) - : scaledLightness + - scaledSaturation - - scaledLightness * scaledSaturation; - var m1 = scaledLightness * 2 - m2; - _red = fuzzyRound(_hueToRgb(m1, m2, scaledHue + 1 / 3) * 255); - _green = fuzzyRound(_hueToRgb(m1, m2, scaledHue) * 255); - _blue = fuzzyRound(_hueToRgb(m1, m2, scaledHue - 1 / 3) * 255); + return space == other.space && + fuzzyEquals(channel0, other.channel0) && + fuzzyEquals(channel1, other.channel1) && + fuzzyEquals(channel2, other.channel2) && + fuzzyEquals(alpha, other.alpha); } - /// An algorithm from the CSS3 spec: - /// http://www.w3.org/TR/css3-color/#hsl-color. - static double _hueToRgb(double m1, double m2, double hue) { - if (hue < 0) hue += 1; - if (hue > 1) hue -= 1; - - if (hue < 1 / 6) { - return m1 + (m2 - m1) * hue * 6; - } else if (hue < 1 / 2) { - return m2; - } else if (hue < 2 / 3) { - return m1 + (m2 - m1) * (2 / 3 - hue) * 6; + int get hashCode { + if (isLegacy) { + var rgb = toSpace(ColorSpace.rgb); + return fuzzyHashCode(rgb.channel0) ^ + fuzzyHashCode(rgb.channel1) ^ + fuzzyHashCode(rgb.channel2) ^ + fuzzyHashCode(alpha); } else { - return m1; - } - } - - /// Returns an `rgb()` or `rgba()` function call that will evaluate to this - /// color. - /// - /// @nodoc - @internal - String toStringAsRgb() { - var isOpaque = fuzzyEquals(alpha, 1); - var buffer = StringBuffer(isOpaque ? "rgb" : "rgba") - ..write("($red, $green, $blue"); - - if (!isOpaque) { - // Write the alpha as a SassNumber to ensure it's valid CSS. - buffer.write(", ${SassNumber(alpha)}"); + return space.hashCode ^ + fuzzyHashCode(channel0) ^ + fuzzyHashCode(channel1) ^ + fuzzyHashCode(channel2) ^ + fuzzyHashCode(alpha); } - - buffer.write(")"); - return buffer.toString(); } } -/// Extension methods that are only visible through the `sass_api` package. -/// -/// These methods are considered less general-purpose and more liable to change -/// than the main [SassColor] interface. -/// -/// {@category Value} -extension SassApiColor on SassColor { - /// Whether the `red`, `green`, and `blue` fields have already been computed - /// for this value. - /// - /// Note that these fields can always be safely computed after the fact; this - /// just allows users such as the Sass embedded compiler to access whichever - /// representation is readily available. - bool get hasCalculatedRgb => _red != null; - - /// Whether the `hue`, `saturation`, and `lightness` fields have already been - /// computed for this value. - /// - /// Note that these fields can always be safely computed after the fact; this - /// just allows users such as the Sass embedded compiler to access whichever - /// representation is readily available. - bool get hasCalculatedHsl => _saturation != null; -} - /// A union interface of possible formats in which a Sass color could be /// defined. /// @@ -362,9 +1005,6 @@ extension SassApiColor on SassColor { abstract class ColorFormat { /// A color defined using the `rgb()` or `rgba()` functions. static const rgbFunction = _ColorFormatEnum("rgbFunction"); - - /// A color defined using the `hsl()` or `hsla()` functions. - static const hslFunction = _ColorFormatEnum("hslFunction"); } /// The class for enum values of the [ColorFormat] type. diff --git a/lib/src/value/color/channel.dart b/lib/src/value/color/channel.dart new file mode 100644 index 000000000..475516ebf --- /dev/null +++ b/lib/src/value/color/channel.dart @@ -0,0 +1,89 @@ +// Copyright 2022 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'package:meta/meta.dart'; + +/// Metadata about a single channel in a known color space. +/// +/// {@category Value} +@sealed +class ColorChannel { + /// The alpha channel that's shared across all colors. + static const alpha = LinearChannel('alpha', 0, 1); + + /// The channel's name. + final String name; + + /// Whether this is a polar angle channel, which represents (in degrees) the + /// angle around a circle. + /// + /// This is true if and only if this is not a [LinearChannel]. + final bool isPolarAngle; + + /// @nodoc + @internal + const ColorChannel(this.name, {required this.isPolarAngle}); + + /// Returns whether this channel is [analogous] to [other]. + /// + /// [analogous]: https://www.w3.org/TR/css-color-4/#interpolation-missing + bool isAnalogous(ColorChannel other) { + switch (name) { + case "red": + case "x": + return other.name == "red" || other.name == "x"; + + case "green": + case "y": + return other.name == "green" || other.name == "y"; + + case "blue": + case "z": + return other.name == "blue" || other.name == "z"; + + case "chroma": + case "saturation": + return other.name == "chroma" || other.name == "saturation"; + + case "lightness": + case "hue": + return other.name == name; + + default: + return false; + } + } +} + +/// Metadata about a color channel with a linear (as opposed to polar) value. +/// +/// {@category Value} +@sealed +class LinearChannel extends ColorChannel { + /// The channel's minimum value. + /// + /// Unless this color space is strictly bounded, this channel's values may + /// still be below this minimum value. It just represents a limit to reference + /// when specifying channels by percentage, as well as a boundary for what's + /// considered in-gamut if the color space has a bounded gamut. + final double min; + + /// The channel's maximum value. + /// + /// Unless this color space is strictly bounded, this channel's values may + /// still be above this maximum value. It just represents a limit to reference + /// when specifying channels by percentage, as well as a boundary for what's + /// considered in-gamut if the color space has a bounded gamut. + final double max; + + /// Whether this channel requires values to be specified with unit `%` and + /// forbids unitless values. + final bool requiresPercent; + + /// @nodoc + @internal + const LinearChannel(String name, this.min, this.max, + {this.requiresPercent = false}) + : super(name, isPolarAngle: false); +} diff --git a/lib/src/value/color/conversions.dart b/lib/src/value/color/conversions.dart new file mode 100644 index 000000000..f1a7a3e87 --- /dev/null +++ b/lib/src/value/color/conversions.dart @@ -0,0 +1,464 @@ +// Copyright 2022 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'dart:typed_data'; + +/// The D50 white point. +/// +/// Definition from https://www.w3.org/TR/css-color-4/#color-conversion-code. +const d50 = [0.3457 / 0.3585, 1.00000, (1.0 - 0.3457 - 0.3585) / 0.3585]; + +// Matrix values from https://www.w3.org/TR/css-color-4/#color-conversion-code. + +/// The transformation matrix for converting LMS colors to OKLab. +/// +/// Note that this can't be directly multiplied with [d65XyzToLms]; see Color +/// Level 4 spec for details on how to convert between XYZ and OKLab. +final lmsToOklab = Float64List.fromList([ + 00.2104542553, 00.7936177850, -0.0040720468, // + 01.9779984951, -2.4285922050, 00.4505937099, + 00.0259040371, 00.7827717662, -0.8086757660 +]); + +/// The transformation matrix for converting OKLab colors to LMS. +/// +/// Note that this can't be directly multiplied with [lmsToD65Xyz]; see Color +/// Level 4 spec for details on how to convert between XYZ and OKLab. +final oklabToLms = Float64List.fromList([ + // + 00.99999999845051981432, 00.396337792173767856780, 00.215803758060758803390, + 01.00000000888176077670, -0.105561342323656349400, -0.063854174771705903402, + 01.00000005467241091770, -0.089484182094965759684, -1.291485537864091739900 +]); + +// The following matrices were precomputed using +// https://gist.github.com/nex3/3d7ecfef467b22e02e7a666db1b8a316. + +// The transformation matrix for converting linear-light srgb colors to +// linear-light display-p3. +final linearSrgbToLinearDisplayP3 = Float64List.fromList([ + 00.82246196871436230, 00.17753803128563775, 00.00000000000000000, // + 00.03319419885096161, 00.96680580114903840, 00.00000000000000000, + 00.01708263072112003, 00.07239744066396346, 00.91051992861491650, +]); + +// The transformation matrix for converting linear-light display-p3 colors to +// linear-light srgb. +final linearDisplayP3ToLinearSrgb = Float64List.fromList([ + 01.22494017628055980, -0.22494017628055996, 00.00000000000000000, // + -0.04205695470968816, 01.04205695470968800, 00.00000000000000000, + -0.01963755459033443, -0.07863604555063188, 01.09827360014096630, +]); + +// The transformation matrix for converting linear-light srgb colors to +// linear-light a98-rgb. +final linearSrgbToLinearA98Rgb = Float64List.fromList([ + 00.71512560685562470, 00.28487439314437535, 00.00000000000000000, // + 00.00000000000000000, 01.00000000000000000, 00.00000000000000000, + 00.00000000000000000, 00.04116194845011846, 00.95883805154988160, +]); + +// The transformation matrix for converting linear-light a98-rgb colors to +// linear-light srgb. +final linearA98RgbToLinearSrgb = Float64List.fromList([ + 01.39835574396077830, -0.39835574396077830, 00.00000000000000000, // + 00.00000000000000000, 01.00000000000000000, 00.00000000000000000, + 00.00000000000000000, -0.04292898929447326, 01.04292898929447330, +]); + +// The transformation matrix for converting linear-light srgb colors to +// linear-light rec2020. +final linearSrgbToLinearRec2020 = Float64List.fromList([ + 00.62740389593469900, 00.32928303837788370, 00.04331306568741722, // + 00.06909728935823208, 00.91954039507545870, 00.01136231556630917, + 00.01639143887515027, 00.08801330787722575, 00.89559525324762400, +]); + +// The transformation matrix for converting linear-light rec2020 colors to +// linear-light srgb. +final linearRec2020ToLinearSrgb = Float64List.fromList([ + 01.66049100210843450, -0.58764113878854950, -0.07284986331988487, // + -0.12455047452159074, 01.13289989712596030, -0.00834942260436947, + -0.01815076335490530, -0.10057889800800737, 01.11872966136291270, +]); + +// The transformation matrix for converting linear-light srgb colors to xyz. +final linearSrgbToXyzD65 = Float64List.fromList([ + 00.41239079926595950, 00.35758433938387796, 00.18048078840183430, // + 00.21263900587151036, 00.71516867876775590, 00.07219231536073371, + 00.01933081871559185, 00.11919477979462598, 00.95053215224966060, +]); + +// The transformation matrix for converting xyz colors to linear-light srgb. +final xyzD65ToLinearSrgb = Float64List.fromList([ + 03.24096994190452130, -1.53738317757009350, -0.49861076029300330, // + -0.96924363628087980, 01.87596750150772060, 00.04155505740717561, + 00.05563007969699360, -0.20397695888897657, 01.05697151424287860, +]); + +// The transformation matrix for converting linear-light srgb colors to lms. +final linearSrgbToLms = Float64List.fromList([ + 00.41222147080000016, 00.53633253629999990, 00.05144599290000001, // + 00.21190349820000007, 00.68069954509999990, 00.10739695660000000, + 00.08830246190000005, 00.28171883759999994, 00.62997870050000000, +]); + +// The transformation matrix for converting lms colors to linear-light srgb. +final lmsToLinearSrgb = Float64List.fromList([ + 04.07674166134799300, -3.30771159040819240, 00.23096992872942781, // + -1.26843800409217660, 02.60975740066337240, -0.34131939631021974, + -0.00419608654183720, -0.70341861445944950, 01.70761470093094480, +]); + +// The transformation matrix for converting linear-light srgb colors to +// linear-light prophoto-rgb. +final linearSrgbToLinearProphotoRgb = Float64List.fromList([ + 00.52927697762261160, 00.33015450197849283, 00.14056852039889556, // + 00.09836585954044917, 00.87347071290696180, 00.02816342755258900, + 00.01687534092138684, 00.11765941425612084, 00.86546524482249230, +]); + +// The transformation matrix for converting linear-light prophoto-rgb colors to +// linear-light srgb. +final linearProphotoRgbToLinearSrgb = Float64List.fromList([ + 02.03438084951699600, -0.72763578993413420, -0.30674505958286180, // + -0.22882573163305037, 01.23174254119010480, -0.00291680955705449, + -0.00855882878391742, -0.15326670213803720, 01.16182553092195470, +]); + +// The transformation matrix for converting linear-light srgb colors to xyz-d50. +final linearSrgbToXyzD50 = Float64List.fromList([ + 00.43606574687426936, 00.38515150959015960, 00.14307841996513868, // + 00.22249317711056518, 00.71688701309448240, 00.06061980979495235, + 00.01392392146316939, 00.09708132423141015, 00.71409935681588070, +]); + +// The transformation matrix for converting xyz-d50 colors to linear-light srgb. +final xyzD50ToLinearSrgb = Float64List.fromList([ + 03.13413585290011780, -1.61738599801804200, -0.49066221791109754, // + -0.97879547655577770, 01.91625437739598840, 00.03344287339036693, + 00.07195539255794733, -0.22897675981518200, 01.40538603511311820, +]); + +// The transformation matrix for converting linear-light display-p3 colors to +// linear-light a98-rgb. +final linearDisplayP3ToLinearA98Rgb = Float64List.fromList([ + 00.86400513747404840, 00.13599486252595164, 00.00000000000000000, // + -0.04205695470968816, 01.04205695470968800, 00.00000000000000000, + -0.02056038078232985, -0.03250613804550798, 01.05306651882783790, +]); + +// The transformation matrix for converting linear-light a98-rgb colors to +// linear-light display-p3. +final linearA98RgbToLinearDisplayP3 = Float64List.fromList([ + 01.15009441814101840, -0.15009441814101834, 00.00000000000000000, // + 00.04641729862941844, 00.95358270137058150, 00.00000000000000000, + 00.02388759479083904, 00.02650477632633013, 00.94960762888283080, +]); + +// The transformation matrix for converting linear-light display-p3 colors to +// linear-light rec2020. +final linearDisplayP3ToLinearRec2020 = Float64List.fromList([ + 00.75383303436172180, 00.19859736905261630, 00.04756959658566187, // + 00.04574384896535833, 00.94177721981169350, 00.01247893122294812, + -0.00121034035451832, 00.01760171730108989, 00.98360862305342840, +]); + +// The transformation matrix for converting linear-light rec2020 colors to +// linear-light display-p3. +final linearRec2020ToLinearDisplayP3 = Float64List.fromList([ + 01.34357825258433200, -0.28217967052613570, -0.06139858205819628, // + -0.06529745278911953, 01.07578791584857460, -0.01049046305945495, + 00.00282178726170095, -0.01959849452449406, 01.01677670726279310, +]); + +// The transformation matrix for converting linear-light display-p3 colors to +// xyz. +final linearDisplayP3ToXyzD65 = Float64List.fromList([ + 00.48657094864821626, 00.26566769316909294, 00.19821728523436250, // + 00.22897456406974884, 00.69173852183650620, 00.07928691409374500, + 00.00000000000000000, 00.04511338185890257, 01.04394436890097570, +]); + +// The transformation matrix for converting xyz colors to linear-light +// display-p3. +final xyzD65ToLinearDisplayP3 = Float64List.fromList([ + 02.49349691194142450, -0.93138361791912360, -0.40271078445071684, // + -0.82948896956157490, 01.76266406031834680, 00.02362468584194359, + 00.03584583024378433, -0.07617238926804170, 00.95688452400768730, +]); + +// The transformation matrix for converting linear-light display-p3 colors to +// lms. +final linearDisplayP3ToLms = Float64List.fromList([ + 00.48137985442585490, 00.46211836973903553, 00.05650177583510960, // + 00.22883194490233110, 00.65321681282840370, 00.11795124216926511, + 00.08394575573016760, 00.22416526885956980, 00.69188897541026260, +]); + +// The transformation matrix for converting lms colors to linear-light +// display-p3. +final lmsToLinearDisplayP3 = Float64List.fromList([ + 03.12776898667772140, -2.25713579553953770, 00.12936680863610234, // + -1.09100904738343900, 02.41333175827934370, -0.32232271065457110, + -0.02601081320950207, -0.50804132569306730, 01.53405213885176520, +]); + +// The transformation matrix for converting linear-light display-p3 colors to +// linear-light prophoto-rgb. +final linearDisplayP3ToLinearProphotoRgb = Float64List.fromList([ + 00.63168691934035890, 00.21393038569465722, 00.15438269496498390, // + 00.08320371426648458, 00.88586513676302430, 00.03093114897049121, + -0.00127273456473881, 00.05075510433665735, 00.95051763022808140, +]); + +// The transformation matrix for converting linear-light prophoto-rgb colors to +// linear-light display-p3. +final linearProphotoRgbToLinearDisplayP3 = Float64List.fromList([ + 01.63257560870691790, -0.37977161848259840, -0.25280399022431950, // + -0.15370040233755072, 01.16670254724250140, -0.01300214490495082, + 00.01039319529676572, -0.06280731264959440, 01.05241411735282870, +]); + +// The transformation matrix for converting linear-light display-p3 colors to +// xyz-d50. +final linearDisplayP3ToXyzD50 = Float64List.fromList([ + 00.51514644296811600, 00.29200998206385770, 00.15713925139759397, // + 00.24120032212525520, 00.69222254113138180, 00.06657713674336294, + -0.00105013914714014, 00.04187827018907460, 00.78427647146852570, +]); + +// The transformation matrix for converting xyz-d50 colors to linear-light +// display-p3. +final xyzD50ToLinearDisplayP3 = Float64List.fromList([ + 02.40393412185549730, -0.99003044249559310, -0.39761363181465614, // + -0.84227001614546880, 01.79895801610670820, 00.01604562477090472, + 00.04819381686413303, -0.09738519815446048, 01.27367136933212730, +]); + +// The transformation matrix for converting linear-light a98-rgb colors to +// linear-light rec2020. +final linearA98RgbToLinearRec2020 = Float64List.fromList([ + 00.87733384166365680, 00.07749370651571998, 00.04517245182062317, // + 00.09662259146620378, 00.89152732024418050, 00.01185008828961569, + 00.02292106270284839, 00.04303668501067932, 00.93404225228647230, +]); + +// The transformation matrix for converting linear-light rec2020 colors to +// linear-light a98-rgb. +final linearRec2020ToLinearA98Rgb = Float64List.fromList([ + 01.15197839471591630, -0.09750305530240860, -0.05447533941350766, // + -0.12455047452159074, 01.13289989712596030, -0.00834942260436947, + -0.02253038278105590, -0.04980650742838876, 01.07233689020944460, +]); + +// The transformation matrix for converting linear-light a98-rgb colors to xyz. +final linearA98RgbToXyzD65 = Float64List.fromList([ + 00.57666904291013080, 00.18555823790654627, 00.18822864623499472, // + 00.29734497525053616, 00.62736356625546600, 00.07529145849399789, + 00.02703136138641237, 00.07068885253582714, 00.99133753683763890, +]); + +// The transformation matrix for converting xyz colors to linear-light a98-rgb. +final xyzD65ToLinearA98Rgb = Float64List.fromList([ + 02.04158790381074600, -0.56500697427885960, -0.34473135077832950, // + -0.96924363628087980, 01.87596750150772060, 00.04155505740717561, + 00.01344428063203102, -0.11836239223101823, 01.01517499439120540, +]); + +// The transformation matrix for converting linear-light a98-rgb colors to lms. +final linearA98RgbToLms = Float64List.fromList([ + 00.57643226147714040, 00.36991322114441194, 00.05365451737844765, // + 00.29631647387335260, 00.59167612662650690, 00.11200739940014041, + 00.12347825480374285, 00.21949869580674647, 00.65702304938951070, +]); + +// The transformation matrix for converting lms colors to linear-light a98-rgb. +final lmsToLinearA98Rgb = Float64List.fromList([ + 02.55403684790806950, -1.62197620262602140, 00.06793935455575403, // + -1.26843800409217660, 02.60975740066337240, -0.34131939631021974, + -0.05623474718052319, -0.56704183411879500, 01.62327658124261400, +]); + +// The transformation matrix for converting linear-light a98-rgb colors to +// linear-light prophoto-rgb. +final linearA98RgbToLinearProphotoRgb = Float64List.fromList([ + 00.74011750180477920, 00.11327951328898105, 00.14660298490623970, // + 00.13755046469802620, 00.83307708026948400, 00.02937245503248977, + 00.02359772990871766, 00.07378347703906656, 00.90261879305221580, +]); + +// The transformation matrix for converting linear-light prophoto-rgb colors to +// linear-light a98-rgb. +final linearProphotoRgbToLinearA98Rgb = Float64List.fromList([ + 01.38965124815152000, -0.16945907691487766, -0.22019217123664242, // + -0.22882573163305037, 01.23174254119010480, -0.00291680955705449, + -0.01762544368426068, -0.09625702306122665, 01.11388246674548740, +]); + +// The transformation matrix for converting linear-light a98-rgb colors to +// xyz-d50. +final linearA98RgbToXyzD50 = Float64List.fromList([ + 00.60977504188618140, 00.20530000261929401, 00.14922063192409227, // + 00.31112461220464155, 00.62565323083468560, 00.06322215696067286, + 00.01947059555648168, 00.06087908649415867, 00.74475492045981980, +]); + +// The transformation matrix for converting xyz-d50 colors to linear-light +// a98-rgb. +final xyzD50ToLinearA98Rgb = Float64List.fromList([ + 01.96246703637688060, -0.61074234048150730, -0.34135809808271540, // + -0.97879547655577770, 01.91625437739598840, 00.03344287339036693, + 00.02870443944957101, -0.14067486633170680, 01.34891418141379370, +]); + +// The transformation matrix for converting linear-light rec2020 colors to xyz. +final linearRec2020ToXyzD65 = Float64List.fromList([ + 00.63695804830129130, 00.14461690358620838, 00.16888097516417205, // + 00.26270021201126703, 00.67799807151887100, 00.05930171646986194, + 00.00000000000000000, 00.02807269304908750, 01.06098505771079090, +]); + +// The transformation matrix for converting xyz colors to linear-light rec2020. +final xyzD65ToLinearRec2020 = Float64List.fromList([ + 01.71665118797126760, -0.35567078377639240, -0.25336628137365980, // + -0.66668435183248900, 01.61648123663493900, 00.01576854581391113, + 00.01763985744531091, -0.04277061325780865, 00.94210312123547400, +]); + +// The transformation matrix for converting linear-light rec2020 colors to lms. +final linearRec2020ToLms = Float64List.fromList([ + 00.61675578719908560, 00.36019839939276255, 00.02304581340815186, // + 00.26513306398328140, 00.63583936407771060, 00.09902757183900800, + 00.10010263423281572, 00.20390651940192997, 00.69599084636525430, +]); + +// The transformation matrix for converting lms colors to linear-light rec2020. +final lmsToLinearRec2020 = Float64List.fromList([ + 02.13990673569556170, -1.24638950878469060, 00.10648277296448995, // + -0.88473586245815630, 02.16323098210838260, -0.27849511943390290, + -0.04857375801465988, -0.45450314291725170, 01.50307690088646130, +]); + +// The transformation matrix for converting linear-light rec2020 colors to +// linear-light prophoto-rgb. +final linearRec2020ToLinearProphotoRgb = Float64List.fromList([ + 00.83518733312972350, 00.04886884858605698, 00.11594381828421951, // + 00.05403324519953363, 00.92891840856920440, 00.01704834623126199, + -0.00234203897072539, 00.03633215316169465, 00.96600988580903070, +]); + +// The transformation matrix for converting linear-light prophoto-rgb colors to +// linear-light rec2020. +final linearProphotoRgbToLinearRec2020 = Float64List.fromList([ + 01.20065932951740800, -0.05756805370122346, -0.14309127581618444, // + -0.06994154955888504, 01.08061789759721400, -0.01067634803832895, + 00.00554147334294746, -0.04078219298657951, 01.03524071964363200, +]); + +// The transformation matrix for converting linear-light rec2020 colors to +// xyz-d50. +final linearRec2020ToXyzD50 = Float64List.fromList([ + 00.67351546318827600, 00.16569726370390453, 00.12508294953738705, // + 00.27905900514112060, 00.67531800574910980, 00.04562298910976962, + -0.00193242713400438, 00.02997782679282923, 00.79705920285163550, +]); + +// The transformation matrix for converting xyz-d50 colors to linear-light +// rec2020. +final xyzD50ToLinearRec2020 = Float64List.fromList([ + 01.64718490467176600, -0.39368189813164710, -0.23595963848828266, // + -0.68266410741738180, 01.64771461274440760, 00.01281708338512084, + 00.02966887665275675, -0.06292589642970030, 01.25355782018657710, +]); + +// The transformation matrix for converting xyz colors to lms. +final xyzD65ToLms = Float64List.fromList([ + 00.81902244321643190, 00.36190625628012210, -0.12887378261216414, // + 00.03298366719802710, 00.92928684689655460, 00.03614466816999844, + 00.04817719956604625, 00.26423952494422764, 00.63354782581369370, +]); + +// The transformation matrix for converting lms colors to xyz. +final lmsToXyzD65 = Float64List.fromList([ + 01.22687987337415570, -0.55781499655548140, 00.28139105017721590, // + -0.04057576262431372, 01.11228682939705960, -0.07171106666151703, + -0.07637294974672143, -0.42149332396279143, 01.58692402442724180, +]); + +// The transformation matrix for converting xyz colors to linear-light +// prophoto-rgb. +final xyzD65ToLinearProphotoRgb = Float64List.fromList([ + 01.40319046337749790, -0.22301514479051668, -0.10160668507413790, // + -0.52623840216330720, 01.48163196292346440, 00.01701879027252688, + -0.01120226528622150, 00.01824640347962099, 00.91124722749150480, +]); + +// The transformation matrix for converting linear-light prophoto-rgb colors to +// xyz. +final linearProphotoRgbToXyzD65 = Float64List.fromList([ + 00.75559074229692100, 00.11271984265940525, 00.08214534209534540, // + 00.26832184357857190, 00.71511525666179120, 00.01656289975963685, + 00.00391597276242580, -0.01293344283684181, 01.09807522083429450, +]); + +// The transformation matrix for converting xyz colors to xyz-d50. +final xyzD65ToXyzD50 = Float64List.fromList([ + 01.04792979254499660, 00.02294687060160952, -0.05019226628920519, // + 00.02962780877005567, 00.99043442675388000, -0.01707379906341879, + -0.00924304064620452, 00.01505519149029816, 00.75187428142813700, +]); + +// The transformation matrix for converting xyz-d50 colors to xyz. +final xyzD50ToXyzD65 = Float64List.fromList([ + 00.95547342148807520, -0.02309845494876452, 00.06325924320057065, // + -0.02836970933386358, 01.00999539808130410, 00.02104144119191730, + 00.01231401486448199, -0.02050764929889898, 01.33036592624212400, +]); + +// The transformation matrix for converting lms colors to linear-light +// prophoto-rgb. +final lmsToLinearProphotoRgb = Float64List.fromList([ + 01.73835514985815240, -0.98795095237343430, 00.24959580241648663, // + -0.70704942624914860, 01.93437008438177620, -0.22732065793919040, + -0.08407883426424761, -0.35754059702097796, 01.44161943124947150, +]); + +// The transformation matrix for converting linear-light prophoto-rgb colors to +// lms. +final linearProphotoRgbToLms = Float64List.fromList([ + 00.71544846349294310, 00.35279154798172740, -0.06824001147467047, // + 00.27441165509049420, 00.66779764080811480, 00.05779070400139092, + 00.10978443849083751, 00.18619828746596980, 00.70401727404319270, +]); + +// The transformation matrix for converting lms colors to xyz-d50. +final lmsToXyzD50 = Float64List.fromList([ + 01.28858621583908840, -0.53787174651736210, 00.21358120705405403, // + -0.00253389352489796, 01.09231682453266550, -0.08978293089853581, + -0.06937383312514489, -0.29500839218634667, 01.18948682779245090, +]); + +// The transformation matrix for converting xyz-d50 colors to lms. +final xyzD50ToLms = Float64List.fromList([ + 00.77070004712402500, 00.34924839871072740, -0.11202352004249890, // + 00.00559650559780223, 00.93707232493333150, 00.06972569131301698, + 00.04633715253432816, 00.25277530868525870, 00.85145807371608350, +]); + +// The transformation matrix for converting linear-light prophoto-rgb colors to +// xyz-d50. +final linearProphotoRgbToXyzD50 = Float64List.fromList([ + 00.79776664490064230, 00.13518129740053308, 00.03134773412839220, // + 00.28807482881940130, 00.71183523424187300, 00.00008993693872564, + 00.00000000000000000, 00.00000000000000000, 00.82510460251046020, +]); + +// The transformation matrix for converting xyz-d50 colors to linear-light +// prophoto-rgb. +final xyzD50ToLinearProphotoRgb = Float64List.fromList([ + 01.34578688164715830, -0.25557208737979464, -0.05110186497554526, // + -0.54463070512490190, 01.50824774284514680, 00.02052744743642139, + 00.00000000000000000, 00.00000000000000000, 01.21196754563894520, +]); diff --git a/lib/src/value/color/interpolation_method.dart b/lib/src/value/color/interpolation_method.dart new file mode 100644 index 000000000..b3ed40b25 --- /dev/null +++ b/lib/src/value/color/interpolation_method.dart @@ -0,0 +1,160 @@ +// Copyright 2022 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'package:meta/meta.dart'; + +import '../../exception.dart'; +import '../../value.dart'; + +/// The method by which two colors are interpolated to find a color in the +/// middle. +/// +/// Used by [SassColor.interpolate]. +/// +/// {@category Value} +class InterpolationMethod { + /// The set of color spaces that can be used for color interpolation. + /// + /// @nodoc + @internal + static const supportedSpaces = { + ColorSpace.srgb, + ColorSpace.srgbLinear, + ColorSpace.lab, + ColorSpace.oklab, + ColorSpace.xyzD50, + ColorSpace.xyzD65, + ColorSpace.hsl, + ColorSpace.hwb, + ColorSpace.lch, + ColorSpace.oklch + }; + + /// The color space in which to perform the interpolation. + final ColorSpace space; + + /// How to interpolate the hues between two colors. + /// + /// This is non-null if and only if [space] is a color space. + final HueInterpolationMethod? hue; + + InterpolationMethod(this.space, [HueInterpolationMethod? hue]) + : hue = space.isPolar ? hue ?? HueInterpolationMethod.shorter : null { + if (!supportedSpaces.contains(space)) { + throw ArgumentError( + "Color space $space can't be used for interpolation."); + } else if (!space.isPolar && hue != null) { + throw ArgumentError( + "Hue interpolation method may not be set for rectangular color space " + "$space."); + } + } + + /// Parses a SassScript value representing an interpolation method, not + /// beginning with "in". + /// + /// Throws a [SassScriptException] if [value] isn't a valid interpolation + /// method. If [value] came from a function argument, [name] is the argument name + /// (without the `$`). This is used for error reporting. + factory InterpolationMethod.fromValue(Value value, [String? name]) { + var list = value.assertCommonListStyle(name, allowSlash: false); + if (list.isEmpty) { + throw SassScriptException( + 'Expected a color interpolation method, got an empty list.', name); + } + + var space = ColorSpace.fromName( + (list.first.assertString(name)..assertUnquoted(name)).text, name); + if (!supportedSpaces.contains(space)) { + throw SassScriptException( + "Color space $space can't be used for interpolation.", name); + } + + if (list.length == 1) return InterpolationMethod(space); + + var hueMethod = HueInterpolationMethod._fromValue(list[1], name); + if (list.length == 2) { + throw SassScriptException( + 'Expected unquoted string "hue" after $value.', name); + } else if ((list[2].assertString(name)..assertUnquoted(name)) + .text + .toLowerCase() != + 'hue') { + throw SassScriptException( + 'Expected unquoted string "hue" at the end of $value, was ${list[2]}.', + name); + } else if (list.length > 2) { + throw SassScriptException( + 'Expected nothing after "hue" in $value.', name); + } else if (!space.isPolar) { + throw SassScriptException( + 'Hue interpolation method "$hueMethod hue" may not be set for ' + 'rectangular color space $space.', + name); + } + + return InterpolationMethod(space, hueMethod); + } + + String toString() => space.toString() + (hue == null ? '' : ' $hue hue'); +} + +/// The method by which two hues are adjusted when interpolating between colors. +/// +/// Used by [InterpolationMethod]. +/// +/// {@category Value} +enum HueInterpolationMethod { + /// Angles are adjusted so that `θ₂ - θ₁ ∈ [-180, 180]`. + /// + /// https://www.w3.org/TR/css-color-4/#shorter + shorter, + + /// Angles are adjusted so that `θ₂ - θ₁ ∈ {0, [180, 360)}`. + /// + /// https://www.w3.org/TR/css-color-4/#hue-longer + longer, + + /// Angles are adjusted so that `θ₂ - θ₁ ∈ [0, 360)`. + /// + /// https://www.w3.org/TR/css-color-4/#hue-increasing + increasing, + + /// Angles are adjusted so that `θ₂ - θ₁ ∈ (-360, 0]`. + /// + /// https://www.w3.org/TR/css-color-4/#hue-decreasing + decreasing, + + /// No fixup is performed. + /// + /// Angles are interpolated in the same way as every other component. + /// + /// https://www.w3.org/TR/css-color-4/#hue-specified + specified; + + /// Parses a SassScript value representing a hue interpolation method, not + /// ending with "hue". + /// + /// Throws a [SassScriptException] if [value] isn't a valid hue interpolation + /// method. If [value] came from a function argument, [name] is the argument + /// name (without the `$`). This is used for error reporting. + factory HueInterpolationMethod._fromValue(Value value, [String? name]) { + var text = (value.assertString(name)..assertUnquoted()).text.toLowerCase(); + switch (text) { + case 'shorter': + return HueInterpolationMethod.shorter; + case 'longer': + return HueInterpolationMethod.longer; + case 'increasing': + return HueInterpolationMethod.increasing; + case 'decreasing': + return HueInterpolationMethod.decreasing; + case 'specified': + return HueInterpolationMethod.specified; + default: + throw SassScriptException( + 'Unknown hue interpolation method $value.', name); + } + } +} diff --git a/lib/src/value/color/space.dart b/lib/src/value/color/space.dart new file mode 100644 index 000000000..f3cd7efd9 --- /dev/null +++ b/lib/src/value/color/space.dart @@ -0,0 +1,336 @@ +// Copyright 2022 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; + +import '../../exception.dart'; +import '../color.dart'; +import 'space/a98_rgb.dart'; +import 'space/display_p3.dart'; +import 'space/hsl.dart'; +import 'space/hwb.dart'; +import 'space/lab.dart'; +import 'space/lch.dart'; +import 'space/lms.dart'; +import 'space/oklab.dart'; +import 'space/oklch.dart'; +import 'space/prophoto_rgb.dart'; +import 'space/rec2020.dart'; +import 'space/rgb.dart'; +import 'space/srgb.dart'; +import 'space/srgb_linear.dart'; +import 'space/xyz_d50.dart'; +import 'space/xyz_d65.dart'; + +// TODO: limit instance methods to sass_api + +/// A color space whose channel names and semantics Sass knows. +/// +/// {@category Value} +@sealed +abstract class ColorSpace { + /// The legacy RGB color space. + static const ColorSpace rgb = RgbColorSpace(); + + /// The legacy HSL color space. + static const ColorSpace hsl = HslColorSpace(); + + /// The legacy HWB color space. + static const ColorSpace hwb = HwbColorSpace(); + + /// The sRGB color space. + /// + /// https://www.w3.org/TR/css-color-4/#predefined-sRGB + static const ColorSpace srgb = SrgbColorSpace(); + + /// The linear-light sRGB color space. + /// + /// https://www.w3.org/TR/css-color-4/#predefined-sRGB-linear + static const ColorSpace srgbLinear = SrgbLinearColorSpace(); + + /// The display-p3 color space. + /// + /// https://www.w3.org/TR/css-color-4/#predefined-display-p3 + static const ColorSpace displayP3 = DisplayP3ColorSpace(); + + /// The a98-rgb color space. + /// + /// https://www.w3.org/TR/css-color-4/#predefined-a98-rgb + static const ColorSpace a98Rgb = A98RgbColorSpace(); + + /// The prophoto-rgb color space. + /// + /// https://www.w3.org/TR/css-color-4/#predefined-prophoto-rgb + static const ColorSpace prophotoRgb = ProphotoRgbColorSpace(); + + /// The rec2020 color space. + /// + /// https://www.w3.org/TR/css-color-4/#predefined-rec2020 + static const ColorSpace rec2020 = Rec2020ColorSpace(); + + /// The xyz-d65 color space. + /// + /// https://www.w3.org/TR/css-color-4/#predefined-xyz + static const ColorSpace xyzD65 = XyzD65ColorSpace(); + + /// The xyz-d50 color space. + /// + /// https://www.w3.org/TR/css-color-4/#predefined-xyz + static const ColorSpace xyzD50 = XyzD50ColorSpace(); + + /// The CIE Lab color space. + /// + /// https://www.w3.org/TR/css-color-4/#cie-lab + static const ColorSpace lab = LabColorSpace(); + + /// The CIE LCH color space. + /// + /// https://www.w3.org/TR/css-color-4/#cie-lab + static const ColorSpace lch = LchColorSpace(); + + /// The internal LMS color space. + /// + /// This only used as an intermediate space for conversions to and from OKLab + /// and OKLCH. It's never used in a real color value and isn't returned by + /// [fromName]. + /// + /// @nodoc + @internal + static const ColorSpace lms = LmsColorSpace(); + + /// The Oklab color space. + /// + /// https://www.w3.org/TR/css-color-4/#ok-lab + static const ColorSpace oklab = OklabColorSpace(); + + + /// The Oklch color space. + /// + /// https://www.w3.org/TR/css-color-4/#ok-lab + static const ColorSpace oklch = OklchColorSpace(); + + /// The CSS name of the color space. + final String name; + + /// See [SassApiColorSpace.channels]. + final List _channels; + + /// See [SassApiColorSpace.isBounded]. + /// + /// @nodoc + @internal + bool get isBoundedInternal; + + /// See [SassApiColorSpace.isStrictlyBounded]. + /// + /// @nodoc + @internal + bool get isStrictlyBoundedInternal => false; + + /// See [SassApiColorSpace.isLegacy]. + /// + /// @nodoc + @internal + bool get isLegacyInternal => false; + + /// See [SassApiColorSpace.isPolar]. + /// + /// @nodoc + @internal + bool get isPolarInternal => false; + + /// @nodoc + @internal + const ColorSpace(this.name, this._channels); + + /// Given a color space name, returns the known color space with that name or + /// throws a [SassScriptException] if there is none. + /// + /// If this came from a function argument, [argumentName] is the argument name + /// (without the `$`). This is used for error reporting. + static ColorSpace fromName(String name, [String? argumentName]) { + switch (name.toLowerCase()) { + case 'rgb': + return rgb; + case 'hwb': + return hwb; + case 'hsl': + return hsl; + case 'srgb': + return srgb; + case 'srgb-linear': + return srgbLinear; + case 'display-p3': + return displayP3; + case 'a98-rgb': + return a98Rgb; + case 'prophoto-rgb': + return prophotoRgb; + case 'rec2020': + return rec2020; + case 'xyz': + case 'xyz-d65': + return xyzD65; + case 'xyz-d50': + return xyzD50; + case 'lab': + return lab; + case 'lch': + return lch; + case 'oklab': + return oklab; + case 'oklch': + return oklch; + default: + throw SassScriptException('Unknown color space "$name".', argumentName); + } + } + + /// Converts a color with the given channels from this color space to [dest]. + /// + /// By default, this uses this color space's [toLinear] and + /// [transformationMatrix] as well as [dest]'s [fromLinear], and relies on + /// individual color space conversions to do more than purely linear + /// conversions. + /// + /// @nodoc + @internal + SassColor convert(ColorSpace dest, double channel0, double channel1, + double channel2, double alpha) { + var linearDest = dest; + switch (dest) { + case ColorSpace.hsl: + case ColorSpace.hwb: + linearDest = ColorSpace.srgb; + break; + + case ColorSpace.lab: + case ColorSpace.lch: + linearDest = ColorSpace.xyzD50; + break; + + case ColorSpace.oklab: + case ColorSpace.oklch: + linearDest = ColorSpace.lms; + break; + } + + double transformed0; + double transformed1; + double transformed2; + if (linearDest == this) { + transformed0 = channel0; + transformed1 = channel1; + transformed2 = channel2; + } else { + var linear0 = toLinear(channel0); + var linear1 = toLinear(channel1); + var linear2 = toLinear(channel2); + var matrix = transformationMatrix(linearDest); + + // (matrix * [linear0, linear1, linear2]).map(linearDest.fromLinear) + transformed0 = linearDest.fromLinear( + matrix[0] * linear0 + matrix[1] * linear1 + matrix[2] * linear2); + transformed1 = linearDest.fromLinear( + matrix[3] * linear0 + matrix[4] * linear1 + matrix[5] * linear2); + transformed2 = linearDest.fromLinear( + matrix[6] * linear0 + matrix[7] * linear1 + matrix[8] * linear2); + } + + switch (dest) { + case ColorSpace.hsl: + case ColorSpace.hwb: + case ColorSpace.lab: + case ColorSpace.lch: + case ColorSpace.oklab: + case ColorSpace.oklch: + return linearDest.convert( + dest, transformed0, transformed1, transformed2, alpha); + + default: + return SassColor.forSpaceInternal( + dest, transformed0, transformed1, transformed2, alpha); + } + } + + /// Converts a channel in this color space into an element of a vector that + /// can be linearly transformed into other color spaces. + /// + /// The precise semantics of this vector may vary from color space to color + /// space. The only requirement is that, for any space `dest` for which + /// `transformationMatrix(dest)` returns a value, + /// `dest.fromLinear(toLinear(channels) * transformationMatrix(dest))` + /// converts from this space to `dest`. + /// + /// If a color space explicitly supports all conversions in [convert], it need + /// not override this at all. + /// + /// @nodoc + @protected + @internal + double toLinear(double channel) => throw UnimplementedError( + "[BUG] Color space $this doesn't support linear conversions."); + + /// Converts an element of a 3-element vector that can be linearly transformed + /// into other color spaces into a channel in this color space. + /// + /// The precise semantics of this vector may vary from color space to color + /// space. The only requirement is that, for any space `dest` for which + /// `transformationMatrix(dest)` returns a value, + /// `dest.fromLinear(toLinear(channels) * transformationMatrix(dest))` + /// converts from this space to `dest`. + /// + /// If a color space explicitly supports all conversions in [convert], it need + /// not override this at all. + /// + /// @nodoc + @protected + @internal + double fromLinear(double channel) => throw UnimplementedError( + "[BUG] Color space $this doesn't support linear conversions."); + + /// Returns the matrix for performing a linear transformation from this color + /// space to [dest]. + /// + /// Specifically, `dest.fromLinear(toLinear(channels) * + /// transformationMatrix(dest))` must convert from this space to `dest`. + /// + /// This only needs to return values for color spaces that aren't explicitly + /// supported in [convert]. If a color space explicitly supports all + /// conversions in [convert], it need not override this at all. + /// + /// @nodoc + @protected + @internal + Float64List transformationMatrix(ColorSpace dest) => throw UnimplementedError( + '[BUG] Color space conversion from $this to $dest not implemented.'); + + String toString() => name; +} + +/// ColorSpace methods that are only visible through the `sass_api` package. +extension SassApiColorSpace on ColorSpace { + // This color space's channels. + List get channels => _channels; + + /// Whether this color space has a bounded gamut. + bool get isBounded => isBoundedInternal; + + /// Whether this color space is _strictly_ bounded. + /// + /// If this is `true`, channel values outside of their bounds are meaningless + /// and therefore forbidden, rather than being considered valid but + /// out-of-gamut. + /// + /// This is only `true` if [isBounded] is also `true`. + bool get isStrictlyBounded => isStrictlyBoundedInternal; + + /// Whether this is a legacy color space. + bool get isLegacy => isLegacyInternal; + + /// Whether this color space uses a polar coordinate system. + bool get isPolar => isPolarInternal; +} diff --git a/lib/src/value/color/space/a98_rgb.dart b/lib/src/value/color/space/a98_rgb.dart new file mode 100644 index 000000000..72469d068 --- /dev/null +++ b/lib/src/value/color/space/a98_rgb.dart @@ -0,0 +1,58 @@ +// Copyright 2022 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; + +import '../conversions.dart'; +import '../space.dart'; +import 'utils.dart'; + +/// The a98-rgb color space. +/// +/// https://www.w3.org/TR/css-color-4/#predefined-a98-rgb +/// +/// @nodoc +@internal +class A98RgbColorSpace extends ColorSpace { + bool get isBoundedInternal => true; + + const A98RgbColorSpace() : super('a98-rgb', rgbChannels); + + @protected + double toLinear(double channel) => + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + channel.sign * math.pow(channel.abs(), 563 / 256); + + @protected + double fromLinear(double channel) => + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + channel.sign * math.pow(channel.abs(), 256 / 563); + + @protected + Float64List transformationMatrix(ColorSpace dest) { + switch (dest) { + case ColorSpace.srgbLinear: + case ColorSpace.srgb: + case ColorSpace.rgb: + return linearA98RgbToLinearSrgb; + case ColorSpace.displayP3: + return linearA98RgbToLinearDisplayP3; + case ColorSpace.prophotoRgb: + return linearA98RgbToLinearProphotoRgb; + case ColorSpace.rec2020: + return linearA98RgbToLinearRec2020; + case ColorSpace.xyzD65: + return linearA98RgbToXyzD65; + case ColorSpace.xyzD50: + return linearA98RgbToXyzD50; + case ColorSpace.lms: + return linearA98RgbToLms; + default: + return super.transformationMatrix(dest); + } + } +} diff --git a/lib/src/value/color/space/display_p3.dart b/lib/src/value/color/space/display_p3.dart new file mode 100644 index 000000000..42568d2fd --- /dev/null +++ b/lib/src/value/color/space/display_p3.dart @@ -0,0 +1,53 @@ +// Copyright 2022 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; + +import '../conversions.dart'; +import '../space.dart'; +import 'utils.dart'; + +/// The display-p3 color space. +/// +/// https://www.w3.org/TR/css-color-4/#predefined-display-p3 +/// +/// @nodoc +@internal +class DisplayP3ColorSpace extends ColorSpace { + bool get isBoundedInternal => true; + + const DisplayP3ColorSpace() : super('display-p3', rgbChannels); + + @protected + double toLinear(double channel) => srgbAndDisplayP3ToLinear(channel); + + @protected + double fromLinear(double channel) => srgbAndDisplayP3FromLinear(channel); + + @protected + Float64List transformationMatrix(ColorSpace dest) { + switch (dest) { + case ColorSpace.srgbLinear: + case ColorSpace.srgb: + case ColorSpace.rgb: + return linearDisplayP3ToLinearSrgb; + case ColorSpace.a98Rgb: + return linearDisplayP3ToLinearA98Rgb; + case ColorSpace.prophotoRgb: + return linearDisplayP3ToLinearProphotoRgb; + case ColorSpace.rec2020: + return linearDisplayP3ToLinearRec2020; + case ColorSpace.xyzD65: + return linearDisplayP3ToXyzD65; + case ColorSpace.xyzD50: + return linearDisplayP3ToXyzD50; + case ColorSpace.lms: + return linearDisplayP3ToLms; + default: + return super.transformationMatrix(dest); + } + } +} diff --git a/lib/src/value/color/space/hsl.dart b/lib/src/value/color/space/hsl.dart new file mode 100644 index 000000000..ac357ebd9 --- /dev/null +++ b/lib/src/value/color/space/hsl.dart @@ -0,0 +1,50 @@ +// Copyright 2022 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +// ignore_for_file: avoid_renaming_method_parameters + +import 'package:meta/meta.dart'; + +import '../../color.dart'; +import 'utils.dart'; + +/// The legacy HSL color space. +/// +/// @nodoc +@internal +class HslColorSpace extends ColorSpace { + bool get isBoundedInternal => true; + bool get isStrictlyBoundedInternal => true; + bool get isLegacyInternal => true; + bool get isPolarInternal => true; + + const HslColorSpace() + : super('hsl', const [ + hueChannel, + LinearChannel('saturation', 0, 100, requiresPercent: true), + LinearChannel('lightness', 0, 100, requiresPercent: true) + ]); + + SassColor convert(ColorSpace dest, double hue, double saturation, + double lightness, double alpha) { + // Algorithm from the CSS3 spec: https://www.w3.org/TR/css3-color/#hsl-color. + var scaledHue = (hue / 360) % 1; + var scaledSaturation = saturation / 100; + var scaledLightness = lightness / 100; + + var m2 = scaledLightness <= 0.5 + ? scaledLightness * (scaledSaturation + 1) + : scaledLightness + + scaledSaturation - + scaledLightness * scaledSaturation; + var m1 = scaledLightness * 2 - m2; + + return ColorSpace.srgb.convert( + dest, + hueToRgb(m1, m2, scaledHue + 1 / 3), + hueToRgb(m1, m2, scaledHue), + hueToRgb(m1, m2, scaledHue - 1 / 3), + alpha); + } +} diff --git a/lib/src/value/color/space/hwb.dart b/lib/src/value/color/space/hwb.dart new file mode 100644 index 000000000..be15226c8 --- /dev/null +++ b/lib/src/value/color/space/hwb.dart @@ -0,0 +1,50 @@ +// Copyright 2022 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +// ignore_for_file: avoid_renaming_method_parameters + +import 'package:meta/meta.dart'; + +import '../../color.dart'; +import 'utils.dart'; + +/// The legacy HWB color space. +/// +/// @nodoc +@internal +class HwbColorSpace extends ColorSpace { + bool get isBoundedInternal => true; + bool get isStrictlyBoundedInternal => true; + bool get isLegacyInternal => true; + bool get isPolarInternal => true; + + const HwbColorSpace() + : super('hwb', const [ + hueChannel, + LinearChannel('whiteness', 0, 100, requiresPercent: true), + LinearChannel('blackness', 0, 100, requiresPercent: true) + ]); + + SassColor convert(ColorSpace dest, double hue, double whiteness, + double blackness, double alpha) { + // From https://www.w3.org/TR/css-color-4/#hwb-to-rgb + var scaledHue = hue % 360 / 360; + var scaledWhiteness = whiteness / 100; + var scaledBlackness = blackness / 100; + + var sum = scaledWhiteness + scaledBlackness; + if (sum > 1) { + scaledWhiteness /= sum; + scaledBlackness /= sum; + } + + var factor = 1 - scaledWhiteness - scaledBlackness; + double toRgb(double hue) => hueToRgb(0, 1, hue) * factor + scaledWhiteness; + + // Non-null because an in-gamut HSL color is guaranteed to be in-gamut for + // HWB as well. + return ColorSpace.srgb.convert(dest, toRgb(scaledHue + 1 / 3), + toRgb(scaledHue), toRgb(scaledHue - 1 / 3), alpha); + } +} diff --git a/lib/src/value/color/space/lab.dart b/lib/src/value/color/space/lab.dart new file mode 100644 index 000000000..bd7b18792 --- /dev/null +++ b/lib/src/value/color/space/lab.dart @@ -0,0 +1,65 @@ +// Copyright 2022 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +// ignore_for_file: avoid_renaming_method_parameters + +import 'dart:math' as math; + +import 'package:meta/meta.dart'; + +import '../../../util/number.dart'; +import '../../color.dart'; +import '../conversions.dart'; +import 'utils.dart'; + +/// The Lab color space. +/// +/// https://www.w3.org/TR/css-color-4/#specifying-lab-lch +/// +/// @nodoc +@internal +class LabColorSpace extends ColorSpace { + bool get isBoundedInternal => false; + + const LabColorSpace() + : super('lab', const [ + LinearChannel('lightness', 0, 100), + LinearChannel('a', -125, 125), + LinearChannel('b', -125, 125) + ]); + + SassColor convert( + ColorSpace dest, double lightness, double a, double b, double alpha) { + switch (dest) { + case ColorSpace.lab: + var powerlessAB = fuzzyEquals(lightness, 0); + return SassColor.lab( + lightness, powerlessAB ? null : a, powerlessAB ? null : b, alpha); + + case ColorSpace.lch: + return labToLch(dest, lightness, a, b, alpha); + + default: + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + // and http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html + var f1 = (lightness + 16) / 116; + + return ColorSpace.xyzD50.convert( + dest, + _convertFToXorZ(a / 500 + f1) * d50[0], + (lightness > labKappa * labEpsilon + ? math.pow((lightness + 16) / 116, 3) * 1.0 + : lightness / labKappa) * + d50[1], + _convertFToXorZ(f1 - b / 200) * d50[2], + alpha); + } + } + + /// Converts an f-format component to the X or Z channel of an XYZ color. + double _convertFToXorZ(double component) { + var cubed = math.pow(component, 3) + 0.0; + return cubed > labEpsilon ? cubed : (116 * component - 16) / labKappa; + } +} diff --git a/lib/src/value/color/space/lch.dart b/lib/src/value/color/space/lch.dart new file mode 100644 index 000000000..d522e6454 --- /dev/null +++ b/lib/src/value/color/space/lch.dart @@ -0,0 +1,37 @@ +// Copyright 2022 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +// ignore_for_file: avoid_renaming_method_parameters + +import 'dart:math' as math; + +import 'package:meta/meta.dart'; + +import '../../color.dart'; +import 'utils.dart'; + +/// The LCH color space. +/// +/// https://www.w3.org/TR/css-color-4/#specifying-lab-lch +/// +/// @nodoc +@internal +class LchColorSpace extends ColorSpace { + bool get isBoundedInternal => false; + bool get isPolarInternal => true; + + const LchColorSpace() + : super('lch', const [ + LinearChannel('lightness', 0, 100), + LinearChannel('chroma', 0, 100), + hueChannel + ]); + + SassColor convert(ColorSpace dest, double lightness, double chroma, + double hue, double alpha) { + var hueRadians = hue * math.pi / 180; + return ColorSpace.lab.convert(dest, lightness, + chroma * math.cos(hueRadians), chroma * math.sin(hueRadians), alpha); + } +} diff --git a/lib/src/value/color/space/lms.dart b/lib/src/value/color/space/lms.dart new file mode 100644 index 000000000..b59791687 --- /dev/null +++ b/lib/src/value/color/space/lms.dart @@ -0,0 +1,115 @@ +// Copyright 2022 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +// ignore_for_file: avoid_renaming_method_parameters + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; + +import '../../../util/number.dart'; +import '../../color.dart'; +import '../conversions.dart'; +import 'utils.dart'; + +/// The LMS color space. +/// +/// This only used as an intermediate space for conversions to and from OKLab +/// and OKLCH. It's never used in a real color value and isn't returned by +/// [ColorSpace.fromName]. +/// +/// @nodoc +@internal +class LmsColorSpace extends ColorSpace { + bool get isBoundedInternal => false; + + const LmsColorSpace() + : super('lms', const [ + LinearChannel('long', 0, 1), + LinearChannel('medium', 0, 1), + LinearChannel('short', 0, 1) + ]); + + SassColor convert( + ColorSpace dest, double long, double medium, double short, double alpha) { + switch (dest) { + case ColorSpace.oklab: + // Algorithm from https://drafts.csswg.org/css-color-4/#color-conversion-code + var longScaled = math.pow(long, 1 / 3); + var mediumScaled = math.pow(medium, 1 / 3); + var shortScaled = math.pow(short, 1 / 3); + var lightness = lmsToOklab[0] * longScaled + + lmsToOklab[1] * mediumScaled + + lmsToOklab[2] * shortScaled; + + return SassColor.oklab( + lightness, + fuzzyEquals(lightness, 0) + ? null + : lmsToOklab[3] * longScaled + + lmsToOklab[4] * mediumScaled + + lmsToOklab[5] * shortScaled, + fuzzyEquals(lightness, 0) + ? null + : lmsToOklab[6] * longScaled + + lmsToOklab[7] * mediumScaled + + lmsToOklab[8] * shortScaled, + alpha); + + case ColorSpace.oklch: + // This is equivalent to converting to OKLab and then to OKLCH, but we + // do it inline to avoid extra list allocations since we expect + // conversions to and from OKLCH to be very common. + var longScaled = math.pow(long, 1 / 3); + var mediumScaled = math.pow(medium, 1 / 3); + var shortScaled = math.pow(short, 1 / 3); + return labToLch( + dest, + lmsToOklab[0] * longScaled + + lmsToOklab[1] * mediumScaled + + lmsToOklab[2] * shortScaled, + lmsToOklab[3] * longScaled + + lmsToOklab[4] * mediumScaled + + lmsToOklab[5] * shortScaled, + lmsToOklab[6] * longScaled + + lmsToOklab[7] * mediumScaled + + lmsToOklab[8] * shortScaled, + alpha); + + default: + return super.convert(dest, long, medium, short, alpha); + } + } + + @protected + double toLinear(double channel) => channel; + + @protected + double fromLinear(double channel) => channel; + + @protected + Float64List transformationMatrix(ColorSpace dest) { + switch (dest) { + case ColorSpace.srgbLinear: + case ColorSpace.srgb: + case ColorSpace.rgb: + return lmsToLinearSrgb; + case ColorSpace.a98Rgb: + return lmsToLinearA98Rgb; + case ColorSpace.prophotoRgb: + return lmsToLinearProphotoRgb; + case ColorSpace.displayP3: + return lmsToLinearDisplayP3; + case ColorSpace.rec2020: + return lmsToLinearRec2020; + case ColorSpace.xyzD65: + return lmsToXyzD65; + case ColorSpace.xyzD50: + return lmsToXyzD50; + default: + return super.transformationMatrix(dest); + } + } +} diff --git a/lib/src/value/color/space/oklab.dart b/lib/src/value/color/space/oklab.dart new file mode 100644 index 000000000..c63f292af --- /dev/null +++ b/lib/src/value/color/space/oklab.dart @@ -0,0 +1,60 @@ +// Copyright 2022 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +// ignore_for_file: avoid_renaming_method_parameters + +import 'dart:math' as math; + +import 'package:meta/meta.dart'; + +import '../../color.dart'; +import '../conversions.dart'; +import 'utils.dart'; + +/// The OKLab color space. +/// +/// https://www.w3.org/TR/css-color-4/#specifying-oklab-oklch +/// +/// @nodoc +@internal +class OklabColorSpace extends ColorSpace { + bool get isBoundedInternal => false; + + const OklabColorSpace() + : super('oklab', const [ + LinearChannel('lightness', 0, 1), + LinearChannel('a', -0.4, 0.4), + LinearChannel('b', -0.4, 0.4) + ]); + + SassColor convert( + ColorSpace dest, double lightness, double a, double b, double alpha) { + if (dest == ColorSpace.oklch) { + return labToLch(dest, lightness, a, b, alpha); + } + + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + return ColorSpace.lms.convert( + dest, + math.pow( + oklabToLms[0] * lightness + + oklabToLms[1] * a + + oklabToLms[2] * b, + 3) + + 0.0, + math.pow( + oklabToLms[3] * lightness + + oklabToLms[4] * a + + oklabToLms[5] * b, + 3) + + 0.0, + math.pow( + oklabToLms[6] * lightness + + oklabToLms[7] * a + + oklabToLms[8] * b, + 3) + + 0.0, + alpha); + } +} diff --git a/lib/src/value/color/space/oklch.dart b/lib/src/value/color/space/oklch.dart new file mode 100644 index 000000000..6bd63c736 --- /dev/null +++ b/lib/src/value/color/space/oklch.dart @@ -0,0 +1,37 @@ +// Copyright 2022 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +// ignore_for_file: avoid_renaming_method_parameters + +import 'dart:math' as math; + +import 'package:meta/meta.dart'; + +import '../../color.dart'; +import 'utils.dart'; + +/// The OKLCH color space. +/// +/// https://www.w3.org/TR/css-color-4/#specifying-oklab-oklch +/// +/// @nodoc +@internal +class OklchColorSpace extends ColorSpace { + bool get isBoundedInternal => false; + bool get isPolarInternal => true; + + const OklchColorSpace() + : super('oklch', const [ + LinearChannel('lightness', 0, 1), + LinearChannel('chroma', 0, 0.4), + hueChannel + ]); + + SassColor convert(ColorSpace dest, double lightness, double chroma, + double hue, double alpha) { + var hueRadians = hue * math.pi / 180; + return ColorSpace.oklab.convert(dest, lightness, + chroma * math.cos(hueRadians), chroma * math.sin(hueRadians), alpha); + } +} diff --git a/lib/src/value/color/space/prophoto_rgb.dart b/lib/src/value/color/space/prophoto_rgb.dart new file mode 100644 index 000000000..7cf0ddb28 --- /dev/null +++ b/lib/src/value/color/space/prophoto_rgb.dart @@ -0,0 +1,64 @@ +// Copyright 2022 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; + +import '../conversions.dart'; +import '../space.dart'; +import 'utils.dart'; + +/// The prophoto-rgb color space. +/// +/// https://www.w3.org/TR/css-color-4/#predefined-prophoto-rgb +/// +/// @nodoc +@internal +class ProphotoRgbColorSpace extends ColorSpace { + bool get isBoundedInternal => true; + + const ProphotoRgbColorSpace() : super('prophoto-rgb', rgbChannels); + + @protected + double toLinear(double channel) { + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + var abs = channel.abs(); + return abs <= 16 / 512 ? channel / 16 : channel.sign * math.pow(abs, 1.8); + } + + @protected + double fromLinear(double channel) { + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + var abs = channel.abs(); + return abs >= 1 / 512 + ? channel.sign * math.pow(abs, 1 / 1.8) + : 16 * channel; + } + + @protected + Float64List transformationMatrix(ColorSpace dest) { + switch (dest) { + case ColorSpace.srgbLinear: + case ColorSpace.srgb: + case ColorSpace.rgb: + return linearProphotoRgbToLinearSrgb; + case ColorSpace.a98Rgb: + return linearProphotoRgbToLinearA98Rgb; + case ColorSpace.displayP3: + return linearProphotoRgbToLinearDisplayP3; + case ColorSpace.rec2020: + return linearProphotoRgbToLinearRec2020; + case ColorSpace.xyzD65: + return linearProphotoRgbToXyzD65; + case ColorSpace.xyzD50: + return linearProphotoRgbToXyzD50; + case ColorSpace.lms: + return linearProphotoRgbToLms; + default: + return super.transformationMatrix(dest); + } + } +} diff --git a/lib/src/value/color/space/rec2020.dart b/lib/src/value/color/space/rec2020.dart new file mode 100644 index 000000000..9c456859b --- /dev/null +++ b/lib/src/value/color/space/rec2020.dart @@ -0,0 +1,72 @@ +// Copyright 2022 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; + +import '../conversions.dart'; +import '../space.dart'; +import 'utils.dart'; + +/// A constant used in the rec2020 gamma encoding/decoding functions. +const _alpha = 1.09929682680944; + +/// A constant used in the rec2020 gamma encoding/decoding functions. +const _beta = 0.018053968510807; + +/// The rec2020 color space. +/// +/// https://www.w3.org/TR/css-color-4/#predefined-rec2020 +/// +/// @nodoc +@internal +class Rec2020ColorSpace extends ColorSpace { + bool get isBoundedInternal => true; + + const Rec2020ColorSpace() : super('rec2020', rgbChannels); + + @protected + double toLinear(double channel) { + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + var abs = channel.abs(); + return abs < _beta * 4.5 + ? channel / 4.5 + : channel.sign * (math.pow((abs + _alpha - 1) / _alpha, 1 / 0.45)); + } + + @protected + double fromLinear(double channel) { + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + var abs = channel.abs(); + return abs > _beta + ? channel.sign * (_alpha * math.pow(abs, 0.45) - (_alpha - 1)) + : 4.5 * channel; + } + + @protected + Float64List transformationMatrix(ColorSpace dest) { + switch (dest) { + case ColorSpace.srgbLinear: + case ColorSpace.srgb: + case ColorSpace.rgb: + return linearRec2020ToLinearSrgb; + case ColorSpace.a98Rgb: + return linearRec2020ToLinearA98Rgb; + case ColorSpace.displayP3: + return linearRec2020ToLinearDisplayP3; + case ColorSpace.prophotoRgb: + return linearRec2020ToLinearProphotoRgb; + case ColorSpace.xyzD65: + return linearRec2020ToXyzD65; + case ColorSpace.xyzD50: + return linearRec2020ToXyzD50; + case ColorSpace.lms: + return linearRec2020ToLms; + default: + return super.transformationMatrix(dest); + } + } +} diff --git a/lib/src/value/color/space/rgb.dart b/lib/src/value/color/space/rgb.dart new file mode 100644 index 000000000..ca2ba3187 --- /dev/null +++ b/lib/src/value/color/space/rgb.dart @@ -0,0 +1,37 @@ +// Copyright 2022 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +// ignore_for_file: avoid_renaming_method_parameters + +import 'package:meta/meta.dart'; + +import '../../color.dart'; +import 'utils.dart'; + +/// The legacy RGB color space. +/// +/// @nodoc +@internal +class RgbColorSpace extends ColorSpace { + bool get isBoundedInternal => true; + bool get isLegacyInternal => true; + + const RgbColorSpace() + : super('rgb', const [ + LinearChannel('red', 0, 255), + LinearChannel('green', 0, 255), + LinearChannel('blue', 0, 255) + ]); + + SassColor convert(ColorSpace dest, double red, double green, double blue, + double alpha) => + ColorSpace.srgb.convert(dest, red / 255, green / 255, blue / 255, alpha); + + @protected + double toLinear(double channel) => srgbAndDisplayP3ToLinear(channel / 255); + + @protected + double fromLinear(double channel) => + srgbAndDisplayP3FromLinear(channel) * 255; +} diff --git a/lib/src/value/color/space/srgb.dart b/lib/src/value/color/space/srgb.dart new file mode 100644 index 000000000..6682e7644 --- /dev/null +++ b/lib/src/value/color/space/srgb.dart @@ -0,0 +1,130 @@ +// Copyright 2022 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +// ignore_for_file: avoid_renaming_method_parameters + +import 'dart:math' as math; + +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; + +import '../../../util/nullable.dart'; +import '../../../util/number.dart'; +import '../../color.dart'; +import '../conversions.dart'; +import 'utils.dart'; + +/// The sRGB color space. +/// +/// https://www.w3.org/TR/css-color-4/#predefined-sRGB +/// +/// @nodoc +@internal +class SrgbColorSpace extends ColorSpace { + bool get isBoundedInternal => true; + + const SrgbColorSpace() : super('srgb', rgbChannels); + + SassColor convert( + ColorSpace dest, double red, double green, double blue, double alpha) { + switch (dest) { + case ColorSpace.hsl: + case ColorSpace.hwb: + if (fuzzyCheckRange(red, 0, 1) == null || + fuzzyCheckRange(green, 0, 1) == null || + fuzzyCheckRange(blue, 0, 1) == null) { + return SassColor.srgb(red, green, blue).toGamut().toSpace(dest); + } + + // Algorithm from https://en.wikipedia.org/wiki/HSL_and_HSV#RGB_to_HSL_and_HSV + var max = math.max(math.max(red, green), blue); + var min = math.min(math.min(red, green), blue); + var delta = max - min; + + double? hue; + if (max == min) { + hue = 0; + } else if (max == red) { + hue = (60 * (green - blue) / delta) % 360; + } else if (max == green) { + hue = (120 + 60 * (blue - red) / delta) % 360; + } else { + // max == blue + hue = (240 + 60 * (red - green) / delta) % 360; + } + + if (dest == ColorSpace.hsl) { + var lightness = fuzzyClamp(50 * (max + min), 0, 100); + + double? saturation; + if (lightness == 0 || lightness == 100) { + saturation = null; + } else if (fuzzyEquals(max, min)) { + saturation = 0; + } else if (lightness < 50) { + saturation = 100 * delta / (max + min); + } else { + saturation = 100 * delta / (2 - max - min); + } + saturation = saturation + .andThen((saturation) => fuzzyClamp(saturation, 0, 100)); + + return SassColor.forSpaceInternal( + dest, + saturation == 0 || saturation == null ? null : hue, + saturation, + lightness, + alpha); + } else { + var whiteness = fuzzyClamp(min * 100, 0, 100); + var blackness = fuzzyClamp(100 - max * 100, 0, 100); + return SassColor.forSpaceInternal( + dest, + fuzzyEquals(whiteness + blackness, 100) ? null : hue, + whiteness, + blackness, + alpha); + } + + case ColorSpace.rgb: + return SassColor.rgb(red * 255, green * 255, blue * 255, alpha); + + case ColorSpace.srgbLinear: + return SassColor.forSpaceInternal( + dest, toLinear(red), toLinear(green), toLinear(blue), alpha); + + default: + return super.convert(dest, red, green, blue, alpha); + } + } + + @protected + double toLinear(double channel) => srgbAndDisplayP3ToLinear(channel); + + @protected + double fromLinear(double channel) => srgbAndDisplayP3FromLinear(channel); + + @protected + Float64List transformationMatrix(ColorSpace dest) { + switch (dest) { + case ColorSpace.displayP3: + return linearSrgbToLinearDisplayP3; + case ColorSpace.a98Rgb: + return linearSrgbToLinearA98Rgb; + case ColorSpace.prophotoRgb: + return linearSrgbToLinearProphotoRgb; + case ColorSpace.rec2020: + return linearSrgbToLinearRec2020; + case ColorSpace.xyzD65: + return linearSrgbToXyzD65; + case ColorSpace.xyzD50: + return linearSrgbToXyzD50; + case ColorSpace.lms: + return linearSrgbToLms; + default: + return super.transformationMatrix(dest); + } + } +} diff --git a/lib/src/value/color/space/srgb_linear.dart b/lib/src/value/color/space/srgb_linear.dart new file mode 100644 index 000000000..c0cc17a6c --- /dev/null +++ b/lib/src/value/color/space/srgb_linear.dart @@ -0,0 +1,72 @@ +// Copyright 2022 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +// ignore_for_file: avoid_renaming_method_parameters + +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; + +import '../../color.dart'; +import '../conversions.dart'; +import 'utils.dart'; + +/// The linear-light sRGB color space. +/// +/// https://www.w3.org/TR/css-color-4/#predefined-sRGB-linear +/// +/// @nodoc +@internal +class SrgbLinearColorSpace extends ColorSpace { + bool get isBoundedInternal => true; + + const SrgbLinearColorSpace() : super('srgb-linear', rgbChannels); + + SassColor convert( + ColorSpace dest, double red, double green, double blue, double alpha) { + switch (dest) { + case ColorSpace.rgb: + case ColorSpace.hsl: + case ColorSpace.hwb: + case ColorSpace.srgb: + return ColorSpace.srgb.convert( + dest, + srgbAndDisplayP3FromLinear(red), + srgbAndDisplayP3FromLinear(green), + srgbAndDisplayP3FromLinear(blue), + alpha); + + default: + return super.convert(dest, red, green, blue, alpha); + } + } + + @protected + double toLinear(double channel) => channel; + + @protected + double fromLinear(double channel) => channel; + + @protected + Float64List transformationMatrix(ColorSpace dest) { + switch (dest) { + case ColorSpace.displayP3: + return linearSrgbToLinearDisplayP3; + case ColorSpace.a98Rgb: + return linearSrgbToLinearA98Rgb; + case ColorSpace.prophotoRgb: + return linearSrgbToLinearProphotoRgb; + case ColorSpace.rec2020: + return linearSrgbToLinearRec2020; + case ColorSpace.xyzD65: + return linearSrgbToXyzD65; + case ColorSpace.xyzD50: + return linearSrgbToXyzD50; + case ColorSpace.lms: + return linearSrgbToLms; + default: + return super.transformationMatrix(dest); + } + } +} diff --git a/lib/src/value/color/space/utils.dart b/lib/src/value/color/space/utils.dart new file mode 100644 index 000000000..805985dd1 --- /dev/null +++ b/lib/src/value/color/space/utils.dart @@ -0,0 +1,85 @@ +// Copyright 2022 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'dart:math' as math; + +import '../../../util/number.dart'; +import '../../color.dart'; + +/// A constant used to convert Lab to/from XYZ. +const labKappa = 24389 / 27; // 29^3/3^3; + +/// A constant used to convert Lab to/from XYZ. +const labEpsilon = 216 / 24389; // 6^3/29^3; + +/// The hue channel shared across all polar color spaces. +const hueChannel = ColorChannel('hue', isPolarAngle: true); + +/// The color channels shared across all RGB color spaces (except the legacy RGB space). +const rgbChannels = [ + LinearChannel('red', 0, 1), + LinearChannel('green', 0, 1), + LinearChannel('blue', 0, 1) +]; + +/// The color channels shared across both XYZ color spaces. +const xyzChannels = [ + LinearChannel('x', 0, 1), + LinearChannel('y', 0, 1), + LinearChannel('z', 0, 1) +]; + +/// Converts a legacy HSL/HWB hue to an RGB channel. +/// +/// The algorithm comes from from the CSS3 spec: +/// http://www.w3.org/TR/css3-color/#hsl-color. +double hueToRgb(double m1, double m2, double hue) { + if (hue < 0) hue += 1; + if (hue > 1) hue -= 1; + + if (hue < 1 / 6) { + return m1 + (m2 - m1) * hue * 6; + } else if (hue < 1 / 2) { + return m2; + } else if (hue < 2 / 3) { + return m1 + (m2 - m1) * (2 / 3 - hue) * 6; + } else { + return m1; + } +} + +/// The algorithm for converting a single `srgb` or `display-p3` channel to +/// linear-light form. +double srgbAndDisplayP3ToLinear(double channel) { + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + var abs = channel.abs(); + return abs < 0.04045 + ? channel / 12.92 + : channel.sign * math.pow((abs + 0.055) / 1.055, 2.4); +} + +/// The algorithm for converting a single `srgb` or `display-p3` channel to +/// gamma-corrected form. +double srgbAndDisplayP3FromLinear(double channel) { + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + var abs = channel.abs(); + return abs <= 0.0031308 + ? channel * 12.92 + : channel.sign * (1.055 * math.pow(abs, 1 / 2.4) - 0.055); +} + +/// Converts a Lab or OKLab color to LCH or OKLCH, respectively. +SassColor labToLch( + ColorSpace dest, double lightness, double a, double b, double alpha) { + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + if (fuzzyEquals(lightness, 0)) { + return SassColor.forSpaceInternal(dest, 0, null, null, alpha); + } + + var chroma = math.sqrt(math.pow(a, 2) + math.pow(b, 2)); + var hue = fuzzyEquals(chroma, 0) ? null : math.atan2(b, a) * 180 / math.pi; + + return SassColor.forSpaceInternal(dest, lightness, chroma, + hue == null || hue >= 0 ? hue : hue + 360, alpha); +} diff --git a/lib/src/value/color/space/xyz_d50.dart b/lib/src/value/color/space/xyz_d50.dart new file mode 100644 index 000000000..7b7c6c781 --- /dev/null +++ b/lib/src/value/color/space/xyz_d50.dart @@ -0,0 +1,80 @@ +// Copyright 2022 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +// ignore_for_file: avoid_renaming_method_parameters + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; + +import '../../color.dart'; +import '../conversions.dart'; +import 'utils.dart'; + +/// The xyz-d50 color space. +/// +/// https://www.w3.org/TR/css-color-4/#predefined-xyz +/// +/// @nodoc +@internal +class XyzD50ColorSpace extends ColorSpace { + bool get isBoundedInternal => false; + + const XyzD50ColorSpace() : super('xyz-d50', xyzChannels); + + SassColor convert( + ColorSpace dest, double x, double y, double z, double alpha) { + switch (dest) { + case ColorSpace.lab: + case ColorSpace.lch: + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + // and http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html + var f0 = _convertComponentToLabF(x / d50[0]); + var f1 = _convertComponentToLabF(y / d50[1]); + var f2 = _convertComponentToLabF(z / d50[2]); + + return ColorSpace.lab.convert( + dest, (116 * f1) - 16, 500 * (f0 - f1), 200 * (f1 - f2), alpha); + + default: + return super.convert(dest, x, y, z, alpha); + } + } + + /// Does a partial conversion of a single XYZ component to Lab. + double _convertComponentToLabF(double component) => component > labEpsilon + ? math.pow(component, 1 / 3) + 0.0 + : (labKappa * component + 16) / 116; + + @protected + double toLinear(double channel) => channel; + + @protected + double fromLinear(double channel) => channel; + + @protected + Float64List transformationMatrix(ColorSpace dest) { + switch (dest) { + case ColorSpace.srgbLinear: + case ColorSpace.srgb: + case ColorSpace.rgb: + return xyzD50ToLinearSrgb; + case ColorSpace.a98Rgb: + return xyzD50ToLinearA98Rgb; + case ColorSpace.prophotoRgb: + return xyzD50ToLinearProphotoRgb; + case ColorSpace.displayP3: + return xyzD50ToLinearDisplayP3; + case ColorSpace.rec2020: + return xyzD50ToLinearRec2020; + case ColorSpace.xyzD65: + return xyzD50ToXyzD65; + case ColorSpace.lms: + return xyzD50ToLms; + default: + return super.transformationMatrix(dest); + } + } +} diff --git a/lib/src/value/color/space/xyz_d65.dart b/lib/src/value/color/space/xyz_d65.dart new file mode 100644 index 000000000..997267aaa --- /dev/null +++ b/lib/src/value/color/space/xyz_d65.dart @@ -0,0 +1,53 @@ +// Copyright 2022 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; + +import '../conversions.dart'; +import '../space.dart'; +import 'utils.dart'; + +/// The xyz-d65 color space. +/// +/// https://www.w3.org/TR/css-color-4/#predefined-xyz +/// +/// @nodoc +@internal +class XyzD65ColorSpace extends ColorSpace { + bool get isBoundedInternal => false; + + const XyzD65ColorSpace() : super('xyz', xyzChannels); + + @protected + double toLinear(double channel) => channel; + + @protected + double fromLinear(double channel) => channel; + + @protected + Float64List transformationMatrix(ColorSpace dest) { + switch (dest) { + case ColorSpace.srgbLinear: + case ColorSpace.srgb: + case ColorSpace.rgb: + return xyzD65ToLinearSrgb; + case ColorSpace.a98Rgb: + return xyzD65ToLinearA98Rgb; + case ColorSpace.prophotoRgb: + return xyzD65ToLinearProphotoRgb; + case ColorSpace.displayP3: + return xyzD65ToLinearDisplayP3; + case ColorSpace.rec2020: + return xyzD65ToLinearRec2020; + case ColorSpace.xyzD50: + return xyzD65ToXyzD50; + case ColorSpace.lms: + return xyzD65ToLms; + default: + return super.transformationMatrix(dest); + } + } +} diff --git a/lib/src/value/list.dart b/lib/src/value/list.dart index 68ca5c91c..78c993840 100644 --- a/lib/src/value/list.dart +++ b/lib/src/value/list.dart @@ -58,6 +58,18 @@ class SassList extends Value { } } + /// Add parentheses to the debug information for lists to help make the list + /// bounds clear. + String toString() { + if (hasBrackets || + lengthAsList == 0 || + (lengthAsList == 1 && separator == ListSeparator.comma)) { + return super.toString(); + } + + return "(${super.toString()})"; + } + /// @nodoc @internal T accept(ValueVisitor visitor) => visitor.visitList(this); diff --git a/lib/src/value/number.dart b/lib/src/value/number.dart index 87c159b96..f18af1e73 100644 --- a/lib/src/value/number.dart +++ b/lib/src/value/number.dart @@ -429,8 +429,7 @@ abstract class SassNumber extends Value { /// [newDenominators]. /// /// Throws a [SassScriptException] if this number's units aren't compatible - /// with [other]'s units, or if either number is unitless but the other is - /// not. + /// with [newNumerators] and [newDenominators] or if this number is unitless. /// /// If this came from a function argument, [name] is the argument name /// (without the `$`). It's used for error reporting. @@ -439,6 +438,10 @@ abstract class SassNumber extends Value { _coerceOrConvertValue(newNumerators, newDenominators, coerceUnitless: false, name: name); + /// A shorthand for [convertValue] with only one numerator unit. + double convertValueToUnit(String unit, [String? name]) => + convertValue([unit], [], name); + /// Returns a copy of this number, converted to the same units as [other]. /// /// Note that [convertValueToMatch] is generally more efficient if the value diff --git a/lib/src/value/number/single_unit.dart b/lib/src/value/number/single_unit.dart index cb439630d..c718d79a9 100644 --- a/lib/src/value/number/single_unit.dart +++ b/lib/src/value/number/single_unit.dart @@ -90,6 +90,11 @@ class SingleUnitSassNumber extends SassNumber { // Call this to generate a consistent error message. super.coerceValueToMatch(other, name, otherName); + double convertValueToUnit(String unit, [String? name]) => + _coerceValueToUnit(unit) ?? + // Call this to generate a consistent error message. + super.convertValueToUnit(unit, name); + SassNumber convertToMatch(SassNumber other, [String? name, String? otherName]) => (other is SingleUnitSassNumber ? _coerceToUnit(other._unit) : null) ?? diff --git a/lib/src/value/string.dart b/lib/src/value/string.dart index 2ded47bd7..68cd98679 100644 --- a/lib/src/value/string.dart +++ b/lib/src/value/string.dart @@ -132,6 +132,30 @@ class SassString extends Value { /// Creates a string with the given [text]. SassString(this._text, {bool quotes = true}) : _hasQuotes = quotes; + /// Throws a [SassScriptException] if this is an unquoted string. + /// + /// If this came from a function argument, [name] is the argument name + /// (without the `$`). It's used for error reporting. + /// + /// @nodoc + @internal + void assertQuoted([String? name]) { + if (hasQuotes) return; + throw SassScriptException('Expected $this to be a quoted string.', name); + } + + /// Throws a [SassScriptException] if this is a quoted string. + /// + /// If this came from a function argument, [name] is the argument name + /// (without the `$`). It's used for error reporting. + /// + /// @nodoc + @internal + void assertUnquoted([String? name]) { + if (!hasQuotes) return; + throw SassScriptException('Expected $this to be an unquoted string.', name); + } + /// Converts [sassIndex] into a Dart-style index into [text]. /// /// Sass indexes are one-based, while Dart indexes are zero-based. Sass diff --git a/lib/src/visitor/async_evaluate.dart b/lib/src/visitor/async_evaluate.dart index 39b935463..e88f40afd 100644 --- a/lib/src/visitor/async_evaluate.dart +++ b/lib/src/visitor/async_evaluate.dart @@ -45,6 +45,7 @@ import 'interface/css.dart'; import 'interface/expression.dart'; import 'interface/modifiable_css.dart'; import 'interface/statement.dart'; +import 'serialize.dart'; /// A function that takes a callback with no arguments. typedef _ScopeCallback = Future Function( @@ -1113,7 +1114,8 @@ class _EvaluateVisitor Future visitDebugRule(DebugRule node) async { var value = await node.expression.accept(this); _logger.debug( - value is SassString ? value.text : value.toString(), node.span); + value is SassString ? value.text : serializeValue(value, inspect: true), + node.span); return null; } @@ -1645,7 +1647,7 @@ class _EvaluateVisitor } } on SassException catch (error, stackTrace) { throwWithTrace(_exception(error.message, error.span), stackTrace); - } on ArgumentError catch (error, stackTrace) { + } on Error catch (error, stackTrace) { throwWithTrace(_exception(error.toString()), stackTrace); } catch (error, stackTrace) { String? message; diff --git a/lib/src/visitor/evaluate.dart b/lib/src/visitor/evaluate.dart index e45ad4b97..fd97f020e 100644 --- a/lib/src/visitor/evaluate.dart +++ b/lib/src/visitor/evaluate.dart @@ -5,7 +5,7 @@ // DO NOT EDIT. This file was generated from async_evaluate.dart. // See tool/grind/synchronize.dart for details. // -// Checksum: a14e075a5435c7457d1d1371d8b97dd327a66ec4 +// Checksum: a92baa2c76cd9dd9bece8f386aec1b502d8e1fa1 // // ignore_for_file: unused_import @@ -54,6 +54,7 @@ import 'interface/css.dart'; import 'interface/expression.dart'; import 'interface/modifiable_css.dart'; import 'interface/statement.dart'; +import 'serialize.dart'; /// A function that takes a callback with no arguments. typedef _ScopeCallback = void Function(void Function() callback); @@ -1117,7 +1118,8 @@ class _EvaluateVisitor Value? visitDebugRule(DebugRule node) { var value = node.expression.accept(this); _logger.debug( - value is SassString ? value.text : value.toString(), node.span); + value is SassString ? value.text : serializeValue(value, inspect: true), + node.span); return null; } @@ -1643,7 +1645,7 @@ class _EvaluateVisitor } } on SassException catch (error, stackTrace) { throwWithTrace(_exception(error.message, error.span), stackTrace); - } on ArgumentError catch (error, stackTrace) { + } on Error catch (error, stackTrace) { throwWithTrace(_exception(error.toString()), stackTrace); } catch (error, stackTrace) { String? message; diff --git a/lib/src/visitor/serialize.dart b/lib/src/visitor/serialize.dart index 7014c7ec1..137186416 100644 --- a/lib/src/visitor/serialize.dart +++ b/lib/src/visitor/serialize.dart @@ -530,88 +530,244 @@ class _SerializeVisitor } void visitColor(SassColor value) { + switch (value.space) { + case ColorSpace.rgb: + case ColorSpace.hsl: + case ColorSpace.hwb: + _writeLegacyColor(value); + break; + + case ColorSpace.lab: + case ColorSpace.oklab: + _buffer + ..write(value.space) + ..writeCharCode($lparen); + _writeChannel(value.channel0OrNull); + if (!_isCompressed && value.space == ColorSpace.lab) { + _buffer.writeCharCode($percent); + } + _buffer.writeCharCode($space); + _writeChannel(value.channel1OrNull); + _buffer.writeCharCode($space); + _writeChannel(value.channel2OrNull); + _maybeWriteSlashAlpha(value.alpha); + _buffer.writeCharCode($rparen); + break; + + case ColorSpace.lch: + case ColorSpace.oklch: + _buffer + ..write(value.space) + ..writeCharCode($lparen); + _writeChannel(value.channel0OrNull); + if (!_isCompressed && value.space == ColorSpace.lch) { + _buffer.writeCharCode($percent); + } + _buffer.writeCharCode($space); + _writeChannel(value.channel1OrNull); + _buffer.writeCharCode($space); + _writeChannel(value.channel2OrNull); + if (!_isCompressed && !value.isChannel2Missing) _buffer.write('deg'); + _maybeWriteSlashAlpha(value.alpha); + _buffer.writeCharCode($rparen); + break; + + default: + _buffer + ..write('color(') + ..write(value.space) + ..writeCharCode($space); + _writeBetween(value.channelsOrNull, ' ', _writeChannel); + _maybeWriteSlashAlpha(value.alpha); + _buffer.writeCharCode($rparen); + break; + } + } + + /// Writes a [channel] which may be missing. + void _writeChannel(double? channel) { + if (channel == null) { + _buffer.write('none'); + } else { + _writeNumber(channel); + } + } + + /// Writes a legacy color to the stylesheet. + /// + /// Unlike newer color spaces, the three legacy color spaces are + /// interchangeable with one another. We choose the shortest representation + /// that's still compatible with all the browsers we support. + void _writeLegacyColor(SassColor color) { + var opaque = fuzzyEquals(color.alpha, 1); + // In compressed mode, emit colors in the shortest representation possible. if (_isCompressed) { - if (!fuzzyEquals(value.alpha, 1)) { - _writeRgb(value); + var rgb = color.toSpace(ColorSpace.rgb); + if (opaque && _tryIntegerRgb(rgb)) return; + + var red = _writeNumberToString(rgb.channel0); + var green = _writeNumberToString(rgb.channel1); + var blue = _writeNumberToString(rgb.channel2); + + var hsl = color.toSpace(ColorSpace.hsl); + var hue = _writeNumberToString(hsl.channel0); + var saturation = _writeNumberToString(hsl.channel1); + var lightness = _writeNumberToString(hsl.channel2); + + // Add two characters for HSL for the %s on saturation and lightness. + if (red.length + green.length + blue.length <= + hue.length + saturation.length + lightness.length + 2) { + _buffer + ..write(opaque ? 'rgb(' : 'rgba(') + ..write(red) + ..writeCharCode($comma) + ..write(green) + ..writeCharCode($comma) + ..write(blue); } else { - var name = namesByColor[value]; - var hexLength = _canUseShortHex(value) ? 4 : 7; - if (name != null && name.length <= hexLength) { - _buffer.write(name); - } else if (_canUseShortHex(value)) { - _buffer.writeCharCode($hash); - _buffer.writeCharCode(hexCharFor(value.red & 0xF)); - _buffer.writeCharCode(hexCharFor(value.green & 0xF)); - _buffer.writeCharCode(hexCharFor(value.blue & 0xF)); - } else { - _buffer.writeCharCode($hash); - _writeHexComponent(value.red); - _writeHexComponent(value.green); - _writeHexComponent(value.blue); - } + _buffer + ..write(opaque ? 'hsl(' : 'hsla(') + ..write(hue) + ..writeCharCode($comma) + ..write(saturation) + ..write('%,') + ..write(lightness) + ..writeCharCode($percent); } - } else { - var format = value.format; - if (format != null) { - if (format == ColorFormat.rgbFunction) { - _writeRgb(value); - } else if (format == ColorFormat.hslFunction) { - _writeHsl(value); - } else { - _buffer.write((format as SpanColorFormat).original); - } - } else if (namesByColor.containsKey(value) && - // Always emit generated transparent colors in rgba format. This works - // around an IE bug. See sass/sass#1782. - !fuzzyEquals(value.alpha, 0)) { - _buffer.write(namesByColor[value]); - } else if (fuzzyEquals(value.alpha, 1)) { - _buffer.writeCharCode($hash); - _writeHexComponent(value.red); - _writeHexComponent(value.green); - _writeHexComponent(value.blue); + if (!opaque) { + _buffer.writeCharCode($comma); + _writeNumber(color.alpha); + } + _buffer.writeCharCode($rparen); + return; + } + + if (color.space == ColorSpace.hsl) { + _writeHsl(color); + return; + } + + var format = color.format; + if (format != null) { + if (format == ColorFormat.rgbFunction) { + _writeRgb(color); } else { - _writeRgb(value); + _buffer.write((format as SpanColorFormat).original); } + return; + } + + // Always emit generated transparent colors in rgba format. This works + // around an IE bug. See sass/sass#1782. + var rgb = color.toSpace(ColorSpace.rgb); + var name = namesByColor[rgb]; + if (opaque) { + if (name != null) { + _buffer.write(name); + return; + } + + if (_canUseHex(rgb)) { + _buffer.writeCharCode($hash); + _writeHexComponent(rgb.channel0.round()); + _writeHexComponent(rgb.channel1.round()); + _writeHexComponent(rgb.channel2.round()); + return; + } + } + + // If an HWB color can't be represented as a hex color, write is as HSL + // rather than RGB since that more clearly captures the author's intent. + if (color.space == ColorSpace.hwb) { + _writeHsl(color); + } else { + _writeRgb(color); } } + /// If [value] can be written as a hex code or a color name, writes it in the + /// shortest format possible and returns `true.` + /// + /// Otherwise, writes nothing and returns `false`. Assumes [value] is in the + /// RGB space. + bool _tryIntegerRgb(SassColor rgb) { + assert(rgb.space == ColorSpace.rgb); + if (!_canUseHex(rgb)) return false; + + var redInt = rgb.channel0.round(); + var greenInt = rgb.channel1.round(); + var blueInt = rgb.channel2.round(); + + var name = namesByColor[rgb]; + var shortHex = _canUseShortHex(redInt, greenInt, blueInt); + if (name != null && name.length <= (shortHex ? 4 : 7)) { + _buffer.write(name); + } else if (shortHex) { + _buffer.writeCharCode($hash); + _buffer.writeCharCode(hexCharFor(redInt & 0xF)); + _buffer.writeCharCode(hexCharFor(greenInt & 0xF)); + _buffer.writeCharCode(hexCharFor(blueInt & 0xF)); + } else { + _buffer.writeCharCode($hash); + _writeHexComponent(redInt); + _writeHexComponent(greenInt); + _writeHexComponent(blueInt); + } + return true; + } + + /// Whether [rgb] can be represented as a hexadecimal color. + bool _canUseHex(SassColor rgb) { + assert(rgb.space == ColorSpace.rgb); + return _canUseHexForChannel(rgb.channel0) && + _canUseHexForChannel(rgb.channel1) && + _canUseHexForChannel(rgb.channel2); + } + + /// Whether [channel]'s value can be represented as a two-character + /// hexadecimal value. + bool _canUseHexForChannel(double channel) => + fuzzyIsInt(channel) && + fuzzyGreaterThanOrEquals(channel, 0) && + fuzzyLessThan(channel, 256); + /// Writes [value] as an `rgb()` or `rgba()` function. - void _writeRgb(SassColor value) { - var opaque = fuzzyEquals(value.alpha, 1); - _buffer - ..write(opaque ? "rgb(" : "rgba(") - ..write(value.red) - ..write(_commaSeparator) - ..write(value.green) - ..write(_commaSeparator) - ..write(value.blue); + void _writeRgb(SassColor color) { + var opaque = fuzzyEquals(color.alpha, 1); + var rgb = color.toSpace(ColorSpace.rgb); + _buffer.write(opaque ? "rgb(" : "rgba("); + _writeNumber(rgb.channel('red')); + _buffer.write(_commaSeparator); + _writeNumber(rgb.channel('green')); + _buffer.write(_commaSeparator); + _writeNumber(rgb.channel('blue')); if (!opaque) { _buffer.write(_commaSeparator); - _writeNumber(value.alpha); + _writeNumber(color.alpha); } _buffer.writeCharCode($rparen); } /// Writes [value] as an `hsl()` or `hsla()` function. - void _writeHsl(SassColor value) { - var opaque = fuzzyEquals(value.alpha, 1); + void _writeHsl(SassColor color) { + var opaque = fuzzyEquals(color.alpha, 1); + var hsl = color.toSpace(ColorSpace.hsl); _buffer.write(opaque ? "hsl(" : "hsla("); - _writeNumber(value.hue); + _writeNumber(hsl.channel('hue')); _buffer.write("deg"); _buffer.write(_commaSeparator); - _writeNumber(value.saturation); + _writeNumber(hsl.channel('saturation')); _buffer.writeCharCode($percent); _buffer.write(_commaSeparator); - _writeNumber(value.lightness); + _writeNumber(hsl.channel('lightness')); _buffer.writeCharCode($percent); if (!opaque) { _buffer.write(_commaSeparator); - _writeNumber(value.alpha); + _writeNumber(color.alpha); } _buffer.writeCharCode($rparen); @@ -623,10 +779,10 @@ class _SerializeVisitor /// Returns whether [color] can be represented as a short hexadecimal color /// (e.g. `#fff`). - bool _canUseShortHex(SassColor color) => - _isSymmetricalHex(color.red) && - _isSymmetricalHex(color.green) && - _isSymmetricalHex(color.blue); + bool _canUseShortHex(int red, int green, int blue) => + _isSymmetricalHex(red) && + _isSymmetricalHex(green) && + _isSymmetricalHex(blue); /// Emits [color] as a hex character pair. void _writeHexComponent(int color) { @@ -635,6 +791,15 @@ class _SerializeVisitor _buffer.writeCharCode(hexCharFor(color & 0xF)); } + /// Writes the alpha component of a color if [alpha] isn't 1. + void _maybeWriteSlashAlpha(double alpha) { + if (fuzzyEquals(alpha, 1)) return; + _writeOptionalSpace(); + _buffer.writeCharCode($slash); + _writeOptionalSpace(); + _writeNumber(alpha); + } + void visitFunction(SassFunction function) { if (!_inspect) { throw SassScriptException("$function isn't a valid CSS value."); @@ -774,16 +939,28 @@ class _SerializeVisitor } } + /// Like [_writeNumber], but returns a string rather than writing to + /// [_buffer]. + String _writeNumberToString(double number) { + var buffer = NoSourceMapBuffer(); + _writeNumber(number, buffer); + return buffer.toString(); + } + /// Writes [number] without exponent notation and with at most /// [SassNumber.precision] digits after the decimal point. - void _writeNumber(double number) { + /// + /// The number is written to [buffer], which defaults to [_buffer]. + void _writeNumber(double number, [SourceMapBuffer? buffer]) { + buffer ??= _buffer; + // Dart always converts integers to strings in the obvious way, so all we // have to do is clamp doubles that are close to being integers. var integer = fuzzyAsInt(number); if (integer != null) { // Node.js still uses exponential notation for integers, so we have to // handle it here. - _buffer.write(_removeExponent(integer.toString())); + buffer.write(_removeExponent(integer.toString())); return; } @@ -796,11 +973,11 @@ class _SerializeVisitor if (canWriteDirectly) { if (_isCompressed && text.codeUnitAt(0) == $0) text = text.substring(1); - _buffer.write(text); + buffer.write(text); return; } - _writeRounded(text); + _writeRounded(text, buffer); } /// If [text] is written in exponent notation, returns a string representation @@ -863,7 +1040,7 @@ class _SerializeVisitor /// Assuming [text] is a number written without exponent notation, rounds it /// to [SassNumber.precision] digits after the decimal and writes the result /// to [_buffer]. - void _writeRounded(String text) { + void _writeRounded(String text, SourceMapBuffer buffer) { assert(RegExp(r"^-?\d+(\.\d+)?$").hasMatch(text), '"$text" should be a number written without exponent notation.'); @@ -871,7 +1048,7 @@ class _SerializeVisitor // integer values. In that case we definitely don't need to adjust for // precision, so we can just write the number as-is without the `.0`. if (text.endsWith(".0")) { - _buffer.write(text.substring(0, text.length - 2)); + buffer.write(text.substring(0, text.length - 2)); return; } @@ -892,7 +1069,7 @@ class _SerializeVisitor if (textIndex == text.length) { // If we get here, [text] has no decmial point. It definitely doesn't // need to be rounded; we can write it as-is. - _buffer.write(text); + buffer.write(text); return; } @@ -907,7 +1084,7 @@ class _SerializeVisitor // truncation is needed. var indexAfterPrecision = textIndex + SassNumber.precision; if (indexAfterPrecision >= text.length) { - _buffer.write(text); + buffer.write(text); return; } @@ -945,11 +1122,11 @@ class _SerializeVisitor // write "0" explicit to avoid adding a minus sign or omitting the number // entirely in compressed mode. if (digitsIndex == 2 && digits[0] == 0 && digits[1] == 0) { - _buffer.writeCharCode($0); + buffer.writeCharCode($0); return; } - if (negative) _buffer.writeCharCode($minus); + if (negative) buffer.writeCharCode($minus); // Write the digits before the decimal point to [_buffer]. Omit the leading // 0 that's added to [digits] to accommodate rounding, and in compressed @@ -960,13 +1137,13 @@ class _SerializeVisitor if (_isCompressed && digits[1] == 0) writtenIndex++; } for (; writtenIndex < firstFractionalDigit; writtenIndex++) { - _buffer.writeCharCode(decimalCharFor(digits[writtenIndex])); + buffer.writeCharCode(decimalCharFor(digits[writtenIndex])); } if (digitsIndex > firstFractionalDigit) { - _buffer.writeCharCode($dot); + buffer.writeCharCode($dot); for (; writtenIndex < digitsIndex; writtenIndex++) { - _buffer.writeCharCode(decimalCharFor(digits[writtenIndex])); + buffer.writeCharCode(decimalCharFor(digits[writtenIndex])); } } } diff --git a/pkg/sass_api/CHANGELOG.md b/pkg/sass_api/CHANGELOG.md index 8e8476613..d10eb9ed3 100644 --- a/pkg/sass_api/CHANGELOG.md +++ b/pkg/sass_api/CHANGELOG.md @@ -1,3 +1,54 @@ +## 5.0.0 + +* **Breaking change:** Remove the `SassApiColor.hasCalculatedRgb` and + `.hasCalculatedHsl` extension methods. These can now be determined by checking + if `SassColor.space` is `KnownColorSpace.rgb` or `KnownColorSpace.hsl`, + respectively. + +* Added a `ColorSpace` class which represents the various color spaces defined + in the CSS spec. + +* Added `SassColor.space` which returns a color's color space. + +* Added `SassColor.channels` and `.channelsOrNull` which returns a list + of channel values, with missing channels converted to 0 or exposed as null, + respectively. + +* Added `SassColor.isLegacy`, `.isInGamut`, `.channel()`, `.isChannelMissing()`, + `.isChannelPowerless()`, `.toSpace()`, `.toGamut()`, `.changeChannels()`, and + `.interpolate()` which do the same thing as the Sass functions of the + corresponding names. + +* `SassColor.rgb()` now allows out-of-bounds and non-integer arguments. + +* `SassColor.hsl()` and `.hwb()` now allow out-of-bounds arguments. + +* Added `SassColor.hwb()`, `.srgb()`, `.srgbLinear()`, `.displayP3()`, + `.a98Rgb()`, `.prophotoRgb()`, `.rec2020()`, `.xyzD50()`, `.xyzD65()`, + `.lab()`, `.lch()`, `.oklab()`, `.oklch()`, and `.forSpace()` constructors. + +* Deprecated `SassColor.red`, `.green`, `.blue`, `.hue`, `.saturation`, + `.lightness`, `.whiteness`, and `.blackness` in favor of + `SassColor.channel()`. + +* Deprecated `SassColor.changeRgb()`, `.changeHsl()`, and `.changeHwb()` in + favor of `SassColor.changeChannels()`. + +* Added `SassNumber.convertValueToUnit()` as a shorthand for + `SassNumber.convertValue()` with a single numerator. + +* Added `InterpolationMethod` and `HueInterpolationMethod` which collectively + represent the method to use to interpolate two colors. + +* Added the `SassApiColorSpace` extension to expose additional members of + `ColorSpace`. + +* Added the `ColorChannel` class to represent information about a single channel + of a color space. + +* Added `SassNumber.convertValueToUnit()` as a shorthand for + `SassNumber.convertValue()` with a single numerator. + ## 4.1.2 * No user-visible changes. @@ -12,8 +63,6 @@ ## 4.0.0 -### Dart API - * **Breaking change:** The first argument to `NumberExpression()` is now a `double` rather than a `num`. diff --git a/pkg/sass_api/pubspec.yaml b/pkg/sass_api/pubspec.yaml index e4a69ccaa..948a998a5 100644 --- a/pkg/sass_api/pubspec.yaml +++ b/pkg/sass_api/pubspec.yaml @@ -2,7 +2,7 @@ name: sass_api # Note: Every time we add a new Sass AST node, we need to bump the *major* # version because it's a breaking change for anyone who's implementing the # visitor interface(s). -version: 4.1.2 +version: 5.0.0-dev description: Additional APIs for Dart Sass. homepage: https://github.com/sass/dart-sass @@ -10,7 +10,7 @@ environment: sdk: ">=2.17.0 <3.0.0" dependencies: - sass: 1.56.2 + sass: 1.57.0 dev_dependencies: dartdoc: ^5.0.0 diff --git a/precompute_matrices.dart b/precompute_matrices.dart new file mode 100644 index 000000000..da67eae9f --- /dev/null +++ b/precompute_matrices.dart @@ -0,0 +1,310 @@ +// Copyright 2022 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found at https://opensource.org/licenses/MIT. + +import 'dart:typed_data'; + +import 'package:rational/rational.dart'; +import 'package:tuple/tuple.dart'; + +// Matrix values from https://www.w3.org/TR/css-color-4/#color-conversion-code. + +enum ColorSpace { + srgb("srgb", "srgb", gammaCorrected: true), + displayP3("display-p3", "displayP3", gammaCorrected: true), + a98Rgb("a98-rgb", "a98Rgb", gammaCorrected: true), + rec2020("rec2020", "rec2020", gammaCorrected: true), + prophotoRgb("prophoto-rgb", "prophotoRgb", gammaCorrected: true), + xyzD65("xyz", "xyzD65", gammaCorrected: false), + lms("lms", "lms", gammaCorrected: false), + xyzD50("xyz-d50", "xyzD50", gammaCorrected: false); + + final String cssName; + final String _dartName; + final bool gammaCorrected; + + String get humanName => gammaCorrected ? 'linear-light $cssName' : cssName; + + String get dartName => + gammaCorrected ? 'linear' + _titleize(_dartName) : _dartName; + String get dartNameTitleized => _titleize(dartName); + + const ColorSpace(this.cssName, this._dartName, + {required this.gammaCorrected}); + + String _titleize(String ident) => ident[0].toUpperCase() + ident.substring(1); + + String toString() => dartName; +} + +final d65 = chromaToXyz(Rational.parse('0.3127'), Rational.parse('0.3290')); +final d50 = chromaToXyz(Rational.parse('0.3457'), Rational.parse('0.3585')); + +final linearToXyzD65 = { + ColorSpace.srgb: + linearLightRgbToXyz(0.640, 0.330, 0.300, 0.600, 0.150, 0.060, d65), + ColorSpace.displayP3: + linearLightRgbToXyz(0.680, 0.320, 0.265, 0.690, 0.150, 0.060, d65), + ColorSpace.a98Rgb: + linearLightRgbToXyz(0.6400, 0.3300, 0.2100, 0.7100, 0.1500, 0.0600, d65), + ColorSpace.rec2020: + linearLightRgbToXyz(0.708, 0.292, 0.170, 0.797, 0.131, 0.046, d65), + ColorSpace.xyzD65: RationalMatrix.identity, + // M1 from https://bottosson.github.io/posts/oklab/#converting-from-xyz-to-oklab + ColorSpace.lms: RationalMatrix.fromFloat64List(Float64List.fromList([ + 0.8190224432164319, 0.3619062562801221, -0.12887378261216414, // + 0.0329836671980271, 0.9292868468965546, 0.03614466816999844, + 0.048177199566046255, 0.26423952494422764, 0.6335478258136937 + ])).invert() +}; + +final linearToXyzD50 = { + ColorSpace.prophotoRgb: linearLightRgbToXyz( + 0.734699, 0.265301, 0.159597, 0.840403, 0.036598, 0.000105, d50), + ColorSpace.xyzD50: RationalMatrix.identity, +}; + +final bradford = RationalMatrix.fromFloat64List(Float64List.fromList([ + 00.8951000, 00.2664000, -0.1614000, // + -0.7502000, 01.7135000, 00.0367000, + 00.0389000, -0.0685000, 01.0296000 +])); + +/// The transformation matrix for converting D65 XYZ colors to D50 XYZ. +final RationalMatrix d65XyzToD50 = () { + // Algorithm from http://www.brucelindbloom.com/index.html?Eqn_ChromAdapt.html + var source = bradford.timesVector(d65); + var destination = bradford.timesVector(d50); + return bradford.invert() * + RationalMatrix([ + [destination[0] / source[0], Rational.zero, Rational.zero], + [Rational.zero, destination[1] / source[1], Rational.zero], + [Rational.zero, Rational.zero, destination[2] / source[2]] + ]) * + bradford; +}(); + +/// The transformation matrix for converting LMS colors to OKLab. +/// +/// Note that this can't be directly multiplied with [d65XyzToLms]; see Color +/// Level 4 spec for details on how to convert between XYZ and OKLab. +final lmsToOklab = RationalMatrix.fromFloat64List(Float64List.fromList([ + 0.2104542553, 0.7936177850, -0.0040720468, // + 1.9779984951, -2.4285922050, 0.4505937099, + 0.0259040371, 0.7827717662, -0.8086757660 +])); + +void main() { + for (var src in linearToXyzD65.entries) { + for (var dest in linearToXyzD65.entries) { + printTransform(src.key, dest.key, dest.value.invert() * src.value); + } + + for (var dest in linearToXyzD50.entries) { + printTransform( + src.key, dest.key, dest.value.invert() * d65XyzToD50 * src.value); + } + } + + for (var src in linearToXyzD50.entries) { + for (var dest in linearToXyzD50.entries) { + printTransform(src.key, dest.key, dest.value.invert() * src.value); + } + } +} + +final seen = >{}; +void printTransform(ColorSpace src, ColorSpace dest, RationalMatrix transform) { + if (src == dest) return; + if (!seen.add(Tuple2(src, dest))) return; + if (!seen.add(Tuple2(dest, src))) return; + + print("// The transformation matrix for converting ${src.humanName} " + "colors to ${dest.humanName}."); + print("final ${src}To${dest.dartNameTitleized} = " + "${transform.toDartString()};"); + print(''); + + print("// The transformation matrix for converting ${dest.humanName} " + "colors to ${src.humanName}."); + print("final ${dest}To${src.dartNameTitleized} = " + "${transform.invert().toDartString()};"); + print(''); +} + +class RationalMatrix { + static final identity = RationalMatrix([ + [Rational.one, Rational.zero, Rational.zero], + [Rational.zero, Rational.one, Rational.zero], + [Rational.zero, Rational.zero, Rational.one] + ]); + + final List> contents; + + RationalMatrix(Iterable> contents) + : contents = List.unmodifiable( + contents.map((iter) => List.unmodifiable(iter))); + + RationalMatrix.empty() + : contents = List.generate(3, (_) => List.filled(3, Rational.zero)); + + factory RationalMatrix.fromFloat64List(Float64List list) => + RationalMatrix(List.generate( + 3, + (i) => List.generate( + 3, (j) => Rational.parse(list[i * 3 + j].toString())))); + + RationalMatrix operator *(RationalMatrix other) => RationalMatrix([ + for (var i = 0; i < 3; i++) + [ + for (var j = 0; j < 3; j++) + [for (var k = 0; k < 3; k++) get(i, k) * other.get(k, j)].sum + ] + ]); + + List timesVector(List vector) => List.generate( + 3, (i) => Iterable.generate(3, (j) => get(i, j) * vector[j]).sum); + + RationalMatrix invert() { + // Using the same naming convention used in + // https://en.wikipedia.org/wiki/Determinant and + // https://en.wikipedia.org/wiki/Invertible_matrix#Inversion_of_3_%C3%97_3_matrices. + var a = get(0, 0); + var b = get(0, 1); + var c = get(0, 2); + var d = get(1, 0); + var e = get(1, 1); + var f = get(1, 2); + var g = get(2, 0); + var h = get(2, 1); + var i = get(2, 2); + + var idet = Rational.one / + (a * e * i + b * f * g + c * d * h - c * e * g - b * d * i - a * f * h); + + return RationalMatrix([ + [(e * i - f * h) * idet, -(b * i - c * h) * idet, (b * f - c * e) * idet], + [ + -(d * i - f * g) * idet, + (a * i - c * g) * idet, + -(a * f - c * d) * idet + ], + [(d * h - e * g) * idet, -(a * h - b * g) * idet, (a * e - b * d) * idet], + ]); + } + + RationalMatrix transpose() => RationalMatrix( + List.generate(3, (i) => List.generate(3, (j) => get(j, i)))); + + Rational get(int i, int j) => contents[i][j]; + + Rational set(int i, int j, Rational value) => contents[i][j] = value; + + String toString() => + '[ ' + + contents + .map((row) => row.map((number) => number.toDoubleString()).join(' ')) + .join('\n ') + + ' ]'; + + String toExactString() => + '[ ' + + contents + .map((row) => row.map((number) => number.toExactString()).join(' ')) + .join('\n ') + + ' ]'; + + String toDartString() { + var buffer = StringBuffer('Float64List.fromList([\n '); + var first = true; + for (var row in contents) { + if (!first) buffer.write('\n '); + buffer.write(row.map((number) => number.toDoubleString()).join(', ')); + buffer.write(','); + if (first) buffer.write(' //'); + first = false; + } + buffer.write('\n])'); + return buffer.toString(); + } +} + +const precision = 17; + +extension on Rational { + String toDoubleString() { + var doubleString = double.parse(toExactString()).toString(); + if (!doubleString.startsWith('-')) doubleString = '0$doubleString'; + return doubleString.toString().padRight(precision + 3, '0'); + } + + String toExactString() { + var newNum = (Rational(numerator) * + Rational(BigInt.from(10).pow(precision), denominator)) + .truncate(); + + var numString = newNum.abs().toString(); + if (numString.length == precision + 1) { + numString = '${numString[0]}.${numString.substring(1)}'; + } else { + numString = '0.${numString.padLeft(precision, '0')}'; + } + + return '${newNum.isNegative ? '-' : '0'}$numString'; + } +} + +extension on Iterable { + Rational get sum => reduce((a, b) => a + b); +} + +RationalMatrix linearLightRgbToXyz( + double redChromaX, + double redChromaY, + double greenChromaX, + double greenChromaY, + double blueChromaX, + double blueChromaY, + List white) => + _linearLightRgbToXyz( + Rational.parse(redChromaX.toString()), + Rational.parse(redChromaY.toString()), + Rational.parse(greenChromaX.toString()), + Rational.parse(greenChromaY.toString()), + Rational.parse(blueChromaX.toString()), + Rational.parse(blueChromaY.toString()), + white); + +RationalMatrix _linearLightRgbToXyz( + Rational redChromaX, + Rational redChromaY, + Rational greenChromaX, + Rational greenChromaY, + Rational blueChromaX, + Rational blueChromaY, + List white) { + // Algorithm from http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html + var xyzRed = chromaToXyz(redChromaX, redChromaY); + var xyzGreen = chromaToXyz(greenChromaX, greenChromaY); + var xyzBlue = chromaToXyz(blueChromaX, blueChromaY); + + var s = RationalMatrix([xyzRed, xyzGreen, xyzBlue]) + .transpose() + .invert() + .timesVector(white); + var sRed = s[0]; + var sGreen = s[1]; + var sBlue = s[2]; + + return RationalMatrix([ + xyzRed.map((value) => sRed * value), + xyzGreen.map((value) => sGreen * value), + xyzBlue.map((value) => sBlue * value) + ]).transpose(); +} + +/// Convert a two-dimensional chroma coordinates into a point in XYZ space. +List chromaToXyz(Rational chromaX, Rational chromaY) => [ + chromaX / chromaY, + Rational.one, + (Rational.one - chromaX - chromaY) / chromaY + ]; diff --git a/pubspec.yaml b/pubspec.yaml index 36f86e43f..b43780c81 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: sass -version: 1.56.2 +version: 1.57.0-dev description: A Sass implementation in Dart. homepage: https://github.com/sass/dart-sass @@ -30,6 +30,7 @@ dependencies: tuple: ^2.0.0 watcher: ^1.0.0 http: ^0.13.3 + rational: any dev_dependencies: analyzer: ^4.7.0 diff --git a/test/dart_api/value/color_test.dart b/test/dart_api/value/color_test.dart index bb7ee5f51..786467e35 100644 --- a/test/dart_api/value/color_test.dart +++ b/test/dart_api/value/color_test.dart @@ -2,6 +2,8 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@Skip("TODO(nweiz): Update these for the new Color API") + import 'package:test/test.dart'; import 'package:sass/sass.dart'; From 60de22c81b8d420dde409cc3f6d997bc7bf17a92 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Tue, 13 Dec 2022 14:56:03 -0800 Subject: [PATCH 02/56] Remove unused rational dependency and precompute_matrices.dart (#1846) I used this offline script to construct the color matrices, but it was never meant to be checked in. --- precompute_matrices.dart | 310 --------------------------------------- pubspec.yaml | 1 - 2 files changed, 311 deletions(-) delete mode 100644 precompute_matrices.dart diff --git a/precompute_matrices.dart b/precompute_matrices.dart deleted file mode 100644 index da67eae9f..000000000 --- a/precompute_matrices.dart +++ /dev/null @@ -1,310 +0,0 @@ -// Copyright 2022 Google Inc. Use of this source code is governed by an -// MIT-style license that can be found at https://opensource.org/licenses/MIT. - -import 'dart:typed_data'; - -import 'package:rational/rational.dart'; -import 'package:tuple/tuple.dart'; - -// Matrix values from https://www.w3.org/TR/css-color-4/#color-conversion-code. - -enum ColorSpace { - srgb("srgb", "srgb", gammaCorrected: true), - displayP3("display-p3", "displayP3", gammaCorrected: true), - a98Rgb("a98-rgb", "a98Rgb", gammaCorrected: true), - rec2020("rec2020", "rec2020", gammaCorrected: true), - prophotoRgb("prophoto-rgb", "prophotoRgb", gammaCorrected: true), - xyzD65("xyz", "xyzD65", gammaCorrected: false), - lms("lms", "lms", gammaCorrected: false), - xyzD50("xyz-d50", "xyzD50", gammaCorrected: false); - - final String cssName; - final String _dartName; - final bool gammaCorrected; - - String get humanName => gammaCorrected ? 'linear-light $cssName' : cssName; - - String get dartName => - gammaCorrected ? 'linear' + _titleize(_dartName) : _dartName; - String get dartNameTitleized => _titleize(dartName); - - const ColorSpace(this.cssName, this._dartName, - {required this.gammaCorrected}); - - String _titleize(String ident) => ident[0].toUpperCase() + ident.substring(1); - - String toString() => dartName; -} - -final d65 = chromaToXyz(Rational.parse('0.3127'), Rational.parse('0.3290')); -final d50 = chromaToXyz(Rational.parse('0.3457'), Rational.parse('0.3585')); - -final linearToXyzD65 = { - ColorSpace.srgb: - linearLightRgbToXyz(0.640, 0.330, 0.300, 0.600, 0.150, 0.060, d65), - ColorSpace.displayP3: - linearLightRgbToXyz(0.680, 0.320, 0.265, 0.690, 0.150, 0.060, d65), - ColorSpace.a98Rgb: - linearLightRgbToXyz(0.6400, 0.3300, 0.2100, 0.7100, 0.1500, 0.0600, d65), - ColorSpace.rec2020: - linearLightRgbToXyz(0.708, 0.292, 0.170, 0.797, 0.131, 0.046, d65), - ColorSpace.xyzD65: RationalMatrix.identity, - // M1 from https://bottosson.github.io/posts/oklab/#converting-from-xyz-to-oklab - ColorSpace.lms: RationalMatrix.fromFloat64List(Float64List.fromList([ - 0.8190224432164319, 0.3619062562801221, -0.12887378261216414, // - 0.0329836671980271, 0.9292868468965546, 0.03614466816999844, - 0.048177199566046255, 0.26423952494422764, 0.6335478258136937 - ])).invert() -}; - -final linearToXyzD50 = { - ColorSpace.prophotoRgb: linearLightRgbToXyz( - 0.734699, 0.265301, 0.159597, 0.840403, 0.036598, 0.000105, d50), - ColorSpace.xyzD50: RationalMatrix.identity, -}; - -final bradford = RationalMatrix.fromFloat64List(Float64List.fromList([ - 00.8951000, 00.2664000, -0.1614000, // - -0.7502000, 01.7135000, 00.0367000, - 00.0389000, -0.0685000, 01.0296000 -])); - -/// The transformation matrix for converting D65 XYZ colors to D50 XYZ. -final RationalMatrix d65XyzToD50 = () { - // Algorithm from http://www.brucelindbloom.com/index.html?Eqn_ChromAdapt.html - var source = bradford.timesVector(d65); - var destination = bradford.timesVector(d50); - return bradford.invert() * - RationalMatrix([ - [destination[0] / source[0], Rational.zero, Rational.zero], - [Rational.zero, destination[1] / source[1], Rational.zero], - [Rational.zero, Rational.zero, destination[2] / source[2]] - ]) * - bradford; -}(); - -/// The transformation matrix for converting LMS colors to OKLab. -/// -/// Note that this can't be directly multiplied with [d65XyzToLms]; see Color -/// Level 4 spec for details on how to convert between XYZ and OKLab. -final lmsToOklab = RationalMatrix.fromFloat64List(Float64List.fromList([ - 0.2104542553, 0.7936177850, -0.0040720468, // - 1.9779984951, -2.4285922050, 0.4505937099, - 0.0259040371, 0.7827717662, -0.8086757660 -])); - -void main() { - for (var src in linearToXyzD65.entries) { - for (var dest in linearToXyzD65.entries) { - printTransform(src.key, dest.key, dest.value.invert() * src.value); - } - - for (var dest in linearToXyzD50.entries) { - printTransform( - src.key, dest.key, dest.value.invert() * d65XyzToD50 * src.value); - } - } - - for (var src in linearToXyzD50.entries) { - for (var dest in linearToXyzD50.entries) { - printTransform(src.key, dest.key, dest.value.invert() * src.value); - } - } -} - -final seen = >{}; -void printTransform(ColorSpace src, ColorSpace dest, RationalMatrix transform) { - if (src == dest) return; - if (!seen.add(Tuple2(src, dest))) return; - if (!seen.add(Tuple2(dest, src))) return; - - print("// The transformation matrix for converting ${src.humanName} " - "colors to ${dest.humanName}."); - print("final ${src}To${dest.dartNameTitleized} = " - "${transform.toDartString()};"); - print(''); - - print("// The transformation matrix for converting ${dest.humanName} " - "colors to ${src.humanName}."); - print("final ${dest}To${src.dartNameTitleized} = " - "${transform.invert().toDartString()};"); - print(''); -} - -class RationalMatrix { - static final identity = RationalMatrix([ - [Rational.one, Rational.zero, Rational.zero], - [Rational.zero, Rational.one, Rational.zero], - [Rational.zero, Rational.zero, Rational.one] - ]); - - final List> contents; - - RationalMatrix(Iterable> contents) - : contents = List.unmodifiable( - contents.map((iter) => List.unmodifiable(iter))); - - RationalMatrix.empty() - : contents = List.generate(3, (_) => List.filled(3, Rational.zero)); - - factory RationalMatrix.fromFloat64List(Float64List list) => - RationalMatrix(List.generate( - 3, - (i) => List.generate( - 3, (j) => Rational.parse(list[i * 3 + j].toString())))); - - RationalMatrix operator *(RationalMatrix other) => RationalMatrix([ - for (var i = 0; i < 3; i++) - [ - for (var j = 0; j < 3; j++) - [for (var k = 0; k < 3; k++) get(i, k) * other.get(k, j)].sum - ] - ]); - - List timesVector(List vector) => List.generate( - 3, (i) => Iterable.generate(3, (j) => get(i, j) * vector[j]).sum); - - RationalMatrix invert() { - // Using the same naming convention used in - // https://en.wikipedia.org/wiki/Determinant and - // https://en.wikipedia.org/wiki/Invertible_matrix#Inversion_of_3_%C3%97_3_matrices. - var a = get(0, 0); - var b = get(0, 1); - var c = get(0, 2); - var d = get(1, 0); - var e = get(1, 1); - var f = get(1, 2); - var g = get(2, 0); - var h = get(2, 1); - var i = get(2, 2); - - var idet = Rational.one / - (a * e * i + b * f * g + c * d * h - c * e * g - b * d * i - a * f * h); - - return RationalMatrix([ - [(e * i - f * h) * idet, -(b * i - c * h) * idet, (b * f - c * e) * idet], - [ - -(d * i - f * g) * idet, - (a * i - c * g) * idet, - -(a * f - c * d) * idet - ], - [(d * h - e * g) * idet, -(a * h - b * g) * idet, (a * e - b * d) * idet], - ]); - } - - RationalMatrix transpose() => RationalMatrix( - List.generate(3, (i) => List.generate(3, (j) => get(j, i)))); - - Rational get(int i, int j) => contents[i][j]; - - Rational set(int i, int j, Rational value) => contents[i][j] = value; - - String toString() => - '[ ' + - contents - .map((row) => row.map((number) => number.toDoubleString()).join(' ')) - .join('\n ') + - ' ]'; - - String toExactString() => - '[ ' + - contents - .map((row) => row.map((number) => number.toExactString()).join(' ')) - .join('\n ') + - ' ]'; - - String toDartString() { - var buffer = StringBuffer('Float64List.fromList([\n '); - var first = true; - for (var row in contents) { - if (!first) buffer.write('\n '); - buffer.write(row.map((number) => number.toDoubleString()).join(', ')); - buffer.write(','); - if (first) buffer.write(' //'); - first = false; - } - buffer.write('\n])'); - return buffer.toString(); - } -} - -const precision = 17; - -extension on Rational { - String toDoubleString() { - var doubleString = double.parse(toExactString()).toString(); - if (!doubleString.startsWith('-')) doubleString = '0$doubleString'; - return doubleString.toString().padRight(precision + 3, '0'); - } - - String toExactString() { - var newNum = (Rational(numerator) * - Rational(BigInt.from(10).pow(precision), denominator)) - .truncate(); - - var numString = newNum.abs().toString(); - if (numString.length == precision + 1) { - numString = '${numString[0]}.${numString.substring(1)}'; - } else { - numString = '0.${numString.padLeft(precision, '0')}'; - } - - return '${newNum.isNegative ? '-' : '0'}$numString'; - } -} - -extension on Iterable { - Rational get sum => reduce((a, b) => a + b); -} - -RationalMatrix linearLightRgbToXyz( - double redChromaX, - double redChromaY, - double greenChromaX, - double greenChromaY, - double blueChromaX, - double blueChromaY, - List white) => - _linearLightRgbToXyz( - Rational.parse(redChromaX.toString()), - Rational.parse(redChromaY.toString()), - Rational.parse(greenChromaX.toString()), - Rational.parse(greenChromaY.toString()), - Rational.parse(blueChromaX.toString()), - Rational.parse(blueChromaY.toString()), - white); - -RationalMatrix _linearLightRgbToXyz( - Rational redChromaX, - Rational redChromaY, - Rational greenChromaX, - Rational greenChromaY, - Rational blueChromaX, - Rational blueChromaY, - List white) { - // Algorithm from http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html - var xyzRed = chromaToXyz(redChromaX, redChromaY); - var xyzGreen = chromaToXyz(greenChromaX, greenChromaY); - var xyzBlue = chromaToXyz(blueChromaX, blueChromaY); - - var s = RationalMatrix([xyzRed, xyzGreen, xyzBlue]) - .transpose() - .invert() - .timesVector(white); - var sRed = s[0]; - var sGreen = s[1]; - var sBlue = s[2]; - - return RationalMatrix([ - xyzRed.map((value) => sRed * value), - xyzGreen.map((value) => sGreen * value), - xyzBlue.map((value) => sBlue * value) - ]).transpose(); -} - -/// Convert a two-dimensional chroma coordinates into a point in XYZ space. -List chromaToXyz(Rational chromaX, Rational chromaY) => [ - chromaX / chromaY, - Rational.one, - (Rational.one - chromaX - chromaY) / chromaY - ]; diff --git a/pubspec.yaml b/pubspec.yaml index b43780c81..d733c907f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,7 +30,6 @@ dependencies: tuple: ^2.0.0 watcher: ^1.0.0 http: ^0.13.3 - rational: any dev_dependencies: analyzer: ^4.7.0 From 0e753218c71e977f9537039c40f65a45defa31f4 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Tue, 20 Dec 2022 13:53:31 -0800 Subject: [PATCH 03/56] Add deprecation warnings for obsolete color functions (#1854) --- CHANGELOG.md | 12 ++ lib/src/functions/color.dart | 217 +++++++++++++++++++++++++---------- 2 files changed, 171 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d588295b1..a100cfba0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,18 @@ [color spaces]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value +* The following functions are now deprecated, and uses should be replaced with + the new color-space-aware functions defined above: + + * The `color.red()`, `color.green()`, `color.blue()`, `color.hue()`, + `color.saturation()`, `color.lightness()`, `color.whiteness()`, and + `color.blackness()` functions, as well as their global counterparts, should + be replaced with calls to `color.channel()`. + + * The global `adjust-hue()`, `saturate()`, `desaturate()`, `lighten()`, + `darken()`, `transaprentize()`, `fade-out()`, `opacify()`, and `fade-in()` + functions should be replaced by `color.adjust()` or `color.scale()`. + ### Dart API * Added a `ColorSpace` class which represents the various color spaces defined diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index 30409fab3..42fac7268 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -27,7 +27,10 @@ const _specialCommaSpaces = {ColorSpace.rgb, ColorSpace.hsl}; /// The global definitions of Sass color functions. final global = UnmodifiableListView([ // ### RGB - _red, _green, _blue, _mix, + _channelFunction("red", (color) => color.red, global: true), + _channelFunction("green", (color) => color.green, global: true), + _channelFunction("blue", (color) => color.blue, global: true), + _mix, BuiltInCallable.overloadedFunction("rgb", { r"$red, $green, $blue, $alpha": (arguments) => _rgb("rgb", arguments), @@ -48,7 +51,11 @@ final global = UnmodifiableListView([ _function("invert", r"$color, $weight: 100%, $space: null", _invert), // ### HSL - _hue, _saturation, _lightness, + _channelFunction("hue", (color) => color.hue, unit: 'deg', global: true), + _channelFunction("saturation", (color) => color.saturation, + unit: '%', global: true), + _channelFunction("lightness", (color) => color.lightness, + unit: '%', global: true), BuiltInCallable.overloadedFunction("hsl", { r"$hue, $saturation, $lightness, $alpha": (arguments) => @@ -92,23 +99,49 @@ final global = UnmodifiableListView([ _function("adjust-hue", r"$color, $degrees", (arguments) { var color = arguments[0].assertColor("color"); var degrees = _angleValue(arguments[1], "degrees"); + + var suggestedValue = SassNumber(degrees, 'deg'); + warn( + "adjust-hue() is deprecated. Suggestion:\n" + "\n" + "color.adjust(\$color, \$hue: $suggestedValue)\n" + "\n" + "More info: https://sass-lang.com/d/color-functions", + deprecation: true); + return color.changeHsl(hue: color.hue + degrees); }), _function("lighten", r"$color, $amount", (arguments) { var color = arguments[0].assertColor("color"); var amount = arguments[1].assertNumber("amount"); - return color.changeHsl( + var result = color.changeHsl( lightness: (color.lightness + amount.valueInRange(0, 100, "amount")) .clamp(0, 100)); + + warn( + "lighten() is deprecated. " + "${_suggestScaleAndAdjust(color, amount.value, 'lightness')}\n" + "\n" + "More info: https://sass-lang.com/d/color-functions", + deprecation: true); + return result; }), _function("darken", r"$color, $amount", (arguments) { var color = arguments[0].assertColor("color"); var amount = arguments[1].assertNumber("amount"); - return color.changeHsl( + var result = color.changeHsl( lightness: (color.lightness - amount.valueInRange(0, 100, "amount")) .clamp(0, 100)); + + warn( + "darken() is deprecated. " + "${_suggestScaleAndAdjust(color, -amount.value, 'lightness')}\n" + "\n" + "More info: https://sass-lang.com/d/color-functions", + deprecation: true); + return result; }), BuiltInCallable.overloadedFunction("saturate", { @@ -119,25 +152,45 @@ final global = UnmodifiableListView([ r"$color, $amount": (arguments) { var color = arguments[0].assertColor("color"); var amount = arguments[1].assertNumber("amount"); - return color.changeHsl( + var result = color.changeHsl( saturation: (color.saturation + amount.valueInRange(0, 100, "amount")) .clamp(0, 100)); + + warn( + "saturate() is deprecated. " + "${_suggestScaleAndAdjust(color, amount.value, 'saturation')}\n" + "\n" + "More info: https://sass-lang.com/d/color-functions", + deprecation: true); + return result; } }), _function("desaturate", r"$color, $amount", (arguments) { var color = arguments[0].assertColor("color"); var amount = arguments[1].assertNumber("amount"); - return color.changeHsl( + var result = color.changeHsl( saturation: (color.saturation - amount.valueInRange(0, 100, "amount")) .clamp(0, 100)); + + warn( + "desaturate() is deprecated. " + "${_suggestScaleAndAdjust(color, -amount.value, 'saturation')}\n" + "\n" + "More info: https://sass-lang.com/d/color-functions", + deprecation: true); + return result; }), // ### Opacity - _function("opacify", r"$color, $amount", _opacify), - _function("fade-in", r"$color, $amount", _opacify), - _function("transparentize", r"$color, $amount", _transparentize), - _function("fade-out", r"$color, $amount", _transparentize), + _function("opacify", r"$color, $amount", + (arguments) => _opacify("opacify", arguments)), + _function("fade-in", r"$color, $amount", + (arguments) => _opacify("fade-in", arguments)), + _function("transparentize", r"$color, $amount", + (arguments) => _transparentize("transparentize", arguments)), + _function("fade-out", r"$color, $amount", + (arguments) => _transparentize("fade-out", arguments)), BuiltInCallable.overloadedFunction("alpha", { r"$color": (arguments) { @@ -232,7 +285,10 @@ final global = UnmodifiableListView([ /// The Sass color module. final module = BuiltInModule("color", functions: [ // ### RGB - _red, _green, _blue, _mix, + _channelFunction("red", (color) => color.red), + _channelFunction("green", (color) => color.green), + _channelFunction("blue", (color) => color.blue), + _mix, _function("invert", r"$color, $weight: 100%, $space: null", (arguments) { var result = _invert(arguments); @@ -248,7 +304,9 @@ final module = BuiltInModule("color", functions: [ }), // ### HSL - _hue, _saturation, _lightness, + _channelFunction("hue", (color) => color.hue, unit: '%'), + _channelFunction("saturation", (color) => color.saturation, unit: '%'), + _channelFunction("lightness", (color) => color.lightness, unit: '%'), _removedColorFunction("adjust-hue", "hue"), _removedColorFunction("lighten", "lightness"), _removedColorFunction("darken", "lightness", negative: true), @@ -284,17 +342,8 @@ final module = BuiltInModule("color", functions: [ space: ColorSpace.hwb, name: 'channels') }), - _function( - "whiteness", - r"$color", - (arguments) => - SassNumber(arguments.first.assertColor("color").whiteness, "%")), - - _function( - "blackness", - r"$color", - (arguments) => - SassNumber(arguments.first.assertColor("color").blackness, "%")), + _channelFunction("whiteness", (color) => color.whiteness, unit: '%'), + _channelFunction("blackness", (color) => color.blackness, unit: '%'), // ### Opacity _removedColorFunction("opacify", "alpha"), @@ -442,18 +491,6 @@ final module = BuiltInModule("color", functions: [ // ### RGB -final _red = _function("red", r"$color", (arguments) { - return SassNumber(arguments.first.assertColor("color").red); -}); - -final _green = _function("green", r"$color", (arguments) { - return SassNumber(arguments.first.assertColor("color").green); -}); - -final _blue = _function("blue", r"$color", (arguments) { - return SassNumber(arguments.first.assertColor("color").blue); -}); - final _mix = _function("mix", r"$color1, $color2, $weight: 50%, $method: null", (arguments) { var color1 = arguments[0].assertColor("color1"); @@ -482,23 +519,6 @@ final _mix = _function("mix", r"$color1, $color2, $weight: 50%, $method: null", return _mixLegacy(color1, color2, weight); }); -// ### HSL - -final _hue = _function("hue", r"$color", - (arguments) => SassNumber(arguments.first.assertColor("color").hue, "deg")); - -final _saturation = _function( - "saturation", - r"$color", - (arguments) => - SassNumber(arguments.first.assertColor("color").saturation, "%")); - -final _lightness = _function( - "lightness", - r"$color", - (arguments) => - SassNumber(arguments.first.assertColor("color").lightness, "%")); - // ### Color Spaces final _complement = @@ -1031,23 +1051,37 @@ SassColor _mixLegacy(SassColor color1, SassColor color2, SassNumber weight) { } /// The definition of the `opacify()` and `fade-in()` functions. -SassColor _opacify(List arguments) { +SassColor _opacify(String name, List arguments) { var color = arguments[0].assertColor("color"); var amount = arguments[1].assertNumber("amount"); - - return color.changeAlpha( + var result = color.changeAlpha( (color.alpha + amount.valueInRangeWithUnit(0, 1, "amount", "")) .clamp(0, 1)); + + warn( + "$name() is deprecated. " + "${_suggestScaleAndAdjust(color, amount.value, 'alpha')}\n" + "\n" + "More info: https://sass-lang.com/d/color-functions", + deprecation: true); + return result; } /// The definition of the `transparentize()` and `fade-out()` functions. -SassColor _transparentize(List arguments) { +SassColor _transparentize(String name, List arguments) { var color = arguments[0].assertColor("color"); var amount = arguments[1].assertNumber("amount"); - - return color.changeAlpha( + var result = color.changeAlpha( (color.alpha - amount.valueInRangeWithUnit(0, 1, "amount", "")) .clamp(0, 1)); + + warn( + "$name() is deprecated. " + "${_suggestScaleAndAdjust(color, -amount.value, 'alpha')}\n" + "\n" + "More info: https://sass-lang.com/d/color-functions", + deprecation: true); + return result; } /// Returns the [colorUntyped] as a [SassColor] in the color space specified by @@ -1282,6 +1316,73 @@ bool _isNone(Value value) => !value.hasQuotes && value.text.toLowerCase() == 'none'; +/// Returns the implementation of a deprecated function that returns the value +/// of the channel named [name], implemented with [getter]. +/// +/// If [unit] is passed, the channel is returned with that unit. The [global] +/// parameter indicates whether this was called using the legacy global syntax. +BuiltInCallable _channelFunction( + String name, num Function(SassColor color) getter, + {String? unit, bool global = false}) { + return _function(name, r"$color", (arguments) { + var result = SassNumber(getter(arguments.first.assertColor("color")), unit); + + warn( + "${global ? '' : 'color.'}$name() is deprecated. Suggestion:\n" + "\n" + 'color.channel(\$color, $name)\n' + "\n" + "More info: https://sass-lang.com/d/color-functions", + deprecation: true); + + return result; + }); +} + +/// Returns suggested translations for deprecated color modification functions +/// in terms of both `color.scale()` and `color.adjust()`. +/// +/// [original] is the color that was passed in, [adjustment] is the requested +/// change, and [channelName] is the name of the modified channel. +String _suggestScaleAndAdjust( + SassColor original, double adjustment, String channelName) { + assert(original.isLegacy); + var channel = channelName == 'alpha' + ? ColorChannel.alpha + : ColorSpace.hsl.channels + .firstWhere((channel) => channel.name == channelName) + as LinearChannel; + + var oldValue = channel == ColorChannel.alpha + ? original.alpha + : original.toSpace(ColorSpace.hsl).channel(channelName); + var newValue = oldValue + adjustment; + + var suggestion = "Suggestion"; + if (adjustment != 0) { + late double factor; + if (newValue > channel.max) { + factor = 1; + } else if (newValue < channel.min) { + factor = -1; + } else if (adjustment > 0) { + factor = adjustment / (channel.max - oldValue); + } else { + factor = (newValue - oldValue) / (oldValue - channel.min); + } + var factorNumber = SassNumber(factor * 100, '%'); + suggestion += "s:\n" + "\n" + "color.scale(\$color, \$$channelName: $factorNumber)\n"; + } else { + suggestion += ":\n\n"; + } + + var difference = + SassNumber(adjustment, channel == ColorChannel.alpha ? null : '%'); + return suggestion + "color.adjust(\$color, \$$channelName: $difference)"; +} + /// Like [BuiltInCallable.function], but always sets the URL to /// `sass:color`. BuiltInCallable _function( From 5fc82b629959ac7748f4582d24b835d49fedbc11 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Wed, 18 Jan 2023 15:01:12 -0800 Subject: [PATCH 04/56] Fix the channel unit for color.hue() --- lib/src/functions/color.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index 42fac7268..d9b2af052 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -304,7 +304,7 @@ final module = BuiltInModule("color", functions: [ }), // ### HSL - _channelFunction("hue", (color) => color.hue, unit: '%'), + _channelFunction("hue", (color) => color.hue, unit: 'deg'), _channelFunction("saturation", (color) => color.saturation, unit: '%'), _channelFunction("lightness", (color) => color.lightness, unit: '%'), _removedColorFunction("adjust-hue", "hue"), From e5aa6425b5e45288c567b18c0131369161680c29 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Tue, 31 Jan 2023 12:34:00 -0800 Subject: [PATCH 05/56] Fix some bugs in `color.complement()` and `.mix()` (#1845) --- lib/src/functions/color.dart | 11 ++++++++--- lib/src/value/color/interpolation_method.dart | 2 +- lib/src/value/color/space.dart | 1 - 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index d9b2af052..6c02361f7 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -511,9 +511,9 @@ final _mix = _function("mix", r"$color1, $color2, $weight: 50%, $method: null", "color1"); } else if (!color2.isLegacy) { throw SassScriptException( - "To use color.mix() with non-legacy color $color1, you must provide a " + "To use color.mix() with non-legacy color $color2, you must provide a " "\$method.", - "color1"); + "color2"); } return _mixLegacy(color1, color2, weight); @@ -524,12 +524,17 @@ final _mix = _function("mix", r"$color1, $color2, $weight: 50%, $method: null", final _complement = _function("complement", r"$color, $space: null", (arguments) { var color = arguments[0].assertColor("color"); - var space = arguments[1] == sassNull + var space = color.isLegacy && arguments[1] == sassNull ? ColorSpace.hsl : ColorSpace.fromName( (arguments[1].assertString("space")..assertUnquoted("space")).text, "space"); + if (!space.isPolar) { + throw SassScriptException( + "Color space $space doesn't have a hue channel.", 'space'); + } + var inSpace = color.toSpace(space); return inSpace.changeChannels({'hue': inSpace.channel('hue') + 180}).toSpace( color.space); diff --git a/lib/src/value/color/interpolation_method.dart b/lib/src/value/color/interpolation_method.dart index b3ed40b25..eb25e68ac 100644 --- a/lib/src/value/color/interpolation_method.dart +++ b/lib/src/value/color/interpolation_method.dart @@ -84,7 +84,7 @@ class InterpolationMethod { throw SassScriptException( 'Expected unquoted string "hue" at the end of $value, was ${list[2]}.', name); - } else if (list.length > 2) { + } else if (list.length > 3) { throw SassScriptException( 'Expected nothing after "hue" in $value.', name); } else if (!space.isPolar) { diff --git a/lib/src/value/color/space.dart b/lib/src/value/color/space.dart index f3cd7efd9..1934c128c 100644 --- a/lib/src/value/color/space.dart +++ b/lib/src/value/color/space.dart @@ -106,7 +106,6 @@ abstract class ColorSpace { /// https://www.w3.org/TR/css-color-4/#ok-lab static const ColorSpace oklab = OklabColorSpace(); - /// The Oklch color space. /// /// https://www.w3.org/TR/css-color-4/#ok-lab From 216ceb797be6dc17601c299f7be5418b4ef30530 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Wed, 22 Mar 2023 17:19:14 -0700 Subject: [PATCH 06/56] Update specs for sass/sass#3497 (#1919) --- lib/src/functions/color.dart | 17 +++-- lib/src/value/color.dart | 75 +++++++++++++------ lib/src/value/color/interpolation_method.dart | 40 +--------- 3 files changed, 67 insertions(+), 65 deletions(-) diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index 6c02361f7..b78488139 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -614,11 +614,6 @@ Value _invert(List arguments) { } if (fuzzyEquals(weight, 1)) return inverted; - if (!InterpolationMethod.supportedSpaces.contains(space)) { - throw SassScriptException( - "Color space $space can't be used for interpolation.", "space"); - } - return color.interpolate(inverted, InterpolationMethod(space), weight: 1 - weight); } @@ -1290,6 +1285,18 @@ SassColor _colorFromChannels(ColorSpace space, SassNumber? channel0, alpha, fromRgbFunction ? ColorFormat.rgbFunction : null); + case ColorSpace.lab: + case ColorSpace.lch: + case ColorSpace.oklab: + case ColorSpace.oklch: + return SassColor.forSpaceInternal( + space, + _channelFromValue(space.channels[0], channel0) + .andThen((lightness) => fuzzyClamp(lightness, 0, 100)), + _channelFromValue(space.channels[1], channel1), + _channelFromValue(space.channels[2], channel2), + alpha); + default: return SassColor.forSpaceInternal( space, diff --git a/lib/src/value/color.dart b/lib/src/value/color.dart index 85e2bad85..922662591 100644 --- a/lib/src/value/color.dart +++ b/lib/src/value/color.dart @@ -123,7 +123,7 @@ class SassColor extends Value { case ColorSpace.oklab: case ColorSpace.lch: case ColorSpace.oklch: - return fuzzyEquals(channel0, 0); + return fuzzyEquals(channel0, 0) || fuzzyEquals(channel0, 100); default: return false; @@ -156,11 +156,13 @@ class SassColor extends Value { switch (space) { case ColorSpace.lab: case ColorSpace.oklab: - return fuzzyEquals(channel0, 0); + return fuzzyEquals(channel0, 0) || fuzzyEquals(channel0, 100); case ColorSpace.lch: case ColorSpace.oklch: - return fuzzyEquals(channel0, 0) || fuzzyEquals(channel1, 0); + return fuzzyEquals(channel0, 0) || + fuzzyEquals(channel0, 100) || + fuzzyEquals(channel1, 0); default: return false; @@ -285,7 +287,7 @@ class SassColor extends Value { [num? alpha]) => SassColor.forSpaceInternal( ColorSpace.hsl, - hue?.toDouble(), + _normalizeHue(hue?.toDouble()), saturation.andThen((saturation) => fuzzyAssertRange(saturation.toDouble(), 0, 100, "saturation")), lightness.andThen((lightness) => @@ -299,7 +301,7 @@ class SassColor extends Value { [num? alpha]) => SassColor.forSpaceInternal( ColorSpace.hwb, - hue?.toDouble(), + _normalizeHue(hue?.toDouble()), whiteness.andThen((whiteness) => fuzzyAssertRange(whiteness.toDouble(), 0, 100, "whiteness")), blackness.andThen((blackness) => @@ -367,21 +369,39 @@ class SassColor extends Value { /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. factory SassColor.lab(double? lightness, double? a, double? b, [double? alpha]) => - SassColor.forSpaceInternal(ColorSpace.lab, lightness, a, b, alpha); + SassColor.forSpaceInternal( + ColorSpace.lab, + lightness.andThen( + (lightness) => fuzzyAssertRange(lightness, 0, 100, "lightness")), + a, + b, + alpha); /// Creates a color in [ColorSpace.lch]. /// /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. factory SassColor.lch(double? lightness, double? chroma, double? hue, [double? alpha]) => - SassColor.forSpaceInternal(ColorSpace.lch, lightness, chroma, hue, alpha); + SassColor.forSpaceInternal( + ColorSpace.lch, + lightness.andThen( + (lightness) => fuzzyAssertRange(lightness, 0, 100, "lightness")), + chroma, + _normalizeHue(hue), + alpha); /// Creates a color in [ColorSpace.oklab]. /// /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. factory SassColor.oklab(double? lightness, double? a, double? b, [double? alpha]) => - SassColor.forSpaceInternal(ColorSpace.oklab, lightness, a, b, alpha); + SassColor.forSpaceInternal( + ColorSpace.oklab, + lightness.andThen( + (lightness) => fuzzyAssertRange(lightness, 0, 100, "lightness")), + a, + b, + alpha); /// Creates a color in [ColorSpace.oklch]. /// @@ -389,7 +409,12 @@ class SassColor extends Value { factory SassColor.oklch(double? lightness, double? chroma, double? hue, [double? alpha]) => SassColor.forSpaceInternal( - ColorSpace.oklch, lightness, chroma, hue, alpha); + ColorSpace.oklch, + lightness.andThen( + (lightness) => fuzzyAssertRange(lightness, 0, 100, "lightness")), + chroma, + _normalizeHue(hue), + alpha); /// Creates a color in the color space named [space]. /// @@ -401,14 +426,17 @@ class SassColor extends Value { throw RangeError.value(channels.length, "channels.length", 'must be exactly ${space.channels.length} for color space "$space"'); } else { - var clampChannels = space == ColorSpace.hsl || space == ColorSpace.hwb; + var clampChannel0 = space.channels[0].name == "lightness"; + var clampChannel12 = space == ColorSpace.hsl || space == ColorSpace.hwb; return SassColor.forSpaceInternal( space, - channels[0], - clampChannels + clampChannel0 + ? channels[0].andThen((value) => fuzzyClamp(value, 0, 100)) + : channels[0], + clampChannel12 ? channels[1].andThen((value) => fuzzyClamp(value, 0, 100)) : channels[1], - clampChannels + clampChannel12 ? channels[2].andThen((value) => fuzzyClamp(value, 0, 100)) : channels[2], alpha); @@ -432,6 +460,12 @@ class SassColor extends Value { "[BUG] Tried to create " "$_space(${channel0OrNull ?? 'none'}, ${channel1OrNull ?? 'none'}, " "${channel2OrNull ?? 'none'})"); + assert( + space.channels[0].name != "lightness" || + fuzzyCheckRange(channel0, 0, 100) != null, + "[BUG] Tried to create " + "$_space(${channel0OrNull ?? 'none'}, ${channel1OrNull ?? 'none'}, " + "${channel2OrNull ?? 'none'})"); assert(space != ColorSpace.lms); _checkChannel(channel0OrNull, space.channels[0].name); @@ -449,6 +483,12 @@ class SassColor extends Value { } } + /// If [hue] isn't null, normalizes it to the range `[0, 360)`. + static double? _normalizeHue(double? hue) { + if (hue == null) return hue; + return (hue % 360 + 360) % 360; + } + /// @nodoc @internal T accept(ValueVisitor visitor) => visitor.visitColor(this); @@ -894,11 +934,6 @@ class SassColor extends Value { double _interpolateHues( double hue1, double hue2, HueInterpolationMethod method, double weight) { // Algorithms from https://www.w3.org/TR/css-color-4/#hue-interpolation - if (method != HueInterpolationMethod.specified) { - hue1 = (hue1 % 360 + 360) % 360; - hue2 = (hue2 % 360 + 360) % 360; - } - switch (method) { case HueInterpolationMethod.shorter: var difference = hue2 - hue1; @@ -925,10 +960,6 @@ class SassColor extends Value { case HueInterpolationMethod.decreasing: if (hue1 < hue2) hue1 += 360; break; - - case HueInterpolationMethod.specified: - // Use the hues as-is. - break; } return hue1 * weight + hue2 * (1 - weight); diff --git a/lib/src/value/color/interpolation_method.dart b/lib/src/value/color/interpolation_method.dart index eb25e68ac..c77c4f3d6 100644 --- a/lib/src/value/color/interpolation_method.dart +++ b/lib/src/value/color/interpolation_method.dart @@ -2,8 +2,6 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import 'package:meta/meta.dart'; - import '../../exception.dart'; import '../../value.dart'; @@ -14,23 +12,6 @@ import '../../value.dart'; /// /// {@category Value} class InterpolationMethod { - /// The set of color spaces that can be used for color interpolation. - /// - /// @nodoc - @internal - static const supportedSpaces = { - ColorSpace.srgb, - ColorSpace.srgbLinear, - ColorSpace.lab, - ColorSpace.oklab, - ColorSpace.xyzD50, - ColorSpace.xyzD65, - ColorSpace.hsl, - ColorSpace.hwb, - ColorSpace.lch, - ColorSpace.oklch - }; - /// The color space in which to perform the interpolation. final ColorSpace space; @@ -41,10 +22,7 @@ class InterpolationMethod { InterpolationMethod(this.space, [HueInterpolationMethod? hue]) : hue = space.isPolar ? hue ?? HueInterpolationMethod.shorter : null { - if (!supportedSpaces.contains(space)) { - throw ArgumentError( - "Color space $space can't be used for interpolation."); - } else if (!space.isPolar && hue != null) { + if (!space.isPolar && hue != null) { throw ArgumentError( "Hue interpolation method may not be set for rectangular color space " "$space."); @@ -66,11 +44,6 @@ class InterpolationMethod { var space = ColorSpace.fromName( (list.first.assertString(name)..assertUnquoted(name)).text, name); - if (!supportedSpaces.contains(space)) { - throw SassScriptException( - "Color space $space can't be used for interpolation.", name); - } - if (list.length == 1) return InterpolationMethod(space); var hueMethod = HueInterpolationMethod._fromValue(list[1], name); @@ -124,14 +97,7 @@ enum HueInterpolationMethod { /// Angles are adjusted so that `θ₂ - θ₁ ∈ (-360, 0]`. /// /// https://www.w3.org/TR/css-color-4/#hue-decreasing - decreasing, - - /// No fixup is performed. - /// - /// Angles are interpolated in the same way as every other component. - /// - /// https://www.w3.org/TR/css-color-4/#hue-specified - specified; + decreasing; /// Parses a SassScript value representing a hue interpolation method, not /// ending with "hue". @@ -150,8 +116,6 @@ enum HueInterpolationMethod { return HueInterpolationMethod.increasing; case 'decreasing': return HueInterpolationMethod.decreasing; - case 'specified': - return HueInterpolationMethod.specified; default: throw SassScriptException( 'Unknown hue interpolation method $value.', name); From 0f5b99e400aa96389d9ff3d069adfaed881e8b1a Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Tue, 1 Aug 2023 23:23:56 -0700 Subject: [PATCH 07/56] Update new color space code to Dart 3 style --- lib/src/functions/color.dart | 244 +++++++++--------- lib/src/js/value/color.dart | 12 +- lib/src/value/color.dart | 190 ++++++-------- lib/src/value/color/channel.dart | 36 +-- lib/src/value/color/interpolation_method.dart | 25 +- lib/src/value/color/space.dart | 108 +++----- lib/src/value/color/space/a98_rgb.dart | 35 +-- lib/src/value/color/space/display_p3.dart | 35 +-- lib/src/value/color/space/lms.dart | 35 +-- lib/src/value/color/space/oklab.dart | 4 +- lib/src/value/color/space/prophoto_rgb.dart | 35 +-- lib/src/value/color/space/rec2020.dart | 35 +-- lib/src/value/color/space/srgb.dart | 33 +-- lib/src/value/color/space/srgb_linear.dart | 63 ++--- lib/src/value/color/space/utils.dart | 15 +- lib/src/value/color/space/xyz_d50.dart | 38 +-- lib/src/value/color/space/xyz_d65.dart | 35 +-- 17 files changed, 388 insertions(+), 590 deletions(-) diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index aa3fd0b6d..56322f0b7 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -11,6 +11,7 @@ import '../deprecation.dart'; import '../evaluation_context.dart'; import '../exception.dart'; import '../module/built_in.dart'; +import '../util/map.dart'; import '../util/nullable.dart'; import '../util/number.dart'; import '../utils.dart'; @@ -589,40 +590,30 @@ Value _invert(List arguments, {bool global = false}) { if (fuzzyEquals(weight, 0)) return color; var inSpace = color.toSpace(space); - SassColor inverted; - switch (space) { - case ColorSpace.hwb: - inverted = SassColor.hwb((inSpace.channel0 + 180) % 360, inSpace.channel2, - inSpace.channel1, inSpace.alpha); - break; - - case ColorSpace.hsl: - inverted = SassColor.hsl((inSpace.channel0 + 180) % 360, inSpace.channel1, - 100 - inSpace.channel2, inSpace.alpha); - break; - - case ColorSpace.lch: - inverted = SassColor.lch(100 - inSpace.channel0, inSpace.channel1, - (inSpace.channel2 + 180) % 360, inSpace.alpha); - break; - - case ColorSpace.oklch: - inverted = SassColor.oklch(1 - inSpace.channel0, inSpace.channel1, - (inSpace.channel2 + 180) % 360, inSpace.alpha); - break; - - default: - var channel0 = space.channels[0] as LinearChannel; - var channel1 = space.channels[1] as LinearChannel; - var channel2 = space.channels[2] as LinearChannel; - inverted = SassColor.forSpaceInternal( + var inverted = switch (space) { + ColorSpace.hwb => SassColor.hwb((inSpace.channel0 + 180) % 360, + inSpace.channel2, inSpace.channel1, inSpace.alpha), + ColorSpace.hsl => SassColor.hsl((inSpace.channel0 + 180) % 360, + inSpace.channel1, 100 - inSpace.channel2, inSpace.alpha), + ColorSpace.lch => SassColor.lch(100 - inSpace.channel0, inSpace.channel1, + (inSpace.channel2 + 180) % 360, inSpace.alpha), + ColorSpace.oklch => SassColor.oklch(1 - inSpace.channel0, inSpace.channel1, + (inSpace.channel2 + 180) % 360, inSpace.alpha), + ColorSpace( + channels: [ + LinearChannel channel0, + LinearChannel channel1, + LinearChannel channel2 + ] + ) => + SassColor.forSpaceInternal( space, _invertChannel(channel0, inSpace.channel0), _invertChannel(channel1, inSpace.channel1), _invertChannel(channel2, inSpace.channel2), - inSpace.alpha); - break; - } + inSpace.alpha), + _ => throw UnsupportedError("Unknown color space $space.") + }; if (fuzzyEquals(weight, 1)) return inverted; return color.interpolate(inverted, InterpolationMethod(space), @@ -704,15 +695,15 @@ SassColor _updateComponents(List arguments, var oldChannels = color.channels; var channelArgs = List.filled(oldChannels.length, null); var channelInfo = color.space.channels; - for (var entry in keywords.entries) { - var channelIndex = channelInfo.indexWhere((info) => entry.key == info.name); + for (var (name, value) in keywords.pairs) { + var channelIndex = channelInfo.indexWhere((info) => name == info.name); if (channelIndex == -1) { throw SassScriptException( "Color space ${color.space} doesn't have a channel with this name.", - entry.key); + name); } - channelArgs[channelIndex] = entry.value.assertNumber(entry.key); + channelArgs[channelIndex] = value.assertNumber(name); } var result = change @@ -765,17 +756,15 @@ double _scaleChannel( var factor = (factorArg..assertUnit('%', channel.name)) .valueInRangeWithUnit(-100, 100, channel.name, '%') / 100; - if (factor == 0) { - return oldValue; - } else if (factor > 0) { - return oldValue >= channel.max + return switch (factor) { + 0 => oldValue, + > 0 => oldValue >= channel.max ? oldValue - : oldValue + (channel.max - oldValue) * factor; - } else { - return oldValue <= channel.min + : oldValue + (channel.max - oldValue) * factor, + _ => oldValue <= channel.min ? oldValue - : oldValue + (oldValue - channel.min) * factor; - } + : oldValue + (oldValue - channel.min) * factor + }; } /// Returns a copy of [color] with its channel values adjusted by the values in @@ -804,32 +793,34 @@ double _adjustChannel(ColorSpace space, ColorChannel channel, double oldValue, SassNumber? adjustmentArg) { if (adjustmentArg == null) return oldValue; - if ((space == ColorSpace.hsl || space == ColorSpace.hwb) && - channel is! LinearChannel) { - // `_channelFromValue` expects all hue values to be compatible with `deg`, - // but we're still in the deprecation period where we allow non-`deg` values - // for HSL and HWB so we have to handle that ahead-of-time. - adjustmentArg = SassNumber(_angleValue(adjustmentArg, 'hue')); - } else if (space == ColorSpace.hsl && channel is LinearChannel) { - // `_channelFromValue` expects lightness/saturation to be `%`, but we're - // still in the deprecation period where we allow non-`%` values so we have - // to handle that ahead-of-time. - _checkPercent(adjustmentArg, channel.name); - adjustmentArg = SassNumber(adjustmentArg.value, '%'); - } else if (channel == ColorChannel.alpha && adjustmentArg.hasUnits) { - // `_channelFromValue` expects alpha to be unitless or `%`, but we're still - // in the deprecation period where we allow other values (and interpret `%` - // as unitless) so we have to handle that ahead-of-time. - warnForDeprecation( - "\$alpha: Passing a number with unit ${adjustmentArg.unitString} is " - "deprecated.\n" - "\n" - "To preserve current behavior: " - "${adjustmentArg.unitSuggestion('alpha')}\n" - "\n" - "More info: https://sass-lang.com/d/function-units", - Deprecation.functionUnits); - adjustmentArg = SassNumber(adjustmentArg.value); + switch ((space, channel)) { + case (ColorSpace.hsl || ColorSpace.hwb, _) when channel is! LinearChannel: + // `_channelFromValue` expects all hue values to be compatible with `deg`, + // but we're still in the deprecation period where we allow non-`deg` + // values for HSL and HWB so we have to handle that ahead-of-time. + adjustmentArg = SassNumber(_angleValue(adjustmentArg, 'hue')); + + case (ColorSpace.hsl, LinearChannel()): + // `_channelFromValue` expects lightness/saturation to be `%`, but we're + // still in the deprecation period where we allow non-`%` values so we + // have to handle that ahead-of-time. + _checkPercent(adjustmentArg, channel.name); + adjustmentArg = SassNumber(adjustmentArg.value, '%'); + + case (_, ColorChannel.alpha) when adjustmentArg.hasUnits: + // `_channelFromValue` expects alpha to be unitless or `%`, but we're + // still in the deprecation period where we allow other values (and + // interpret `%` as unitless) so we have to handle that ahead-of-time. + warnForDeprecation( + "\$alpha: Passing a number with unit ${adjustmentArg.unitString} is " + "deprecated.\n" + "\n" + "To preserve current behavior: " + "${adjustmentArg.unitSuggestion('alpha')}\n" + "\n" + "More info: https://sass-lang.com/d/function-units", + Deprecation.functionUnits); + adjustmentArg = SassNumber(adjustmentArg.value); } var result = oldValue + _channelFromValue(channel, adjustmentArg)!; @@ -846,17 +837,13 @@ double _adjustChannel(ColorSpace space, ColorChannel channel, double oldValue, ColorSpace? _sniffLegacyColorSpace(Map keywords) { for (var key in keywords.keys) { switch (key) { - case "red": - case "green": - case "blue": + case "red" || "green" || "blue": return ColorSpace.rgb; - case "saturation": - case "lightness": + case "saturation" || "lightness": return ColorSpace.hsl; - case "whiteness": - case "blackness": + case "whiteness" || "blackness": return ColorSpace.hwb; } } @@ -1138,24 +1125,20 @@ ColorSpace _spaceOrDefault(SassColor color, Value space, [String? name]) => Value _parseChannels(String functionName, Value input, {ColorSpace? space, String? name}) { if (input.isVar) return _functionString(functionName, [input]); - var inputList = input.assertCommonListStyle(name, allowSlash: true); Value components; Value? alphaValue; - switch (inputList) { + switch (input.assertCommonListStyle(name, allowSlash: true)) { case [var components_, var alphaValue_] when input.separator == ListSeparator.slash: components = components_; alphaValue = alphaValue_; - case _ when input.separator == ListSeparator.slash: + case var inputList when input.separator == ListSeparator.slash: throw SassScriptException( "Only 2 slash-separated elements allowed, but ${inputList.length} " "${pluralize('was', inputList.length, plural: 'were')} passed."); - case []: - components = input; - case [..., SassString(hasQuotes: false, :var text)] when text.contains('/'): return _functionString(functionName, [input]); @@ -1169,46 +1152,51 @@ Value _parseChannels(String functionName, Value input, List channels; SassString? spaceName; - var componentList = components.assertCommonListStyle(name, allowSlash: false); - if (componentList.isEmpty) { - throw SassScriptException('Color component list may not be empty.', name); - } else if (components.isVar) { - channels = [components]; - } else { - if (space == null) { - spaceName = componentList.first.assertString(name)..assertUnquoted(name); - space = - spaceName.isVar ? null : ColorSpace.fromName(spaceName.text, name); - channels = [...componentList.skip(1)]; - - if (const { - ColorSpace.rgb, - ColorSpace.hsl, - ColorSpace.hwb, - ColorSpace.lab, - ColorSpace.lch, - ColorSpace.oklab, - ColorSpace.oklch - }.contains(space)) { - throw SassScriptException( - "The color() function doesn't support the color space $space. Use " - "the $space() function instead.", - name); + switch (components.assertCommonListStyle(name, allowSlash: false)) { + case []: + throw SassScriptException('Color component list may not be empty.', name); + + case _ when components.isVar: + channels = [components]; + + case [var first, ...var rest] && var componentList: + if (space == null) { + spaceName = first.assertString(name)..assertUnquoted(name); + space = + spaceName.isVar ? null : ColorSpace.fromName(spaceName.text, name); + channels = rest; + + if (space + case ColorSpace.rgb || + ColorSpace.hsl || + ColorSpace.hwb || + ColorSpace.lab || + ColorSpace.lch || + ColorSpace.oklab || + ColorSpace.oklch) { + throw SassScriptException( + "The color() function doesn't support the color space $space. Use " + "the $space() function instead.", + name); + } + } else { + channels = componentList; } - } else { - channels = componentList; - } - for (var channel in channels) { - if (!channel.isSpecialNumber && - channel is! SassNumber && - !_isNone(channel)) { - var channelName = - space?.channels[channels.indexOf(channel)].name ?? 'channel'; - throw SassScriptException( - 'Expected $channelName $channel to be a number.', name); + for (var channel in channels) { + if (!channel.isSpecialNumber && + channel is! SassNumber && + !_isNone(channel)) { + var channelName = + space?.channels[channels.indexOf(channel)].name ?? 'channel'; + throw SassScriptException( + 'Expected $channelName $channel to be a number.', name); + } } - } + + // dart-lang/sdk#51926 + case _: + throw "unreachable"; } if (alphaValue?.isSpecialNumber ?? false) { @@ -1319,16 +1307,14 @@ SassColor _colorFromChannels(ColorSpace space, SassNumber? channel0, /// Converts a channel value from a [SassNumber] into a [double] according to /// [channel]. double? _channelFromValue(ColorChannel channel, SassNumber? value) => - value.andThen((value) { - if (channel is! LinearChannel) { - return value.coerceValueToUnit('deg', channel.name); - } else if (channel.requiresPercent && !value.hasUnit('%')) { - throw SassScriptException( - 'Expected $value to have unit "%".', channel.name); - } else { - return _percentageOrUnitless(value, channel.max, channel.name); - } - }); + value.andThen((value) => switch (channel) { + LinearChannel(requiresPercent: true) when !value.hasUnit('%') => + throw SassScriptException( + 'Expected $value to have unit "%".', channel.name), + LinearChannel() => + _percentageOrUnitless(value, channel.max, channel.name), + _ => value.coerceValueToUnit('deg', channel.name) + }); /// Returns whether [value] is an unquoted string case-insensitively equal to /// "none". diff --git a/lib/src/js/value/color.dart b/lib/src/js/value/color.dart index c326ee36c..c594fad6a 100644 --- a/lib/src/js/value/color.dart +++ b/lib/src/js/value/color.dart @@ -39,15 +39,11 @@ final JSClass colorClass = () { } else if (options.red != null || options.green != null || options.blue != null) { - var red = options.red; - var green = options.green; - var blue = options.blue; - var alpha = options.alpha; return self.changeChannels({ - if (red != null) "red": red, - if (green != null) "green": green, - if (blue != null) "blue": blue, - if (alpha != null) "alpha": alpha + if (options.red case var red?) "red": red, + if (options.green case var green?) "green": green, + if (options.blue case var blue?) "blue": blue, + if (options.alpha case var alpha?) "alpha": alpha }); } else { return self.changeAlpha(options.alpha ?? self.alpha); diff --git a/lib/src/value/color.dart b/lib/src/value/color.dart index 922662591..75f608f0c 100644 --- a/lib/src/value/color.dart +++ b/lib/src/value/color.dart @@ -70,18 +70,11 @@ class SassColor extends Value { /// /// @nodoc @internal - bool get isChannel0Powerless { - switch (space) { - case ColorSpace.hsl: - return fuzzyEquals(channel1, 0) || fuzzyEquals(channel2, 0); - - case ColorSpace.hwb: - return fuzzyEquals(channel1 + channel2, 100); - - default: - return false; - } - } + bool get isChannel0Powerless => switch (space) { + ColorSpace.hsl => fuzzyEquals(channel1, 0) || fuzzyEquals(channel2, 0), + ColorSpace.hwb => fuzzyEquals(channel1 + channel2, 100), + _ => false + }; /// This color's first channel. /// @@ -114,21 +107,15 @@ class SassColor extends Value { /// /// @nodoc @internal - bool get isChannel1Powerless { - switch (space) { - case ColorSpace.hsl: - return fuzzyEquals(channel2, 0); - - case ColorSpace.lab: - case ColorSpace.oklab: - case ColorSpace.lch: - case ColorSpace.oklch: - return fuzzyEquals(channel0, 0) || fuzzyEquals(channel0, 100); - - default: - return false; - } - } + bool get isChannel1Powerless => switch (space) { + ColorSpace.hsl => fuzzyEquals(channel2, 0), + ColorSpace.lab || + ColorSpace.oklab || + ColorSpace.lch || + ColorSpace.oklch => + fuzzyEquals(channel0, 0) || fuzzyEquals(channel0, 100), + _ => false + }; /// This color's second channel. /// @@ -152,22 +139,15 @@ class SassColor extends Value { /// /// @nodoc @internal - bool get isChannel2Powerless { - switch (space) { - case ColorSpace.lab: - case ColorSpace.oklab: - return fuzzyEquals(channel0, 0) || fuzzyEquals(channel0, 100); - - case ColorSpace.lch: - case ColorSpace.oklch: - return fuzzyEquals(channel0, 0) || + bool get isChannel2Powerless => switch (space) { + ColorSpace.lab || + ColorSpace.oklab => + fuzzyEquals(channel0, 0) || fuzzyEquals(channel0, 100), + ColorSpace.lch || ColorSpace.oklch => fuzzyEquals(channel0, 0) || fuzzyEquals(channel0, 100) || - fuzzyEquals(channel1, 0); - - default: - return false; - } - } + fuzzyEquals(channel1, 0), + _ => false + }; /// This color's third channel. /// @@ -475,11 +455,13 @@ class SassColor extends Value { /// Throws a [RangeError] if [channel] isn't a finite number. void _checkChannel(double? channel, String name) { - if (channel == null) return; - if (channel.isNaN) { - throw RangeError.value(channel, name, 'must be a number.'); - } else if (!channel.isFinite) { - throw RangeError.value(channel, name, 'must be finite.'); + switch (channel) { + case null: + return; + case double(isNaN: true): + throw RangeError.value(channel, name, 'must be a number.'); + case double(isFinite: false): + throw RangeError.value(channel, name, 'must be finite.'); } } @@ -632,20 +614,18 @@ class SassColor extends Value { assert(!current.isInGamut); assert(current.space == space); - if (space == ColorSpace.rgb) { - return SassColor.rgb( - fuzzyClamp(current.channel0, 0, 255), - fuzzyClamp(current.channel1, 0, 255), - fuzzyClamp(current.channel2, 0, 255), - current.alpha); - } else { - return SassColor.forSpaceInternal( - space, - fuzzyClamp(current.channel0, 0, 1), - fuzzyClamp(current.channel1, 0, 1), - fuzzyClamp(current.channel2, 0, 1), - current.alpha); - } + return space == ColorSpace.rgb + ? SassColor.rgb( + fuzzyClamp(current.channel0, 0, 255), + fuzzyClamp(current.channel1, 0, 255), + fuzzyClamp(current.channel2, 0, 255), + current.alpha) + : SassColor.forSpaceInternal( + space, + fuzzyClamp(current.channel0, 0, 1), + fuzzyClamp(current.channel1, 0, 1), + fuzzyClamp(current.channel2, 0, 1), + current.alpha); } /// Returns the ΔEOK measure between [color1] and [color2]. @@ -855,7 +835,6 @@ class SassColor extends Value { var channel2_1 = (missing2_1 ? color1 : color2).channel1; var channel2_2 = (missing2_2 ? color1 : color2).channel2; - // TODO: handle missing channels var thisMultiplier = alpha * weight; var otherMultiplier = other.alpha * (1 - weight); var mixedAlpha = alpha * weight + other.alpha * (1 - weight); @@ -872,40 +851,27 @@ class SassColor extends Value { : (channel1_2 * thisMultiplier + channel2_2 * otherMultiplier) / mixedAlpha; - SassColor mixed; - switch (method.space) { - case ColorSpace.hsl: - case ColorSpace.hwb: - mixed = SassColor.forSpaceInternal( - method.space, - missing1_0 && missing2_0 - ? null - : _interpolateHues(channel1_0, channel2_0, method.hue!, weight), - mixed1, - mixed2, - mixedAlpha); - break; - - case ColorSpace.lch: - case ColorSpace.oklch: - mixed = SassColor.forSpaceInternal( - method.space, - mixed0, - mixed1, - missing1_2 && missing2_2 - ? null - : _interpolateHues(channel1_2, channel2_2, method.hue!, weight), - mixedAlpha); - break; - - default: - assert(!space.isPolar); - mixed = SassColor.forSpaceInternal( - method.space, mixed0, mixed1, mixed2, mixedAlpha); - break; + return switch (method.space) { + ColorSpace.hsl || ColorSpace.hwb => SassColor.forSpaceInternal( + method.space, + missing1_0 && missing2_0 + ? null + : _interpolateHues(channel1_0, channel2_0, method.hue!, weight), + mixed1, + mixed2, + mixedAlpha), + ColorSpace.lch || ColorSpace.oklch => SassColor.forSpaceInternal( + method.space, + mixed0, + mixed1, + missing1_2 && missing2_2 + ? null + : _interpolateHues(channel1_2, channel2_2, method.hue!, weight), + mixedAlpha), + _ => SassColor.forSpaceInternal( + method.space, mixed0, mixed1, mixed2, mixedAlpha) } - - return mixed.toSpace(space); + .toSpace(space); } /// Returns whether [output], which was converted to its color space from @@ -936,30 +902,28 @@ class SassColor extends Value { // Algorithms from https://www.w3.org/TR/css-color-4/#hue-interpolation switch (method) { case HueInterpolationMethod.shorter: - var difference = hue2 - hue1; - if (difference > 180) { - hue1 += 360; - } else if (difference < -180) { - hue2 += 360; + switch (hue2 - hue1) { + case > 180: + hue1 += 360; + case < -180: + hue2 += 360; } - break; case HueInterpolationMethod.longer: - var difference = hue2 - hue1; - if (difference > 0 && difference < 180) { - hue2 += 360; - } else if (difference > -180 && difference <= 0) { - hue1 += 360; + switch (hue2 - hue1) { + case > 0 && < 180: + hue2 += 360; + case > -180 && <= 0: + hue1 += 360; } - break; - case HueInterpolationMethod.increasing: - if (hue2 < hue1) hue2 += 360; - break; + case HueInterpolationMethod.increasing when hue2 < hue1: + hue2 += 360; + + case HueInterpolationMethod.decreasing when hue1 < hue2: + hue1 += 360; - case HueInterpolationMethod.decreasing: - if (hue1 < hue2) hue1 += 360; - break; + case _: // do nothing } return hue1 * weight + hue2 * (1 - weight); diff --git a/lib/src/value/color/channel.dart b/lib/src/value/color/channel.dart index 475516ebf..6fe9f9dac 100644 --- a/lib/src/value/color/channel.dart +++ b/lib/src/value/color/channel.dart @@ -28,32 +28,16 @@ class ColorChannel { /// Returns whether this channel is [analogous] to [other]. /// /// [analogous]: https://www.w3.org/TR/css-color-4/#interpolation-missing - bool isAnalogous(ColorChannel other) { - switch (name) { - case "red": - case "x": - return other.name == "red" || other.name == "x"; - - case "green": - case "y": - return other.name == "green" || other.name == "y"; - - case "blue": - case "z": - return other.name == "blue" || other.name == "z"; - - case "chroma": - case "saturation": - return other.name == "chroma" || other.name == "saturation"; - - case "lightness": - case "hue": - return other.name == name; - - default: - return false; - } - } + bool isAnalogous(ColorChannel other) => switch ((name, other.name)) { + ("red" || "x", "red" || "x") || + ("green" || "y", "gren" || "y") || + ("blue" || "z", "blue" || "z") || + ("chroma" || "saturation", "chroma" || "saturation") || + ("lightness", "lightness") || + ("hue", "hue") => + true, + _ => false + }; } /// Metadata about a color channel with a linear (as opposed to polar) value. diff --git a/lib/src/value/color/interpolation_method.dart b/lib/src/value/color/interpolation_method.dart index c77c4f3d6..6a4a67535 100644 --- a/lib/src/value/color/interpolation_method.dart +++ b/lib/src/value/color/interpolation_method.dart @@ -105,20 +105,13 @@ enum HueInterpolationMethod { /// Throws a [SassScriptException] if [value] isn't a valid hue interpolation /// method. If [value] came from a function argument, [name] is the argument /// name (without the `$`). This is used for error reporting. - factory HueInterpolationMethod._fromValue(Value value, [String? name]) { - var text = (value.assertString(name)..assertUnquoted()).text.toLowerCase(); - switch (text) { - case 'shorter': - return HueInterpolationMethod.shorter; - case 'longer': - return HueInterpolationMethod.longer; - case 'increasing': - return HueInterpolationMethod.increasing; - case 'decreasing': - return HueInterpolationMethod.decreasing; - default: - throw SassScriptException( - 'Unknown hue interpolation method $value.', name); - } - } + factory HueInterpolationMethod._fromValue(Value value, [String? name]) => + switch ((value.assertString(name)..assertUnquoted()).text.toLowerCase()) { + 'shorter' => HueInterpolationMethod.shorter, + 'longer' => HueInterpolationMethod.longer, + 'increasing' => HueInterpolationMethod.increasing, + 'decreasing' => HueInterpolationMethod.decreasing, + _ => throw SassScriptException( + 'Unknown hue interpolation method $value.', name) + }; } diff --git a/lib/src/value/color/space.dart b/lib/src/value/color/space.dart index 1934c128c..882f5be91 100644 --- a/lib/src/value/color/space.dart +++ b/lib/src/value/color/space.dart @@ -25,8 +25,6 @@ import 'space/srgb_linear.dart'; import 'space/xyz_d50.dart'; import 'space/xyz_d65.dart'; -// TODO: limit instance methods to sass_api - /// A color space whose channel names and semantics Sass knows. /// /// {@category Value} @@ -150,43 +148,26 @@ abstract class ColorSpace { /// /// If this came from a function argument, [argumentName] is the argument name /// (without the `$`). This is used for error reporting. - static ColorSpace fromName(String name, [String? argumentName]) { - switch (name.toLowerCase()) { - case 'rgb': - return rgb; - case 'hwb': - return hwb; - case 'hsl': - return hsl; - case 'srgb': - return srgb; - case 'srgb-linear': - return srgbLinear; - case 'display-p3': - return displayP3; - case 'a98-rgb': - return a98Rgb; - case 'prophoto-rgb': - return prophotoRgb; - case 'rec2020': - return rec2020; - case 'xyz': - case 'xyz-d65': - return xyzD65; - case 'xyz-d50': - return xyzD50; - case 'lab': - return lab; - case 'lch': - return lch; - case 'oklab': - return oklab; - case 'oklch': - return oklch; - default: - throw SassScriptException('Unknown color space "$name".', argumentName); - } - } + static ColorSpace fromName(String name, [String? argumentName]) => + switch (name.toLowerCase()) { + 'rgb' => rgb, + 'hwb' => hwb, + 'hsl' => hsl, + 'srgb' => srgb, + 'srgb-linear' => srgbLinear, + 'display-p3' => displayP3, + 'a98-rgb' => a98Rgb, + 'prophoto-rgb' => prophotoRgb, + 'rec2020' => rec2020, + 'xyz' || 'xyz-d65' => xyzD65, + 'xyz-d50' => xyzD50, + 'lab' => lab, + 'lch' => lch, + 'oklab' => oklab, + 'oklch' => oklch, + _ => throw SassScriptException( + 'Unknown color space "$name".', argumentName) + }; /// Converts a color with the given channels from this color space to [dest]. /// @@ -199,23 +180,12 @@ abstract class ColorSpace { @internal SassColor convert(ColorSpace dest, double channel0, double channel1, double channel2, double alpha) { - var linearDest = dest; - switch (dest) { - case ColorSpace.hsl: - case ColorSpace.hwb: - linearDest = ColorSpace.srgb; - break; - - case ColorSpace.lab: - case ColorSpace.lch: - linearDest = ColorSpace.xyzD50; - break; - - case ColorSpace.oklab: - case ColorSpace.oklch: - linearDest = ColorSpace.lms; - break; - } + var linearDest = switch (dest) { + ColorSpace.hsl || ColorSpace.hwb => ColorSpace.srgb, + ColorSpace.lab || ColorSpace.lch => ColorSpace.xyzD50, + ColorSpace.oklab || ColorSpace.oklch => ColorSpace.lms, + _ => dest + }; double transformed0; double transformed1; @@ -239,20 +209,18 @@ abstract class ColorSpace { matrix[6] * linear0 + matrix[7] * linear1 + matrix[8] * linear2); } - switch (dest) { - case ColorSpace.hsl: - case ColorSpace.hwb: - case ColorSpace.lab: - case ColorSpace.lch: - case ColorSpace.oklab: - case ColorSpace.oklch: - return linearDest.convert( - dest, transformed0, transformed1, transformed2, alpha); - - default: - return SassColor.forSpaceInternal( - dest, transformed0, transformed1, transformed2, alpha); - } + return switch (dest) { + ColorSpace.hsl || + ColorSpace.hwb || + ColorSpace.lab || + ColorSpace.lch || + ColorSpace.oklab || + ColorSpace.oklch => + linearDest.convert( + dest, transformed0, transformed1, transformed2, alpha), + _ => SassColor.forSpaceInternal( + dest, transformed0, transformed1, transformed2, alpha) + }; } /// Converts a channel in this color space into an element of a vector that diff --git a/lib/src/value/color/space/a98_rgb.dart b/lib/src/value/color/space/a98_rgb.dart index 72469d068..940ea8c94 100644 --- a/lib/src/value/color/space/a98_rgb.dart +++ b/lib/src/value/color/space/a98_rgb.dart @@ -33,26 +33,17 @@ class A98RgbColorSpace extends ColorSpace { channel.sign * math.pow(channel.abs(), 256 / 563); @protected - Float64List transformationMatrix(ColorSpace dest) { - switch (dest) { - case ColorSpace.srgbLinear: - case ColorSpace.srgb: - case ColorSpace.rgb: - return linearA98RgbToLinearSrgb; - case ColorSpace.displayP3: - return linearA98RgbToLinearDisplayP3; - case ColorSpace.prophotoRgb: - return linearA98RgbToLinearProphotoRgb; - case ColorSpace.rec2020: - return linearA98RgbToLinearRec2020; - case ColorSpace.xyzD65: - return linearA98RgbToXyzD65; - case ColorSpace.xyzD50: - return linearA98RgbToXyzD50; - case ColorSpace.lms: - return linearA98RgbToLms; - default: - return super.transformationMatrix(dest); - } - } + Float64List transformationMatrix(ColorSpace dest) => switch (dest) { + ColorSpace.srgbLinear || + ColorSpace.srgb || + ColorSpace.rgb => + linearA98RgbToLinearSrgb, + ColorSpace.displayP3 => linearA98RgbToLinearDisplayP3, + ColorSpace.prophotoRgb => linearA98RgbToLinearProphotoRgb, + ColorSpace.rec2020 => linearA98RgbToLinearRec2020, + ColorSpace.xyzD65 => linearA98RgbToXyzD65, + ColorSpace.xyzD50 => linearA98RgbToXyzD50, + ColorSpace.lms => linearA98RgbToLms, + _ => super.transformationMatrix(dest) + }; } diff --git a/lib/src/value/color/space/display_p3.dart b/lib/src/value/color/space/display_p3.dart index 42568d2fd..2cff9f830 100644 --- a/lib/src/value/color/space/display_p3.dart +++ b/lib/src/value/color/space/display_p3.dart @@ -28,26 +28,17 @@ class DisplayP3ColorSpace extends ColorSpace { double fromLinear(double channel) => srgbAndDisplayP3FromLinear(channel); @protected - Float64List transformationMatrix(ColorSpace dest) { - switch (dest) { - case ColorSpace.srgbLinear: - case ColorSpace.srgb: - case ColorSpace.rgb: - return linearDisplayP3ToLinearSrgb; - case ColorSpace.a98Rgb: - return linearDisplayP3ToLinearA98Rgb; - case ColorSpace.prophotoRgb: - return linearDisplayP3ToLinearProphotoRgb; - case ColorSpace.rec2020: - return linearDisplayP3ToLinearRec2020; - case ColorSpace.xyzD65: - return linearDisplayP3ToXyzD65; - case ColorSpace.xyzD50: - return linearDisplayP3ToXyzD50; - case ColorSpace.lms: - return linearDisplayP3ToLms; - default: - return super.transformationMatrix(dest); - } - } + Float64List transformationMatrix(ColorSpace dest) => switch (dest) { + ColorSpace.srgbLinear || + ColorSpace.srgb || + ColorSpace.rgb => + linearDisplayP3ToLinearSrgb, + ColorSpace.a98Rgb => linearDisplayP3ToLinearA98Rgb, + ColorSpace.prophotoRgb => linearDisplayP3ToLinearProphotoRgb, + ColorSpace.rec2020 => linearDisplayP3ToLinearRec2020, + ColorSpace.xyzD65 => linearDisplayP3ToXyzD65, + ColorSpace.xyzD50 => linearDisplayP3ToXyzD50, + ColorSpace.lms => linearDisplayP3ToLms, + _ => super.transformationMatrix(dest) + }; } diff --git a/lib/src/value/color/space/lms.dart b/lib/src/value/color/space/lms.dart index b59791687..aac56eb63 100644 --- a/lib/src/value/color/space/lms.dart +++ b/lib/src/value/color/space/lms.dart @@ -90,26 +90,17 @@ class LmsColorSpace extends ColorSpace { double fromLinear(double channel) => channel; @protected - Float64List transformationMatrix(ColorSpace dest) { - switch (dest) { - case ColorSpace.srgbLinear: - case ColorSpace.srgb: - case ColorSpace.rgb: - return lmsToLinearSrgb; - case ColorSpace.a98Rgb: - return lmsToLinearA98Rgb; - case ColorSpace.prophotoRgb: - return lmsToLinearProphotoRgb; - case ColorSpace.displayP3: - return lmsToLinearDisplayP3; - case ColorSpace.rec2020: - return lmsToLinearRec2020; - case ColorSpace.xyzD65: - return lmsToXyzD65; - case ColorSpace.xyzD50: - return lmsToXyzD50; - default: - return super.transformationMatrix(dest); - } - } + Float64List transformationMatrix(ColorSpace dest) => switch (dest) { + ColorSpace.srgbLinear || + ColorSpace.srgb || + ColorSpace.rgb => + lmsToLinearSrgb, + ColorSpace.a98Rgb => lmsToLinearA98Rgb, + ColorSpace.prophotoRgb => lmsToLinearProphotoRgb, + ColorSpace.displayP3 => lmsToLinearDisplayP3, + ColorSpace.rec2020 => lmsToLinearRec2020, + ColorSpace.xyzD65 => lmsToXyzD65, + ColorSpace.xyzD50 => lmsToXyzD50, + _ => super.transformationMatrix(dest) + }; } diff --git a/lib/src/value/color/space/oklab.dart b/lib/src/value/color/space/oklab.dart index c63f292af..de63b7673 100644 --- a/lib/src/value/color/space/oklab.dart +++ b/lib/src/value/color/space/oklab.dart @@ -30,9 +30,7 @@ class OklabColorSpace extends ColorSpace { SassColor convert( ColorSpace dest, double lightness, double a, double b, double alpha) { - if (dest == ColorSpace.oklch) { - return labToLch(dest, lightness, a, b, alpha); - } + if (dest == ColorSpace.oklch) return labToLch(dest, lightness, a, b, alpha); // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code return ColorSpace.lms.convert( diff --git a/lib/src/value/color/space/prophoto_rgb.dart b/lib/src/value/color/space/prophoto_rgb.dart index 7cf0ddb28..5fcddafdf 100644 --- a/lib/src/value/color/space/prophoto_rgb.dart +++ b/lib/src/value/color/space/prophoto_rgb.dart @@ -39,26 +39,17 @@ class ProphotoRgbColorSpace extends ColorSpace { } @protected - Float64List transformationMatrix(ColorSpace dest) { - switch (dest) { - case ColorSpace.srgbLinear: - case ColorSpace.srgb: - case ColorSpace.rgb: - return linearProphotoRgbToLinearSrgb; - case ColorSpace.a98Rgb: - return linearProphotoRgbToLinearA98Rgb; - case ColorSpace.displayP3: - return linearProphotoRgbToLinearDisplayP3; - case ColorSpace.rec2020: - return linearProphotoRgbToLinearRec2020; - case ColorSpace.xyzD65: - return linearProphotoRgbToXyzD65; - case ColorSpace.xyzD50: - return linearProphotoRgbToXyzD50; - case ColorSpace.lms: - return linearProphotoRgbToLms; - default: - return super.transformationMatrix(dest); - } - } + Float64List transformationMatrix(ColorSpace dest) => switch (dest) { + ColorSpace.srgbLinear || + ColorSpace.srgb || + ColorSpace.rgb => + linearProphotoRgbToLinearSrgb, + ColorSpace.a98Rgb => linearProphotoRgbToLinearA98Rgb, + ColorSpace.displayP3 => linearProphotoRgbToLinearDisplayP3, + ColorSpace.rec2020 => linearProphotoRgbToLinearRec2020, + ColorSpace.xyzD65 => linearProphotoRgbToXyzD65, + ColorSpace.xyzD50 => linearProphotoRgbToXyzD50, + ColorSpace.lms => linearProphotoRgbToLms, + _ => super.transformationMatrix(dest) + }; } diff --git a/lib/src/value/color/space/rec2020.dart b/lib/src/value/color/space/rec2020.dart index 9c456859b..414977cab 100644 --- a/lib/src/value/color/space/rec2020.dart +++ b/lib/src/value/color/space/rec2020.dart @@ -47,26 +47,17 @@ class Rec2020ColorSpace extends ColorSpace { } @protected - Float64List transformationMatrix(ColorSpace dest) { - switch (dest) { - case ColorSpace.srgbLinear: - case ColorSpace.srgb: - case ColorSpace.rgb: - return linearRec2020ToLinearSrgb; - case ColorSpace.a98Rgb: - return linearRec2020ToLinearA98Rgb; - case ColorSpace.displayP3: - return linearRec2020ToLinearDisplayP3; - case ColorSpace.prophotoRgb: - return linearRec2020ToLinearProphotoRgb; - case ColorSpace.xyzD65: - return linearRec2020ToXyzD65; - case ColorSpace.xyzD50: - return linearRec2020ToXyzD50; - case ColorSpace.lms: - return linearRec2020ToLms; - default: - return super.transformationMatrix(dest); - } - } + Float64List transformationMatrix(ColorSpace dest) => switch (dest) { + ColorSpace.srgbLinear || + ColorSpace.srgb || + ColorSpace.rgb => + linearRec2020ToLinearSrgb, + ColorSpace.a98Rgb => linearRec2020ToLinearA98Rgb, + ColorSpace.displayP3 => linearRec2020ToLinearDisplayP3, + ColorSpace.prophotoRgb => linearRec2020ToLinearProphotoRgb, + ColorSpace.xyzD65 => linearRec2020ToXyzD65, + ColorSpace.xyzD50 => linearRec2020ToXyzD50, + ColorSpace.lms => linearRec2020ToLms, + _ => super.transformationMatrix(dest), + }; } diff --git a/lib/src/value/color/space/srgb.dart b/lib/src/value/color/space/srgb.dart index 6682e7644..677456818 100644 --- a/lib/src/value/color/space/srgb.dart +++ b/lib/src/value/color/space/srgb.dart @@ -30,8 +30,7 @@ class SrgbColorSpace extends ColorSpace { SassColor convert( ColorSpace dest, double red, double green, double blue, double alpha) { switch (dest) { - case ColorSpace.hsl: - case ColorSpace.hwb: + case ColorSpace.hsl || ColorSpace.hwb: if (fuzzyCheckRange(red, 0, 1) == null || fuzzyCheckRange(green, 0, 1) == null || fuzzyCheckRange(blue, 0, 1) == null) { @@ -107,24 +106,14 @@ class SrgbColorSpace extends ColorSpace { double fromLinear(double channel) => srgbAndDisplayP3FromLinear(channel); @protected - Float64List transformationMatrix(ColorSpace dest) { - switch (dest) { - case ColorSpace.displayP3: - return linearSrgbToLinearDisplayP3; - case ColorSpace.a98Rgb: - return linearSrgbToLinearA98Rgb; - case ColorSpace.prophotoRgb: - return linearSrgbToLinearProphotoRgb; - case ColorSpace.rec2020: - return linearSrgbToLinearRec2020; - case ColorSpace.xyzD65: - return linearSrgbToXyzD65; - case ColorSpace.xyzD50: - return linearSrgbToXyzD50; - case ColorSpace.lms: - return linearSrgbToLms; - default: - return super.transformationMatrix(dest); - } - } + Float64List transformationMatrix(ColorSpace dest) => switch (dest) { + ColorSpace.displayP3 => linearSrgbToLinearDisplayP3, + ColorSpace.a98Rgb => linearSrgbToLinearA98Rgb, + ColorSpace.prophotoRgb => linearSrgbToLinearProphotoRgb, + ColorSpace.rec2020 => linearSrgbToLinearRec2020, + ColorSpace.xyzD65 => linearSrgbToXyzD65, + ColorSpace.xyzD50 => linearSrgbToXyzD50, + ColorSpace.lms => linearSrgbToLms, + _ => super.transformationMatrix(dest), + }; } diff --git a/lib/src/value/color/space/srgb_linear.dart b/lib/src/value/color/space/srgb_linear.dart index c0cc17a6c..b9a0fd13a 100644 --- a/lib/src/value/color/space/srgb_linear.dart +++ b/lib/src/value/color/space/srgb_linear.dart @@ -23,24 +23,21 @@ class SrgbLinearColorSpace extends ColorSpace { const SrgbLinearColorSpace() : super('srgb-linear', rgbChannels); - SassColor convert( - ColorSpace dest, double red, double green, double blue, double alpha) { - switch (dest) { - case ColorSpace.rgb: - case ColorSpace.hsl: - case ColorSpace.hwb: - case ColorSpace.srgb: - return ColorSpace.srgb.convert( - dest, - srgbAndDisplayP3FromLinear(red), - srgbAndDisplayP3FromLinear(green), - srgbAndDisplayP3FromLinear(blue), - alpha); - - default: - return super.convert(dest, red, green, blue, alpha); - } - } + SassColor convert(ColorSpace dest, double red, double green, double blue, + double alpha) => + switch (dest) { + ColorSpace.rgb || + ColorSpace.hsl || + ColorSpace.hwb || + ColorSpace.srgb => + ColorSpace.srgb.convert( + dest, + srgbAndDisplayP3FromLinear(red), + srgbAndDisplayP3FromLinear(green), + srgbAndDisplayP3FromLinear(blue), + alpha), + _ => super.convert(dest, red, green, blue, alpha) + }; @protected double toLinear(double channel) => channel; @@ -49,24 +46,14 @@ class SrgbLinearColorSpace extends ColorSpace { double fromLinear(double channel) => channel; @protected - Float64List transformationMatrix(ColorSpace dest) { - switch (dest) { - case ColorSpace.displayP3: - return linearSrgbToLinearDisplayP3; - case ColorSpace.a98Rgb: - return linearSrgbToLinearA98Rgb; - case ColorSpace.prophotoRgb: - return linearSrgbToLinearProphotoRgb; - case ColorSpace.rec2020: - return linearSrgbToLinearRec2020; - case ColorSpace.xyzD65: - return linearSrgbToXyzD65; - case ColorSpace.xyzD50: - return linearSrgbToXyzD50; - case ColorSpace.lms: - return linearSrgbToLms; - default: - return super.transformationMatrix(dest); - } - } + Float64List transformationMatrix(ColorSpace dest) => switch (dest) { + ColorSpace.displayP3 => linearSrgbToLinearDisplayP3, + ColorSpace.a98Rgb => linearSrgbToLinearA98Rgb, + ColorSpace.prophotoRgb => linearSrgbToLinearProphotoRgb, + ColorSpace.rec2020 => linearSrgbToLinearRec2020, + ColorSpace.xyzD65 => linearSrgbToXyzD65, + ColorSpace.xyzD50 => linearSrgbToXyzD50, + ColorSpace.lms => linearSrgbToLms, + _ => super.transformationMatrix(dest) + }; } diff --git a/lib/src/value/color/space/utils.dart b/lib/src/value/color/space/utils.dart index 805985dd1..b1e16f2d9 100644 --- a/lib/src/value/color/space/utils.dart +++ b/lib/src/value/color/space/utils.dart @@ -38,15 +38,12 @@ double hueToRgb(double m1, double m2, double hue) { if (hue < 0) hue += 1; if (hue > 1) hue -= 1; - if (hue < 1 / 6) { - return m1 + (m2 - m1) * hue * 6; - } else if (hue < 1 / 2) { - return m2; - } else if (hue < 2 / 3) { - return m1 + (m2 - m1) * (2 / 3 - hue) * 6; - } else { - return m1; - } + return switch (hue) { + < 1 / 6 => m1 + (m2 - m1) * hue * 6, + < 1 / 2 => m2, + < 2 / 3 => m1 + (m2 - m1) * (2 / 3 - hue) * 6, + _ => m1 + }; } /// The algorithm for converting a single `srgb` or `display-p3` channel to diff --git a/lib/src/value/color/space/xyz_d50.dart b/lib/src/value/color/space/xyz_d50.dart index 7b7c6c781..d135a85b1 100644 --- a/lib/src/value/color/space/xyz_d50.dart +++ b/lib/src/value/color/space/xyz_d50.dart @@ -27,8 +27,7 @@ class XyzD50ColorSpace extends ColorSpace { SassColor convert( ColorSpace dest, double x, double y, double z, double alpha) { switch (dest) { - case ColorSpace.lab: - case ColorSpace.lch: + case ColorSpace.lab || ColorSpace.lch: // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code // and http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html var f0 = _convertComponentToLabF(x / d50[0]); @@ -55,26 +54,17 @@ class XyzD50ColorSpace extends ColorSpace { double fromLinear(double channel) => channel; @protected - Float64List transformationMatrix(ColorSpace dest) { - switch (dest) { - case ColorSpace.srgbLinear: - case ColorSpace.srgb: - case ColorSpace.rgb: - return xyzD50ToLinearSrgb; - case ColorSpace.a98Rgb: - return xyzD50ToLinearA98Rgb; - case ColorSpace.prophotoRgb: - return xyzD50ToLinearProphotoRgb; - case ColorSpace.displayP3: - return xyzD50ToLinearDisplayP3; - case ColorSpace.rec2020: - return xyzD50ToLinearRec2020; - case ColorSpace.xyzD65: - return xyzD50ToXyzD65; - case ColorSpace.lms: - return xyzD50ToLms; - default: - return super.transformationMatrix(dest); - } - } + Float64List transformationMatrix(ColorSpace dest) => switch (dest) { + ColorSpace.srgbLinear || + ColorSpace.srgb || + ColorSpace.rgb => + xyzD50ToLinearSrgb, + ColorSpace.a98Rgb => xyzD50ToLinearA98Rgb, + ColorSpace.prophotoRgb => xyzD50ToLinearProphotoRgb, + ColorSpace.displayP3 => xyzD50ToLinearDisplayP3, + ColorSpace.rec2020 => xyzD50ToLinearRec2020, + ColorSpace.xyzD65 => xyzD50ToXyzD65, + ColorSpace.lms => xyzD50ToLms, + _ => super.transformationMatrix(dest) + }; } diff --git a/lib/src/value/color/space/xyz_d65.dart b/lib/src/value/color/space/xyz_d65.dart index 997267aaa..979e7b126 100644 --- a/lib/src/value/color/space/xyz_d65.dart +++ b/lib/src/value/color/space/xyz_d65.dart @@ -28,26 +28,17 @@ class XyzD65ColorSpace extends ColorSpace { double fromLinear(double channel) => channel; @protected - Float64List transformationMatrix(ColorSpace dest) { - switch (dest) { - case ColorSpace.srgbLinear: - case ColorSpace.srgb: - case ColorSpace.rgb: - return xyzD65ToLinearSrgb; - case ColorSpace.a98Rgb: - return xyzD65ToLinearA98Rgb; - case ColorSpace.prophotoRgb: - return xyzD65ToLinearProphotoRgb; - case ColorSpace.displayP3: - return xyzD65ToLinearDisplayP3; - case ColorSpace.rec2020: - return xyzD65ToLinearRec2020; - case ColorSpace.xyzD50: - return xyzD65ToXyzD50; - case ColorSpace.lms: - return xyzD65ToLms; - default: - return super.transformationMatrix(dest); - } - } + Float64List transformationMatrix(ColorSpace dest) => switch (dest) { + ColorSpace.srgbLinear || + ColorSpace.srgb || + ColorSpace.rgb => + xyzD65ToLinearSrgb, + ColorSpace.a98Rgb => xyzD65ToLinearA98Rgb, + ColorSpace.prophotoRgb => xyzD65ToLinearProphotoRgb, + ColorSpace.displayP3 => xyzD65ToLinearDisplayP3, + ColorSpace.rec2020 => xyzD65ToLinearRec2020, + ColorSpace.xyzD50 => xyzD65ToXyzD50, + ColorSpace.lms => xyzD65ToLms, + _ => super.transformationMatrix(dest) + }; } From 01322084abb82408d88a182f6b660513f7a75fa1 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Wed, 2 Aug 2023 15:23:07 -0700 Subject: [PATCH 08/56] Fix alpha unit behavior --- CHANGELOG.md | 7 ++++++- lib/src/functions/color.dart | 20 ++++++++++++++++++-- test/deprecations_test.dart | 6 ------ 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 802deb5f4..3e6d4f2ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,15 @@ ## 1.65.0 +* **Breaking change**: Passing a number with unit `%` to the `$alpha` parameter + of `color.change()`, `color.adjust()`, `change-color()`, and `adjust-color()` + is now interpreted as a percentage, instead of ignoring the unit. For example, + `color.change(red, $alpha: 50%)` now returns `rgb(255 0 0 / 0.5)`. + * Add support for CSS Color Level 4 [color spaces]. Each color value now tracks its color space along with the values of each channel in that color space. There are two general principles to keep in mind when dealing with new color spaces: - + 1. With the exception of legacy color spaces (`rgb`, `hsl`, and `hwb`), colors will always be emitted in the color space they were defined in unless they're explicitly converted. diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index 56322f0b7..9d2400af6 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -728,8 +728,24 @@ SassColor _changeColor( channelArgs[0] ?? SassNumber(color.channel0), channelArgs[1] ?? SassNumber(color.channel1, latterUnits), channelArgs[2] ?? SassNumber(color.channel2, latterUnits), - alphaArg.andThen( - (alphaArg) => _percentageOrUnitless(alphaArg, 1, 'alpha')) ?? + alphaArg.andThen((alphaArg) { + if (!alphaArg.hasUnits) { + return alphaArg.value; + } else if (alphaArg.hasUnit('%')) { + return alphaArg.value / 100; + } else { + warnForDeprecation( + "\$alpha: Passing a unit other than % ($alphaArg) is " + "deprecated.\n" + "\n" + "To preserve current behavior: " + "${alphaArg.unitSuggestion('alpha')}\n" + "\n" + "See https://sass-lang.com/d/function-units", + Deprecation.functionUnits); + return alphaArg.value; + } + }) ?? color.alpha); } diff --git a/test/deprecations_test.dart b/test/deprecations_test.dart index 28cd1e2fd..157963477 100644 --- a/test/deprecations_test.dart +++ b/test/deprecations_test.dart @@ -98,12 +98,6 @@ void main() { _expectDeprecation("a {b: hsl(10deg, 0%, 0)}", Deprecation.functionUnits); }); - test("an alpha value with a percent unit", () { - _expectDeprecation( - r"@use 'sass:color'; a {b: color.change(red, $alpha: 1%)}", - Deprecation.functionUnits); - }); - test("an alpha value with a non-percent unit", () { _expectDeprecation( r"@use 'sass:color'; a {b: color.change(red, $alpha: 1px)}", From 90c00040db88fc8af230ec82fb08640f1327619a Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Wed, 2 Aug 2023 16:09:39 -0700 Subject: [PATCH 09/56] Fix hsl() deprotofy tests --- test/embedded/function_test.dart | 37 ++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/test/embedded/function_test.dart b/test/embedded/function_test.dart index af4bd62db..e56520e36 100644 --- a/test/embedded/function_test.dart +++ b/test/embedded/function_test.dart @@ -984,64 +984,73 @@ void main() { group("without alpha:", () { group("hue", () { test("0", () async { - expect(await _deprotofy(_hsl(0, 50, 50, 1.0)), "#bf4040"); + expect( + await _deprotofy(_hsl(0, 50, 50, 1.0)), "hsl(0, 50%, 50%)"); }); test("360", () async { - expect(await _deprotofy(_hsl(360, 50, 50, 1.0)), "#bf4040"); + expect( + await _deprotofy(_hsl(360, 50, 50, 1.0)), "hsl(0, 50%, 50%)"); }); test("below 0", () async { - expect(await _deprotofy(_hsl(-100, 50, 50, 1.0)), "#6a40bf"); + expect(await _deprotofy(_hsl(-100, 50, 50, 1.0)), + "hsl(260, 50%, 50%)"); }); test("between 0 and 360", () async { - expect(await _deprotofy(_hsl(100, 50, 50, 1.0)), "#6abf40"); + expect(await _deprotofy(_hsl(100, 50, 50, 1.0)), + "hsl(100, 50%, 50%)"); }); test("above 360", () async { - expect(await _deprotofy(_hsl(560, 50, 50, 1.0)), "#4095bf"); + expect(await _deprotofy(_hsl(560, 50, 50, 1.0)), + "hsl(200, 50%, 50%)"); }); }); group("saturation", () { test("0", () async { - expect(await _deprotofy(_hsl(0, 0, 50, 1.0)), "gray"); + expect(await _deprotofy(_hsl(0, 0, 50, 1.0)), "hsl(0, 0%, 50%)"); }); test("100", () async { - expect(await _deprotofy(_hsl(0, 100, 50, 1.0)), "red"); + expect( + await _deprotofy(_hsl(0, 100, 50, 1.0)), "hsl(0, 100%, 50%)"); }); test("in the middle", () async { - expect(await _deprotofy(_hsl(0, 42, 50, 1.0)), "#b54a4a"); + expect( + await _deprotofy(_hsl(0, 42, 50, 1.0)), "hsl(0, 42%, 50%)"); }); }); group("lightness", () { test("0", () async { - expect(await _deprotofy(_hsl(0, 50, 0, 1.0)), "black"); + expect(await _deprotofy(_hsl(0, 50, 0, 1.0)), "hsl(0, 50%, 0%)"); }); test("100", () async { - expect(await _deprotofy(_hsl(0, 50, 100, 1.0)), "white"); + expect( + await _deprotofy(_hsl(0, 50, 100, 1.0)), "hsl(0, 50%, 100%)"); }); test("in the middle", () async { - expect(await _deprotofy(_hsl(0, 50, 42, 1.0)), "#a13636"); + expect( + await _deprotofy(_hsl(0, 50, 42, 1.0)), "hsl(0, 50%, 42%)"); }); }); }); group("with alpha", () { test("0", () async { - expect( - await _deprotofy(_hsl(10, 20, 30, 0.0)), "rgba(92, 66, 61, 0)"); + expect(await _deprotofy(_hsl(10, 20, 30, 0.0)), + "hsla(10, 20%, 30%, 0)"); }); test("between 0 and 1", () async { expect(await _deprotofy(_hsl(10, 20, 30, 0.123)), - "rgba(92, 66, 61, 0.123)"); + "hsla(10, 20%, 30%, 0.123)"); }); }); }); From 254679b3377c6b4081b50a09fc74c13b9586e45d Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Wed, 2 Aug 2023 17:08:41 -0700 Subject: [PATCH 10/56] Fix embedded protofier tests --- lib/src/util/number.dart | 5 +++-- test/embedded/function_test.dart | 30 +++++++++++++++--------------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/lib/src/util/number.dart b/lib/src/util/number.dart index cec845879..f65eab058 100644 --- a/lib/src/util/number.dart +++ b/lib/src/util/number.dart @@ -108,10 +108,11 @@ double? fuzzyCheckRange(double number, num min, num max) { /// /// If [number] is [fuzzyEquals] to [min] or [max], it's clamped to the /// appropriate value. [name] is used in error reporting. -double fuzzyAssertRange(double number, double min, double max, [String? name]) { +double fuzzyAssertRange(double number, int min, int max, [String? name]) { var result = fuzzyCheckRange(number, min, max); if (result != null) return result; - throw RangeError.value(number, name, "must be between $min and $max"); + throw RangeError.range( + number, min, max, name, "must be between $min and $max"); } /// Return [num1] modulo [num2], using Sass's [floored division] modulo diff --git a/test/embedded/function_test.dart b/test/embedded/function_test.dart index e56520e36..8667b6e81 100644 --- a/test/embedded/function_test.dart +++ b/test/embedded/function_test.dart @@ -965,6 +965,21 @@ void main() { expect(await _deprotofy(_rgb(0xaa, 0xbb, 0xcc, 1.0)), equals('#aabbcc')); }); + + test("with red above 255", () async { + expect(await _deprotofy(_rgb(256, 0, 0, 1.0)), + equals('rgb(256, 0, 0)')); + }); + + test("with green above 255", () async { + expect(await _deprotofy(_rgb(0, 256, 0, 1.0)), + equals('rgb(0, 256, 0)')); + }); + + test("with blue above 255", () async { + expect(await _deprotofy(_rgb(0, 0, 256, 1.0)), + equals('rgb(0, 0, 256)')); + }); }); group("with alpha", () { @@ -1603,21 +1618,6 @@ void main() { group("and rejects", () { group("a color", () { - test("with red above 255", () async { - await _expectDeprotofyError(_rgb(256, 0, 0, 1.0), - "RgbColor.red must be between 0 and 255, was 256"); - }); - - test("with green above 255", () async { - await _expectDeprotofyError(_rgb(0, 256, 0, 1.0), - "RgbColor.green must be between 0 and 255, was 256"); - }); - - test("with blue above 255", () async { - await _expectDeprotofyError(_rgb(0, 0, 256, 1.0), - "RgbColor.blue must be between 0 and 255, was 256"); - }); - test("with RGB alpha below 0", () async { await _expectDeprotofyError(_rgb(0, 0, 0, -0.1), "RgbColor.alpha must be between 0 and 1, was -0.1"); From a56e92d5ea4dae9705047446172b5b37e9d3d60a Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Mon, 21 Aug 2023 16:05:29 -0700 Subject: [PATCH 11/56] Reformat and fix analysis --- lib/src/functions/color.dart | 10 ++++++---- lib/src/value/color.dart | 13 +++++++++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index aeaf477f0..4b3a7d8dd 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -910,8 +910,9 @@ Value _rgb(String name, List arguments) { arguments[1].assertNumber("green"), arguments[2].assertNumber("blue"), alpha.andThen((alpha) => - _percentageOrUnitless(alpha.assertNumber("alpha"), 1, "alpha") - .clamp(0, 1)) ?? 1, + _percentageOrUnitless(alpha.assertNumber("alpha"), 1, "alpha") + .clamp(0, 1)) ?? + 1, fromRgbFunction: true); } @@ -959,8 +960,9 @@ Value _hsl(String name, List arguments) { arguments[1].assertNumber("saturation"), arguments[2].assertNumber("lightness"), alpha.andThen((alpha) => - _percentageOrUnitless(alpha.assertNumber("alpha"), 1, "alpha") - .clamp(0, 1)) ?? 1); + _percentageOrUnitless(alpha.assertNumber("alpha"), 1, "alpha") + .clamp(0, 1)) ?? + 1); } /// Asserts that [angle] is a number and returns its value in degrees. diff --git a/lib/src/value/color.dart b/lib/src/value/color.dart index c0e4b98be..e1ed6277f 100644 --- a/lib/src/value/color.dart +++ b/lib/src/value/color.dart @@ -277,7 +277,7 @@ class SassColor extends Value { /// /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. factory SassColor.hsl(num? hue, num? saturation, num? lightness, - [num? alpha= 1]) => + [num? alpha = 1]) => SassColor.forSpaceInternal( ColorSpace.hsl, _normalizeHue(hue?.toDouble()), @@ -388,7 +388,8 @@ class SassColor extends Value { /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components /// /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. - factory SassColor.xyzD50(double? x, double? y, double? z, [double? alpha = 1]) => + factory SassColor.xyzD50(double? x, double? y, double? z, + [double? alpha = 1]) => SassColor.forSpaceInternal(ColorSpace.xyzD50, x, y, z, alpha); /// Creates a color in [ColorSpace.xyzD65]. @@ -399,7 +400,8 @@ class SassColor extends Value { /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components /// /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. - factory SassColor.xyzD65(double? x, double? y, double? z, [double? alpha = 1]) => + factory SassColor.xyzD65(double? x, double? y, double? z, + [double? alpha = 1]) => SassColor.forSpaceInternal(ColorSpace.xyzD65, x, y, z, alpha); /// Creates a color in [ColorSpace.lab]. @@ -514,7 +516,10 @@ class SassColor extends Value { SassColor.forSpaceInternal(this._space, this.channel0OrNull, this.channel1OrNull, this.channel2OrNull, double? alpha, [this.format]) - : _alpha = alpha.andThen((alpha) => fuzzyAssertRange(alpha, 0, 1, "alpha")) { + // TODO(nweiz): Support missing alpha channels. + : _alpha = + alpha.andThen((alpha) => fuzzyAssertRange(alpha, 0, 1, "alpha")) ?? + 1.0 { assert(format == null || _space == ColorSpace.rgb); assert( !(space == ColorSpace.hsl || space == ColorSpace.hwb) || From 7d17629317ced706b66d0bc634b434139cb70290 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Mon, 21 Aug 2023 16:08:34 -0700 Subject: [PATCH 12/56] Remove unused imports --- lib/src/value/color.dart | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/src/value/color.dart b/lib/src/value/color.dart index e1ed6277f..fb12a5e75 100644 --- a/lib/src/value/color.dart +++ b/lib/src/value/color.dart @@ -8,10 +8,7 @@ import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; -import '../deprecation.dart'; -import '../evaluation_context.dart'; import '../exception.dart'; -import '../io.dart'; import '../util/nullable.dart'; import '../util/number.dart'; import '../value.dart'; From c273c4539d6b9a3405e97caf6e73bb104c3100bf Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Tue, 22 Aug 2023 13:54:42 -0700 Subject: [PATCH 13/56] Track missing alpha channels in colors (#2051) See sass/sass#2831 --- lib/src/functions/color.dart | 2 +- lib/src/js/value/color.dart | 13 +-- lib/src/value/color.dart | 154 ++++++++++++++++++++++------------- 3 files changed, 106 insertions(+), 63 deletions(-) diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index 4b3a7d8dd..e7f989bb6 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -578,7 +578,7 @@ Value _invert(List arguments, {bool global = false}) { var rgb = color.toSpace(ColorSpace.rgb); return _mixLegacy( SassColor.rgb(255.0 - rgb.channel0, 255.0 - rgb.channel1, - 255.0 - rgb.channel2, color.alpha), + 255.0 - rgb.channel2, color.alphaOrNull), color, weightNumber); } diff --git a/lib/src/js/value/color.dart b/lib/src/js/value/color.dart index 9f2871fbf..092e7933e 100644 --- a/lib/src/js/value/color.dart +++ b/lib/src/js/value/color.dart @@ -12,13 +12,14 @@ import '../utils.dart'; final JSClass colorClass = () { var jsClass = createJSClass('sass.SassColor', (Object self, _Channels color) { if (color.red != null) { - return SassColor.rgb(color.red!, color.green!, color.blue!, color.alpha); + return SassColor.rgb(color.red!, color.green!, color.blue!, + _handleUndefinedAlpha(color.alpha)); } else if (color.saturation != null) { return SassColor.hsl(color.hue!, color.saturation!, color.lightness!, - _handleNullAlpha(color.alpha)); + _handleUndefinedAlpha(color.alpha)); } else { return SassColor.hwb(color.hue!, color.whiteness!, color.blackness!, - _handleNullAlpha(color.alpha)); + _handleUndefinedAlpha(color.alpha)); } }); @@ -69,9 +70,9 @@ final JSClass colorClass = () { /// Converts an undefined [alpha] to 1. /// -/// This ensures that an explicitly null alpha will produce a deprecation -/// warning when passed to the Dart API. -num? _handleNullAlpha(num? alpha) => isUndefined(alpha) ? 1 : alpha; +/// This ensures that an explicitly null alpha will be treated as a missing +/// component. +num? _handleUndefinedAlpha(num? alpha) => isUndefined(alpha) ? 1 : alpha; @JS() @anonymous diff --git a/lib/src/value/color.dart b/lib/src/value/color.dart index fb12a5e75..96abc5263 100644 --- a/lib/src/value/color.dart +++ b/lib/src/value/color.dart @@ -82,6 +82,8 @@ class SassColor extends Value { /// indicates a [missing] component. /// /// [missing]: https://www.w3.org/TR/css-color-4/#missing + /// + /// @nodoc final double? channel0OrNull; /// This color's second channel. @@ -123,6 +125,8 @@ class SassColor extends Value { /// indicates a [missing] component. /// /// [missing]: https://www.w3.org/TR/css-color-4/#missing + /// + /// @nodoc final double? channel1OrNull; /// Returns whether this color's third channel is [missing]. @@ -164,6 +168,8 @@ class SassColor extends Value { /// indicates a [missing] component. /// /// [missing]: https://www.w3.org/TR/css-color-4/#missing + /// + /// @nodoc final double? channel2OrNull; /// The format in which this color was originally written and should be @@ -177,8 +183,24 @@ class SassColor extends Value { final ColorFormat? format; /// This color's alpha channel, between `0` and `1`. - double get alpha => _alpha; - final double _alpha; + double get alpha => alphaOrNull ?? 0; + + /// This color's alpha channel. + /// + /// If this is `null`, that indicates a [missing] component. + /// + /// [missing]: https://www.w3.org/TR/css-color-4/#missing + /// + /// @nodoc + final double? alphaOrNull; + + /// Returns whether this color's alpha channel is [missing]. + /// + /// [missing]: https://www.w3.org/TR/css-color-4/#missing + /// + /// @nodoc + @internal + bool get isAlphaMissing => alphaOrNull == null; /// Whether this is a legacy color—that is, a color defined using /// pre-color-spaces syntax that preserves comaptibility with old color @@ -247,8 +269,9 @@ class SassColor extends Value { /// Creates a color in [ColorSpace.rgb]. /// - /// **Note:** Passing `null` to [alpha] represents a [missing component], not - /// the default value of `1` + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. /// /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components /// @@ -261,14 +284,15 @@ class SassColor extends Value { /// @nodoc @internal factory SassColor.rgbInternal(num? red, num? green, num? blue, - [num? alpha, ColorFormat? format]) => + [num? alpha = 1, ColorFormat? format]) => SassColor.forSpaceInternal(ColorSpace.rgb, red?.toDouble(), green?.toDouble(), blue?.toDouble(), alpha?.toDouble(), format); /// Creates a color in [ColorSpace.hsl]. /// - /// **Note:** Passing `null` to [alpha] represents a [missing component], not - /// the default value of `1` + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. /// /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components /// @@ -286,8 +310,9 @@ class SassColor extends Value { /// Creates a color in [ColorSpace.hwb]. /// - /// **Note:** Passing `null` to [alpha] represents a [missing component], not - /// the default value of `1` + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. /// /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components /// @@ -305,8 +330,9 @@ class SassColor extends Value { /// Creates a color in [ColorSpace.srgb]. /// - /// **Note:** Passing `null` to [alpha] represents a [missing component], not - /// the default value of `1` + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. /// /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components /// @@ -317,8 +343,9 @@ class SassColor extends Value { /// Creates a color in [ColorSpace.srgbLinear]. /// - /// **Note:** Passing `null` to [alpha] represents a [missing component], not - /// the default value of `1` + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. /// /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components /// @@ -330,8 +357,9 @@ class SassColor extends Value { /// Creates a color in [ColorSpace.displayP3]. /// - /// **Note:** Passing `null` to [alpha] represents a [missing component], not - /// the default value of `1` + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. /// /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components /// @@ -342,8 +370,9 @@ class SassColor extends Value { /// Creates a color in [ColorSpace.a98Rgb]. /// - /// **Note:** Passing `null` to [alpha] represents a [missing component], not - /// the default value of `1` + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. /// /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components /// @@ -354,8 +383,9 @@ class SassColor extends Value { /// Creates a color in [ColorSpace.prophotoRgb]. /// - /// **Note:** Passing `null` to [alpha] represents a [missing component], not - /// the default value of `1` + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. /// /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components /// @@ -367,8 +397,9 @@ class SassColor extends Value { /// Creates a color in [ColorSpace.rec2020]. /// - /// **Note:** Passing `null` to [alpha] represents a [missing component], not - /// the default value of `1` + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. /// /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components /// @@ -379,8 +410,9 @@ class SassColor extends Value { /// Creates a color in [ColorSpace.xyzD50]. /// - /// **Note:** Passing `null` to [alpha] represents a [missing component], not - /// the default value of `1` + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. /// /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components /// @@ -391,8 +423,9 @@ class SassColor extends Value { /// Creates a color in [ColorSpace.xyzD65]. /// - /// **Note:** Passing `null` to [alpha] represents a [missing component], not - /// the default value of `1` + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. /// /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components /// @@ -403,8 +436,9 @@ class SassColor extends Value { /// Creates a color in [ColorSpace.lab]. /// - /// **Note:** Passing `null` to [alpha] represents a [missing component], not - /// the default value of `1` + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. /// /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components /// @@ -421,8 +455,9 @@ class SassColor extends Value { /// Creates a color in [ColorSpace.lch]. /// - /// **Note:** Passing `null` to [alpha] represents a [missing component], not - /// the default value of `1` + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. /// /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components /// @@ -439,8 +474,9 @@ class SassColor extends Value { /// Creates a color in [ColorSpace.oklab]. /// - /// **Note:** Passing `null` to [alpha] represents a [missing component], not - /// the default value of `1` + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. /// /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components /// @@ -457,8 +493,9 @@ class SassColor extends Value { /// Creates a color in [ColorSpace.oklch]. /// - /// **Note:** Passing `null` to [alpha] represents a [missing component], not - /// the default value of `1` + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. /// /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components /// @@ -475,8 +512,9 @@ class SassColor extends Value { /// Creates a color in the color space named [space]. /// - /// **Note:** Passing `null` to [alpha] represents a [missing component], not - /// the default value of `1` + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. /// /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components /// @@ -511,12 +549,9 @@ class SassColor extends Value { /// @nodoc @internal SassColor.forSpaceInternal(this._space, this.channel0OrNull, - this.channel1OrNull, this.channel2OrNull, double? alpha, - [this.format]) - // TODO(nweiz): Support missing alpha channels. - : _alpha = - alpha.andThen((alpha) => fuzzyAssertRange(alpha, 0, 1, "alpha")) ?? - 1.0 { + this.channel1OrNull, this.channel2OrNull, double? alpha, [this.format]) + : alphaOrNull = + alpha.andThen((alpha) => fuzzyAssertRange(alpha, 0, 1, "alpha")) { assert(format == null || _space == ColorSpace.rgb); assert( !(space == ColorSpace.hsl || space == ColorSpace.hwb) || @@ -588,6 +623,7 @@ class SassColor extends Value { if (channel == channels[0].name) return channel0; if (channel == channels[1].name) return channel1; if (channel == channels[2].name) return channel2; + if (channel == 'alpha') return alpha; throw SassScriptException( "Color $this doesn't have a channel named \"$channel\".", channelName); @@ -607,6 +643,7 @@ class SassColor extends Value { if (channel == channels[0].name) return isChannel0Missing; if (channel == channels[1].name) return isChannel1Missing; if (channel == channels[2].name) return isChannel2Missing; + if (channel == 'alpha') return isAlphaMissing; throw SassScriptException( "Color $this doesn't have a channel named \"$channel\".", channelName); @@ -626,6 +663,7 @@ class SassColor extends Value { if (channel == channels[0].name) return isChannel0Powerless; if (channel == channels[1].name) return isChannel1Powerless; if (channel == channels[2].name) return isChannel2Powerless; + if (channel == 'alpha') return false; throw SassScriptException( "Color $this doesn't have a channel named \"$channel\".", channelName); @@ -666,10 +704,10 @@ class SassColor extends Value { if (fuzzyGreaterThanOrEquals(originOklch.channel0, 1)) { return space == ColorSpace.rgb - ? SassColor.rgb(255, 255, 255, alpha) - : SassColor.forSpaceInternal(space, 1, 1, 1, alpha); + ? SassColor.rgb(255, 255, 255, alphaOrNull) + : SassColor.forSpaceInternal(space, 1, 1, 1, alphaOrNull); } else if (fuzzyLessThanOrEquals(originOklch.channel0, 0)) { - return SassColor.forSpaceInternal(space, 0, 0, 0, alpha); + return SassColor.forSpaceInternal(space, 0, 0, 0, alphaOrNull); } // Always target RGB for legacy colors because HSL and HWB can't even @@ -704,13 +742,13 @@ class SassColor extends Value { fuzzyClamp(current.channel0, 0, 255), fuzzyClamp(current.channel1, 0, 255), fuzzyClamp(current.channel2, 0, 255), - current.alpha) + current.alphaOrNull) : SassColor.forSpaceInternal( space, fuzzyClamp(current.channel0, 0, 1), fuzzyClamp(current.channel1, 0, 1), fuzzyClamp(current.channel2, 0, 1), - current.alpha); + current.alphaOrNull); } /// Returns the ΔEOK measure between [color1] and [color2]. @@ -867,10 +905,10 @@ class SassColor extends Value { return SassColor.forSpaceInternal( this.space, - _clampChannelIfNecessary(new0, this.space, 0) ?? channel0, - _clampChannelIfNecessary(new1, this.space, 1) ?? channel1, - _clampChannelIfNecessary(new2, this.space, 2) ?? channel2, - alpha ?? this.alpha); + _clampChannelIfNecessary(new0, this.space, 0) ?? channel0OrNull, + _clampChannelIfNecessary(new1, this.space, 1) ?? channel1OrNull, + _clampChannelIfNecessary(new2, this.space, 2) ?? channel2OrNull, + alpha ?? alphaOrNull); } /// If [space] is strictly bounded and its [index]th channel isn't polar, @@ -919,22 +957,26 @@ class SassColor extends Value { var channel2_0 = (missing2_0 ? color1 : color2).channel0; var channel2_1 = (missing2_1 ? color1 : color2).channel1; var channel2_2 = (missing2_2 ? color1 : color2).channel2; + var alpha1 = alphaOrNull ?? other.alpha; + var alpha2 = other.alphaOrNull ?? alpha; - var thisMultiplier = alpha * weight; - var otherMultiplier = other.alpha * (1 - weight); - var mixedAlpha = alpha * weight + other.alpha * (1 - weight); + var thisMultiplier = (alphaOrNull ?? 1) * weight; + var otherMultiplier = (other.alphaOrNull ?? 1) * (1 - weight); + var mixedAlpha = isAlphaMissing && other.isAlphaMissing + ? null + : alpha1 * weight + alpha2 * (1 - weight); var mixed0 = missing1_0 && missing2_0 ? null : (channel1_0 * thisMultiplier + channel2_0 * otherMultiplier) / - mixedAlpha; + (mixedAlpha ?? 1); var mixed1 = missing1_1 && missing2_1 ? null : (channel1_1 * thisMultiplier + channel2_1 * otherMultiplier) / - mixedAlpha; + (mixedAlpha ?? 1); var mixed2 = missing1_2 && missing2_2 ? null : (channel1_2 * thisMultiplier + channel2_2 * otherMultiplier) / - mixedAlpha; + (mixedAlpha ?? 1); return switch (method.space) { ColorSpace.hsl || ColorSpace.hwb => SassColor.forSpaceInternal( From 3cc99eb9754b4612b7c7315a1154405321076235 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Thu, 28 Sep 2023 11:08:46 -0700 Subject: [PATCH 14/56] Update implementation for lab() tests (#2093) --- .github/workflows/ci.yml | 5 +- lib/src/functions/color.dart | 106 ++++++++++++++++++++++++--------- lib/src/parse/stylesheet.dart | 5 ++ lib/src/visitor/serialize.dart | 26 +++++--- 4 files changed, 103 insertions(+), 39 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f8fdc4f2..68d7f79fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -233,7 +233,10 @@ jobs: matrix: os: [ubuntu-latest, windows-latest, macos-latest] dart_channel: [stable] - include: [{os: ubuntu-latest, dart_channel: dev}] + # TODO(nweiz): Re-enable this when + # https://github.com/dart-lang/sdk/issues/52121#issuecomment-1728534228 + # is addressed. + # include: [{os: ubuntu-latest, dart_channel: dev}] steps: - uses: actions/checkout@v3 diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index e7f989bb6..bc34ceb0f 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -11,6 +11,7 @@ import '../deprecation.dart'; import '../evaluation_context.dart'; import '../exception.dart'; import '../module/built_in.dart'; +import '../parse/scss.dart'; import '../util/map.dart'; import '../util/nullable.dart'; import '../util/number.dart'; @@ -431,6 +432,17 @@ final module = BuiltInModule("color", functions: [ _function("is-legacy", r"$color", (arguments) => SassBoolean(arguments[0].assertColor("color").isLegacy)), + _function( + "is-missing", + r"$color, $channel", + (arguments) => SassBoolean(arguments[0] + .assertColor("color") + .isChannelMissing( + (arguments[1].assertString("channel")..assertQuoted("channel")) + .text, + colorName: "color", + channelName: "channel"))), + _function( "is-in-gamut", r"$color, $space: null", @@ -1142,29 +1154,9 @@ Value _parseChannels(String functionName, Value input, {ColorSpace? space, String? name}) { if (input.isVar) return _functionString(functionName, [input]); - Value components; - Value? alphaValue; - switch (input.assertCommonListStyle(name, allowSlash: true)) { - case [var components_, var alphaValue_] - when input.separator == ListSeparator.slash: - components = components_; - alphaValue = alphaValue_; - - case var inputList when input.separator == ListSeparator.slash: - throw SassScriptException( - "Only 2 slash-separated elements allowed, but ${inputList.length} " - "${pluralize('was', inputList.length, plural: 'were')} passed."); - - case [..., SassString(hasQuotes: false, :var text)] when text.contains('/'): - return _functionString(functionName, [input]); - - case [...var initial, SassNumber(asSlash: (var before, var after))]: - components = SassList([...initial, before], ListSeparator.space); - alphaValue = after; - - case _: - components = input; - } + var parsedSlash = _parseSlashChannels(input, name: name); + if (parsedSlash == null) return _functionString(functionName, [input]); + var (components, alphaValue) = parsedSlash; List channels; SassString? spaceName; @@ -1221,11 +1213,13 @@ Value _parseChannels(String functionName, Value input, : _functionString(functionName, [input]); } - var alpha = alphaValue == null - ? 1.0 - : _percentageOrUnitless(alphaValue.assertNumber(name), 1, 'alpha') - .clamp(0, 1) - .toDouble(); + var alpha = switch (alphaValue) { + null => 1.0, + SassString(hasQuotes: false, text: 'none') => null, + _ => _percentageOrUnitless(alphaValue.assertNumber(name), 1, 'alpha') + .clamp(0, 1) + .toDouble() + }; // `space` will be null if either `components` or `spaceName` is a `var()`. // Again, we check this here rather than returning early in those cases so @@ -1255,10 +1249,64 @@ Value _parseChannels(String functionName, Value input, fromRgbFunction: space == ColorSpace.rgb); } +/// Parses [input]'s slash-separated third number and alpha value, if one +/// exists. +/// +/// Returns a single value that contains the space-separated list of components, +/// and an alpha value if one was specified. If this channel set couldn't be +/// parsed and should be returned as-is, returns null. +/// +/// Throws a [SassScriptException] if [input] is invalid. If [input] came from a +/// function argument, [name] is the argument name (without the `$`). It's used +/// for error reporting. +(Value components, Value? alpha)? _parseSlashChannels(Value input, + {String? name}) => + switch (input.assertCommonListStyle(name, allowSlash: true)) { + [var components, var alphaValue] + when input.separator == ListSeparator.slash => + (components, alphaValue), + var inputList when input.separator == ListSeparator.slash => + throw SassScriptException( + "Only 2 slash-separated elements allowed, but ${inputList.length} " + "${pluralize('was', inputList.length, plural: 'were')} passed.", + name), + [...var initial, SassString(hasQuotes: false, :var text)] => switch ( + text.split('/')) { + [_] => (input, null), + [var channel3 && 'none', var alpha] || + [var channel3, var alpha && 'none'] => + switch ((_parseNumberOrNone(channel3), _parseNumberOrNone(alpha))) { + (var channel3Value?, var alphaValue?) => ( + SassList([...initial, channel3Value], ListSeparator.space), + alphaValue + ), + _ => null + }, + _ => null + }, + [...var initial, SassNumber(asSlash: (var before, var after))] => ( + SassList([...initial, before], ListSeparator.space), + after + ), + _ => (input, null) + }; + +/// Parses [text] as either a Sass number or the unquoted Sass string "none". +/// +/// If neither matches, returns null. +Value? _parseNumberOrNone(String text) { + if (text == 'none') return SassString('none', quotes: false); + try { + return ScssParser(text).parseNumber(); + } on SassFormatException { + return null; + } +} + /// Creates a [SassColor] for the given [space] from the given channel values, /// or throws a [SassScriptException] if the channel values are invalid. SassColor _colorFromChannels(ColorSpace space, SassNumber? channel0, - SassNumber? channel1, SassNumber? channel2, double alpha, + SassNumber? channel1, SassNumber? channel2, double? alpha, {bool fromRgbFunction = false}) { switch (space) { case ColorSpace.hsl: diff --git a/lib/src/parse/stylesheet.dart b/lib/src/parse/stylesheet.dart index 9e9f7b2ed..df2906174 100644 --- a/lib/src/parse/stylesheet.dart +++ b/lib/src/parse/stylesheet.dart @@ -117,6 +117,11 @@ abstract class StylesheetParser extends Parser { Expression parseExpression() => _parseSingleProduction(_expression); + SassNumber parseNumber() { + var expression = _parseSingleProduction(_number); + return SassNumber(expression.value, expression.unit); + } + VariableDeclaration parseVariableDeclaration() => _parseSingleProduction(() => lookingAtIdentifier() ? _variableDeclarationWithNamespace() diff --git a/lib/src/visitor/serialize.dart b/lib/src/visitor/serialize.dart index ad3b9bdde..93b112201 100644 --- a/lib/src/visitor/serialize.dart +++ b/lib/src/visitor/serialize.dart @@ -570,14 +570,16 @@ final class _SerializeVisitor ..write(value.space) ..writeCharCode($lparen); _writeChannel(value.channel0OrNull); - if (!_isCompressed && value.space == ColorSpace.lab) { + if (!_isCompressed && + value.space == ColorSpace.lab && + !value.isChannel0Missing) { _buffer.writeCharCode($percent); } _buffer.writeCharCode($space); _writeChannel(value.channel1OrNull); _buffer.writeCharCode($space); _writeChannel(value.channel2OrNull); - _maybeWriteSlashAlpha(value.alpha); + _maybeWriteSlashAlpha(value); _buffer.writeCharCode($rparen); case ColorSpace.lch || ColorSpace.oklch: @@ -585,7 +587,9 @@ final class _SerializeVisitor ..write(value.space) ..writeCharCode($lparen); _writeChannel(value.channel0OrNull); - if (!_isCompressed && value.space == ColorSpace.lch) { + if (!_isCompressed && + value.space == ColorSpace.lch && + !value.isChannel0Missing) { _buffer.writeCharCode($percent); } _buffer.writeCharCode($space); @@ -593,7 +597,7 @@ final class _SerializeVisitor _buffer.writeCharCode($space); _writeChannel(value.channel2OrNull); if (!_isCompressed && !value.isChannel2Missing) _buffer.write('deg'); - _maybeWriteSlashAlpha(value.alpha); + _maybeWriteSlashAlpha(value); _buffer.writeCharCode($rparen); case _: @@ -602,7 +606,7 @@ final class _SerializeVisitor ..write(value.space) ..writeCharCode($space); _writeBetween(value.channelsOrNull, ' ', _writeChannel); - _maybeWriteSlashAlpha(value.alpha); + _maybeWriteSlashAlpha(value); _buffer.writeCharCode($rparen); } } @@ -812,13 +816,17 @@ final class _SerializeVisitor _buffer.writeCharCode(hexCharFor(color & 0xF)); } - /// Writes the alpha component of a color if [alpha] isn't 1. - void _maybeWriteSlashAlpha(double alpha) { - if (fuzzyEquals(alpha, 1)) return; + /// Writes the alpha component of [color] if it isn't 1. + void _maybeWriteSlashAlpha(SassColor color) { + if (fuzzyEquals(color.alpha, 1)) return; _writeOptionalSpace(); _buffer.writeCharCode($slash); _writeOptionalSpace(); - _writeNumber(alpha); + if (color.isAlphaMissing) { + _buffer.write('none'); + } else { + _writeNumber(color.alpha); + } } void visitFunction(SassFunction function) { From 2e12e3f3ec05031734d46bfb10c83425ed0c6f9a Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Thu, 5 Oct 2023 18:33:58 -0700 Subject: [PATCH 15/56] Poke CI From 2eef6a2408b7f52bf49802699633e67b3ada450c Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Fri, 6 Oct 2023 14:51:19 -0700 Subject: [PATCH 16/56] Update for `oklab()` tests (#2094) --- lib/src/functions/color.dart | 25 +++++++++++---------- lib/src/value/color.dart | 3 ++- lib/src/value/color/channel.dart | 28 ++++++++++++++++++++--- lib/src/value/color/space/oklab.dart | 2 +- lib/src/value/color/space/oklch.dart | 2 +- lib/src/value/color/space/utils.dart | 3 ++- lib/src/visitor/serialize.dart | 33 ++++++++++------------------ 7 files changed, 57 insertions(+), 39 deletions(-) diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index 713c74a49..7edfc51dc 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -476,11 +476,12 @@ final module = BuiltInModule("color", functions: [ var channelInfo = color.space.channels[channelIndex]; var channelValue = color.channels[channelIndex]; + var unit = channelInfo.associatedUnit; + if (unit == '%') { + channelValue = channelValue * 100 / (channelInfo as LinearChannel).max; + } - return channelInfo is LinearChannel - ? SassNumber(channelValue, - channelInfo.min == 0 && channelInfo.max == 100 ? '%' : null) - : SassNumber(channelValue, 'deg'); + return SassNumber(channelValue, unit); }), _function("same", r"$color1, $color2", (arguments) { @@ -1198,7 +1199,8 @@ Value _parseChannels(String functionName, Value input, var channelName = space?.channels[channels.indexOf(channel)].name ?? 'channel'; throw SassScriptException( - 'Expected $channelName $channel to be a number.', name); + 'Expected $channelName channel to be a number, was $channel.', + name); } } @@ -1346,14 +1348,15 @@ SassColor _colorFromChannels(ColorSpace space, SassNumber? channel0, alpha, fromRgbFunction ? ColorFormat.rgbFunction : null); - case ColorSpace.lab: - case ColorSpace.lch: - case ColorSpace.oklab: - case ColorSpace.oklch: + case ColorSpace.lab || + ColorSpace.lch || + ColorSpace.oklab || + ColorSpace.oklch: return SassColor.forSpaceInternal( space, - _channelFromValue(space.channels[0], channel0) - .andThen((lightness) => fuzzyClamp(lightness, 0, 100)), + _channelFromValue(space.channels[0], channel0).andThen((lightness) => + fuzzyClamp( + lightness, 0, (space.channels[0] as LinearChannel).max)), _channelFromValue(space.channels[1], channel1), _channelFromValue(space.channels[2], channel2), alpha); diff --git a/lib/src/value/color.dart b/lib/src/value/color.dart index 96abc5263..6ffdb2bfa 100644 --- a/lib/src/value/color.dart +++ b/lib/src/value/color.dart @@ -531,7 +531,8 @@ class SassColor extends Value { return SassColor.forSpaceInternal( space, clampChannel0 - ? channels[0].andThen((value) => fuzzyClamp(value, 0, 100)) + ? channels[0].andThen((value) => fuzzyClamp( + value, 0, (space.channels[0] as LinearChannel).max)) : channels[0], clampChannel12 ? channels[1].andThen((value) => fuzzyClamp(value, 0, 100)) diff --git a/lib/src/value/color/channel.dart b/lib/src/value/color/channel.dart index 6fe9f9dac..61cd115cf 100644 --- a/lib/src/value/color/channel.dart +++ b/lib/src/value/color/channel.dart @@ -21,9 +21,21 @@ class ColorChannel { /// This is true if and only if this is not a [LinearChannel]. final bool isPolarAngle; + /// The unit that's associated with this channel. + /// + /// Some channels are typically written without units, while others have a + /// specific unit that is conventionally applied to their values. Although any + /// compatible unit or unitless value will work for input¹, this unit is used + /// when the value is serialized or returned from a Sass function. + /// + /// 1: Unless [LinearChannel.requiresPercent] is set, in which case unitless + /// values are not allowed. + final String? associatedUnit; + /// @nodoc @internal - const ColorChannel(this.name, {required this.isPolarAngle}); + const ColorChannel(this.name, + {required this.isPolarAngle, this.associatedUnit}); /// Returns whether this channel is [analogous] to [other]. /// @@ -65,9 +77,19 @@ class LinearChannel extends ColorChannel { /// forbids unitless values. final bool requiresPercent; + /// Creates a linear color channel. + /// + /// By default, [ColorChannel.associatedUnit] is set to `%` if and only if + /// [min] is 0 and [max] is 100. However, if [conventionallyPercent] is + /// true, it's set to `%`, and if it's false, it's set to null. + /// /// @nodoc @internal const LinearChannel(String name, this.min, this.max, - {this.requiresPercent = false}) - : super(name, isPolarAngle: false); + {this.requiresPercent = false, bool? conventionallyPercent}) + : super(name, + isPolarAngle: false, + associatedUnit: (conventionallyPercent ?? (min == 0 && max == 100)) + ? '%' + : null); } diff --git a/lib/src/value/color/space/oklab.dart b/lib/src/value/color/space/oklab.dart index de63b7673..cf8951bbd 100644 --- a/lib/src/value/color/space/oklab.dart +++ b/lib/src/value/color/space/oklab.dart @@ -23,7 +23,7 @@ class OklabColorSpace extends ColorSpace { const OklabColorSpace() : super('oklab', const [ - LinearChannel('lightness', 0, 1), + LinearChannel('lightness', 0, 1, conventionallyPercent: true), LinearChannel('a', -0.4, 0.4), LinearChannel('b', -0.4, 0.4) ]); diff --git a/lib/src/value/color/space/oklch.dart b/lib/src/value/color/space/oklch.dart index 6bd63c736..30887f6e7 100644 --- a/lib/src/value/color/space/oklch.dart +++ b/lib/src/value/color/space/oklch.dart @@ -23,7 +23,7 @@ class OklchColorSpace extends ColorSpace { const OklchColorSpace() : super('oklch', const [ - LinearChannel('lightness', 0, 1), + LinearChannel('lightness', 0, 1, conventionallyPercent: true), LinearChannel('chroma', 0, 0.4), hueChannel ]); diff --git a/lib/src/value/color/space/utils.dart b/lib/src/value/color/space/utils.dart index b1e16f2d9..427da3188 100644 --- a/lib/src/value/color/space/utils.dart +++ b/lib/src/value/color/space/utils.dart @@ -14,7 +14,8 @@ const labKappa = 24389 / 27; // 29^3/3^3; const labEpsilon = 216 / 24389; // 6^3/29^3; /// The hue channel shared across all polar color spaces. -const hueChannel = ColorChannel('hue', isPolarAngle: true); +const hueChannel = + ColorChannel('hue', isPolarAngle: true, associatedUnit: 'deg'); /// The color channels shared across all RGB color spaces (except the legacy RGB space). const rgbChannels = [ diff --git a/lib/src/visitor/serialize.dart b/lib/src/visitor/serialize.dart index a7101a827..8f4569665 100644 --- a/lib/src/visitor/serialize.dart +++ b/lib/src/visitor/serialize.dart @@ -560,38 +560,29 @@ final class _SerializeVisitor case ColorSpace.rgb || ColorSpace.hsl || ColorSpace.hwb: _writeLegacyColor(value); - case ColorSpace.lab || ColorSpace.oklab: + case ColorSpace.lab || + ColorSpace.oklab || + ColorSpace.lch || + ColorSpace.oklch: _buffer ..write(value.space) ..writeCharCode($lparen); - _writeChannel(value.channel0OrNull); - if (!_isCompressed && - value.space == ColorSpace.lab && - !value.isChannel0Missing) { + if (!_isCompressed && !value.isChannel0Missing) { + var max = (value.space.channels[0] as LinearChannel).max; + _writeNumber(value.channel0 * 100 / max); _buffer.writeCharCode($percent); + } else { + _writeChannel(value.channel0OrNull); } _buffer.writeCharCode($space); _writeChannel(value.channel1OrNull); _buffer.writeCharCode($space); _writeChannel(value.channel2OrNull); - _maybeWriteSlashAlpha(value); - _buffer.writeCharCode($rparen); - - case ColorSpace.lch || ColorSpace.oklch: - _buffer - ..write(value.space) - ..writeCharCode($lparen); - _writeChannel(value.channel0OrNull); if (!_isCompressed && - value.space == ColorSpace.lch && - !value.isChannel0Missing) { - _buffer.writeCharCode($percent); + !value.isChannel2Missing && + value.space.channels[2].isPolarAngle) { + _buffer.write('deg'); } - _buffer.writeCharCode($space); - _writeChannel(value.channel1OrNull); - _buffer.writeCharCode($space); - _writeChannel(value.channel2OrNull); - if (!_isCompressed && !value.isChannel2Missing) _buffer.write('deg'); _maybeWriteSlashAlpha(value); _buffer.writeCharCode($rparen); From 5ea5ab0e811a4ba72aaaf3aa53bbeeb0795744d8 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Tue, 10 Oct 2023 13:44:38 -0700 Subject: [PATCH 17/56] Add support for relative color syntax (#2112) See sass/sass#3673 --- lib/src/functions/color.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index 7edfc51dc..ee69e6896 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -1165,6 +1165,10 @@ Value _parseChannels(String functionName, Value input, case []: throw SassScriptException('Color component list may not be empty.', name); + case [SassString(:var text, hasQuotes: false), ...] + when text.toLowerCase() == "from": + return _functionString(functionName, [input]); + case _ when components.isVar: channels = [components]; From 5b3de085f20441732176c785e01c3403c6eb4ac9 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Wed, 11 Oct 2023 16:33:33 -0700 Subject: [PATCH 18/56] Update for `lch()` tests (#2108) --- lib/src/functions/color.dart | 2 +- lib/src/value/color.dart | 2 +- lib/src/value/color/space/lch.dart | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index ee69e6896..472006e6b 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -1384,7 +1384,7 @@ double? _channelFromValue(ColorChannel channel, SassNumber? value) => 'Expected $value to have unit "%".', channel.name), LinearChannel() => _percentageOrUnitless(value, channel.max, channel.name), - _ => value.coerceValueToUnit('deg', channel.name) + _ => value.coerceValueToUnit('deg', channel.name) % 360 }); /// Returns whether [value] is an unquoted string case-insensitively equal to diff --git a/lib/src/value/color.dart b/lib/src/value/color.dart index 6ffdb2bfa..33b6c2f47 100644 --- a/lib/src/value/color.dart +++ b/lib/src/value/color.dart @@ -249,7 +249,7 @@ class SassColor extends Value { /// This color's hue, between `0` and `360`. @Deprecated('Use channel() instead.') - double get hue => _legacyChannel(ColorSpace.hsl, 'hue') % 360; + double get hue => _legacyChannel(ColorSpace.hsl, 'hue'); /// This color's saturation, a percentage between `0` and `100`. @Deprecated('Use channel() instead.') diff --git a/lib/src/value/color/space/lch.dart b/lib/src/value/color/space/lch.dart index d522e6454..c66baab64 100644 --- a/lib/src/value/color/space/lch.dart +++ b/lib/src/value/color/space/lch.dart @@ -24,7 +24,7 @@ class LchColorSpace extends ColorSpace { const LchColorSpace() : super('lch', const [ LinearChannel('lightness', 0, 100), - LinearChannel('chroma', 0, 100), + LinearChannel('chroma', 0, 150), hueChannel ]); From c848cb4d345c9abb22374ab398f62a714728a16a Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Tue, 17 Oct 2023 14:59:50 -0700 Subject: [PATCH 19/56] Fix the output space for color.changeChannels() (#2119) --- lib/src/value/color.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/value/color.dart b/lib/src/value/color.dart index 33b6c2f47..392c1aff8 100644 --- a/lib/src/value/color.dart +++ b/lib/src/value/color.dart @@ -842,7 +842,7 @@ class SassColor extends Value { if (space != null && space != this.space) { return toSpace(space) .changeChannels(newValues, colorName: colorName) - .toSpace(space); + .toSpace(this.space); } double? new0; From 95a0ec5cce6af2979e8fe40bd76983847df48f4c Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Tue, 17 Oct 2023 15:00:18 -0700 Subject: [PATCH 20/56] [Color 4] Properly serialize missing channels in legacy colors (#2115) --- lib/src/visitor/serialize.dart | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/lib/src/visitor/serialize.dart b/lib/src/visitor/serialize.dart index 8f4569665..62527112f 100644 --- a/lib/src/visitor/serialize.dart +++ b/lib/src/visitor/serialize.dart @@ -557,9 +557,38 @@ final class _SerializeVisitor void visitColor(SassColor value) { switch (value.space) { - case ColorSpace.rgb || ColorSpace.hsl || ColorSpace.hwb: + case ColorSpace.rgb || ColorSpace.hsl || ColorSpace.hwb + when !value.isChannel0Missing && + !value.isChannel1Missing && + !value.isChannel2Missing && + !value.isAlphaMissing: _writeLegacyColor(value); + case ColorSpace.rgb: + _buffer.write('rgb('); + _writeChannel(value.channel0OrNull); + _buffer.writeCharCode($space); + _writeChannel(value.channel1OrNull); + _buffer.writeCharCode($space); + _writeChannel(value.channel2OrNull); + _maybeWriteSlashAlpha(value); + _buffer.writeCharCode($rparen); + + case ColorSpace.hsl || ColorSpace.hwb: + _buffer + ..write(value.space) + ..writeCharCode($lparen); + _writeChannel(value.channel0OrNull); + if (!_isCompressed && !value.isChannel0Missing) _buffer.write('deg'); + _buffer.writeCharCode($space); + _writeChannel(value.channel1OrNull); + if (!value.isChannel1Missing) _buffer.writeCharCode($percent); + _buffer.writeCharCode($space); + _writeChannel(value.channel2OrNull); + if (!value.isChannel2Missing) _buffer.writeCharCode($percent); + _maybeWriteSlashAlpha(value); + _buffer.writeCharCode($rparen); + case ColorSpace.lab || ColorSpace.oklab || ColorSpace.lch || From 67a110998a1f198d5bc56a9da51a1a9bab193aa3 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Wed, 18 Oct 2023 14:53:42 -0700 Subject: [PATCH 21/56] Use a negative-safe cube root in LMS conversions (#2121) See w3c/csswg-drafts#9477 --- lib/src/value/color/space/lms.dart | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/src/value/color/space/lms.dart b/lib/src/value/color/space/lms.dart index aac56eb63..62596fa9b 100644 --- a/lib/src/value/color/space/lms.dart +++ b/lib/src/value/color/space/lms.dart @@ -37,9 +37,9 @@ class LmsColorSpace extends ColorSpace { switch (dest) { case ColorSpace.oklab: // Algorithm from https://drafts.csswg.org/css-color-4/#color-conversion-code - var longScaled = math.pow(long, 1 / 3); - var mediumScaled = math.pow(medium, 1 / 3); - var shortScaled = math.pow(short, 1 / 3); + var longScaled = _cubeRootPreservingSign(long); + var mediumScaled = _cubeRootPreservingSign(medium); + var shortScaled = _cubeRootPreservingSign(short); var lightness = lmsToOklab[0] * longScaled + lmsToOklab[1] * mediumScaled + lmsToOklab[2] * shortScaled; @@ -62,9 +62,9 @@ class LmsColorSpace extends ColorSpace { // This is equivalent to converting to OKLab and then to OKLCH, but we // do it inline to avoid extra list allocations since we expect // conversions to and from OKLCH to be very common. - var longScaled = math.pow(long, 1 / 3); - var mediumScaled = math.pow(medium, 1 / 3); - var shortScaled = math.pow(short, 1 / 3); + var longScaled = _cubeRootPreservingSign(long); + var mediumScaled = _cubeRootPreservingSign(medium); + var shortScaled = _cubeRootPreservingSign(short); return labToLch( dest, lmsToOklab[0] * longScaled + @@ -83,6 +83,11 @@ class LmsColorSpace extends ColorSpace { } } + /// Returns the cube root of the absolute value of [number] with the same sign + /// as [number]. + double _cubeRootPreservingSign(double number) => + math.pow(number.abs(), 1 / 3) * number.sign; + @protected double toLinear(double channel) => channel; From 3cac435ea5efd7d4ff17d9a366d3cb317e959000 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Tue, 10 Oct 2023 16:40:14 -0700 Subject: [PATCH 22/56] Never gamut-map during color space conversions --- lib/src/value/color/space/srgb.dart | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/src/value/color/space/srgb.dart b/lib/src/value/color/space/srgb.dart index 677456818..2b35a7c6d 100644 --- a/lib/src/value/color/space/srgb.dart +++ b/lib/src/value/color/space/srgb.dart @@ -31,12 +31,6 @@ class SrgbColorSpace extends ColorSpace { ColorSpace dest, double red, double green, double blue, double alpha) { switch (dest) { case ColorSpace.hsl || ColorSpace.hwb: - if (fuzzyCheckRange(red, 0, 1) == null || - fuzzyCheckRange(green, 0, 1) == null || - fuzzyCheckRange(blue, 0, 1) == null) { - return SassColor.srgb(red, green, blue).toGamut().toSpace(dest); - } - // Algorithm from https://en.wikipedia.org/wiki/HSL_and_HSV#RGB_to_HSL_and_HSV var max = math.max(math.max(red, green), blue); var min = math.min(math.min(red, green), blue); From 3252cde98ee8b9d096b32560e0bfd5139f68c3ce Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Tue, 17 Oct 2023 17:26:40 -0700 Subject: [PATCH 23/56] Emit the correct space for color.to-gamut() --- lib/src/functions/color.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index 472006e6b..241bf1a53 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -459,7 +459,7 @@ final module = BuiltInModule("color", functions: [ ? ColorSpace.srgb : space) .toGamut() - .toSpace(space); + .toSpace(color.space); }), _function("channel", r"$color, $channel, $space: null", (arguments) { From 341d20aa6ab0ec6d994fa16b97c2c9f49bdf7521 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Tue, 17 Oct 2023 17:26:51 -0700 Subject: [PATCH 24/56] Don't emit powerless components for legacy colors in color.to-space --- lib/src/functions/color.dart | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index 241bf1a53..9b780243f 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -423,11 +423,20 @@ final module = BuiltInModule("color", functions: [ (arguments) => SassString(arguments.first.assertColor("color").space.name, quotes: false)), - _function( - "to-space", - r"$color, $space", - (arguments) => - _colorInSpace(arguments[0], arguments[1].assertString("space"))), + _function("to-space", r"$color, $space", (arguments) { + var converted = _colorInSpace(arguments[0], arguments[1]); + // `color.to-space()` never returns missing channels for legacy color + // spaces because they're less compatible and users are probably using a + // legacy space because they want a highly compatible color. + return converted.isLegacy && + (converted.isChannel0Missing || + converted.isChannel1Missing || + converted.isChannel2Missing || + converted.isAlphaMissing) + ? SassColor.forSpaceInternal(converted.space, converted.channel0, + converted.channel1, converted.channel2, converted.alpha) + : converted; + }), _function("is-legacy", r"$color", (arguments) => SassBoolean(arguments[0].assertColor("color").isLegacy)), From 391182665dce6b844536c3394143666c80e6071d Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Tue, 17 Oct 2023 18:03:57 -0700 Subject: [PATCH 25/56] Make Dart Errors from user-defined callbacks easier to debug --- lib/src/visitor/async_evaluate.dart | 29 ++++++++++++++------------- lib/src/visitor/evaluate.dart | 31 +++++++++++++++-------------- 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/lib/src/visitor/async_evaluate.dart b/lib/src/visitor/async_evaluate.dart index a380c9f39..58f7dbd12 100644 --- a/lib/src/visitor/async_evaluate.dart +++ b/lib/src/visitor/async_evaluate.dart @@ -1728,13 +1728,7 @@ final class _EvaluateVisitor } on ArgumentError catch (error, stackTrace) { throwWithTrace(_exception(error.toString()), error, stackTrace); } catch (error, stackTrace) { - String? message; - try { - message = (error as dynamic).message as String; - } catch (_) { - message = error.toString(); - } - throwWithTrace(_exception(message), error, stackTrace); + throwWithTrace(_exception(_getErrorMessage(error)), error, stackTrace); } finally { _importSpan = null; } @@ -2995,13 +2989,8 @@ final class _EvaluateVisitor } on SassException { rethrow; } catch (error, stackTrace) { - String? message; - try { - message = (error as dynamic).message as String; - } catch (_) { - message = error.toString(); - } - throwWithTrace(_exception(message, nodeWithSpan.span), error, stackTrace); + throwWithTrace(_exception(_getErrorMessage(error), nodeWithSpan.span), + error, stackTrace); } _callableNode = oldCallableNode; @@ -3841,6 +3830,18 @@ final class _EvaluateVisitor stackTrace); } } + + /// Returns the best human-readable message for [error]. + String _getErrorMessage(Object error) { + // Built-in Dart Error objects often require their full toString()s for + // full context. + if (error is Error) return error.toString(); + try { + return (error as dynamic).message as String; + } catch (_) { + return error.toString(); + } + } } /// A helper class for [_EvaluateVisitor] that adds `@import`ed CSS nodes to the diff --git a/lib/src/visitor/evaluate.dart b/lib/src/visitor/evaluate.dart index b87658a68..b0256c07c 100644 --- a/lib/src/visitor/evaluate.dart +++ b/lib/src/visitor/evaluate.dart @@ -5,7 +5,7 @@ // DO NOT EDIT. This file was generated from async_evaluate.dart. // See tool/grind/synchronize.dart for details. // -// Checksum: 08ee05790c0d5cce3b190278723c52dadaee1701 +// Checksum: ba8431368d89d6d8b141fbcca43b0170409af9ba // // ignore_for_file: unused_import @@ -1725,13 +1725,7 @@ final class _EvaluateVisitor } on ArgumentError catch (error, stackTrace) { throwWithTrace(_exception(error.toString()), error, stackTrace); } catch (error, stackTrace) { - String? message; - try { - message = (error as dynamic).message as String; - } catch (_) { - message = error.toString(); - } - throwWithTrace(_exception(message), error, stackTrace); + throwWithTrace(_exception(_getErrorMessage(error)), error, stackTrace); } finally { _importSpan = null; } @@ -2971,13 +2965,8 @@ final class _EvaluateVisitor } on SassException { rethrow; } catch (error, stackTrace) { - String? message; - try { - message = (error as dynamic).message as String; - } catch (_) { - message = error.toString(); - } - throwWithTrace(_exception(message, nodeWithSpan.span), error, stackTrace); + throwWithTrace(_exception(_getErrorMessage(error), nodeWithSpan.span), + error, stackTrace); } _callableNode = oldCallableNode; @@ -3789,6 +3778,18 @@ final class _EvaluateVisitor stackTrace); } } + + /// Returns the best human-readable message for [error]. + String _getErrorMessage(Object error) { + // Built-in Dart Error objects often require their full toString()s for + // full context. + if (error is Error) return error.toString(); + try { + return (error as dynamic).message as String; + } catch (_) { + return error.toString(); + } + } } /// A helper class for [_EvaluateVisitor] that adds `@import`ed CSS nodes to the From 0091d01cf3ea41c844416b1dacd1d7c99ecd8280 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Wed, 18 Oct 2023 17:00:59 -0700 Subject: [PATCH 26/56] Properly pass missing channels through color conversions --- lib/src/value/color.dart | 3 +- lib/src/value/color/space.dart | 97 ++++++++++++++------- lib/src/value/color/space/a98_rgb.dart | 2 +- lib/src/value/color/space/display_p3.dart | 2 +- lib/src/value/color/space/hsl.dart | 20 +++-- lib/src/value/color/space/hwb.dart | 18 ++-- lib/src/value/color/space/lab.dart | 27 ++++-- lib/src/value/color/space/lch.dart | 19 ++-- lib/src/value/color/space/lms.dart | 48 ++++++---- lib/src/value/color/space/oklab.dart | 26 ++++-- lib/src/value/color/space/oklch.dart | 19 ++-- lib/src/value/color/space/prophoto_rgb.dart | 2 +- lib/src/value/color/space/rec2020.dart | 2 +- lib/src/value/color/space/rgb.dart | 13 ++- lib/src/value/color/space/srgb.dart | 36 +++++--- lib/src/value/color/space/srgb_linear.dart | 13 +-- lib/src/value/color/space/utils.dart | 22 +++-- lib/src/value/color/space/xyz_d50.dart | 32 +++++-- lib/src/value/color/space/xyz_d65.dart | 2 +- 19 files changed, 273 insertions(+), 130 deletions(-) diff --git a/lib/src/value/color.dart b/lib/src/value/color.dart index 392c1aff8..36d5ab8fc 100644 --- a/lib/src/value/color.dart +++ b/lib/src/value/color.dart @@ -694,7 +694,8 @@ class SassColor extends Value { /// automatic conversions. SassColor toSpace(ColorSpace space) => this.space == space ? this - : this.space.convert(space, channel0, channel1, channel2, alpha); + : this.space.convert( + space, channel0OrNull, channel1OrNull, channel2OrNull, alpha); /// Returns a copy of this color that's in-gamut in the current color space. SassColor toGamut() { diff --git a/lib/src/value/color/space.dart b/lib/src/value/color/space.dart index 882f5be91..2127a200e 100644 --- a/lib/src/value/color/space.dart +++ b/lib/src/value/color/space.dart @@ -29,7 +29,7 @@ import 'space/xyz_d65.dart'; /// /// {@category Value} @sealed -abstract class ColorSpace { +abstract base class ColorSpace { /// The legacy RGB color space. static const ColorSpace rgb = RgbColorSpace(); @@ -178,48 +178,83 @@ abstract class ColorSpace { /// /// @nodoc @internal - SassColor convert(ColorSpace dest, double channel0, double channel1, - double channel2, double alpha) { + SassColor convert(ColorSpace dest, double? channel0, double? channel1, + double? channel2, double? alpha) => + convertLinear(dest, channel0, channel1, channel2, alpha); + + /// The default implementation of [convert], which always starts with a linear + /// transformation from RGB or XYZ channels to a linear destination space, + /// which may then further convert to a polar space. + /// + /// @nodoc + @internal + @protected + @nonVirtual + SassColor convertLinear( + ColorSpace dest, double? red, double? green, double? blue, double? alpha, + {bool missingLightness = false, + bool missingChroma = false, + bool missingHue = false, + bool missingA = false, + bool missingB = false}) { var linearDest = switch (dest) { - ColorSpace.hsl || ColorSpace.hwb => ColorSpace.srgb, - ColorSpace.lab || ColorSpace.lch => ColorSpace.xyzD50, - ColorSpace.oklab || ColorSpace.oklch => ColorSpace.lms, + ColorSpace.hsl || ColorSpace.hwb => const SrgbColorSpace(), + ColorSpace.lab || ColorSpace.lch => const XyzD50ColorSpace(), + ColorSpace.oklab || ColorSpace.oklch => const LmsColorSpace(), _ => dest }; - double transformed0; - double transformed1; - double transformed2; + double? transformedRed; + double? transformedGreen; + double? transformedBlue; if (linearDest == this) { - transformed0 = channel0; - transformed1 = channel1; - transformed2 = channel2; + transformedRed = red; + transformedGreen = green; + transformedBlue = blue; } else { - var linear0 = toLinear(channel0); - var linear1 = toLinear(channel1); - var linear2 = toLinear(channel2); + var linearRed = toLinear(red ?? 0); + var linearGreen = toLinear(green ?? 0); + var linearBlue = toLinear(blue ?? 0); var matrix = transformationMatrix(linearDest); - // (matrix * [linear0, linear1, linear2]).map(linearDest.fromLinear) - transformed0 = linearDest.fromLinear( - matrix[0] * linear0 + matrix[1] * linear1 + matrix[2] * linear2); - transformed1 = linearDest.fromLinear( - matrix[3] * linear0 + matrix[4] * linear1 + matrix[5] * linear2); - transformed2 = linearDest.fromLinear( - matrix[6] * linear0 + matrix[7] * linear1 + matrix[8] * linear2); + // (matrix * [linearRed, linearGreen, linearBlue]).map(linearDest.fromLinear) + transformedRed = linearDest.fromLinear(matrix[0] * linearRed + + matrix[1] * linearGreen + + matrix[2] * linearBlue); + transformedGreen = linearDest.fromLinear(matrix[3] * linearRed + + matrix[4] * linearGreen + + matrix[5] * linearBlue); + transformedBlue = linearDest.fromLinear(matrix[6] * linearRed + + matrix[7] * linearGreen + + matrix[8] * linearBlue); } return switch (dest) { - ColorSpace.hsl || - ColorSpace.hwb || - ColorSpace.lab || - ColorSpace.lch || - ColorSpace.oklab || - ColorSpace.oklch => - linearDest.convert( - dest, transformed0, transformed1, transformed2, alpha), + ColorSpace.hsl || ColorSpace.hwb => const SrgbColorSpace().convert( + dest, transformedRed, transformedGreen, transformedBlue, alpha, + missingLightness: missingLightness, + missingChroma: missingChroma, + missingHue: missingHue), + ColorSpace.lab || ColorSpace.lch => const XyzD50ColorSpace().convert( + dest, transformedRed, transformedGreen, transformedBlue, alpha, + missingLightness: missingLightness, + missingChroma: missingChroma, + missingHue: missingHue, + missingA: missingA, + missingB: missingB), + ColorSpace.oklab || ColorSpace.oklch => const LmsColorSpace().convert( + dest, transformedRed, transformedGreen, transformedBlue, alpha, + missingLightness: missingLightness, + missingChroma: missingChroma, + missingHue: missingHue, + missingA: missingA, + missingB: missingB), _ => SassColor.forSpaceInternal( - dest, transformed0, transformed1, transformed2, alpha) + dest, + red == null ? null : transformedRed, + green == null ? null : transformedGreen, + blue == null ? null : transformedBlue, + alpha) }; } diff --git a/lib/src/value/color/space/a98_rgb.dart b/lib/src/value/color/space/a98_rgb.dart index 940ea8c94..df61a6d50 100644 --- a/lib/src/value/color/space/a98_rgb.dart +++ b/lib/src/value/color/space/a98_rgb.dart @@ -17,7 +17,7 @@ import 'utils.dart'; /// /// @nodoc @internal -class A98RgbColorSpace extends ColorSpace { +final class A98RgbColorSpace extends ColorSpace { bool get isBoundedInternal => true; const A98RgbColorSpace() : super('a98-rgb', rgbChannels); diff --git a/lib/src/value/color/space/display_p3.dart b/lib/src/value/color/space/display_p3.dart index 2cff9f830..b1c56df5d 100644 --- a/lib/src/value/color/space/display_p3.dart +++ b/lib/src/value/color/space/display_p3.dart @@ -16,7 +16,7 @@ import 'utils.dart'; /// /// @nodoc @internal -class DisplayP3ColorSpace extends ColorSpace { +final class DisplayP3ColorSpace extends ColorSpace { bool get isBoundedInternal => true; const DisplayP3ColorSpace() : super('display-p3', rgbChannels); diff --git a/lib/src/value/color/space/hsl.dart b/lib/src/value/color/space/hsl.dart index ac357ebd9..94bfba84d 100644 --- a/lib/src/value/color/space/hsl.dart +++ b/lib/src/value/color/space/hsl.dart @@ -7,13 +7,14 @@ import 'package:meta/meta.dart'; import '../../color.dart'; +import 'srgb.dart'; import 'utils.dart'; /// The legacy HSL color space. /// /// @nodoc @internal -class HslColorSpace extends ColorSpace { +final class HslColorSpace extends ColorSpace { bool get isBoundedInternal => true; bool get isStrictlyBoundedInternal => true; bool get isLegacyInternal => true; @@ -26,12 +27,12 @@ class HslColorSpace extends ColorSpace { LinearChannel('lightness', 0, 100, requiresPercent: true) ]); - SassColor convert(ColorSpace dest, double hue, double saturation, - double lightness, double alpha) { + SassColor convert(ColorSpace dest, double? hue, double? saturation, + double? lightness, double? alpha) { // Algorithm from the CSS3 spec: https://www.w3.org/TR/css3-color/#hsl-color. - var scaledHue = (hue / 360) % 1; - var scaledSaturation = saturation / 100; - var scaledLightness = lightness / 100; + var scaledHue = ((hue ?? 0) / 360) % 1; + var scaledSaturation = (saturation ?? 0) / 100; + var scaledLightness = (lightness ?? 0) / 100; var m2 = scaledLightness <= 0.5 ? scaledLightness * (scaledSaturation + 1) @@ -40,11 +41,14 @@ class HslColorSpace extends ColorSpace { scaledLightness * scaledSaturation; var m1 = scaledLightness * 2 - m2; - return ColorSpace.srgb.convert( + return const SrgbColorSpace().convert( dest, hueToRgb(m1, m2, scaledHue + 1 / 3), hueToRgb(m1, m2, scaledHue), hueToRgb(m1, m2, scaledHue - 1 / 3), - alpha); + alpha, + missingLightness: lightness == null, + missingChroma: saturation == null, + missingHue: hue == null); } } diff --git a/lib/src/value/color/space/hwb.dart b/lib/src/value/color/space/hwb.dart index be15226c8..9f0ecd131 100644 --- a/lib/src/value/color/space/hwb.dart +++ b/lib/src/value/color/space/hwb.dart @@ -7,13 +7,14 @@ import 'package:meta/meta.dart'; import '../../color.dart'; +import 'srgb.dart'; import 'utils.dart'; /// The legacy HWB color space. /// /// @nodoc @internal -class HwbColorSpace extends ColorSpace { +final class HwbColorSpace extends ColorSpace { bool get isBoundedInternal => true; bool get isStrictlyBoundedInternal => true; bool get isLegacyInternal => true; @@ -26,12 +27,12 @@ class HwbColorSpace extends ColorSpace { LinearChannel('blackness', 0, 100, requiresPercent: true) ]); - SassColor convert(ColorSpace dest, double hue, double whiteness, - double blackness, double alpha) { + SassColor convert(ColorSpace dest, double? hue, double? whiteness, + double? blackness, double? alpha) { // From https://www.w3.org/TR/css-color-4/#hwb-to-rgb - var scaledHue = hue % 360 / 360; - var scaledWhiteness = whiteness / 100; - var scaledBlackness = blackness / 100; + var scaledHue = (hue ?? 0) % 360 / 360; + var scaledWhiteness = (whiteness ?? 0) / 100; + var scaledBlackness = (blackness ?? 0) / 100; var sum = scaledWhiteness + scaledBlackness; if (sum > 1) { @@ -44,7 +45,8 @@ class HwbColorSpace extends ColorSpace { // Non-null because an in-gamut HSL color is guaranteed to be in-gamut for // HWB as well. - return ColorSpace.srgb.convert(dest, toRgb(scaledHue + 1 / 3), - toRgb(scaledHue), toRgb(scaledHue - 1 / 3), alpha); + return const SrgbColorSpace().convert(dest, toRgb(scaledHue + 1 / 3), + toRgb(scaledHue), toRgb(scaledHue - 1 / 3), alpha, + missingHue: hue == null); } } diff --git a/lib/src/value/color/space/lab.dart b/lib/src/value/color/space/lab.dart index bd7b18792..58a94bd68 100644 --- a/lib/src/value/color/space/lab.dart +++ b/lib/src/value/color/space/lab.dart @@ -12,6 +12,7 @@ import '../../../util/number.dart'; import '../../color.dart'; import '../conversions.dart'; import 'utils.dart'; +import 'xyz_d50.dart'; /// The Lab color space. /// @@ -19,7 +20,7 @@ import 'utils.dart'; /// /// @nodoc @internal -class LabColorSpace extends ColorSpace { +final class LabColorSpace extends ColorSpace { bool get isBoundedInternal => false; const LabColorSpace() @@ -30,30 +31,38 @@ class LabColorSpace extends ColorSpace { ]); SassColor convert( - ColorSpace dest, double lightness, double a, double b, double alpha) { + ColorSpace dest, double? lightness, double? a, double? b, double? alpha, + {bool missingChroma = false, bool missingHue = false}) { switch (dest) { case ColorSpace.lab: - var powerlessAB = fuzzyEquals(lightness, 0); - return SassColor.lab( - lightness, powerlessAB ? null : a, powerlessAB ? null : b, alpha); + var powerlessAB = lightness == null || fuzzyEquals(lightness, 0); + return SassColor.lab(lightness, a == null || powerlessAB ? null : a, + b == null || powerlessAB ? null : b, alpha); case ColorSpace.lch: return labToLch(dest, lightness, a, b, alpha); default: + var missingLightness = lightness == null; + lightness ??= 0; // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code // and http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html var f1 = (lightness + 16) / 116; - return ColorSpace.xyzD50.convert( + return const XyzD50ColorSpace().convert( dest, - _convertFToXorZ(a / 500 + f1) * d50[0], + _convertFToXorZ((a ?? 0) / 500 + f1) * d50[0], (lightness > labKappa * labEpsilon ? math.pow((lightness + 16) / 116, 3) * 1.0 : lightness / labKappa) * d50[1], - _convertFToXorZ(f1 - b / 200) * d50[2], - alpha); + _convertFToXorZ(f1 - (b ?? 0) / 200) * d50[2], + alpha, + missingLightness: missingLightness, + missingChroma: missingChroma, + missingHue: missingHue, + missingA: a == null, + missingB: b == null); } } diff --git a/lib/src/value/color/space/lch.dart b/lib/src/value/color/space/lch.dart index c66baab64..47c5848bb 100644 --- a/lib/src/value/color/space/lch.dart +++ b/lib/src/value/color/space/lch.dart @@ -9,6 +9,7 @@ import 'dart:math' as math; import 'package:meta/meta.dart'; import '../../color.dart'; +import 'lab.dart'; import 'utils.dart'; /// The LCH color space. @@ -17,7 +18,7 @@ import 'utils.dart'; /// /// @nodoc @internal -class LchColorSpace extends ColorSpace { +final class LchColorSpace extends ColorSpace { bool get isBoundedInternal => false; bool get isPolarInternal => true; @@ -28,10 +29,16 @@ class LchColorSpace extends ColorSpace { hueChannel ]); - SassColor convert(ColorSpace dest, double lightness, double chroma, - double hue, double alpha) { - var hueRadians = hue * math.pi / 180; - return ColorSpace.lab.convert(dest, lightness, - chroma * math.cos(hueRadians), chroma * math.sin(hueRadians), alpha); + SassColor convert(ColorSpace dest, double? lightness, double? chroma, + double? hue, double? alpha) { + var hueRadians = (hue ?? 0) * math.pi / 180; + return const LabColorSpace().convert( + dest, + lightness, + (chroma ?? 0) * math.cos(hueRadians), + (chroma ?? 0) * math.sin(hueRadians), + alpha, + missingChroma: chroma == null, + missingHue: hue == null); } } diff --git a/lib/src/value/color/space/lms.dart b/lib/src/value/color/space/lms.dart index 62596fa9b..675bbe74b 100644 --- a/lib/src/value/color/space/lms.dart +++ b/lib/src/value/color/space/lms.dart @@ -22,7 +22,7 @@ import 'utils.dart'; /// /// @nodoc @internal -class LmsColorSpace extends ColorSpace { +final class LmsColorSpace extends ColorSpace { bool get isBoundedInternal => false; const LmsColorSpace() @@ -32,26 +32,31 @@ class LmsColorSpace extends ColorSpace { LinearChannel('short', 0, 1) ]); - SassColor convert( - ColorSpace dest, double long, double medium, double short, double alpha) { + SassColor convert(ColorSpace dest, double? long, double? medium, + double? short, double? alpha, + {bool missingLightness = false, + bool missingChroma = false, + bool missingHue = false, + bool missingA = false, + bool missingB = false}) { switch (dest) { case ColorSpace.oklab: // Algorithm from https://drafts.csswg.org/css-color-4/#color-conversion-code - var longScaled = _cubeRootPreservingSign(long); - var mediumScaled = _cubeRootPreservingSign(medium); - var shortScaled = _cubeRootPreservingSign(short); + var longScaled = _cubeRootPreservingSign(long ?? 0); + var mediumScaled = _cubeRootPreservingSign(medium ?? 0); + var shortScaled = _cubeRootPreservingSign(short ?? 0); var lightness = lmsToOklab[0] * longScaled + lmsToOklab[1] * mediumScaled + lmsToOklab[2] * shortScaled; return SassColor.oklab( - lightness, - fuzzyEquals(lightness, 0) + missingLightness ? null : lightness, + missingA || fuzzyEquals(lightness, 0) ? null : lmsToOklab[3] * longScaled + lmsToOklab[4] * mediumScaled + lmsToOklab[5] * shortScaled, - fuzzyEquals(lightness, 0) + missingB || fuzzyEquals(lightness, 0) ? null : lmsToOklab[6] * longScaled + lmsToOklab[7] * mediumScaled + @@ -62,24 +67,33 @@ class LmsColorSpace extends ColorSpace { // This is equivalent to converting to OKLab and then to OKLCH, but we // do it inline to avoid extra list allocations since we expect // conversions to and from OKLCH to be very common. - var longScaled = _cubeRootPreservingSign(long); - var mediumScaled = _cubeRootPreservingSign(medium); - var shortScaled = _cubeRootPreservingSign(short); + var longScaled = _cubeRootPreservingSign(long ?? 0); + var mediumScaled = _cubeRootPreservingSign(medium ?? 0); + var shortScaled = _cubeRootPreservingSign(short ?? 0); return labToLch( dest, - lmsToOklab[0] * longScaled + - lmsToOklab[1] * mediumScaled + - lmsToOklab[2] * shortScaled, + missingLightness + ? null + : lmsToOklab[0] * longScaled + + lmsToOklab[1] * mediumScaled + + lmsToOklab[2] * shortScaled, lmsToOklab[3] * longScaled + lmsToOklab[4] * mediumScaled + lmsToOklab[5] * shortScaled, lmsToOklab[6] * longScaled + lmsToOklab[7] * mediumScaled + lmsToOklab[8] * shortScaled, - alpha); + alpha, + missingChroma: missingChroma, + missingHue: missingHue); default: - return super.convert(dest, long, medium, short, alpha); + return super.convertLinear(dest, long, medium, short, alpha, + missingLightness: missingLightness, + missingChroma: missingChroma, + missingHue: missingHue, + missingA: missingA, + missingB: missingB); } } diff --git a/lib/src/value/color/space/oklab.dart b/lib/src/value/color/space/oklab.dart index cf8951bbd..8af468e11 100644 --- a/lib/src/value/color/space/oklab.dart +++ b/lib/src/value/color/space/oklab.dart @@ -10,6 +10,7 @@ import 'package:meta/meta.dart'; import '../../color.dart'; import '../conversions.dart'; +import 'lms.dart'; import 'utils.dart'; /// The OKLab color space. @@ -18,7 +19,7 @@ import 'utils.dart'; /// /// @nodoc @internal -class OklabColorSpace extends ColorSpace { +final class OklabColorSpace extends ColorSpace { bool get isBoundedInternal => false; const OklabColorSpace() @@ -29,11 +30,21 @@ class OklabColorSpace extends ColorSpace { ]); SassColor convert( - ColorSpace dest, double lightness, double a, double b, double alpha) { - if (dest == ColorSpace.oklch) return labToLch(dest, lightness, a, b, alpha); + ColorSpace dest, double? lightness, double? a, double? b, double? alpha, + {bool missingChroma = false, bool missingHue = false}) { + if (dest == ColorSpace.oklch) { + return labToLch(dest, lightness, a, b, alpha, + missingChroma: missingChroma, missingHue: missingHue); + } + var missingLightness = lightness == null; + var missingA = a == null; + var missingB = b == null; + lightness ??= 0; + a ??= 0; + b ??= 0; // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code - return ColorSpace.lms.convert( + return const LmsColorSpace().convert( dest, math.pow( oklabToLms[0] * lightness + @@ -53,6 +64,11 @@ class OklabColorSpace extends ColorSpace { oklabToLms[8] * b, 3) + 0.0, - alpha); + alpha, + missingLightness: missingLightness, + missingChroma: missingChroma, + missingHue: missingHue, + missingA: missingA, + missingB: missingB); } } diff --git a/lib/src/value/color/space/oklch.dart b/lib/src/value/color/space/oklch.dart index 30887f6e7..61ed5e55d 100644 --- a/lib/src/value/color/space/oklch.dart +++ b/lib/src/value/color/space/oklch.dart @@ -9,6 +9,7 @@ import 'dart:math' as math; import 'package:meta/meta.dart'; import '../../color.dart'; +import 'oklab.dart'; import 'utils.dart'; /// The OKLCH color space. @@ -17,7 +18,7 @@ import 'utils.dart'; /// /// @nodoc @internal -class OklchColorSpace extends ColorSpace { +final class OklchColorSpace extends ColorSpace { bool get isBoundedInternal => false; bool get isPolarInternal => true; @@ -28,10 +29,16 @@ class OklchColorSpace extends ColorSpace { hueChannel ]); - SassColor convert(ColorSpace dest, double lightness, double chroma, - double hue, double alpha) { - var hueRadians = hue * math.pi / 180; - return ColorSpace.oklab.convert(dest, lightness, - chroma * math.cos(hueRadians), chroma * math.sin(hueRadians), alpha); + SassColor convert(ColorSpace dest, double? lightness, double? chroma, + double? hue, double? alpha) { + var hueRadians = (hue ?? 0) * math.pi / 180; + return const OklabColorSpace().convert( + dest, + lightness, + (chroma ?? 0) * math.cos(hueRadians), + (chroma ?? 0) * math.sin(hueRadians), + alpha, + missingChroma: chroma == null, + missingHue: hue == null); } } diff --git a/lib/src/value/color/space/prophoto_rgb.dart b/lib/src/value/color/space/prophoto_rgb.dart index 5fcddafdf..0de23ada9 100644 --- a/lib/src/value/color/space/prophoto_rgb.dart +++ b/lib/src/value/color/space/prophoto_rgb.dart @@ -17,7 +17,7 @@ import 'utils.dart'; /// /// @nodoc @internal -class ProphotoRgbColorSpace extends ColorSpace { +final class ProphotoRgbColorSpace extends ColorSpace { bool get isBoundedInternal => true; const ProphotoRgbColorSpace() : super('prophoto-rgb', rgbChannels); diff --git a/lib/src/value/color/space/rec2020.dart b/lib/src/value/color/space/rec2020.dart index 414977cab..ca5dcf0e5 100644 --- a/lib/src/value/color/space/rec2020.dart +++ b/lib/src/value/color/space/rec2020.dart @@ -23,7 +23,7 @@ const _beta = 0.018053968510807; /// /// @nodoc @internal -class Rec2020ColorSpace extends ColorSpace { +final class Rec2020ColorSpace extends ColorSpace { bool get isBoundedInternal => true; const Rec2020ColorSpace() : super('rec2020', rgbChannels); diff --git a/lib/src/value/color/space/rgb.dart b/lib/src/value/color/space/rgb.dart index ca2ba3187..9933f9cea 100644 --- a/lib/src/value/color/space/rgb.dart +++ b/lib/src/value/color/space/rgb.dart @@ -13,7 +13,7 @@ import 'utils.dart'; /// /// @nodoc @internal -class RgbColorSpace extends ColorSpace { +final class RgbColorSpace extends ColorSpace { bool get isBoundedInternal => true; bool get isLegacyInternal => true; @@ -24,9 +24,14 @@ class RgbColorSpace extends ColorSpace { LinearChannel('blue', 0, 255) ]); - SassColor convert(ColorSpace dest, double red, double green, double blue, - double alpha) => - ColorSpace.srgb.convert(dest, red / 255, green / 255, blue / 255, alpha); + SassColor convert(ColorSpace dest, double? red, double? green, double? blue, + double? alpha) => + ColorSpace.srgb.convert( + dest, + red == null ? null : red / 255, + green == null ? null : green / 255, + blue == null ? null : blue / 255, + alpha); @protected double toLinear(double channel) => srgbAndDisplayP3ToLinear(channel / 255); diff --git a/lib/src/value/color/space/srgb.dart b/lib/src/value/color/space/srgb.dart index 2b35a7c6d..7c107e68b 100644 --- a/lib/src/value/color/space/srgb.dart +++ b/lib/src/value/color/space/srgb.dart @@ -22,15 +22,22 @@ import 'utils.dart'; /// /// @nodoc @internal -class SrgbColorSpace extends ColorSpace { +final class SrgbColorSpace extends ColorSpace { bool get isBoundedInternal => true; const SrgbColorSpace() : super('srgb', rgbChannels); SassColor convert( - ColorSpace dest, double red, double green, double blue, double alpha) { + ColorSpace dest, double? red, double? green, double? blue, double? alpha, + {bool missingLightness = false, + bool missingChroma = false, + bool missingHue = false}) { switch (dest) { case ColorSpace.hsl || ColorSpace.hwb: + red ??= 0; + green ??= 0; + blue ??= 0; + // Algorithm from https://en.wikipedia.org/wiki/HSL_and_HSV#RGB_to_HSL_and_HSV var max = math.max(math.max(red, green), blue); var min = math.min(math.min(red, green), blue); @@ -66,30 +73,39 @@ class SrgbColorSpace extends ColorSpace { return SassColor.forSpaceInternal( dest, - saturation == 0 || saturation == null ? null : hue, - saturation, - lightness, + missingHue || saturation == 0 || saturation == null ? null : hue, + missingChroma ? null : saturation, + missingLightness ? null : lightness, alpha); } else { var whiteness = fuzzyClamp(min * 100, 0, 100); var blackness = fuzzyClamp(100 - max * 100, 0, 100); return SassColor.forSpaceInternal( dest, - fuzzyEquals(whiteness + blackness, 100) ? null : hue, + missingHue || fuzzyEquals(whiteness + blackness, 100) + ? null + : hue, whiteness, blackness, alpha); } case ColorSpace.rgb: - return SassColor.rgb(red * 255, green * 255, blue * 255, alpha); + return SassColor.rgb( + red == null ? null : red * 255, + green == null ? null : green * 255, + blue == null ? null : blue * 255, + alpha); case ColorSpace.srgbLinear: - return SassColor.forSpaceInternal( - dest, toLinear(red), toLinear(green), toLinear(blue), alpha); + return SassColor.forSpaceInternal(dest, red.andThen(toLinear), + green.andThen(toLinear), blue.andThen(toLinear), alpha); default: - return super.convert(dest, red, green, blue, alpha); + return super.convertLinear(dest, red, green, blue, alpha, + missingLightness: missingLightness, + missingChroma: missingChroma, + missingHue: missingHue); } } diff --git a/lib/src/value/color/space/srgb_linear.dart b/lib/src/value/color/space/srgb_linear.dart index b9a0fd13a..3e5151da4 100644 --- a/lib/src/value/color/space/srgb_linear.dart +++ b/lib/src/value/color/space/srgb_linear.dart @@ -8,6 +8,7 @@ import 'dart:typed_data'; import 'package:meta/meta.dart'; +import '../../../util/nullable.dart'; import '../../color.dart'; import '../conversions.dart'; import 'utils.dart'; @@ -18,13 +19,13 @@ import 'utils.dart'; /// /// @nodoc @internal -class SrgbLinearColorSpace extends ColorSpace { +final class SrgbLinearColorSpace extends ColorSpace { bool get isBoundedInternal => true; const SrgbLinearColorSpace() : super('srgb-linear', rgbChannels); - SassColor convert(ColorSpace dest, double red, double green, double blue, - double alpha) => + SassColor convert(ColorSpace dest, double? red, double? green, double? blue, + double? alpha) => switch (dest) { ColorSpace.rgb || ColorSpace.hsl || @@ -32,9 +33,9 @@ class SrgbLinearColorSpace extends ColorSpace { ColorSpace.srgb => ColorSpace.srgb.convert( dest, - srgbAndDisplayP3FromLinear(red), - srgbAndDisplayP3FromLinear(green), - srgbAndDisplayP3FromLinear(blue), + red.andThen(srgbAndDisplayP3FromLinear), + green.andThen(srgbAndDisplayP3FromLinear), + blue.andThen(srgbAndDisplayP3FromLinear), alpha), _ => super.convert(dest, red, green, blue, alpha) }; diff --git a/lib/src/value/color/space/utils.dart b/lib/src/value/color/space/utils.dart index 427da3188..87dedc9c8 100644 --- a/lib/src/value/color/space/utils.dart +++ b/lib/src/value/color/space/utils.dart @@ -68,16 +68,26 @@ double srgbAndDisplayP3FromLinear(double channel) { } /// Converts a Lab or OKLab color to LCH or OKLCH, respectively. +/// +/// The [missingChroma] and [missingHue] arguments indicate whether this came +/// from a color that was missing its chroma or hue channels, respectively. SassColor labToLch( - ColorSpace dest, double lightness, double a, double b, double alpha) { + ColorSpace dest, double? lightness, double? a, double? b, double? alpha, + {bool missingChroma = false, bool missingHue = false}) { // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code - if (fuzzyEquals(lightness, 0)) { + if (lightness == null || fuzzyEquals(lightness, 0)) { return SassColor.forSpaceInternal(dest, 0, null, null, alpha); } - var chroma = math.sqrt(math.pow(a, 2) + math.pow(b, 2)); - var hue = fuzzyEquals(chroma, 0) ? null : math.atan2(b, a) * 180 / math.pi; + var chroma = math.sqrt(math.pow(a ?? 0, 2) + math.pow(b ?? 0, 2)); + var hue = missingHue || fuzzyEquals(chroma, 0) + ? null + : math.atan2(b ?? 0, a ?? 0) * 180 / math.pi; - return SassColor.forSpaceInternal(dest, lightness, chroma, - hue == null || hue >= 0 ? hue : hue + 360, alpha); + return SassColor.forSpaceInternal( + dest, + lightness, + missingChroma ? null : chroma, + hue == null || hue >= 0 ? hue : hue + 360, + alpha); } diff --git a/lib/src/value/color/space/xyz_d50.dart b/lib/src/value/color/space/xyz_d50.dart index d135a85b1..fab064cb9 100644 --- a/lib/src/value/color/space/xyz_d50.dart +++ b/lib/src/value/color/space/xyz_d50.dart @@ -19,26 +19,42 @@ import 'utils.dart'; /// /// @nodoc @internal -class XyzD50ColorSpace extends ColorSpace { +final class XyzD50ColorSpace extends ColorSpace { bool get isBoundedInternal => false; const XyzD50ColorSpace() : super('xyz-d50', xyzChannels); SassColor convert( - ColorSpace dest, double x, double y, double z, double alpha) { + ColorSpace dest, double? x, double? y, double? z, double? alpha, + {bool missingLightness = false, + bool missingChroma = false, + bool missingHue = false, + bool missingA = false, + bool missingB = false}) { switch (dest) { case ColorSpace.lab || ColorSpace.lch: // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code // and http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html - var f0 = _convertComponentToLabF(x / d50[0]); - var f1 = _convertComponentToLabF(y / d50[1]); - var f2 = _convertComponentToLabF(z / d50[2]); + var f0 = _convertComponentToLabF((x ?? 0) / d50[0]); + var f1 = _convertComponentToLabF((y ?? 0) / d50[1]); + var f2 = _convertComponentToLabF((z ?? 0) / d50[2]); + var lightness = missingLightness ? null : (116 * f1) - 16; + var a = 500 * (f0 - f1); + var b = 200 * (f1 - f2); - return ColorSpace.lab.convert( - dest, (116 * f1) - 16, 500 * (f0 - f1), 200 * (f1 - f2), alpha); + return dest == ColorSpace.lab + ? SassColor.lab( + lightness, missingA ? null : a, missingB ? null : b, alpha) + : labToLch(ColorSpace.lch, lightness, a, b, alpha, + missingChroma: missingChroma, missingHue: missingHue); default: - return super.convert(dest, x, y, z, alpha); + return super.convertLinear(dest, x, y, z, alpha, + missingLightness: missingLightness, + missingChroma: missingChroma, + missingHue: missingHue, + missingA: missingA, + missingB: missingB); } } diff --git a/lib/src/value/color/space/xyz_d65.dart b/lib/src/value/color/space/xyz_d65.dart index 979e7b126..4915a8cbc 100644 --- a/lib/src/value/color/space/xyz_d65.dart +++ b/lib/src/value/color/space/xyz_d65.dart @@ -16,7 +16,7 @@ import 'utils.dart'; /// /// @nodoc @internal -class XyzD65ColorSpace extends ColorSpace { +final class XyzD65ColorSpace extends ColorSpace { bool get isBoundedInternal => false; const XyzD65ColorSpace() : super('xyz', xyzChannels); From 1870b61ec9169bf993b5b7e833f1bdcde987bbc5 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Wed, 18 Oct 2023 17:57:10 -0700 Subject: [PATCH 27/56] Don't treat max/min lightness as making components powerless See sass/sass#3654 --- lib/src/value/color.dart | 19 +++---------------- lib/src/value/color/space/lms.dart | 4 ++-- lib/src/value/color/space/utils.dart | 4 ---- 3 files changed, 5 insertions(+), 22 deletions(-) diff --git a/lib/src/value/color.dart b/lib/src/value/color.dart index 36d5ab8fc..b76e5d34f 100644 --- a/lib/src/value/color.dart +++ b/lib/src/value/color.dart @@ -71,7 +71,7 @@ class SassColor extends Value { /// @nodoc @internal bool get isChannel0Powerless => switch (space) { - ColorSpace.hsl => fuzzyEquals(channel1, 0) || fuzzyEquals(channel2, 0), + ColorSpace.hsl => fuzzyEquals(channel1, 0), ColorSpace.hwb => fuzzyEquals(channel1 + channel2, 100), _ => false }; @@ -109,15 +109,7 @@ class SassColor extends Value { /// /// @nodoc @internal - bool get isChannel1Powerless => switch (space) { - ColorSpace.hsl => fuzzyEquals(channel2, 0), - ColorSpace.lab || - ColorSpace.oklab || - ColorSpace.lch || - ColorSpace.oklch => - fuzzyEquals(channel0, 0) || fuzzyEquals(channel0, 100), - _ => false - }; + final bool isChannel1Powerless = false; /// This color's second channel. /// @@ -144,12 +136,7 @@ class SassColor extends Value { /// @nodoc @internal bool get isChannel2Powerless => switch (space) { - ColorSpace.lab || - ColorSpace.oklab => - fuzzyEquals(channel0, 0) || fuzzyEquals(channel0, 100), - ColorSpace.lch || ColorSpace.oklch => fuzzyEquals(channel0, 0) || - fuzzyEquals(channel0, 100) || - fuzzyEquals(channel1, 0), + ColorSpace.lch || ColorSpace.oklch => fuzzyEquals(channel1, 0), _ => false }; diff --git a/lib/src/value/color/space/lms.dart b/lib/src/value/color/space/lms.dart index 675bbe74b..c05b880b4 100644 --- a/lib/src/value/color/space/lms.dart +++ b/lib/src/value/color/space/lms.dart @@ -51,12 +51,12 @@ final class LmsColorSpace extends ColorSpace { return SassColor.oklab( missingLightness ? null : lightness, - missingA || fuzzyEquals(lightness, 0) + missingA ? null : lmsToOklab[3] * longScaled + lmsToOklab[4] * mediumScaled + lmsToOklab[5] * shortScaled, - missingB || fuzzyEquals(lightness, 0) + missingB ? null : lmsToOklab[6] * longScaled + lmsToOklab[7] * mediumScaled + diff --git a/lib/src/value/color/space/utils.dart b/lib/src/value/color/space/utils.dart index 87dedc9c8..d5ecf2eb4 100644 --- a/lib/src/value/color/space/utils.dart +++ b/lib/src/value/color/space/utils.dart @@ -75,10 +75,6 @@ SassColor labToLch( ColorSpace dest, double? lightness, double? a, double? b, double? alpha, {bool missingChroma = false, bool missingHue = false}) { // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code - if (lightness == null || fuzzyEquals(lightness, 0)) { - return SassColor.forSpaceInternal(dest, 0, null, null, alpha); - } - var chroma = math.sqrt(math.pow(a ?? 0, 2) + math.pow(b ?? 0, 2)); var hue = missingHue || fuzzyEquals(chroma, 0) ? null From 930c18c7a19cb05ac6d2cb052589b8791b698ca1 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Thu, 19 Oct 2023 14:56:18 -0700 Subject: [PATCH 28/56] Return legacy colors with missing channels as-is from to-space() --- lib/src/functions/color.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index 9b780243f..5c1c77283 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -432,7 +432,8 @@ final module = BuiltInModule("color", functions: [ (converted.isChannel0Missing || converted.isChannel1Missing || converted.isChannel2Missing || - converted.isAlphaMissing) + converted.isAlphaMissing) && + converted.space != (arguments[0] as SassColor).space ? SassColor.forSpaceInternal(converted.space, converted.channel0, converted.channel1, converted.channel2, converted.alpha) : converted; From bf58324451bc53f8b6e152e1d690610662a103d9 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Thu, 16 Nov 2023 16:16:49 -0800 Subject: [PATCH 29/56] Poke CI From c91178dbb5b202a97f32e97998f1c58218e812be Mon Sep 17 00:00:00 2001 From: James Stuckey Weber Date: Fri, 17 Nov 2023 18:27:54 -0500 Subject: [PATCH 30/56] [Color 4] Dart JS API implementation (#2117) Co-authored-by: Jonny Gerig Meyer Co-authored-by: Natalie Weizenbaum --- CHANGELOG.md | 26 ++ lib/src/deprecation.dart | 4 + lib/src/embedded/protofier.dart | 111 ++++++-- lib/src/evaluation_context.dart | 21 ++ lib/src/js/utils.dart | 5 + lib/src/js/value/color.dart | 472 ++++++++++++++++++++++++++++--- test/embedded/function_test.dart | 50 +--- tool/grind.dart | 2 +- 8 files changed, 584 insertions(+), 107 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60bb0b156..ef53f976e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,6 +152,32 @@ * Added `InterpolationMethod` and `HueInterpolationMethod` which collectively represent the method to use to interpolate two colors. +### JS API + +* Modify `SassColor` to accept a new `space` option, with support for all the + new color spaces defined in Color Level 4. + +* Add `SassColor.space` which returns a color's color space. + +* Add `SassColor.channels` and `.channelsOrNull` which returns a list of channel + values, with missing channels converted to 0 or exposed as null, respectively. + +* Add `SassColor.isLegacy`, `.isInGamut()`, `.channel()`, `.isChannelMissing()`, + `.isChannelPowerless()`, `.toSpace()`, `.toGamut()`, `.change()`, and + `.interpolate()` which do the same thing as the Sass functions of the + corresponding names. + +* Deprecate `SassColor.red`, `.green`, `.blue`, `.hue`, `.saturation`, + `.lightness`, `.whiteness`, and `.blackness` in favor of + `SassColor.channel()`. + +### Embedded Sass + +* Add `Color` SassScript value, with support for all the new color spaces + defined in Color Level 4. + +* Remove `RgbColor`, `HslColor` and `HwbColor` SassScript values. + ## 1.69.5 ### JS API diff --git a/lib/src/deprecation.dart b/lib/src/deprecation.dart index 367760c1c..432b9c041 100644 --- a/lib/src/deprecation.dart +++ b/lib/src/deprecation.dart @@ -76,6 +76,10 @@ enum Deprecation { deprecatedIn: '1.70.0', description: 'Using global Sass color functions.'), + color4Api('color-4-api', + deprecatedIn: '1.70.0', + description: 'Methods of interacting with legacy SassColors.'), + /// Deprecation for `@import` rules. import.future('import', description: '@import rules.'), diff --git a/lib/src/embedded/protofier.dart b/lib/src/embedded/protofier.dart index 54a4f8693..f039daea2 100644 --- a/lib/src/embedded/protofier.dart +++ b/lib/src/embedded/protofier.dart @@ -53,17 +53,12 @@ final class Protofier { ..quoted = value.hasQuotes; case SassNumber(): result.number = _protofyNumber(value); - case SassColor(space: ColorSpace.hsl): - result.hslColor = Value_HslColor() - ..hue = value.channel('hue') * 1.0 - ..saturation = value.channel('saturation') - ..lightness = value.channel('lightness') - ..alpha = value.alpha * 1.0; case SassColor(): - result.rgbColor = Value_RgbColor() - ..red = value.channel('red').clamp(0, 255).round() - ..green = value.channel('green').clamp(0, 255).round() - ..blue = value.channel('blue').clamp(0, 255).round() + result.color = Value_Color() + ..space = value.space.name + ..channel1 = value.channel0 + ..channel2 = value.channel1 + ..channel3 = value.channel2 ..alpha = value.alpha * 1.0; case SassArgumentList(): _argumentLists.add(value); @@ -181,17 +176,85 @@ final class Protofier { case Value_Value.number: return _deprotofyNumber(value.number); - case Value_Value.rgbColor: - return SassColor.rgb(value.rgbColor.red, value.rgbColor.green, - value.rgbColor.blue, value.rgbColor.alpha); - - case Value_Value.hslColor: - return SassColor.hsl(value.hslColor.hue, value.hslColor.saturation, - value.hslColor.lightness, value.hslColor.alpha); - - case Value_Value.hwbColor: - return SassColor.hwb(value.hwbColor.hue, value.hwbColor.whiteness, - value.hwbColor.blackness, value.hwbColor.alpha); + case Value_Value.color: + var space = ColorSpace.fromName(value.color.space); + switch (space) { + case ColorSpace.rgb: + return SassColor.rgb(value.color.channel1, value.color.channel2, + value.color.channel3, value.color.alpha); + + case ColorSpace.hsl: + return SassColor.hsl(value.color.channel1, value.color.channel2, + value.color.channel3, value.color.alpha); + + case ColorSpace.hwb: + return SassColor.hwb(value.color.channel1, value.color.channel2, + value.color.channel3, value.color.alpha); + + case ColorSpace.lab: + return SassColor.lab(value.color.channel1, value.color.channel2, + value.color.channel3, value.color.alpha); + case ColorSpace.oklab: + return SassColor.oklab(value.color.channel1, value.color.channel2, + value.color.channel3, value.color.alpha); + + case ColorSpace.lch: + return SassColor.lch(value.color.channel1, value.color.channel2, + value.color.channel3, value.color.alpha); + case ColorSpace.oklch: + return SassColor.oklch(value.color.channel1, value.color.channel2, + value.color.channel3, value.color.alpha); + + case ColorSpace.srgb: + return SassColor.srgb(value.color.channel1, value.color.channel2, + value.color.channel3, value.color.alpha); + case ColorSpace.srgbLinear: + return SassColor.srgbLinear( + value.color.channel1, + value.color.channel2, + value.color.channel3, + value.color.alpha); + case ColorSpace.displayP3: + return SassColor.displayP3( + value.color.channel1, + value.color.channel2, + value.color.channel3, + value.color.alpha); + case ColorSpace.a98Rgb: + return SassColor.a98Rgb( + value.color.channel1, + value.color.channel2, + value.color.channel3, + value.color.alpha); + case ColorSpace.prophotoRgb: + return SassColor.prophotoRgb( + value.color.channel1, + value.color.channel2, + value.color.channel3, + value.color.alpha); + case ColorSpace.rec2020: + return SassColor.rec2020( + value.color.channel1, + value.color.channel2, + value.color.channel3, + value.color.alpha); + + case ColorSpace.xyzD50: + return SassColor.xyzD50( + value.color.channel1, + value.color.channel2, + value.color.channel3, + value.color.alpha); + case ColorSpace.xyzD65: + return SassColor.xyzD65( + value.color.channel1, + value.color.channel2, + value.color.channel3, + value.color.alpha); + + default: + throw "Unreachable"; + } case Value_Value.argumentList: if (value.argumentList.id != 0) { @@ -276,10 +339,8 @@ final class Protofier { throw paramsError(error.toString()); } - if (value.whichValue() == Value_Value.rgbColor) { - name = 'RgbColor.$name'; - } else if (value.whichValue() == Value_Value.hslColor) { - name = 'HslColor.$name'; + if (value.whichValue() == Value_Value.color) { + name = 'Color.$name'; } throw paramsError( diff --git a/lib/src/evaluation_context.dart b/lib/src/evaluation_context.dart index 5c1b074a9..9ae8f1380 100644 --- a/lib/src/evaluation_context.dart +++ b/lib/src/evaluation_context.dart @@ -7,6 +7,7 @@ import 'dart:async'; import 'package:source_span/source_span.dart'; import 'deprecation.dart'; +import 'logger.dart'; /// An interface that exposes information about the current Sass evaluation. /// @@ -25,6 +26,16 @@ abstract interface class EvaluationContext { } } + /// The current evaluation context, or null if there isn't a Sass stylesheet + /// currently being evaluated. + static EvaluationContext? get _currentOrNull { + if (Zone.current[#_evaluationContext] case EvaluationContext context) { + return context; + } else { + return null; + } + } + /// Returns the span for the currently executing callable. /// /// For normal exception reporting, this should be avoided in favor of @@ -58,6 +69,16 @@ void warnForDeprecation(String message, Deprecation deprecation) { EvaluationContext.current.warn(message, deprecation); } +/// Prints a deprecation warning with [message] of type [deprecation], +/// using stderr if there is no [EvaluationContext.current]. +void warnForDeprecationFromApi(String message, Deprecation deprecation) { + if (EvaluationContext._currentOrNull case var context?) { + context.warn(message, deprecation); + } else { + Logger.stderr().warnForDeprecation(deprecation, message); + } +} + /// Runs [callback] with [context] as [EvaluationContext.current]. /// /// This is zone-based, so if [callback] is asynchronous [warn] is set for the diff --git a/lib/src/js/utils.dart b/lib/src/js/utils.dart index 687484c9a..349a7b7bb 100644 --- a/lib/src/js/utils.dart +++ b/lib/src/js/utils.dart @@ -27,6 +27,11 @@ bool isUndefined(Object? value) => _isUndefined.call(value) as bool; final _isUndefined = JSFunction("value", "return value === undefined;"); +/// Returns whether or not [value] is the JS `null` value. +bool isNull(Object? value) => _isNull.call(value) as bool; + +final _isNull = JSFunction("value", "return value === null;"); + @JS("Error") external JSClass get jsErrorClass; diff --git a/lib/src/js/value/color.dart b/lib/src/js/value/color.dart index 092e7933e..43d01400c 100644 --- a/lib/src/js/value/color.dart +++ b/lib/src/js/value/color.dart @@ -2,66 +2,364 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'dart:js_util'; + import 'package:js/js.dart'; +import 'package:node_interop/js.dart'; +import '../../deprecation.dart'; +import '../../evaluation_context.dart'; import '../../value.dart'; +import '../immutable.dart'; import '../reflection.dart'; import '../utils.dart'; /// The JavaScript `SassColor` class. final JSClass colorClass = () { - var jsClass = createJSClass('sass.SassColor', (Object self, _Channels color) { - if (color.red != null) { - return SassColor.rgb(color.red!, color.green!, color.blue!, - _handleUndefinedAlpha(color.alpha)); - } else if (color.saturation != null) { - return SassColor.hsl(color.hue!, color.saturation!, color.lightness!, - _handleUndefinedAlpha(color.alpha)); - } else { - return SassColor.hwb(color.hue!, color.whiteness!, color.blackness!, - _handleUndefinedAlpha(color.alpha)); + var jsClass = createJSClass('sass.SassColor', + (Object self, _ConstructionOptions options) { + var constructionSpace = _constructionSpace(options); + switch (constructionSpace) { + case ColorSpace.rgb: + _checkNullAlphaDeprecation(options); + return SassColor.rgb(options.red, options.green, options.blue, + _handleUndefinedAlpha(options.alpha)); + + case ColorSpace.hsl: + _checkNullAlphaDeprecation(options); + return SassColor.hsl(options.hue, options.saturation, options.lightness, + _handleUndefinedAlpha(options.alpha)); + + case ColorSpace.hwb: + _checkNullAlphaDeprecation(options); + return SassColor.hwb(options.hue, options.whiteness, options.blackness, + _handleUndefinedAlpha(options.alpha)); + + case ColorSpace.lab: + return SassColor.lab(options.lightness, options.a, options.b, + _handleUndefinedAlpha(options.alpha)); + case ColorSpace.oklab: + return SassColor.oklab(options.lightness, options.a, options.b, + _handleUndefinedAlpha(options.alpha)); + + case ColorSpace.lch: + return SassColor.lch(options.lightness, options.chroma, options.hue, + _handleUndefinedAlpha(options.alpha)); + case ColorSpace.oklch: + return SassColor.oklch(options.lightness, options.chroma, options.hue, + _handleUndefinedAlpha(options.alpha)); + + case ColorSpace.srgb: + return SassColor.srgb(options.red, options.green, options.blue, + _handleUndefinedAlpha(options.alpha)); + case ColorSpace.srgbLinear: + return SassColor.srgbLinear(options.red, options.green, options.blue, + _handleUndefinedAlpha(options.alpha)); + case ColorSpace.displayP3: + return SassColor.displayP3(options.red, options.green, options.blue, + _handleUndefinedAlpha(options.alpha)); + case ColorSpace.a98Rgb: + return SassColor.a98Rgb(options.red, options.green, options.blue, + _handleUndefinedAlpha(options.alpha)); + case ColorSpace.prophotoRgb: + return SassColor.prophotoRgb(options.red, options.green, options.blue, + _handleUndefinedAlpha(options.alpha)); + case ColorSpace.rec2020: + return SassColor.rec2020(options.red, options.green, options.blue, + _handleUndefinedAlpha(options.alpha)); + + // `xyz` name is mapped to `xyzD65` space. + case ColorSpace.xyzD50: + return SassColor.xyzD50(options.x, options.y, options.z, + _handleUndefinedAlpha(options.alpha)); + case ColorSpace.xyzD65: + return SassColor.xyzD65(options.x, options.y, options.z, + _handleUndefinedAlpha(options.alpha)); + + default: + throw "Unreachable"; } }); - jsClass.defineMethod('change', (SassColor self, _Channels options) { - if (options.whiteness != null || options.blackness != null) { - return self.changeHwb( - hue: options.hue ?? self.hue, - whiteness: options.whiteness ?? self.whiteness, - blackness: options.blackness ?? self.blackness, - alpha: options.alpha ?? self.alpha); - } else if (options.hue != null || - options.saturation != null || - options.lightness != null) { - return self.changeHsl( - hue: options.hue ?? self.hue, - saturation: options.saturation ?? self.saturation, - lightness: options.lightness ?? self.lightness, - alpha: options.alpha ?? self.alpha); - } else if (options.red != null || - options.green != null || - options.blue != null) { - return self.changeChannels({ - if (options.red case var red?) "red": red, - if (options.green case var green?) "green": green, - if (options.blue case var blue?) "blue": blue, - if (options.alpha case var alpha?) "alpha": alpha - }); - } else { - return self.changeAlpha(options.alpha ?? self.alpha); + jsClass.defineMethods({ + 'equals': (SassColor self, Object other) => self == other, + 'hashCode': (SassColor self) => self.hashCode, + 'toSpace': (SassColor self, String space) => _toSpace(self, space), + 'isInGamut': (SassColor self, [String? space]) => + _toSpace(self, space).isInGamut, + 'toGamut': (SassColor self, [String? space]) { + var originalSpace = self.space; + return _toSpace(self, space).toGamut().toSpace(originalSpace); + }, + 'channel': (SassColor self, String channel, [_ChannelOptions? options]) => + _toSpace(self, options?.space).channel(channel), + 'isChannelMissing': (SassColor self, String channel) => + self.isChannelMissing(channel), + 'isChannelPowerless': (SassColor self, String channel, + [_ChannelOptions? options]) => + _toSpace(self, options?.space).isChannelPowerless(channel), + 'change': (SassColor self, _ConstructionOptions options) { + var spaceSetExplicitly = options.space != null; + var space = + spaceSetExplicitly ? ColorSpace.fromName(options.space!) : self.space; + + if (self.isLegacy && !spaceSetExplicitly) { + if (hasProperty(options, 'whiteness') || + hasProperty(options, 'blackness')) { + space = ColorSpace.hwb; + } else if (hasProperty(options, 'hue') && + self.space == ColorSpace.hwb) { + space = ColorSpace.hwb; + } else if (hasProperty(options, 'hue') || + hasProperty(options, 'saturation') || + hasProperty(options, 'lightness')) { + space = ColorSpace.hsl; + } else if (hasProperty(options, 'red')) { + space = ColorSpace.rgb; + } + if (space != self.space) { + warnForDeprecationFromApi( + "Changing a channel not in this color's space without explicitly specifying " + "the `space` option is deprecated." + "\n" + "More info: https://sass-lang.com/d/color-4-api", + Deprecation.color4Api); + } + } + + for (final key in objectKeys(options)) { + if (['alpha', 'space'].contains(key)) continue; + if (!space.channels.any((channel) => channel.name == key)) { + jsThrow(JsError("`$key` is not a valid channel in `$space`.")); + } + } + + var color = self.toSpace(space); + + SassColor changedColor; + + double? changedValue(String channel) { + return _changeComponentValue(color, channel, options); + } + + switch (space) { + case ColorSpace.hsl when spaceSetExplicitly: + changedColor = SassColor.hsl( + changedValue('hue'), + changedValue('saturation'), + changedValue('lightness'), + changedValue('alpha')); + break; + + case ColorSpace.hsl: + if (isNull(options.hue)) { + _emitColor4ApiNullDeprecation('hue'); + } else if (isNull(options.saturation)) { + _emitColor4ApiNullDeprecation('saturation'); + } else if (isNull(options.lightness)) { + _emitColor4ApiNullDeprecation('lightness'); + } + if (isNull(options.alpha)) { + _emitNullAlphaDeprecation(); + } + changedColor = SassColor.hsl( + options.hue ?? color.channel('hue'), + options.saturation ?? color.channel('saturation'), + options.lightness ?? color.channel('lightness'), + options.alpha ?? color.channel('alpha')); + break; + + case ColorSpace.hwb when spaceSetExplicitly: + changedColor = SassColor.hwb( + changedValue('hue'), + changedValue('whiteness'), + changedValue('blackness'), + changedValue('alpha')); + break; + + case ColorSpace.hwb: + if (isNull(options.hue)) { + _emitColor4ApiNullDeprecation('hue'); + } else if (isNull(options.whiteness)) { + _emitColor4ApiNullDeprecation('whiteness'); + } else if (isNull(options.blackness)) { + _emitColor4ApiNullDeprecation('blackness'); + } + if (isNull(options.alpha)) _emitNullAlphaDeprecation(); + changedColor = SassColor.hwb( + options.hue ?? color.channel('hue'), + options.whiteness ?? color.channel('whiteness'), + options.blackness ?? color.channel('blackness'), + options.alpha ?? color.channel('alpha')); + + break; + + case ColorSpace.rgb when spaceSetExplicitly: + changedColor = SassColor.rgb( + changedValue('red'), + changedValue('green'), + changedValue('blue'), + changedValue('alpha')); + break; + + case ColorSpace.rgb: + if (isNull(options.red)) { + _emitColor4ApiNullDeprecation('red'); + } else if (isNull(options.green)) { + _emitColor4ApiNullDeprecation('green'); + } else if (isNull(options.blue)) { + _emitColor4ApiNullDeprecation('blue'); + } + if (isNull(options.alpha)) { + _emitNullAlphaDeprecation(); + } + changedColor = SassColor.rgb( + options.red ?? color.channel('red'), + options.green ?? color.channel('green'), + options.blue ?? color.channel('blue'), + options.alpha ?? color.channel('alpha')); + break; + + case ColorSpace.lab: + changedColor = SassColor.lab(changedValue('lightness'), + changedValue('a'), changedValue('b'), changedValue('alpha')); + break; + + case ColorSpace.oklab: + changedColor = SassColor.oklab(changedValue('lightness'), + changedValue('a'), changedValue('b'), changedValue('alpha')); + break; + + case ColorSpace.lch: + changedColor = SassColor.lch( + changedValue('lightness'), + changedValue('chroma'), + changedValue('hue'), + changedValue('alpha')); + break; + case ColorSpace.oklch: + changedColor = SassColor.oklch( + changedValue('lightness'), + changedValue('chroma'), + changedValue('hue'), + changedValue('alpha')); + break; + + case ColorSpace.a98Rgb: + changedColor = SassColor.a98Rgb( + changedValue('red'), + changedValue('green'), + changedValue('blue'), + changedValue('alpha')); + break; + case ColorSpace.displayP3: + changedColor = SassColor.displayP3( + changedValue('red'), + changedValue('green'), + changedValue('blue'), + changedValue('alpha')); + break; + case ColorSpace.prophotoRgb: + changedColor = SassColor.prophotoRgb( + changedValue('red'), + changedValue('green'), + changedValue('blue'), + changedValue('alpha')); + break; + case ColorSpace.rec2020: + changedColor = SassColor.rec2020( + changedValue('red'), + changedValue('green'), + changedValue('blue'), + changedValue('alpha')); + break; + case ColorSpace.srgb: + changedColor = SassColor.srgb( + changedValue('red'), + changedValue('green'), + changedValue('blue'), + changedValue('alpha')); + break; + case ColorSpace.srgbLinear: + changedColor = SassColor.srgbLinear( + changedValue('red'), + changedValue('green'), + changedValue('blue'), + changedValue('alpha')); + break; + + case ColorSpace.xyzD50: + changedColor = SassColor.forSpaceInternal(space, changedValue('x'), + changedValue('y'), changedValue('z'), changedValue('alpha')); + break; + case ColorSpace.xyzD65: + changedColor = SassColor.forSpaceInternal(space, changedValue('x'), + changedValue('y'), changedValue('z'), changedValue('alpha')); + break; + + default: + throw "No space set"; + } + + return changedColor.toSpace(self.space); + }, + 'interpolate': + (SassColor self, SassColor color2, _InterpolationOptions options) { + InterpolationMethod interpolationMethod; + + if (options.method case var method?) { + var hue = HueInterpolationMethod.values.byName(method); + interpolationMethod = InterpolationMethod(self.space, hue); + } else if (!self.space.isPolar) { + interpolationMethod = InterpolationMethod(self.space); + } else { + interpolationMethod = + InterpolationMethod(self.space, HueInterpolationMethod.shorter); + } + + return self.interpolate(color2, interpolationMethod, + weight: options.weight); } }); jsClass.defineGetters({ - 'red': (SassColor self) => self.red, - 'green': (SassColor self) => self.green, - 'blue': (SassColor self) => self.blue, - 'hue': (SassColor self) => self.hue, - 'saturation': (SassColor self) => self.saturation, - 'lightness': (SassColor self) => self.lightness, - 'whiteness': (SassColor self) => self.whiteness, - 'blackness': (SassColor self) => self.blackness, + 'red': (SassColor self) { + _emitColor4ApiChannelDeprecation('red'); + return self.red; + }, + 'green': (SassColor self) { + _emitColor4ApiChannelDeprecation('green'); + return self.green; + }, + 'blue': (SassColor self) { + _emitColor4ApiChannelDeprecation('blue'); + return self.blue; + }, + 'hue': (SassColor self) { + _emitColor4ApiChannelDeprecation('hue'); + return self.hue; + }, + 'saturation': (SassColor self) { + _emitColor4ApiChannelDeprecation('saturation'); + return self.saturation; + }, + 'lightness': (SassColor self) { + _emitColor4ApiChannelDeprecation('lightness'); + return self.lightness; + }, + 'whiteness': (SassColor self) { + _emitColor4ApiChannelDeprecation('whiteness'); + return self.whiteness; + }, + 'blackness': (SassColor self) { + _emitColor4ApiChannelDeprecation('blackness'); + return self.blackness; + }, 'alpha': (SassColor self) => self.alpha, + 'space': (SassColor self) => self.space.name, + 'isLegacy': (SassColor self) => self.isLegacy, + 'channelsOrNull': (SassColor self) => ImmutableList(self.channelsOrNull), + 'channels': (SassColor self) => ImmutableList(self.channels) }); getJSClass(SassColor.rgb(0, 0, 0)).injectSuperclass(jsClass); @@ -72,7 +370,66 @@ final JSClass colorClass = () { /// /// This ensures that an explicitly null alpha will be treated as a missing /// component. -num? _handleUndefinedAlpha(num? alpha) => isUndefined(alpha) ? 1 : alpha; +double? _handleUndefinedAlpha(double? alpha) => isUndefined(alpha) ? 1 : alpha; + +/// This procedure takes a `channel` name, an object `changes` and a SassColor +/// `initial` and returns the result of applying the change for `channel` to +/// `initial`. +double? _changeComponentValue( + SassColor initial, String channel, _ConstructionOptions changes) => + hasProperty(changes, channel) && !isUndefined(getProperty(changes, channel)) + ? getProperty(changes, channel) + : initial.channel(channel); + +/// Determines the construction space based on the provided options. +ColorSpace _constructionSpace(_ConstructionOptions options) { + if (options.space != null) return ColorSpace.fromName(options.space!); + if (options.red != null) return ColorSpace.rgb; + if (options.saturation != null) return ColorSpace.hsl; + if (options.whiteness != null) return ColorSpace.hwb; + throw "No color space found"; +} + +// Return a SassColor in a named space, or in its original space. +SassColor _toSpace(SassColor self, String? space) { + return self.toSpace(ColorSpace.fromName(space ?? self.space.name)); +} + +// If alpha is explicitly null and space is not set, emit deprecation. +void _checkNullAlphaDeprecation(_ConstructionOptions options) { + if (!isUndefined(options.alpha) && + identical(options.alpha, null) && + identical(options.space, null)) { + _emitNullAlphaDeprecation(); + } +} + +// Warn users about null-alpha deprecation. +void _emitNullAlphaDeprecation() { + warnForDeprecationFromApi( + "Passing `alpha: null` without setting `space` is deprecated." + "\n" + "More info: https://sass-lang.com/d/null-alpha", + Deprecation.nullAlpha); +} + +// Warn users about `null` channel values without setting `space`. +void _emitColor4ApiNullDeprecation(String name) { + warnForDeprecationFromApi( + "Passing `$name: null` without setting `space` is deprecated." + "\n" + "More info: https://sass-lang.com/d/color-4-api", + Deprecation.color4Api); +} + +// Warn users about legacy color channel getters. +void _emitColor4ApiChannelDeprecation(String name) { + warnForDeprecationFromApi( + "$name is deprecated, use `channel` instead." + "\n" + "More info: https://sass-lang.com/d/color-4-api", + Deprecation.color4Api); +} @JS() @anonymous @@ -86,4 +443,29 @@ class _Channels { external double? get whiteness; external double? get blackness; external double? get alpha; + external double? get a; + external double? get b; + external double? get x; + external double? get y; + external double? get z; + external double? get chroma; +} + +@JS() +@anonymous +class _ConstructionOptions extends _Channels { + external String? get space; +} + +@JS() +@anonymous +class _ChannelOptions { + String? space; +} + +@JS() +@anonymous +class _InterpolationOptions { + external double? weight; + external String? method; } diff --git a/test/embedded/function_test.dart b/test/embedded/function_test.dart index 9c8f225ac..db8917ff3 100644 --- a/test/embedded/function_test.dart +++ b/test/embedded/function_test.dart @@ -1620,46 +1620,22 @@ void main() { group("a color", () { test("with RGB alpha below 0", () async { await _expectDeprotofyError(_rgb(0, 0, 0, -0.1), - "RgbColor.alpha must be between 0 and 1, was -0.1"); + "Color.alpha must be between 0 and 1, was -0.1"); }); test("with RGB alpha above 1", () async { await _expectDeprotofyError(_rgb(0, 0, 0, 1.1), - "RgbColor.alpha must be between 0 and 1, was 1.1"); - }); - - test("with saturation below 0", () async { - await _expectDeprotofyError(_hsl(0, -0.1, 0, 1.0), - "HslColor.saturation must be between 0 and 100, was -0.1"); - }); - - test("with saturation above 100", () async { - await _expectDeprotofyError( - _hsl(0, 100.1, 0, 1.0), - "HslColor.saturation must be between 0 and 100, was " - "100.1"); - }); - - test("with lightness below 0", () async { - await _expectDeprotofyError(_hsl(0, 0, -0.1, 1.0), - "HslColor.lightness must be between 0 and 100, was -0.1"); - }); - - test("with lightness above 100", () async { - await _expectDeprotofyError( - _hsl(0, 0, 100.1, 1.0), - "HslColor.lightness must be between 0 and 100, was " - "100.1"); + "Color.alpha must be between 0 and 1, was 1.1"); }); test("with HSL alpha below 0", () async { await _expectDeprotofyError(_hsl(0, 0, 0, -0.1), - "HslColor.alpha must be between 0 and 1, was -0.1"); + "Color.alpha must be between 0 and 1, was -0.1"); }); test("with HSL alpha above 1", () async { await _expectDeprotofyError(_hsl(0, 0, 0, 1.1), - "HslColor.alpha must be between 0 and 1, was 1.1"); + "Color.alpha must be between 0 and 1, was 1.1"); }); }); @@ -1923,18 +1899,20 @@ Future _roundTrip(Value value) async { /// Returns a [Value] that's an RGB color with the given fields. Value _rgb(int red, int green, int blue, double alpha) => Value() - ..rgbColor = (Value_RgbColor() - ..red = red - ..green = green - ..blue = blue + ..color = (Value_Color() + ..space = 'rgb' + ..channel1 = red * 1.0 + ..channel2 = green * 1.0 + ..channel3 = blue * 1.0 ..alpha = alpha); /// Returns a [Value] that's an HSL color with the given fields. Value _hsl(num hue, num saturation, num lightness, double alpha) => Value() - ..hslColor = (Value_HslColor() - ..hue = hue * 1.0 - ..saturation = saturation * 1.0 - ..lightness = lightness * 1.0 + ..color = (Value_Color() + ..space = 'hsl' + ..channel1 = hue * 1.0 + ..channel2 = saturation * 1.0 + ..channel3 = lightness * 1.0 ..alpha = alpha); /// Asserts that [process] emits a [CompileFailure] result with the given diff --git a/tool/grind.dart b/tool/grind.dart index 425730c03..9d320742c 100644 --- a/tool/grind.dart +++ b/tool/grind.dart @@ -201,7 +201,7 @@ String _readAndResolveMarkdown(String path) => File(path) return included.substring(headerMatch.end, sectionEnd).trim(); }); -/// Returns a map from JS type declaration file names to their contnets. +/// Returns a map from JS type declaration file names to their contents. Map _fetchJSTypes() { var languageRepo = cloneOrCheckout("https://github.com/sass/sass", "main", name: 'language'); From 70b9abbab833d313525a91d9945eb2b5806ff34c Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Wed, 27 Mar 2024 12:31:27 -0700 Subject: [PATCH 31/56] [Color 4] Update behavior to match sass/sass#3819 --- lib/src/functions/color.dart | 77 ++++++----- lib/src/util/number.dart | 5 + lib/src/value/color.dart | 194 ++++++++++----------------- lib/src/value/color/channel.dart | 13 +- lib/src/value/color/space.dart | 19 +-- lib/src/value/color/space/hsl.dart | 7 +- lib/src/value/color/space/hwb.dart | 1 - lib/src/value/color/space/lab.dart | 3 +- lib/src/value/color/space/lch.dart | 5 +- lib/src/value/color/space/oklab.dart | 5 +- lib/src/value/color/space/oklch.dart | 7 +- lib/src/value/color/space/srgb.dart | 43 +++--- lib/src/visitor/serialize.dart | 43 ++++++ 13 files changed, 217 insertions(+), 205 deletions(-) diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index 5c1c77283..c607317c1 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. import 'dart:collection'; +import 'dart:math' as math; import 'package:collection/collection.dart'; @@ -769,7 +770,8 @@ SassColor _changeColor( return alphaArg.value; } }) ?? - color.alpha); + color.alpha, + clamp: false); } /// Returns a copy of [color] with its channel values scaled by the values in @@ -862,10 +864,15 @@ double _adjustChannel(ColorSpace space, ColorChannel channel, double oldValue, adjustmentArg = SassNumber(adjustmentArg.value); } - var result = oldValue + _channelFromValue(channel, adjustmentArg)!; - return space.isStrictlyBounded && channel is LinearChannel - ? fuzzyClamp(result, channel.min, channel.max) - : result; + var result = + oldValue + _channelFromValue(channel, adjustmentArg, clamp: false)!; + return switch (channel) { + LinearChannel(lowerClamped: true, :var min) when result < min => + oldValue < min ? math.max(oldValue, result) : min, + LinearChannel(upperClamped: true, :var max) when result > max => + oldValue > max ? math.min(oldValue, result) : max, + _ => result + }; } /// Given a map of arguments passed to [_updateComponents] for a legacy color, @@ -1321,24 +1328,28 @@ Value? _parseNumberOrNone(String text) { /// Creates a [SassColor] for the given [space] from the given channel values, /// or throws a [SassScriptException] if the channel values are invalid. +/// +/// If [clamp] is true, this will clamp any clamped channels. SassColor _colorFromChannels(ColorSpace space, SassNumber? channel0, SassNumber? channel1, SassNumber? channel2, double? alpha, - {bool fromRgbFunction = false}) { + {bool clamp = true, bool fromRgbFunction = false}) { switch (space) { case ColorSpace.hsl: if (channel1 != null) _checkPercent(channel1, 'saturation'); if (channel2 != null) _checkPercent(channel2, 'lightness'); return SassColor.hsl( channel0.andThen((channel0) => _angleValue(channel0, 'hue')), - channel1?.value.clamp(0, 100).toDouble(), - channel2?.value.clamp(0, 100).toDouble(), + _channelFromValue(space.channels[1], _forcePercent(channel1), + clamp: clamp), + _channelFromValue(space.channels[2], _forcePercent(channel2), + clamp: clamp), alpha); case ColorSpace.hwb: channel1?.assertUnit('%', 'whiteness'); channel2?.assertUnit('%', 'blackness'); - var whiteness = channel1?.value.clamp(0, 100).toDouble(); - var blackness = channel2?.value.clamp(0, 100).toDouble(); + var whiteness = channel1?.value.toDouble(); + var blackness = channel2?.value.toDouble(); if (whiteness != null && blackness != null && @@ -1356,44 +1367,48 @@ SassColor _colorFromChannels(ColorSpace space, SassNumber? channel0, case ColorSpace.rgb: return SassColor.rgbInternal( - _channelFromValue(space.channels[0], channel0), - _channelFromValue(space.channels[1], channel1), - _channelFromValue(space.channels[2], channel2), + _channelFromValue(space.channels[0], channel0, clamp: clamp), + _channelFromValue(space.channels[1], channel1, clamp: clamp), + _channelFromValue(space.channels[2], channel2, clamp: clamp), alpha, fromRgbFunction ? ColorFormat.rgbFunction : null); - case ColorSpace.lab || - ColorSpace.lch || - ColorSpace.oklab || - ColorSpace.oklch: - return SassColor.forSpaceInternal( - space, - _channelFromValue(space.channels[0], channel0).andThen((lightness) => - fuzzyClamp( - lightness, 0, (space.channels[0] as LinearChannel).max)), - _channelFromValue(space.channels[1], channel1), - _channelFromValue(space.channels[2], channel2), - alpha); - default: return SassColor.forSpaceInternal( space, - _channelFromValue(space.channels[0], channel0), - _channelFromValue(space.channels[1], channel1), - _channelFromValue(space.channels[2], channel2), + _channelFromValue(space.channels[0], channel0, clamp: clamp), + _channelFromValue(space.channels[1], channel1, clamp: clamp), + _channelFromValue(space.channels[2], channel2, clamp: clamp), alpha); } } +/// Returns [number] with unit `'%'` regardless of its original unit. +SassNumber? _forcePercent(SassNumber? number) => switch (number) { + null => null, + SassNumber(numeratorUnits: ['%'], denominatorUnits: []) => number, + _ => SassNumber(number.value, '%') + }; + /// Converts a channel value from a [SassNumber] into a [double] according to /// [channel]. -double? _channelFromValue(ColorChannel channel, SassNumber? value) => +/// +/// If [clamp] is true, this clamps [value] according to [channel]'s clamping +/// rules. +double? _channelFromValue(ColorChannel channel, SassNumber? value, + {bool clamp = true}) => value.andThen((value) => switch (channel) { LinearChannel(requiresPercent: true) when !value.hasUnit('%') => throw SassScriptException( 'Expected $value to have unit "%".', channel.name), - LinearChannel() => + LinearChannel(lowerClamped: false, upperClamped: false) => + _percentageOrUnitless(value, channel.max, channel.name), + LinearChannel() when !clamp => _percentageOrUnitless(value, channel.max, channel.name), + LinearChannel(:var lowerClamped, :var upperClamped) => + _percentageOrUnitless(value, channel.max, channel.name).clamp( + lowerClamped ? channel.min : double.negativeInfinity, + upperClamped ? channel.max : double.infinity), _ => value.coerceValueToUnit('deg', channel.name) % 360 }); diff --git a/lib/src/util/number.dart b/lib/src/util/number.dart index 21c5e2680..83138e9d1 100644 --- a/lib/src/util/number.dart +++ b/lib/src/util/number.dart @@ -93,6 +93,11 @@ double fuzzyClamp(double number, double min, double max) { return number; } +/// Returns whether [number] is within [min] and [max] inclusive, using fuzzy +/// equality. +bool fuzzyInRange(double number, num min, num max) => + fuzzyGreaterThanOrEquals(number, min) && fuzzyLessThanOrEquals(number, max); + /// Returns [number] if it's within [min] and [max], or `null` if it's not. /// /// If [number] is [fuzzyEquals] to [min] or [max], it's clamped to the diff --git a/lib/src/value/color.dart b/lib/src/value/color.dart index b76e5d34f..85721d790 100644 --- a/lib/src/value/color.dart +++ b/lib/src/value/color.dart @@ -196,23 +196,24 @@ class SassColor extends Value { /// Whether this color is in-gamut for its color space. bool get isInGamut { - // Strictly-bounded spaces can't even represent out-of-gamut colors, so - // any color that exists must be bounded. - if (!space.isBounded || space.isStrictlyBounded) return true; + if (!space.isBounded) return true; // There aren't (currently) any color spaces that are bounded but not // STRICTLY bounded, and have polar-angle channels. - var channel0Info = space.channels[0] as LinearChannel; - var channel1Info = space.channels[1] as LinearChannel; - var channel2Info = space.channels[2] as LinearChannel; - return fuzzyLessThanOrEquals(channel0, channel0Info.max) && - fuzzyGreaterThanOrEquals(channel0, channel0Info.min) && - fuzzyLessThanOrEquals(channel1, channel1Info.max) && - fuzzyGreaterThanOrEquals(channel1, channel1Info.min) && - fuzzyLessThanOrEquals(channel2, channel2Info.max) && - fuzzyGreaterThanOrEquals(channel2, channel2Info.min); + return _isChannelInGamut(channel0, space.channels[0]) && + _isChannelInGamut(channel1, space.channels[1]) && + _isChannelInGamut(channel2, space.channels[2]); } + /// Returns whether [value] is in-gamut for the given [channel]. + bool _isChannelInGamut(double value, ColorChannel channel) => + switch (channel) { + LinearChannel(:var min, :var max) => + fuzzyLessThanOrEquals(value, max) && + fuzzyGreaterThanOrEquals(value, min), + _ => true + }; + /// This color's red channel, between `0` and `255`. /// /// **Note:** This is rounded to the nearest integer, which may be lossy. Use @@ -272,8 +273,8 @@ class SassColor extends Value { @internal factory SassColor.rgbInternal(num? red, num? green, num? blue, [num? alpha = 1, ColorFormat? format]) => - SassColor.forSpaceInternal(ColorSpace.rgb, red?.toDouble(), - green?.toDouble(), blue?.toDouble(), alpha?.toDouble(), format); + SassColor._forSpace(ColorSpace.rgb, red?.toDouble(), green?.toDouble(), + blue?.toDouble(), alpha?.toDouble(), format); /// Creates a color in [ColorSpace.hsl]. /// @@ -286,14 +287,8 @@ class SassColor extends Value { /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. factory SassColor.hsl(num? hue, num? saturation, num? lightness, [num? alpha = 1]) => - SassColor.forSpaceInternal( - ColorSpace.hsl, - _normalizeHue(hue?.toDouble()), - saturation.andThen((saturation) => - fuzzyAssertRange(saturation.toDouble(), 0, 100, "saturation")), - lightness.andThen((lightness) => - fuzzyAssertRange(lightness.toDouble(), 0, 100, "lightness")), - alpha?.toDouble()); + SassColor.forSpaceInternal(ColorSpace.hsl, hue?.toDouble(), + saturation?.toDouble(), lightness?.toDouble(), alpha?.toDouble()); /// Creates a color in [ColorSpace.hwb]. /// @@ -306,14 +301,8 @@ class SassColor extends Value { /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. factory SassColor.hwb(num? hue, num? whiteness, num? blackness, [num? alpha = 1]) => - SassColor.forSpaceInternal( - ColorSpace.hwb, - _normalizeHue(hue?.toDouble()), - whiteness.andThen((whiteness) => - fuzzyAssertRange(whiteness.toDouble(), 0, 100, "whiteness")), - blackness.andThen((blackness) => - fuzzyAssertRange(blackness.toDouble(), 0, 100, "blackness")), - alpha?.toDouble()); + SassColor.forSpaceInternal(ColorSpace.hwb, hue?.toDouble(), + whiteness?.toDouble(), blackness?.toDouble(), alpha?.toDouble()); /// Creates a color in [ColorSpace.srgb]. /// @@ -326,7 +315,7 @@ class SassColor extends Value { /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. factory SassColor.srgb(double? red, double? green, double? blue, [double? alpha = 1]) => - SassColor.forSpaceInternal(ColorSpace.srgb, red, green, blue, alpha); + SassColor._forSpace(ColorSpace.srgb, red, green, blue, alpha); /// Creates a color in [ColorSpace.srgbLinear]. /// @@ -339,8 +328,7 @@ class SassColor extends Value { /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. factory SassColor.srgbLinear(double? red, double? green, double? blue, [double? alpha = 1]) => - SassColor.forSpaceInternal( - ColorSpace.srgbLinear, red, green, blue, alpha); + SassColor._forSpace(ColorSpace.srgbLinear, red, green, blue, alpha); /// Creates a color in [ColorSpace.displayP3]. /// @@ -353,7 +341,7 @@ class SassColor extends Value { /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. factory SassColor.displayP3(double? red, double? green, double? blue, [double? alpha = 1]) => - SassColor.forSpaceInternal(ColorSpace.displayP3, red, green, blue, alpha); + SassColor._forSpace(ColorSpace.displayP3, red, green, blue, alpha); /// Creates a color in [ColorSpace.a98Rgb]. /// @@ -366,7 +354,7 @@ class SassColor extends Value { /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. factory SassColor.a98Rgb(double? red, double? green, double? blue, [double? alpha = 1]) => - SassColor.forSpaceInternal(ColorSpace.a98Rgb, red, green, blue, alpha); + SassColor._forSpace(ColorSpace.a98Rgb, red, green, blue, alpha); /// Creates a color in [ColorSpace.prophotoRgb]. /// @@ -379,8 +367,7 @@ class SassColor extends Value { /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. factory SassColor.prophotoRgb(double? red, double? green, double? blue, [double? alpha = 1]) => - SassColor.forSpaceInternal( - ColorSpace.prophotoRgb, red, green, blue, alpha); + SassColor._forSpace(ColorSpace.prophotoRgb, red, green, blue, alpha); /// Creates a color in [ColorSpace.rec2020]. /// @@ -393,7 +380,7 @@ class SassColor extends Value { /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. factory SassColor.rec2020(double? red, double? green, double? blue, [double? alpha = 1]) => - SassColor.forSpaceInternal(ColorSpace.rec2020, red, green, blue, alpha); + SassColor._forSpace(ColorSpace.rec2020, red, green, blue, alpha); /// Creates a color in [ColorSpace.xyzD50]. /// @@ -406,7 +393,7 @@ class SassColor extends Value { /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. factory SassColor.xyzD50(double? x, double? y, double? z, [double? alpha = 1]) => - SassColor.forSpaceInternal(ColorSpace.xyzD50, x, y, z, alpha); + SassColor._forSpace(ColorSpace.xyzD50, x, y, z, alpha); /// Creates a color in [ColorSpace.xyzD65]. /// @@ -419,7 +406,7 @@ class SassColor extends Value { /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. factory SassColor.xyzD65(double? x, double? y, double? z, [double? alpha = 1]) => - SassColor.forSpaceInternal(ColorSpace.xyzD65, x, y, z, alpha); + SassColor._forSpace(ColorSpace.xyzD65, x, y, z, alpha); /// Creates a color in [ColorSpace.lab]. /// @@ -432,13 +419,7 @@ class SassColor extends Value { /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. factory SassColor.lab(double? lightness, double? a, double? b, [double? alpha = 1]) => - SassColor.forSpaceInternal( - ColorSpace.lab, - lightness.andThen( - (lightness) => fuzzyAssertRange(lightness, 0, 100, "lightness")), - a, - b, - alpha); + SassColor._forSpace(ColorSpace.lab, lightness, a, b, alpha); /// Creates a color in [ColorSpace.lch]. /// @@ -451,13 +432,7 @@ class SassColor extends Value { /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. factory SassColor.lch(double? lightness, double? chroma, double? hue, [double? alpha = 1]) => - SassColor.forSpaceInternal( - ColorSpace.lch, - lightness.andThen( - (lightness) => fuzzyAssertRange(lightness, 0, 100, "lightness")), - chroma, - _normalizeHue(hue), - alpha); + SassColor.forSpaceInternal(ColorSpace.lch, lightness, chroma, hue, alpha); /// Creates a color in [ColorSpace.oklab]. /// @@ -470,13 +445,7 @@ class SassColor extends Value { /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. factory SassColor.oklab(double? lightness, double? a, double? b, [double? alpha = 1]) => - SassColor.forSpaceInternal( - ColorSpace.oklab, - lightness.andThen( - (lightness) => fuzzyAssertRange(lightness, 0, 100, "lightness")), - a, - b, - alpha); + SassColor._forSpace(ColorSpace.oklab, lightness, a, b, alpha); /// Creates a color in [ColorSpace.oklch]. /// @@ -490,12 +459,7 @@ class SassColor extends Value { factory SassColor.oklch(double? lightness, double? chroma, double? hue, [double? alpha = 1]) => SassColor.forSpaceInternal( - ColorSpace.oklch, - lightness.andThen( - (lightness) => fuzzyAssertRange(lightness, 0, 100, "lightness")), - chroma, - _normalizeHue(hue), - alpha); + ColorSpace.oklch, lightness, chroma, hue, alpha); /// Creates a color in the color space named [space]. /// @@ -508,52 +472,50 @@ class SassColor extends Value { /// Throws a [RangeError] if [alpha] isn't between `0` and `1` or if /// [channels] is the wrong length for [space]. factory SassColor.forSpace(ColorSpace space, List channels, - [double? alpha = 1]) { - if (channels.length != space.channels.length) { - throw RangeError.value(channels.length, "channels.length", - 'must be exactly ${space.channels.length} for color space "$space"'); - } else { - var clampChannel0 = space.channels[0].name == "lightness"; - var clampChannel12 = space == ColorSpace.hsl || space == ColorSpace.hwb; - return SassColor.forSpaceInternal( - space, - clampChannel0 - ? channels[0].andThen((value) => fuzzyClamp( - value, 0, (space.channels[0] as LinearChannel).max)) - : channels[0], - clampChannel12 - ? channels[1].andThen((value) => fuzzyClamp(value, 0, 100)) - : channels[1], - clampChannel12 - ? channels[2].andThen((value) => fuzzyClamp(value, 0, 100)) - : channels[2], - alpha); - } - } + [double? alpha = 1]) => + channels.length == space.channels.length + ? SassColor.forSpaceInternal( + space, channels[0], channels[1], channels[2], alpha) + : throw RangeError.value(channels.length, "channels.length", + 'must be exactly ${space.channels.length} for color space "$space"'); /// Like [forSpace], but takes three channels explicitly rather than wrapping /// and unwrapping them in an array. /// /// @nodoc + factory SassColor.forSpaceInternal(ColorSpace space, double? channel0, + double? channel1, double? channel2, + [double? alpha = 1]) => + switch (space) { + ColorSpace.hsl => SassColor._forSpace( + space, + _normalizeHue(channel0, + invert: channel1 != null && fuzzyLessThan(channel1, 0)), + channel1?.abs(), + channel2, + alpha), + ColorSpace.hwb => SassColor._forSpace(space, + _normalizeHue(channel0, invert: false), channel1, channel2, alpha), + ColorSpace.lch || ColorSpace.oklch => SassColor._forSpace( + space, + channel0, + channel1?.abs(), + _normalizeHue(channel2, + invert: channel1 != null && fuzzyLessThan(channel1, 0)), + alpha), + _ => SassColor._forSpace(space, channel0, channel1, channel2, alpha) + }; + + /// Like [forSpaceInternal], but doesn't do _any_ pre-processing of any + /// channels. + /// + /// @nodoc @internal - SassColor.forSpaceInternal(this._space, this.channel0OrNull, - this.channel1OrNull, this.channel2OrNull, double? alpha, [this.format]) + SassColor._forSpace(this._space, this.channel0OrNull, this.channel1OrNull, + this.channel2OrNull, double? alpha, [this.format]) : alphaOrNull = alpha.andThen((alpha) => fuzzyAssertRange(alpha, 0, 1, "alpha")) { assert(format == null || _space == ColorSpace.rgb); - assert( - !(space == ColorSpace.hsl || space == ColorSpace.hwb) || - (fuzzyCheckRange(channel1, 0, 100) != null && - fuzzyCheckRange(channel2, 0, 100) != null), - "[BUG] Tried to create " - "$_space(${channel0OrNull ?? 'none'}, ${channel1OrNull ?? 'none'}, " - "${channel2OrNull ?? 'none'})"); - assert( - space.channels[0].name != "lightness" || - fuzzyCheckRange(channel0, 0, 100) != null, - "[BUG] Tried to create " - "$_space(${channel0OrNull ?? 'none'}, ${channel1OrNull ?? 'none'}, " - "${channel2OrNull ?? 'none'})"); assert(space != ColorSpace.lms); _checkChannel(channel0OrNull, space.channels[0].name); @@ -574,9 +536,11 @@ class SassColor extends Value { } /// If [hue] isn't null, normalizes it to the range `[0, 360)`. - static double? _normalizeHue(double? hue) { + /// + /// If [invert] is true, this returns the hue 180deg offset from the original value. + static double? _normalizeHue(double? hue, {required bool invert}) { if (hue == null) return hue; - return (hue % 360 + 360) % 360; + return (hue % 360 + 360 + (invert ? 180 : 0)) % 360; } /// @nodoc @@ -892,22 +856,8 @@ class SassColor extends Value { } } - return SassColor.forSpaceInternal( - this.space, - _clampChannelIfNecessary(new0, this.space, 0) ?? channel0OrNull, - _clampChannelIfNecessary(new1, this.space, 1) ?? channel1OrNull, - _clampChannelIfNecessary(new2, this.space, 2) ?? channel2OrNull, - alpha ?? alphaOrNull); - } - - /// If [space] is strictly bounded and its [index]th channel isn't polar, - /// clamps [value] between its minimum and maximum. - double? _clampChannelIfNecessary(double? value, ColorSpace space, int index) { - if (value == null) return value; - if (!space.isStrictlyBounded) return value; - var channel = space.channels[index]; - if (channel is! LinearChannel) return value; - return fuzzyClamp(value, channel.min, channel.max); + return SassColor.forSpaceInternal(this.space, new0 ?? channel0OrNull, + new1 ?? channel1OrNull, new2 ?? channel2OrNull, alpha ?? alphaOrNull); } /// Returns a color partway between [this] and [other] according to [method], diff --git a/lib/src/value/color/channel.dart b/lib/src/value/color/channel.dart index 61cd115cf..378de1cd9 100644 --- a/lib/src/value/color/channel.dart +++ b/lib/src/value/color/channel.dart @@ -77,6 +77,14 @@ class LinearChannel extends ColorChannel { /// forbids unitless values. final bool requiresPercent; + /// Whether the lower bound of this channel is clamped when the color is + /// created using the global function syntax. + final bool lowerClamped; + + /// Whether the upper bound of this channel is clamped when the color is + /// created using the global function syntax. + final bool upperClamped; + /// Creates a linear color channel. /// /// By default, [ColorChannel.associatedUnit] is set to `%` if and only if @@ -86,7 +94,10 @@ class LinearChannel extends ColorChannel { /// @nodoc @internal const LinearChannel(String name, this.min, this.max, - {this.requiresPercent = false, bool? conventionallyPercent}) + {this.requiresPercent = false, + this.lowerClamped = false, + this.upperClamped = false, + bool? conventionallyPercent}) : super(name, isPolarAngle: false, associatedUnit: (conventionallyPercent ?? (min == 0 && max == 100)) diff --git a/lib/src/value/color/space.dart b/lib/src/value/color/space.dart index 2127a200e..1d96ae705 100644 --- a/lib/src/value/color/space.dart +++ b/lib/src/value/color/space.dart @@ -121,12 +121,6 @@ abstract base class ColorSpace { @internal bool get isBoundedInternal; - /// See [SassApiColorSpace.isStrictlyBounded]. - /// - /// @nodoc - @internal - bool get isStrictlyBoundedInternal => false; - /// See [SassApiColorSpace.isLegacy]. /// /// @nodoc @@ -183,8 +177,8 @@ abstract base class ColorSpace { convertLinear(dest, channel0, channel1, channel2, alpha); /// The default implementation of [convert], which always starts with a linear - /// transformation from RGB or XYZ channels to a linear destination space, - /// which may then further convert to a polar space. + /// transformation from RGB or XYZ channels to a linear destination space, and + /// may then further convert to a polar space. /// /// @nodoc @internal @@ -321,15 +315,6 @@ extension SassApiColorSpace on ColorSpace { /// Whether this color space has a bounded gamut. bool get isBounded => isBoundedInternal; - /// Whether this color space is _strictly_ bounded. - /// - /// If this is `true`, channel values outside of their bounds are meaningless - /// and therefore forbidden, rather than being considered valid but - /// out-of-gamut. - /// - /// This is only `true` if [isBounded] is also `true`. - bool get isStrictlyBounded => isStrictlyBoundedInternal; - /// Whether this is a legacy color space. bool get isLegacy => isLegacyInternal; diff --git a/lib/src/value/color/space/hsl.dart b/lib/src/value/color/space/hsl.dart index 94bfba84d..94ec83553 100644 --- a/lib/src/value/color/space/hsl.dart +++ b/lib/src/value/color/space/hsl.dart @@ -16,15 +16,16 @@ import 'utils.dart'; @internal final class HslColorSpace extends ColorSpace { bool get isBoundedInternal => true; - bool get isStrictlyBoundedInternal => true; bool get isLegacyInternal => true; bool get isPolarInternal => true; const HslColorSpace() : super('hsl', const [ hueChannel, - LinearChannel('saturation', 0, 100, requiresPercent: true), - LinearChannel('lightness', 0, 100, requiresPercent: true) + LinearChannel('saturation', 0, 100, + requiresPercent: true, lowerClamped: true), + LinearChannel('lightness', 0, 100, + requiresPercent: true, lowerClamped: true, upperClamped: true) ]); SassColor convert(ColorSpace dest, double? hue, double? saturation, diff --git a/lib/src/value/color/space/hwb.dart b/lib/src/value/color/space/hwb.dart index 9f0ecd131..956768b19 100644 --- a/lib/src/value/color/space/hwb.dart +++ b/lib/src/value/color/space/hwb.dart @@ -16,7 +16,6 @@ import 'utils.dart'; @internal final class HwbColorSpace extends ColorSpace { bool get isBoundedInternal => true; - bool get isStrictlyBoundedInternal => true; bool get isLegacyInternal => true; bool get isPolarInternal => true; diff --git a/lib/src/value/color/space/lab.dart b/lib/src/value/color/space/lab.dart index 58a94bd68..7766e706d 100644 --- a/lib/src/value/color/space/lab.dart +++ b/lib/src/value/color/space/lab.dart @@ -25,7 +25,8 @@ final class LabColorSpace extends ColorSpace { const LabColorSpace() : super('lab', const [ - LinearChannel('lightness', 0, 100), + LinearChannel('lightness', 0, 100, + lowerClamped: true, upperClamped: true), LinearChannel('a', -125, 125), LinearChannel('b', -125, 125) ]); diff --git a/lib/src/value/color/space/lch.dart b/lib/src/value/color/space/lch.dart index 47c5848bb..095babeef 100644 --- a/lib/src/value/color/space/lch.dart +++ b/lib/src/value/color/space/lch.dart @@ -24,8 +24,9 @@ final class LchColorSpace extends ColorSpace { const LchColorSpace() : super('lch', const [ - LinearChannel('lightness', 0, 100), - LinearChannel('chroma', 0, 150), + LinearChannel('lightness', 0, 100, + lowerClamped: true, upperClamped: true), + LinearChannel('chroma', 0, 150, lowerClamped: true), hueChannel ]); diff --git a/lib/src/value/color/space/oklab.dart b/lib/src/value/color/space/oklab.dart index 8af468e11..c24806635 100644 --- a/lib/src/value/color/space/oklab.dart +++ b/lib/src/value/color/space/oklab.dart @@ -24,7 +24,10 @@ final class OklabColorSpace extends ColorSpace { const OklabColorSpace() : super('oklab', const [ - LinearChannel('lightness', 0, 1, conventionallyPercent: true), + LinearChannel('lightness', 0, 1, + conventionallyPercent: true, + lowerClamped: true, + upperClamped: true), LinearChannel('a', -0.4, 0.4), LinearChannel('b', -0.4, 0.4) ]); diff --git a/lib/src/value/color/space/oklch.dart b/lib/src/value/color/space/oklch.dart index 61ed5e55d..bdf7fea65 100644 --- a/lib/src/value/color/space/oklch.dart +++ b/lib/src/value/color/space/oklch.dart @@ -24,8 +24,11 @@ final class OklchColorSpace extends ColorSpace { const OklchColorSpace() : super('oklch', const [ - LinearChannel('lightness', 0, 1, conventionallyPercent: true), - LinearChannel('chroma', 0, 0.4), + LinearChannel('lightness', 0, 1, + conventionallyPercent: true, + lowerClamped: true, + upperClamped: true), + LinearChannel('chroma', 0, 0.4, lowerClamped: true), hueChannel ]); diff --git a/lib/src/value/color/space/srgb.dart b/lib/src/value/color/space/srgb.dart index 7c107e68b..963cdf733 100644 --- a/lib/src/value/color/space/srgb.dart +++ b/lib/src/value/color/space/srgb.dart @@ -38,53 +38,48 @@ final class SrgbColorSpace extends ColorSpace { green ??= 0; blue ??= 0; - // Algorithm from https://en.wikipedia.org/wiki/HSL_and_HSV#RGB_to_HSL_and_HSV + // Algorithm from https://drafts.csswg.org/css-color-4/#rgb-to-hsl var max = math.max(math.max(red, green), blue); var min = math.min(math.min(red, green), blue); var delta = max - min; - double? hue; + double hue; if (max == min) { hue = 0; } else if (max == red) { - hue = (60 * (green - blue) / delta) % 360; + hue = 60 * (green - blue) / delta + 360; } else if (max == green) { - hue = (120 + 60 * (blue - red) / delta) % 360; + hue = 60 * (blue - red) / delta + 120; } else { // max == blue - hue = (240 + 60 * (red - green) / delta) % 360; + hue = 60 * (red - green) / delta + 240; } if (dest == ColorSpace.hsl) { - var lightness = fuzzyClamp(50 * (max + min), 0, 100); - - double? saturation; - if (lightness == 0 || lightness == 100) { - saturation = null; - } else if (fuzzyEquals(max, min)) { - saturation = 0; - } else if (lightness < 50) { - saturation = 100 * delta / (max + min); - } else { - saturation = 100 * delta / (2 - max - min); + var lightness = (min + max) / 2; + + var saturation = lightness == 0 || lightness == 1 + ? 0.0 + : 100 * (max - lightness) / math.min(lightness, 1 - lightness); + if (saturation < 0) { + hue += 180; + saturation = saturation.abs(); } - saturation = saturation - .andThen((saturation) => fuzzyClamp(saturation, 0, 100)); return SassColor.forSpaceInternal( dest, - missingHue || saturation == 0 || saturation == null ? null : hue, + missingHue || fuzzyEquals(saturation, 0) ? null : hue % 360, missingChroma ? null : saturation, - missingLightness ? null : lightness, + missingLightness ? null : lightness * 100, alpha); } else { - var whiteness = fuzzyClamp(min * 100, 0, 100); - var blackness = fuzzyClamp(100 - max * 100, 0, 100); + var whiteness = min * 100; + var blackness = 100 - max * 100; return SassColor.forSpaceInternal( dest, - missingHue || fuzzyEquals(whiteness + blackness, 100) + missingHue || fuzzyGreaterThanOrEquals(whiteness + blackness, 100) ? null - : hue, + : hue % 360, whiteness, blackness, alpha); diff --git a/lib/src/visitor/serialize.dart b/lib/src/visitor/serialize.dart index 62527112f..af9d547d7 100644 --- a/lib/src/visitor/serialize.dart +++ b/lib/src/visitor/serialize.dart @@ -589,6 +589,15 @@ final class _SerializeVisitor _maybeWriteSlashAlpha(value); _buffer.writeCharCode($rparen); + // case ColorSpace.lab || + // ColorSpace.oklab || + // ColorSpace.lch || + // ColorSpace.oklch when fuzzyInRange(value.channel0, 0, 100) && !value.isChannel1Missing && !value.isChannel2Missing: + // case ColorSpace.lch || + // ColorSpace.oklch when fuzzyLessThan(value.channel1, 0) && !value.isChannel0Missing && !value.isChannel1Missing: + // // color-mix() is currently more widely supported than relative color + // // syntax, so we use it to serialize out-of-gamut + case ColorSpace.lab || ColorSpace.oklab || ColorSpace.lch || @@ -643,6 +652,14 @@ final class _SerializeVisitor void _writeLegacyColor(SassColor color) { var opaque = fuzzyEquals(color.alpha, 1); + // Out-of-gamut colors can _only_ be represented accurately as HSL, because + // only HSL isn't clamped at parse time (except negative saturation which + // isn't necessary anyway). + if (!color.isInGamut && !_inspect) { + _writeHsl(color); + return; + } + // In compressed mode, emit colors in the shortest representation possible. if (_isCompressed) { var rgb = color.toSpace(ColorSpace.rgb); @@ -688,6 +705,9 @@ final class _SerializeVisitor if (color.space == ColorSpace.hsl) { _writeHsl(color); return; + } else if (_inspect && color.space == ColorSpace.hwb) { + _writeHwb(color); + return; } switch (color.format) { @@ -813,6 +833,29 @@ final class _SerializeVisitor _buffer.writeCharCode($rparen); } + /// Writes [value] as an `hwb()` function. + /// + /// This is only used in inspect mode, and so only supports the new color syntax. + void _writeHwb(SassColor color) { + var opaque = fuzzyEquals(color.alpha, 1); + _buffer.write("hwb("); + var hwb = color.toSpace(ColorSpace.hwb); + _writeNumber(hwb.channel('hue')); + _buffer.writeCharCode($space); + _writeNumber(hwb.channel('whiteness')); + _buffer.writeCharCode($percent); + _buffer.writeCharCode($space); + _writeNumber(hwb.channel('blackness')); + _buffer.writeCharCode($percent); + + if (!fuzzyEquals(color.alpha, 1)) { + _buffer.write(' / '); + _writeNumber(color.alpha); + } + + _buffer.writeCharCode($rparen); + } + /// Returns whether [color]'s hex pair representation is symmetrical (e.g. /// `FF`). bool _isSymmetricalHex(int color) => color & 0xF == color >> 4; From 5808b84861bd80f39915fe8a64c331dbfe4865ef Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Wed, 27 Mar 2024 13:19:33 -0700 Subject: [PATCH 32/56] Poke CI From 7269a443a15456425dc44acaee2a532c3373d7c9 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Thu, 28 Mar 2024 14:10:43 -0700 Subject: [PATCH 33/56] Fix analysis issues --- lib/src/value/color.dart | 3 --- lib/src/value/color/channel.dart | 5 ++--- lib/src/value/color/space/lms.dart | 1 - lib/src/visitor/serialize.dart | 1 - 4 files changed, 2 insertions(+), 8 deletions(-) diff --git a/lib/src/value/color.dart b/lib/src/value/color.dart index 85721d790..631aa83c6 100644 --- a/lib/src/value/color.dart +++ b/lib/src/value/color.dart @@ -508,9 +508,6 @@ class SassColor extends Value { /// Like [forSpaceInternal], but doesn't do _any_ pre-processing of any /// channels. - /// - /// @nodoc - @internal SassColor._forSpace(this._space, this.channel0OrNull, this.channel1OrNull, this.channel2OrNull, double? alpha, [this.format]) : alphaOrNull = diff --git a/lib/src/value/color/channel.dart b/lib/src/value/color/channel.dart index 378de1cd9..125d21390 100644 --- a/lib/src/value/color/channel.dart +++ b/lib/src/value/color/channel.dart @@ -93,13 +93,12 @@ class LinearChannel extends ColorChannel { /// /// @nodoc @internal - const LinearChannel(String name, this.min, this.max, + const LinearChannel(super.name, this.min, this.max, {this.requiresPercent = false, this.lowerClamped = false, this.upperClamped = false, bool? conventionallyPercent}) - : super(name, - isPolarAngle: false, + : super(isPolarAngle: false, associatedUnit: (conventionallyPercent ?? (min == 0 && max == 100)) ? '%' : null); diff --git a/lib/src/value/color/space/lms.dart b/lib/src/value/color/space/lms.dart index c05b880b4..0ea82eb01 100644 --- a/lib/src/value/color/space/lms.dart +++ b/lib/src/value/color/space/lms.dart @@ -9,7 +9,6 @@ import 'dart:typed_data'; import 'package:meta/meta.dart'; -import '../../../util/number.dart'; import '../../color.dart'; import '../conversions.dart'; import 'utils.dart'; diff --git a/lib/src/visitor/serialize.dart b/lib/src/visitor/serialize.dart index 443ab0749..e40414314 100644 --- a/lib/src/visitor/serialize.dart +++ b/lib/src/visitor/serialize.dart @@ -856,7 +856,6 @@ final class _SerializeVisitor /// /// This is only used in inspect mode, and so only supports the new color syntax. void _writeHwb(SassColor color) { - var opaque = fuzzyEquals(color.alpha, 1); _buffer.write("hwb("); var hwb = color.toSpace(ColorSpace.hwb); _writeNumber(hwb.channel('hue')); From b70c96b611c8a0f2eb76230944134c37961e58c3 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Thu, 28 Mar 2024 16:52:58 -0700 Subject: [PATCH 34/56] Update HWB powerless definition --- lib/src/value/color.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/value/color.dart b/lib/src/value/color.dart index 631aa83c6..f398e5077 100644 --- a/lib/src/value/color.dart +++ b/lib/src/value/color.dart @@ -72,7 +72,7 @@ class SassColor extends Value { @internal bool get isChannel0Powerless => switch (space) { ColorSpace.hsl => fuzzyEquals(channel1, 0), - ColorSpace.hwb => fuzzyEquals(channel1 + channel2, 100), + ColorSpace.hwb => fuzzyGreaterThanOrEquals(channel1 + channel2, 100), _ => false }; From e29122e4381f81494a025ee22594e85339a58b38 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Fri, 29 Mar 2024 13:55:40 -0700 Subject: [PATCH 35/56] Reformat --- lib/src/value/color/channel.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/value/color/channel.dart b/lib/src/value/color/channel.dart index 125d21390..eb07f8ff1 100644 --- a/lib/src/value/color/channel.dart +++ b/lib/src/value/color/channel.dart @@ -98,7 +98,8 @@ class LinearChannel extends ColorChannel { this.lowerClamped = false, this.upperClamped = false, bool? conventionallyPercent}) - : super(isPolarAngle: false, + : super( + isPolarAngle: false, associatedUnit: (conventionallyPercent ?? (min == 0 && max == 100)) ? '%' : null); From 2d44fad117c9500410342a8b37b10edbbcd35f5d Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Fri, 29 Mar 2024 14:08:24 -0700 Subject: [PATCH 36/56] Fix embedded test --- test/embedded/function_test.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/embedded/function_test.dart b/test/embedded/function_test.dart index 158d51aa5..3b03c941b 100644 --- a/test/embedded/function_test.dart +++ b/test/embedded/function_test.dart @@ -968,17 +968,17 @@ void main() { test("with red above 255", () async { expect(await _deprotofy(_rgb(256, 0, 0, 1.0)), - equals('rgb(256, 0, 0)')); + equals('hsl(0, 100.7874015748%, 50.1960784314%)')); }); test("with green above 255", () async { expect(await _deprotofy(_rgb(0, 256, 0, 1.0)), - equals('rgb(0, 256, 0)')); + equals('hsl(120, 100.7874015748%, 50.1960784314%)')); }); test("with blue above 255", () async { expect(await _deprotofy(_rgb(0, 0, 256, 1.0)), - equals('rgb(0, 0, 256)')); + equals('hsl(240, 100.7874015748%, 50.1960784314%)')); }); }); From 60d1561f03052e659c82d4e45565a32b13744c7d Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Fri, 29 Mar 2024 14:53:51 -0700 Subject: [PATCH 37/56] Poke CI From 975fc867d2a441879bdf8694c3267542e5b0c8fa Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Fri, 29 Mar 2024 17:35:41 -0700 Subject: [PATCH 38/56] Don't clamp HSL lightness --- lib/src/value/color/space/hsl.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/src/value/color/space/hsl.dart b/lib/src/value/color/space/hsl.dart index 94ec83553..bc4a02164 100644 --- a/lib/src/value/color/space/hsl.dart +++ b/lib/src/value/color/space/hsl.dart @@ -24,8 +24,7 @@ final class HslColorSpace extends ColorSpace { hueChannel, LinearChannel('saturation', 0, 100, requiresPercent: true, lowerClamped: true), - LinearChannel('lightness', 0, 100, - requiresPercent: true, lowerClamped: true, upperClamped: true) + LinearChannel('lightness', 0, 100, requiresPercent: true) ]); SassColor convert(ColorSpace dest, double? hue, double? saturation, From cd6a903c65731c568b392ff0bc082923552188f1 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Mon, 1 Apr 2024 12:16:45 -0700 Subject: [PATCH 39/56] Support missing channels in color.change() --- lib/src/functions/color.dart | 98 ++++++++++++++++++++++-------------- 1 file changed, 61 insertions(+), 37 deletions(-) diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index c607317c1..61feb112c 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -717,7 +717,7 @@ SassColor _updateComponents(List arguments, : _colorInSpace(originalColor, spaceKeyword ?? sassNull); var oldChannels = color.channels; - var channelArgs = List.filled(oldChannels.length, null); + var channelArgs = List.filled(oldChannels.length, null); var channelInfo = color.space.channels; for (var (name, value) in keywords.pairs) { var channelIndex = channelInfo.indexWhere((info) => name == info.name); @@ -727,14 +727,21 @@ SassColor _updateComponents(List arguments, name); } - channelArgs[channelIndex] = value.assertNumber(name); + channelArgs[channelIndex] = value; } - var result = change - ? _changeColor(color, channelArgs, alphaArg) - : scale - ? _scaleColor(color, channelArgs, alphaArg) - : _adjustColor(color, channelArgs, alphaArg); + SassColor result; + if (change) { + result = _changeColor(color, channelArgs, alphaArg); + } else { + var channelNumbers = [ + for (var i = 0; i < channelInfo.length; i++) + channelArgs[i]?.assertNumber(channelInfo[i].name) + ]; + result = scale + ? _scaleColor(color, channelNumbers, alphaArg) + : _adjustColor(color, channelNumbers, alphaArg); + } return result.toSpace(originalColor.space); } @@ -742,36 +749,53 @@ SassColor _updateComponents(List arguments, /// Returns a copy of [color] with its channel values replaced by those in /// [channelArgs] and [alphaArg], if specified. SassColor _changeColor( - SassColor color, List channelArgs, SassNumber? alphaArg) { - var latterUnits = - color.space == ColorSpace.hsl || color.space == ColorSpace.hwb - ? '%' - : null; - return _colorFromChannels( - color.space, - channelArgs[0] ?? SassNumber(color.channel0), - channelArgs[1] ?? SassNumber(color.channel1, latterUnits), - channelArgs[2] ?? SassNumber(color.channel2, latterUnits), - alphaArg.andThen((alphaArg) { - if (!alphaArg.hasUnits) { - return alphaArg.value; - } else if (alphaArg.hasUnit('%')) { - return alphaArg.value / 100; - } else { - warnForDeprecation( - "\$alpha: Passing a unit other than % ($alphaArg) is " - "deprecated.\n" - "\n" - "To preserve current behavior: " - "${alphaArg.unitSuggestion('alpha')}\n" - "\n" - "See https://sass-lang.com/d/function-units", - Deprecation.functionUnits); - return alphaArg.value; - } - }) ?? - color.alpha, - clamp: false); + SassColor color, List channelArgs, SassNumber? alphaArg) => + _colorFromChannels( + color.space, + _channelForChange(channelArgs[0], color, 0), + _channelForChange(channelArgs[1], color, 1), + _channelForChange(channelArgs[2], color, 2), + alphaArg.andThen((alphaArg) { + if (!alphaArg.hasUnits) { + return alphaArg.value; + } else if (alphaArg.hasUnit('%')) { + return alphaArg.value / 100; + } else { + warnForDeprecation( + "\$alpha: Passing a unit other than % ($alphaArg) is " + "deprecated.\n" + "\n" + "To preserve current behavior: " + "${alphaArg.unitSuggestion('alpha')}\n" + "\n" + "See https://sass-lang.com/d/function-units", + Deprecation.functionUnits); + return alphaArg.value; + } + }) ?? + color.alpha, + clamp: false); + +/// Returns the value for a single channel in `color.change()`. +/// +/// The [channelArg] is the argument passed in by the user, if one exists. If no +/// argument is passed, the channel at [index] in [color] is used instead. +SassNumber? _channelForChange(Value? channelArg, SassColor color, int channel) { + if (channelArg == null) { + return switch (color.channelsOrNull[channel]) { + var value? => SassNumber( + value, + (color.space == ColorSpace.hsl || color.space == ColorSpace.hwb) && + channel > 0 + ? '%' + : null), + _ => null + }; + } + if (_isNone(channelArg)) return null; + if (channelArg is SassNumber) return channelArg; + throw SassScriptException('$channelArg is not a number or unquoted "none".', + color.space.channels[channel].name); } /// Returns a copy of [color] with its channel values scaled by the values in From f3869d9ba5afecf6d75a5cfb88a0a49c81961ee0 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Mon, 1 Apr 2024 12:17:01 -0700 Subject: [PATCH 40/56] Properly serialize out-of-gamut {ok,}l{ab,ch} colors --- lib/src/visitor/serialize.dart | 84 ++++++++++++++++++++++++++-------- 1 file changed, 66 insertions(+), 18 deletions(-) diff --git a/lib/src/visitor/serialize.dart b/lib/src/visitor/serialize.dart index e40414314..9b497d45e 100644 --- a/lib/src/visitor/serialize.dart +++ b/lib/src/visitor/serialize.dart @@ -608,14 +608,35 @@ final class _SerializeVisitor _maybeWriteSlashAlpha(value); _buffer.writeCharCode($rparen); - // case ColorSpace.lab || - // ColorSpace.oklab || - // ColorSpace.lch || - // ColorSpace.oklch when fuzzyInRange(value.channel0, 0, 100) && !value.isChannel1Missing && !value.isChannel2Missing: - // case ColorSpace.lch || - // ColorSpace.oklch when fuzzyLessThan(value.channel1, 0) && !value.isChannel0Missing && !value.isChannel1Missing: - // // color-mix() is currently more widely supported than relative color - // // syntax, so we use it to serialize out-of-gamut + case ColorSpace.lab || + ColorSpace.oklab || + ColorSpace.lch || + ColorSpace.oklch + when !_inspect && + !fuzzyInRange(value.channel0, 0, 100) && + !value.isChannel1Missing && + !value.isChannel2Missing: + case ColorSpace.lch || ColorSpace.oklch + when !_inspect && + fuzzyLessThan(value.channel1, 0) && + !value.isChannel0Missing && + !value.isChannel1Missing: + // color-mix() is currently more widely supported than relative color + // syntax, so we use it to serialize out-of-gamut colors in a way that + // maintains the color space defined in Sass while (per spec) not + // clamping their values. In practice, all browsers clamp out-of-gamut + // values, but there's not much we can do about that at time of writing. + _buffer.write('color-mix(in '); + _buffer.write(value.space); + _buffer.write(_commaSeparator); + // The XYZ space has no gamut restrictions, so we use it to represent + // the out-of-gamut color before converting into the target space. + _writeColorFunction(value.toSpace(ColorSpace.xyzD65)); + _writeOptionalSpace(); + _buffer.write('100%'); + _buffer.write(_commaSeparator); + _buffer.write(_isCompressed ? 'red' : 'black'); + _buffer.writeCharCode($rparen); case ColorSpace.lab || ColorSpace.oklab || @@ -624,6 +645,21 @@ final class _SerializeVisitor _buffer ..write(value.space) ..writeCharCode($lparen); + + // color-mix() can't represent out-of-bounds colors with missing + // channels, so in this case we use the less-supported but + // more-expressive relative color syntax instead. Relative color syntax + // never clamps channels. + var polar = value.space.channels[2].isPolarAngle; + if (!_inspect && + (!fuzzyInRange(value.channel0, 0, 100) || + (polar && fuzzyLessThan(value.channel1, 0)))) { + _buffer + ..write('from ') + ..write(_isCompressed ? 'red' : 'black') + ..writeCharCode($space); + } + if (!_isCompressed && !value.isChannel0Missing) { var max = (value.space.channels[0] as LinearChannel).max; _writeNumber(value.channel0 * 100 / max); @@ -635,22 +671,14 @@ final class _SerializeVisitor _writeChannel(value.channel1OrNull); _buffer.writeCharCode($space); _writeChannel(value.channel2OrNull); - if (!_isCompressed && - !value.isChannel2Missing && - value.space.channels[2].isPolarAngle) { + if (!_isCompressed && !value.isChannel2Missing && polar) { _buffer.write('deg'); } _maybeWriteSlashAlpha(value); _buffer.writeCharCode($rparen); case _: - _buffer - ..write('color(') - ..write(value.space) - ..writeCharCode($space); - _writeBetween(value.channelsOrNull, ' ', _writeChannel); - _maybeWriteSlashAlpha(value); - _buffer.writeCharCode($rparen); + _writeColorFunction(value); } } @@ -874,6 +902,26 @@ final class _SerializeVisitor _buffer.writeCharCode($rparen); } + /// Writes [color] using the `color()` function syntax. + void _writeColorFunction(SassColor color) { + assert(!{ + ColorSpace.rgb, + ColorSpace.hsl, + ColorSpace.hwb, + ColorSpace.lab, + ColorSpace.oklab, + ColorSpace.lch, + ColorSpace.oklch + }.contains(color.space)); + _buffer + ..write('color(') + ..write(color.space) + ..writeCharCode($space); + _writeBetween(color.channelsOrNull, ' ', _writeChannel); + _maybeWriteSlashAlpha(color); + _buffer.writeCharCode($rparen); + } + /// Returns whether [color]'s hex pair representation is symmetrical (e.g. /// `FF`). bool _isSymmetricalHex(int color) => color & 0xF == color >> 4; From 4d7b7087bc05251c3d7d86a50bc5e9d56f52407a Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Mon, 1 Apr 2024 12:35:13 -0700 Subject: [PATCH 41/56] Add a note about new behavior of color.adjust() --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index badcd0736..d33101437 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ is now interpreted as a percentage, instead of ignoring the unit. For example, `color.change(red, $alpha: 50%)` now returns `rgb(255 0 0 / 0.5)`. +* **Potentially breaking compatibility fix**: Passing large positive or negative + values to `color.adjust()` can now cause a color's channels to go outside that + color's gamut. In most cases this will currently be clipped by the browser and + end up showing the same color as before, but once browsers implement gamut + mapping it may produce a different result. + * Add support for CSS Color Level 4 [color spaces]. Each color value now tracks its color space along with the values of each channel in that color space. There are two general principles to keep in mind when dealing with new color From 602c60d14eb4a1f66d3d328c8c59f9fabaf1a859 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Wed, 3 Apr 2024 12:57:24 -0700 Subject: [PATCH 42/56] Mark the rgb space as clampeed --- lib/src/value/color/space/rgb.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/src/value/color/space/rgb.dart b/lib/src/value/color/space/rgb.dart index 9933f9cea..b12fd40bd 100644 --- a/lib/src/value/color/space/rgb.dart +++ b/lib/src/value/color/space/rgb.dart @@ -19,9 +19,10 @@ final class RgbColorSpace extends ColorSpace { const RgbColorSpace() : super('rgb', const [ - LinearChannel('red', 0, 255), - LinearChannel('green', 0, 255), - LinearChannel('blue', 0, 255) + LinearChannel('red', 0, 255, lowerClamped: true, upperClamped: true), + LinearChannel('green', 0, 255, + lowerClamped: true, upperClamped: true), + LinearChannel('blue', 0, 255, lowerClamped: true, upperClamped: true) ]); SassColor convert(ColorSpace dest, double? red, double? green, double? blue, From b9fb0aba15d0a8340e6c3f73f2751d6cf79c8f8e Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Fri, 12 Apr 2024 17:40:48 -0700 Subject: [PATCH 43/56] Quote channel names in color functions (#2218) --- lib/src/functions/color.dart | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index 61feb112c..c9c12cb38 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -448,11 +448,8 @@ final module = BuiltInModule("color", functions: [ r"$color, $channel", (arguments) => SassBoolean(arguments[0] .assertColor("color") - .isChannelMissing( - (arguments[1].assertString("channel")..assertQuoted("channel")) - .text, - colorName: "color", - channelName: "channel"))), + .isChannelMissing(_channelName(arguments[1]), + colorName: "color", channelName: "channel"))), _function( "is-in-gamut", @@ -475,7 +472,7 @@ final module = BuiltInModule("color", functions: [ _function("channel", r"$color, $channel, $space: null", (arguments) { var color = _colorInSpace(arguments[0], arguments[2]); - var channelName = arguments[1].assertString("channel").text.toLowerCase(); + var channelName = _channelName(arguments[1]); if (channelName == "alpha") return SassNumber(color.alpha); var channelIndex = color.space.channels @@ -511,10 +508,8 @@ final module = BuiltInModule("color", functions: [ "is-powerless", r"$color, $channel, $space: null", (arguments) => SassBoolean(_colorInSpace(arguments[0], arguments[2]) - .isChannelPowerless( - arguments[1].assertString("channel").text.toLowerCase(), - colorName: "color", - channelName: "channel"))), + .isChannelPowerless(_channelName(arguments[1]), + colorName: "color", channelName: "channel"))), _complement, @@ -1510,6 +1505,12 @@ String _suggestScaleAndAdjust( return suggestion + "color.adjust(\$color, \$$channelName: $difference)"; } +/// Asserts that `value` is an unquoted string and throws an error if it's not. +/// +/// Assumes that `value` comes from a parameter named `$channel`. +String _channelName(Value value) => + (value.assertString("channel")..assertQuoted("channel")).text.toLowerCase(); + /// Like [BuiltInCallable.function], but always sets the URL to /// `sass:color`. BuiltInCallable _function( From 440430d862c1f1da881057e3d84253597ce420d3 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Fri, 19 Apr 2024 16:18:26 -0700 Subject: [PATCH 44/56] Add a parameter to determine how to gamut-map a color (#2222) --- lib/src/functions/color.dart | 36 ++++--- lib/src/js/value/color.dart | 19 +++- lib/src/util/number.dart | 10 -- lib/src/value/color.dart | 70 +------------- lib/src/value/color/gamut_map_method.dart | 65 +++++++++++++ .../value/color/gamut_map_method/clip.dart | 30 ++++++ .../color/gamut_map_method/local_minde.dart | 94 +++++++++++++++++++ 7 files changed, 228 insertions(+), 96 deletions(-) create mode 100644 lib/src/value/color/gamut_map_method.dart create mode 100644 lib/src/value/color/gamut_map_method/clip.dart create mode 100644 lib/src/value/color/gamut_map_method/local_minde.dart diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index c9c12cb38..1a3dcbfb5 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -457,17 +457,25 @@ final module = BuiltInModule("color", functions: [ (arguments) => SassBoolean(_colorInSpace(arguments[0], arguments[1]).isInGamut)), - _function("to-gamut", r"$color, $space: null", (arguments) { + _function("to-gamut", r"$color, $space: null, $method: null", (arguments) { var color = arguments[0].assertColor("color"); var space = _spaceOrDefault(color, arguments[1], "space"); + if (arguments[2] == sassNull) { + throw SassScriptException( + "color.to-gamut() requires a \$method argument for forwards-" + "compatibility with changes in the CSS spec. Suggestion:\n" + "\n" + "\$method: local-minde", + "method"); + } + + // Assign this before checking [space.isBounded] so that invalid method + // names consistently produce errors. + var method = GamutMapMethod.fromName( + (arguments[2].assertString("method")..assertUnquoted("method")).text); if (!space.isBounded) return color; - return color - .toSpace(space == ColorSpace.hsl || space == ColorSpace.hwb - ? ColorSpace.srgb - : space) - .toGamut() - .toSpace(color.space); + return color.toSpace(space).toGamut(method).toSpace(color.space); }), _function("channel", r"$color, $channel, $space: null", (arguments) { @@ -671,8 +679,10 @@ final _change = _function("change", r"$color, $kwargs...", (arguments) => _updateComponents(arguments, change: true)); final _ieHexStr = _function("ie-hex-str", r"$color", (arguments) { - var color = - arguments[0].assertColor("color").toSpace(ColorSpace.rgb).toGamut(); + var color = arguments[0] + .assertColor("color") + .toSpace(ColorSpace.rgb) + .toGamut(GamutMapMethod.localMinde); String hexString(double component) => fuzzyRound(component).toRadixString(16).padLeft(2, '0').toUpperCase(); return SassString( @@ -841,11 +851,9 @@ SassColor _adjustColor( channelArgs[2]), // The color space doesn't matter for alpha, as long as it's not // strictly bounded. - fuzzyClamp( - _adjustChannel( - ColorSpace.lab, ColorChannel.alpha, color.alpha, alphaArg), - 0, - 1)); + _adjustChannel( + ColorSpace.lab, ColorChannel.alpha, color.alpha, alphaArg) + .clamp(0, 1)); /// Returns [oldValue] adjusted by [adjustmentArg] according to the definition /// in [space]'s [channel]. diff --git a/lib/src/js/value/color.dart b/lib/src/js/value/color.dart index 43d01400c..8d2a34743 100644 --- a/lib/src/js/value/color.dart +++ b/lib/src/js/value/color.dart @@ -87,9 +87,11 @@ final JSClass colorClass = () { 'toSpace': (SassColor self, String space) => _toSpace(self, space), 'isInGamut': (SassColor self, [String? space]) => _toSpace(self, space).isInGamut, - 'toGamut': (SassColor self, [String? space]) { + 'toGamut': (SassColor self, _ToGamutOptions options) { var originalSpace = self.space; - return _toSpace(self, space).toGamut().toSpace(originalSpace); + return _toSpace(self, options.space) + .toGamut(GamutMapMethod.fromName(options.method)) + .toSpace(originalSpace); }, 'channel': (SassColor self, String channel, [_ChannelOptions? options]) => _toSpace(self, options?.space).channel(channel), @@ -460,12 +462,19 @@ class _ConstructionOptions extends _Channels { @JS() @anonymous class _ChannelOptions { - String? space; + external String? get space; +} + +@JS() +@anonymous +class _ToGamutOptions { + external String? get space; + external String get method; } @JS() @anonymous class _InterpolationOptions { - external double? weight; - external String? method; + external double? get weight; + external String? get method; } diff --git a/lib/src/util/number.dart b/lib/src/util/number.dart index 83138e9d1..0cd82fb70 100644 --- a/lib/src/util/number.dart +++ b/lib/src/util/number.dart @@ -83,16 +83,6 @@ int fuzzyRound(num number) { } } -/// Returns [number], clamped to be within [min] and [max]. -/// -/// If [number] is [fuzzyEquals] to [min] or [max], it's clamped to the -/// appropriate value. -double fuzzyClamp(double number, double min, double max) { - if (fuzzyLessThanOrEquals(number, min)) return min; - if (fuzzyGreaterThanOrEquals(number, max)) return max; - return number; -} - /// Returns whether [number] is within [min] and [max] inclusive, using fuzzy /// equality. bool fuzzyInRange(double number, num min, num max) => diff --git a/lib/src/value/color.dart b/lib/src/value/color.dart index f398e5077..0768c052d 100644 --- a/lib/src/value/color.dart +++ b/lib/src/value/color.dart @@ -2,8 +2,6 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import 'dart:math' as math; - import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; @@ -14,6 +12,7 @@ import '../util/number.dart'; import '../value.dart'; import '../visitor/interface/value.dart'; +export 'color/gamut_map_method.dart'; export 'color/interpolation_method.dart'; export 'color/channel.dart'; export 'color/space.dart'; @@ -646,71 +645,8 @@ class SassColor extends Value { space, channel0OrNull, channel1OrNull, channel2OrNull, alpha); /// Returns a copy of this color that's in-gamut in the current color space. - SassColor toGamut() { - if (isInGamut) return this; - - // Algorithm from https://www.w3.org/TR/css-color-4/#css-gamut-mapping-algorithm - var originOklch = toSpace(ColorSpace.oklch); - - if (fuzzyGreaterThanOrEquals(originOklch.channel0, 1)) { - return space == ColorSpace.rgb - ? SassColor.rgb(255, 255, 255, alphaOrNull) - : SassColor.forSpaceInternal(space, 1, 1, 1, alphaOrNull); - } else if (fuzzyLessThanOrEquals(originOklch.channel0, 0)) { - return SassColor.forSpaceInternal(space, 0, 0, 0, alphaOrNull); - } - - // Always target RGB for legacy colors because HSL and HWB can't even - // represent out-of-gamut colors. - var targetSpace = isLegacy ? ColorSpace.rgb : space; - - var min = 0.0; - var max = originOklch.channel1; - while (true) { - var chroma = (min + max) / 2; - // Never null because [targetSpace] can't be HSL or HWB. - var current = ColorSpace.oklch.convert(targetSpace, originOklch.channel0, - chroma, originOklch.channel2, originOklch.alpha); - if (current.isInGamut) { - min = chroma; - continue; - } - - var clipped = _clip(current); - if (_deltaEOK(clipped, current) < 0.02) return clipped; - max = chroma; - } - } - - /// Returns [current] clipped into its space's gamut. - SassColor _clip(SassColor current) { - assert(!current.isInGamut); - assert(current.space == space); - - return space == ColorSpace.rgb - ? SassColor.rgb( - fuzzyClamp(current.channel0, 0, 255), - fuzzyClamp(current.channel1, 0, 255), - fuzzyClamp(current.channel2, 0, 255), - current.alphaOrNull) - : SassColor.forSpaceInternal( - space, - fuzzyClamp(current.channel0, 0, 1), - fuzzyClamp(current.channel1, 0, 1), - fuzzyClamp(current.channel2, 0, 1), - current.alphaOrNull); - } - - /// Returns the ΔEOK measure between [color1] and [color2]. - double _deltaEOK(SassColor color1, SassColor color2) { - // Algorithm from https://www.w3.org/TR/css-color-4/#color-difference-OK - var lab1 = color1.toSpace(ColorSpace.oklab); - var lab2 = color2.toSpace(ColorSpace.oklab); - - return math.sqrt(math.pow(lab1.channel0 - lab2.channel0, 2) + - math.pow(lab1.channel1 - lab2.channel1, 2) + - math.pow(lab1.channel2 - lab2.channel2, 2)); - } + SassColor toGamut(GamutMapMethod method) => + isInGamut ? this : method.map(this); /// Changes one or more of this color's RGB channels and returns the result. @Deprecated('Use changeChannels() instead.') diff --git a/lib/src/value/color/gamut_map_method.dart b/lib/src/value/color/gamut_map_method.dart new file mode 100644 index 000000000..f934d5940 --- /dev/null +++ b/lib/src/value/color/gamut_map_method.dart @@ -0,0 +1,65 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'package:meta/meta.dart'; + +import '../../exception.dart'; +import '../color.dart'; +import 'gamut_map_method/clip.dart'; +import 'gamut_map_method/local_minde.dart'; + +/// Different algorithms that can be used to map an out-of-gamut Sass color into +/// the gamut for its color space. +/// +/// {@category Value} +@sealed +abstract base class GamutMapMethod { + /// Clamp each color channel that's outside the gamut to the minimum or + /// maximum value for that channel. + /// + /// This algorithm will produce poor visual results, but it may be useful to + /// match the behavior of other situations in which a color can be clipped. + static const GamutMapMethod clip = ClipGamutMap(); + + /// The algorithm specified in [the original Color Level 4 candidate + /// recommendation]. + /// + /// This maps in the Oklch color space, using the [deltaEOK] color difference + /// formula and the [local-MINDE] improvement. + /// + /// [the original Color Level 4 candidate recommendation]: https://www.w3.org/TR/2024/CRD-css-color-4-20240213/#css-gamut-mapping + /// [deltaEOK]: https://www.w3.org/TR/2024/CRD-css-color-4-20240213/#color-difference-OK + /// [local-MINDE]: https://www.w3.org/TR/2024/CRD-css-color-4-20240213/#GM-chroma-local-MINDE + static const GamutMapMethod localMinde = LocalMindeGamutMap(); + + /// The Sass name of the gamut-mapping algorithm. + final String name; + + /// @nodoc + @internal + const GamutMapMethod(this.name); + + /// Parses a [GamutMapMethod] from its Sass name. + /// + /// Throws a [SassScriptException] if there is no method with the given + /// [name]. If this came from a function argument, [argumentName] is the + /// argument name (without the `$`). This is used for error reporting. + factory GamutMapMethod.fromName(String name, [String? argumentName]) => + switch (name) { + 'clip' => GamutMapMethod.clip, + 'local-minde' => GamutMapMethod.localMinde, + _ => throw SassScriptException( + 'Unknown gamut map method "$name".', argumentName) + }; + + /// Maps [color] to its gamut using this method's algorithm. + /// + /// Callers should use [SassColor.toGamut] instead of this method. + /// + /// @nodoc + @internal + SassColor map(SassColor color); + + String toString() => name; +} diff --git a/lib/src/value/color/gamut_map_method/clip.dart b/lib/src/value/color/gamut_map_method/clip.dart new file mode 100644 index 000000000..363f59374 --- /dev/null +++ b/lib/src/value/color/gamut_map_method/clip.dart @@ -0,0 +1,30 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'package:meta/meta.dart'; + +import '../../color.dart'; + +/// Gamut mapping by clipping individual channels. +/// +/// @nodoc +@internal +final class ClipGamutMap extends GamutMapMethod { + const ClipGamutMap() : super("clip"); + + SassColor map(SassColor color) => SassColor.forSpaceInternal( + color.space, + _clampChannel(color.channel0OrNull, color.space.channels[0]), + _clampChannel(color.channel1OrNull, color.space.channels[1]), + _clampChannel(color.channel2OrNull, color.space.channels[2]), + color.alphaOrNull); + + /// Clamps the channel value [value] within the bounds given by [channel]. + double? _clampChannel(double? value, ColorChannel channel) => value == null + ? null + : switch (channel) { + LinearChannel(:var min, :var max) => value.clamp(min, max), + _ => value + }; +} diff --git a/lib/src/value/color/gamut_map_method/local_minde.dart b/lib/src/value/color/gamut_map_method/local_minde.dart new file mode 100644 index 000000000..b444cdf55 --- /dev/null +++ b/lib/src/value/color/gamut_map_method/local_minde.dart @@ -0,0 +1,94 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'dart:math' as math; + +import 'package:meta/meta.dart'; + +import '../../../util/number.dart'; +import '../../color.dart'; + +/// Gamut mapping using the deltaEOK difference formula and the local-MINDE +/// improvement. +/// +/// @nodoc +@internal +final class LocalMindeGamutMap extends GamutMapMethod { + /// A constant from the gamut-mapping algorithm. + static const _jnd = 0.02; + + /// A constant from the gamut-mapping algorithm. + static const _epsilon = 0.0001; + + const LocalMindeGamutMap() : super("local-minde"); + + SassColor map(SassColor color) { + // Algorithm from https://www.w3.org/TR/2022/CRD-css-color-4-20221101/#css-gamut-mapping-algorithm + var originOklch = color.toSpace(ColorSpace.oklch); + + // The channel equivalents to `current` in the Color 4 algorithm. + var lightness = originOklch.channel0OrNull; + var hue = originOklch.channel2OrNull; + var alpha = originOklch.alphaOrNull; + + if (fuzzyGreaterThanOrEquals(lightness ?? 0, 1)) { + return color.space == ColorSpace.rgb + ? SassColor.rgb(255, 255, 255, color.alphaOrNull) + : SassColor.forSpaceInternal(color.space, 1, 1, 1, color.alphaOrNull); + } else if (fuzzyLessThanOrEquals(lightness ?? 0, 0)) { + return SassColor.forSpaceInternal( + color.space, 0, 0, 0, color.alphaOrNull); + } + + var clipped = color.toGamut(GamutMapMethod.clip); + if (_deltaEOK(clipped, color) < _jnd) return clipped; + + var min = 0.0; + var max = originOklch.channel1; + var minInGamut = true; + while (max - min > _epsilon) { + var chroma = (min + max) / 2; + + // In the Color 4 algorithm `current` is in Oklch, but all its actual uses + // other than modifying chroma convert it to `color.space` first so we + // just store it in that space to begin with. + var current = + ColorSpace.oklch.convert(color.space, lightness, chroma, hue, alpha); + + // Per [this comment], the intention of the algorithm is to fall through + // this clause if `minInGamut = false` without checking + // `current.isInGamut` at all, even though that's unclear from the + // pseudocode. `minInGamut = false` *should* imply `current.isInGamut = + // false`. + // + // [this comment]: https://github.com/w3c/csswg-drafts/issues/10226#issuecomment-2065534713 + if (minInGamut && current.isInGamut) { + min = chroma; + continue; + } + + clipped = current.toGamut(GamutMapMethod.clip); + var e = _deltaEOK(clipped, current); + if (e < _jnd) { + if (_jnd - e < _epsilon) return clipped; + minInGamut = false; + min = chroma; + } else { + max = chroma; + } + } + return clipped; + } + + /// Returns the ΔEOK measure between [color1] and [color2]. + double _deltaEOK(SassColor color1, SassColor color2) { + // Algorithm from https://www.w3.org/TR/css-color-4/#color-difference-OK + var lab1 = color1.toSpace(ColorSpace.oklab); + var lab2 = color2.toSpace(ColorSpace.oklab); + + return math.sqrt(math.pow(lab1.channel0 - lab2.channel0, 2) + + math.pow(lab1.channel1 - lab2.channel1, 2) + + math.pow(lab1.channel2 - lab2.channel2, 2)); + } +} From 9fb49b0b5374ccfb7f1adec721335b98235d10cb Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Wed, 24 Apr 2024 15:25:13 -0700 Subject: [PATCH 45/56] Fix bugs caught by to-gamut() tests (#2227) --- lib/src/value/color/gamut_map_method/local_minde.dart | 7 +++---- lib/src/visitor/serialize.dart | 10 ++++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/lib/src/value/color/gamut_map_method/local_minde.dart b/lib/src/value/color/gamut_map_method/local_minde.dart index b444cdf55..a8b896d21 100644 --- a/lib/src/value/color/gamut_map_method/local_minde.dart +++ b/lib/src/value/color/gamut_map_method/local_minde.dart @@ -33,12 +33,11 @@ final class LocalMindeGamutMap extends GamutMapMethod { var alpha = originOklch.alphaOrNull; if (fuzzyGreaterThanOrEquals(lightness ?? 0, 1)) { - return color.space == ColorSpace.rgb - ? SassColor.rgb(255, 255, 255, color.alphaOrNull) + return color.isLegacy + ? SassColor.rgb(255, 255, 255, color.alphaOrNull).toSpace(color.space) : SassColor.forSpaceInternal(color.space, 1, 1, 1, color.alphaOrNull); } else if (fuzzyLessThanOrEquals(lightness ?? 0, 0)) { - return SassColor.forSpaceInternal( - color.space, 0, 0, 0, color.alphaOrNull); + return SassColor.rgb(0, 0, 0, color.alphaOrNull).toSpace(color.space); } var clipped = color.toGamut(GamutMapMethod.clip); diff --git a/lib/src/visitor/serialize.dart b/lib/src/visitor/serialize.dart index 9b497d45e..ae70415c3 100644 --- a/lib/src/visitor/serialize.dart +++ b/lib/src/visitor/serialize.dart @@ -608,14 +608,16 @@ final class _SerializeVisitor _maybeWriteSlashAlpha(value); _buffer.writeCharCode($rparen); - case ColorSpace.lab || - ColorSpace.oklab || - ColorSpace.lch || - ColorSpace.oklch + case ColorSpace.lab || ColorSpace.lch when !_inspect && !fuzzyInRange(value.channel0, 0, 100) && !value.isChannel1Missing && !value.isChannel2Missing: + case ColorSpace.oklab || ColorSpace.oklch + when !_inspect && + !fuzzyInRange(value.channel0, 0, 1) && + !value.isChannel1Missing && + !value.isChannel2Missing: case ColorSpace.lch || ColorSpace.oklch when !_inspect && fuzzyLessThan(value.channel1, 0) && From 7863d0cb1537f237d4b8e38465fd8c1caeb10a76 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Wed, 24 Apr 2024 16:47:22 -0700 Subject: [PATCH 46/56] Don't case-normalize channel names, per spec (#2228) --- lib/src/functions/color.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index 1a3dcbfb5..ccfa9d898 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -1517,7 +1517,7 @@ String _suggestScaleAndAdjust( /// /// Assumes that `value` comes from a parameter named `$channel`. String _channelName(Value value) => - (value.assertString("channel")..assertQuoted("channel")).text.toLowerCase(); + (value.assertString("channel")..assertQuoted("channel")).text; /// Like [BuiltInCallable.function], but always sets the URL to /// `sass:color`. From 09fd9c2c3cd211e5d39ef6549142dfb86cfbd50c Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Fri, 26 Apr 2024 14:25:49 -0700 Subject: [PATCH 47/56] Fix more cases where channel names were being case-normalized (#2229) See sass/sass#3851 --- lib/src/value/color.dart | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/src/value/color.dart b/lib/src/value/color.dart index 0768c052d..088900483 100644 --- a/lib/src/value/color.dart +++ b/lib/src/value/color.dart @@ -566,7 +566,6 @@ class SassColor extends Value { /// for this color and [channelName] is the argument name for [channel] /// (without the `$`). These are used for error reporting. double channel(String channel, {String? colorName, String? channelName}) { - channel = channel.toLowerCase(); var channels = space.channels; if (channel == channels[0].name) return channel0; if (channel == channels[1].name) return channel1; @@ -586,7 +585,6 @@ class SassColor extends Value { /// (without the `$`). These are used for error reporting. bool isChannelMissing(String channel, {String? colorName, String? channelName}) { - channel = channel.toLowerCase(); var channels = space.channels; if (channel == channels[0].name) return isChannel0Missing; if (channel == channels[1].name) return isChannel1Missing; @@ -606,7 +604,6 @@ class SassColor extends Value { /// (without the `$`). These are used for error reporting. bool isChannelPowerless(String channel, {String? colorName, String? channelName}) { - channel = channel.toLowerCase(); var channels = space.channels; if (channel == channels[0].name) return isChannel0Powerless; if (channel == channels[1].name) return isChannel1Powerless; @@ -767,7 +764,7 @@ class SassColor extends Value { } for (var entry in newValues.entries) { - var channel = entry.key.toLowerCase(); + var channel = entry.key; if (channel == channels[0].name) { setChannel0(entry.value); } else if (channel == channels[1].name) { From ce124ba841a503cdc8492b3bdfc8a8dd213cf1fe Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Thu, 2 May 2024 16:53:20 -0700 Subject: [PATCH 48/56] Throw proper errors for out-of-range alpha values in color.change() (#2231) --- lib/src/functions/color.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index ccfa9d898..592069c93 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -762,9 +762,10 @@ SassColor _changeColor( _channelForChange(channelArgs[2], color, 2), alphaArg.andThen((alphaArg) { if (!alphaArg.hasUnits) { - return alphaArg.value; + return alphaArg.valueInRange(0, 1, "alpha"); } else if (alphaArg.hasUnit('%')) { - return alphaArg.value / 100; + return alphaArg.valueInRangeWithUnit(0, 100, "alpha", "%") / + 100; } else { warnForDeprecation( "\$alpha: Passing a unit other than % ($alphaArg) is " @@ -775,7 +776,7 @@ SassColor _changeColor( "\n" "See https://sass-lang.com/d/function-units", Deprecation.functionUnits); - return alphaArg.value; + return alphaArg.valueInRange(0, 1, "alpha"); } }) ?? color.alpha, From f0dc4cddbc529bc568f63c88546ece98a2e75094 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Mon, 6 May 2024 13:54:37 -0700 Subject: [PATCH 49/56] [Color 4] Support `none` alpha values in `color.change()` (#2233) --- lib/src/functions/color.dart | 50 +++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index 592069c93..3ad370e50 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -711,7 +711,7 @@ SassColor _updateComponents(List arguments, var spaceKeyword = keywords.remove("space")?.assertString("space") ?..assertUnquoted("space"); - var alphaArg = keywords.remove('alpha')?.assertNumber('alpha'); + var alphaArg = keywords.remove('alpha'); // For backwards-compatibility, we allow legacy colors to modify channels in // any legacy color space. @@ -743,9 +743,10 @@ SassColor _updateComponents(List arguments, for (var i = 0; i < channelInfo.length; i++) channelArgs[i]?.assertNumber(channelInfo[i].name) ]; + var alphaNumber = alphaArg?.assertNumber("alpha"); result = scale - ? _scaleColor(color, channelNumbers, alphaArg) - : _adjustColor(color, channelNumbers, alphaArg); + ? _scaleColor(color, channelNumbers, alphaNumber) + : _adjustColor(color, channelNumbers, alphaNumber); } return result.toSpace(originalColor.space); @@ -754,32 +755,33 @@ SassColor _updateComponents(List arguments, /// Returns a copy of [color] with its channel values replaced by those in /// [channelArgs] and [alphaArg], if specified. SassColor _changeColor( - SassColor color, List channelArgs, SassNumber? alphaArg) => + SassColor color, List channelArgs, Value? alphaArg) => _colorFromChannels( color.space, _channelForChange(channelArgs[0], color, 0), _channelForChange(channelArgs[1], color, 1), _channelForChange(channelArgs[2], color, 2), - alphaArg.andThen((alphaArg) { - if (!alphaArg.hasUnits) { - return alphaArg.valueInRange(0, 1, "alpha"); - } else if (alphaArg.hasUnit('%')) { - return alphaArg.valueInRangeWithUnit(0, 100, "alpha", "%") / - 100; - } else { - warnForDeprecation( - "\$alpha: Passing a unit other than % ($alphaArg) is " - "deprecated.\n" - "\n" - "To preserve current behavior: " - "${alphaArg.unitSuggestion('alpha')}\n" - "\n" - "See https://sass-lang.com/d/function-units", - Deprecation.functionUnits); - return alphaArg.valueInRange(0, 1, "alpha"); - } - }) ?? - color.alpha, + switch (alphaArg) { + null => color.alpha, + _ when _isNone(alphaArg) => null, + SassNumber(hasUnits: false) => alphaArg.valueInRange(0, 1, "alpha"), + SassNumber() when alphaArg.hasUnit('%') => + alphaArg.valueInRangeWithUnit(0, 100, "alpha", "%") / 100, + SassNumber() => () { + warnForDeprecation( + "\$alpha: Passing a unit other than % ($alphaArg) is " + "deprecated.\n" + "\n" + "To preserve current behavior: " + "${alphaArg.unitSuggestion('alpha')}\n" + "\n" + "See https://sass-lang.com/d/function-units", + Deprecation.functionUnits); + return alphaArg.valueInRange(0, 1, "alpha"); + }(), + _ => throw SassScriptException( + '$alphaArg is not a number or unquoted "none".', 'alpha') + }, clamp: false); /// Returns the value for a single channel in `color.change()`. From 2c92c89b0b079bf244914194842b7bb380f7f27a Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Thu, 9 May 2024 13:17:21 -0700 Subject: [PATCH 50/56] [Color 4] Update `color.same()` and color equality (#2232) See sass/sass#3852 See sass/sass#3858 --- lib/src/functions/color.dart | 38 ++++++++++++++++++++++++++++++------ lib/src/util/number.dart | 11 +++++++++++ lib/src/value/color.dart | 28 +++++++++++++++++--------- 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index 3ad370e50..206bfb67e 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -504,12 +504,38 @@ final module = BuiltInModule("color", functions: [ var color1 = arguments[0].assertColor('color1'); var color2 = arguments[1].assertColor('color2'); - // Convert both colors into the same space to compare them. Usually we - // just use color1's space, but since HSL and HWB can't represent - // out-of-gamut colors we use RGB for all legacy color spaces. - var targetSpace = color1.isLegacy ? ColorSpace.rgb : color1.space; - return SassBoolean( - color1.toSpace(targetSpace) == color2.toSpace(targetSpace)); + /// Converts [color] to the xyz-d65 space without any mising channels. + SassColor toXyzNoMissing(SassColor color) => switch (color) { + SassColor(space: ColorSpace.xyzD65, hasMissingChannel: false) => + color, + SassColor( + space: ColorSpace.xyzD65, + :var channel0, + :var channel1, + :var channel2, + :var alpha + ) => + SassColor.xyzD65(channel0, channel1, channel2, alpha), + SassColor( + :var space, + :var channel0, + :var channel1, + :var channel2, + :var alpha + ) => + // Use [ColorSpace.convert] manually so that we can convert missing + // channels to 0 without having to create new intermediate color + // objects. + space.convert( + ColorSpace.xyzD65, channel0, channel1, channel2, alpha) + }; + + return SassBoolean(color1.space == color2.space + ? fuzzyEquals(color1.channel0, color2.channel0) && + fuzzyEquals(color1.channel1, color2.channel1) && + fuzzyEquals(color1.channel2, color2.channel2) && + fuzzyEquals(color1.alpha, color2.alpha) + : toXyzNoMissing(color1) == toXyzNoMissing(color2)); }), _function( diff --git a/lib/src/util/number.dart b/lib/src/util/number.dart index 0cd82fb70..9eca12928 100644 --- a/lib/src/util/number.dart +++ b/lib/src/util/number.dart @@ -30,6 +30,17 @@ bool fuzzyEquals(num number1, num number2) { (number2 * _inverseEpsilon).round(); } +/// Like [fuzzyEquals], but allows null values for [number1] and [number2]. +/// +/// null values are only equal to one another. +bool fuzzyEqualsNullable(num? number1, num? number2) { + if (number1 == number2) return true; + if (number1 == null || number2 == null) return false; + return (number1 - number2).abs() <= _epsilon && + (number1 * _inverseEpsilon).round() == + (number2 * _inverseEpsilon).round(); +} + /// Returns a hash code for [number] that matches [fuzzyEquals]. int fuzzyHashCode(double number) { if (!number.isFinite) return number.hashCode; diff --git a/lib/src/value/color.dart b/lib/src/value/color.dart index 088900483..9a61f91eb 100644 --- a/lib/src/value/color.dart +++ b/lib/src/value/color.dart @@ -213,6 +213,16 @@ class SassColor extends Value { _ => true }; + /// Whether this color has any missing channels. + /// + /// @nodoc + @internal + bool get hasMissingChannel => + isChannel0Missing || + isChannel1Missing || + isChannel2Missing || + isAlphaMissing; + /// This color's red channel, between `0` and `255`. /// /// **Note:** This is rounded to the nearest integer, which may be lossy. Use @@ -953,21 +963,21 @@ class SassColor extends Value { if (isLegacy) { if (!other.isLegacy) return false; - if (!fuzzyEquals(alpha, other.alpha)) return false; - if (space == ColorSpace.rgb && other.space == ColorSpace.rgb) { - return fuzzyEquals(channel0, other.channel0) && - fuzzyEquals(channel1, other.channel1) && - fuzzyEquals(channel2, other.channel2); + if (!fuzzyEqualsNullable(alphaOrNull, other.alphaOrNull)) return false; + if (space == other.space) { + return fuzzyEqualsNullable(channel0OrNull, other.channel0OrNull) && + fuzzyEqualsNullable(channel1OrNull, other.channel1OrNull) && + fuzzyEqualsNullable(channel2OrNull, other.channel2OrNull); } else { return toSpace(ColorSpace.rgb) == other.toSpace(ColorSpace.rgb); } } return space == other.space && - fuzzyEquals(channel0, other.channel0) && - fuzzyEquals(channel1, other.channel1) && - fuzzyEquals(channel2, other.channel2) && - fuzzyEquals(alpha, other.alpha); + fuzzyEqualsNullable(channel0OrNull, other.channel0OrNull) && + fuzzyEqualsNullable(channel1OrNull, other.channel1OrNull) && + fuzzyEqualsNullable(channel2OrNull, other.channel2OrNull) && + fuzzyEqualsNullable(alphaOrNull, other.alphaOrNull); } int get hashCode { From 07b33cec79bc8bdbdc3f64d3c77737d73e504fda Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Fri, 10 May 2024 14:10:31 -0700 Subject: [PATCH 51/56] [Color 4] Fix `color.invert()` behavior to match the spec (#2237) --- lib/src/functions/color.dart | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index 206bfb67e..4a074886d 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -630,10 +630,11 @@ Value _invert(List arguments, {bool global = false}) { _checkPercent(weightNumber, "weight"); var rgb = color.toSpace(ColorSpace.rgb); return _mixLegacy( - SassColor.rgb(255.0 - rgb.channel0, 255.0 - rgb.channel1, - 255.0 - rgb.channel2, color.alphaOrNull), - color, - weightNumber); + SassColor.rgb(255.0 - rgb.channel0, 255.0 - rgb.channel1, + 255.0 - rgb.channel2, color.alphaOrNull), + color, + weightNumber) + .toSpace(color.space); } var space = ColorSpace.fromName( @@ -668,7 +669,7 @@ Value _invert(List arguments, {bool global = false}) { _ => throw UnsupportedError("Unknown color space $space.") }; - if (fuzzyEquals(weight, 1)) return inverted; + if (fuzzyEquals(weight, 1)) return inverted.toSpace(color.space); return color.interpolate(inverted, InterpolationMethod(space), weight: 1 - weight); } @@ -1144,10 +1145,10 @@ SassColor _mixLegacy(SassColor color1, SassColor color2, SassNumber weight) { var weight2 = 1 - weight1; return SassColor.rgb( - fuzzyRound(rgb1.channel0 * weight1 + rgb2.channel0 * weight2), - fuzzyRound(color1.green * weight1 + color2.green * weight2), - fuzzyRound(color1.blue * weight1 + color2.blue * weight2), - color1.alpha * weightScale + color2.alpha * (1 - weightScale)); + rgb1.channel0 * weight1 + rgb2.channel0 * weight2, + rgb1.channel1 * weight1 + rgb2.channel1 * weight2, + rgb1.channel2 * weight1 + rgb2.channel2 * weight2, + rgb1.alpha * weightScale + rgb2.alpha * (1 - weightScale)); } /// The definition of the `opacify()` and `fade-in()` functions. From 68769682a47ac111a6e13c92eef690bc8c128d4b Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Fri, 10 May 2024 14:27:10 -0700 Subject: [PATCH 52/56] [Color 4] Be stricter about slash-separated strings (#2238) --- lib/src/functions/color.dart | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index 4a074886d..9a0b6af01 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -1270,15 +1270,18 @@ Value _parseChannels(String functionName, Value input, channels = componentList; } - for (var channel in channels) { + for (var i = 0; i < channels.length; i++) { + var channel = channels[i]; if (!channel.isSpecialNumber && channel is! SassNumber && !_isNone(channel)) { - var channelName = - space?.channels[channels.indexOf(channel)].name ?? 'channel'; + var channelName = space?.channels + .elementAtOrNull(i) + ?.name + .andThen((name) => '$name channel') ?? + 'channel ${i + 1}'; throw SassScriptException( - 'Expected $channelName channel to be a number, was $channel.', - name); + 'Expected $channelName to be a number, was $channel.', name); } } @@ -1353,15 +1356,11 @@ Value _parseChannels(String functionName, Value input, [...var initial, SassString(hasQuotes: false, :var text)] => switch ( text.split('/')) { [_] => (input, null), - [var channel3 && 'none', var alpha] || - [var channel3, var alpha && 'none'] => - switch ((_parseNumberOrNone(channel3), _parseNumberOrNone(alpha))) { - (var channel3Value?, var alphaValue?) => ( - SassList([...initial, channel3Value], ListSeparator.space), - alphaValue - ), - _ => null - }, + [var channel3, var alpha] => ( + SassList([...initial, _parseNumberOrString(channel3)], + ListSeparator.space), + _parseNumberOrString(alpha) + ), _ => null }, [...var initial, SassNumber(asSlash: (var before, var after))] => ( @@ -1371,15 +1370,12 @@ Value _parseChannels(String functionName, Value input, _ => (input, null) }; -/// Parses [text] as either a Sass number or the unquoted Sass string "none". -/// -/// If neither matches, returns null. -Value? _parseNumberOrNone(String text) { - if (text == 'none') return SassString('none', quotes: false); +/// Parses [text] as either a Sass number or an unquoted Sass string. +Value _parseNumberOrString(String text) { try { return ScssParser(text).parseNumber(); } on SassFormatException { - return null; + return SassString(text, quotes: false); } } From f1b51cd8d2a3dfd62f6ede0986a8ec5fd5ff8bb9 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Wed, 17 Jul 2024 18:21:43 -0700 Subject: [PATCH 53/56] [Color 4] Update behavior to match updated spec (#2251) --- CHANGELOG.md | 4 + lib/src/ast/css/modifiable/node.dart | 8 +- lib/src/ast/sass/at_root_query.dart | 4 +- .../ast/sass/expression/binary_operation.dart | 6 +- lib/src/ast/sass/expression/list.dart | 2 +- lib/src/ast/sass/expression/string.dart | 2 +- .../ast/sass/expression/unary_operation.dart | 4 +- lib/src/ast/selector.dart | 2 +- lib/src/ast/selector/list.dart | 4 +- lib/src/ast/selector/pseudo.dart | 2 +- lib/src/ast/selector/simple.dart | 2 +- lib/src/async_environment.dart | 4 +- lib/src/environment.dart | 6 +- lib/src/extend/extension_store.dart | 10 +- lib/src/functions/color.dart | 366 +++++++++++------- lib/src/js/legacy/value/color.dart | 10 +- lib/src/stylesheet_graph.dart | 2 +- lib/src/util/map.dart | 2 +- lib/src/util/multi_span.dart | 2 +- lib/src/util/nullable.dart | 2 +- lib/src/util/number.dart | 6 + lib/src/util/span.dart | 4 +- lib/src/value.dart | 44 +-- lib/src/value/calculation.dart | 6 +- lib/src/value/color.dart | 62 ++- .../value/color/gamut_map_method/clip.dart | 3 +- lib/src/value/number.dart | 32 +- lib/src/visitor/serialize.dart | 36 +- 28 files changed, 368 insertions(+), 269 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7802eca1..c6d344d64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ is now interpreted as a percentage, instead of ignoring the unit. For example, `color.change(red, $alpha: 50%)` now returns `rgb(255 0 0 / 0.5)`. +* **Potentially breaking compatibility fix**: Sass no longer rounds RGB channels + to the nearest integer. This means that, for example, `rgb(0 0 1) != rgb(0 0 + 0.6)`. This matches the latest version of the CSS spec and browser behavior. + * **Potentially breaking compatibility fix**: Passing large positive or negative values to `color.adjust()` can now cause a color's channels to go outside that color's gamut. In most cases this will currently be clipped by the browser and diff --git a/lib/src/ast/css/modifiable/node.dart b/lib/src/ast/css/modifiable/node.dart index bef0be821..1b27f8258 100644 --- a/lib/src/ast/css/modifiable/node.dart +++ b/lib/src/ast/css/modifiable/node.dart @@ -17,7 +17,7 @@ abstract base class ModifiableCssNode extends CssNode { ModifiableCssParentNode? get parent => _parent; ModifiableCssParentNode? _parent; - /// The index of [this] in `parent.children`. + /// The index of `this` in `parent.children`. /// /// This makes [remove] more efficient. int? _indexInParent; @@ -33,7 +33,7 @@ abstract base class ModifiableCssNode extends CssNode { T accept(ModifiableCssVisitor visitor); - /// Removes [this] from [parent]'s child list. + /// Removes `this` from [parent]'s child list. /// /// Throws a [StateError] if [parent] is `null`. void remove() { @@ -65,10 +65,10 @@ abstract base class ModifiableCssParentNode extends ModifiableCssNode : _children = children, children = UnmodifiableListView(children); - /// Returns whether [this] is equal to [other], ignoring their child nodes. + /// Returns whether `this` is equal to [other], ignoring their child nodes. bool equalsIgnoringChildren(ModifiableCssNode other); - /// Returns a copy of [this] with an empty [children] list. + /// Returns a copy of `this` with an empty [children] list. /// /// This is *not* a deep copy. If other parts of this node are modifiable, /// they are shared between the new and old nodes. diff --git a/lib/src/ast/sass/at_root_query.dart b/lib/src/ast/sass/at_root_query.dart index 3bad9cf20..58b2e2f94 100644 --- a/lib/src/ast/sass/at_root_query.dart +++ b/lib/src/ast/sass/at_root_query.dart @@ -61,7 +61,7 @@ final class AtRootQuery { {Object? url, Logger? logger, InterpolationMap? interpolationMap}) => AtRootQueryParser(contents, url: url, logger: logger).parse(); - /// Returns whether [this] excludes [node]. + /// Returns whether `this` excludes [node]. /// /// @nodoc @internal @@ -76,6 +76,6 @@ final class AtRootQuery { }; } - /// Returns whether [this] excludes an at-rule with the given [name]. + /// Returns whether `this` excludes an at-rule with the given [name]. bool excludesName(String name) => (_all || names.contains(name)) != include; } diff --git a/lib/src/ast/sass/expression/binary_operation.dart b/lib/src/ast/sass/expression/binary_operation.dart index dc750900a..15ab22bba 100644 --- a/lib/src/ast/sass/expression/binary_operation.dart +++ b/lib/src/ast/sass/expression/binary_operation.dart @@ -153,13 +153,13 @@ enum BinaryOperator { /// The modulo operator, `%`. modulo('modulo', '%', 6); - /// The English name of [this]. + /// The English name of `this`. final String name; - /// The Sass syntax for [this]. + /// The Sass syntax for `this`. final String operator; - /// The precedence of [this]. + /// The precedence of `this`. /// /// An operator with higher precedence binds tighter. final int precedence; diff --git a/lib/src/ast/sass/expression/list.dart b/lib/src/ast/sass/expression/list.dart index 01416afa4..5bf768cac 100644 --- a/lib/src/ast/sass/expression/list.dart +++ b/lib/src/ast/sass/expression/list.dart @@ -58,7 +58,7 @@ final class ListExpression implements Expression { return buffer.toString(); } - /// Returns whether [expression], contained in [this], needs parentheses when + /// Returns whether [expression], contained in `this`, needs parentheses when /// printed as Sass source. bool _elementNeedsParens(Expression expression) => switch (expression) { ListExpression( diff --git a/lib/src/ast/sass/expression/string.dart b/lib/src/ast/sass/expression/string.dart index 2e7824345..a8539146a 100644 --- a/lib/src/ast/sass/expression/string.dart +++ b/lib/src/ast/sass/expression/string.dart @@ -23,7 +23,7 @@ final class StringExpression implements Expression { /// included. final Interpolation text; - /// Whether [this] has quotes. + /// Whether `this` has quotes. final bool hasQuotes; FileSpan get span => text.span; diff --git a/lib/src/ast/sass/expression/unary_operation.dart b/lib/src/ast/sass/expression/unary_operation.dart index d437fafc2..18e5f0c27 100644 --- a/lib/src/ast/sass/expression/unary_operation.dart +++ b/lib/src/ast/sass/expression/unary_operation.dart @@ -63,10 +63,10 @@ enum UnaryOperator { /// The boolean negation operator, `not`. not('not', 'not'); - /// The English name of [this]. + /// The English name of `this`. final String name; - /// The Sass syntax for [this]. + /// The Sass syntax for `this`. final String operator; const UnaryOperator(this.name, this.operator); diff --git a/lib/src/ast/selector.dart b/lib/src/ast/selector.dart index 953ccf7aa..fcabcdc25 100644 --- a/lib/src/ast/selector.dart +++ b/lib/src/ast/selector.dart @@ -83,7 +83,7 @@ abstract base class Selector implements AstNode { Selector(this.span); - /// Prints a warning if [this] is a bogus selector. + /// Prints a warning if `this` is a bogus selector. /// /// This may only be called from within a custom Sass function. This will /// throw a [SassException] in Dart Sass 2.0.0. diff --git a/lib/src/ast/selector/list.dart b/lib/src/ast/selector/list.dart index 8da4598f6..b45fa2a2d 100644 --- a/lib/src/ast/selector/list.dart +++ b/lib/src/ast/selector/list.dart @@ -96,9 +96,9 @@ final class SelectorList extends Selector { return contents.isEmpty ? null : SelectorList(contents, span); } - /// Returns a new selector list that represents [this] nested within [parent]. + /// Returns a new selector list that represents `this` nested within [parent]. /// - /// By default, this replaces [ParentSelector]s in [this] with [parent]. If + /// By default, this replaces [ParentSelector]s in `this` with [parent]. If /// [preserveParentSelectors] is true, this instead preserves those selectors /// as parent selectors. /// diff --git a/lib/src/ast/selector/pseudo.dart b/lib/src/ast/selector/pseudo.dart index 44a263d15..4301d13e1 100644 --- a/lib/src/ast/selector/pseudo.dart +++ b/lib/src/ast/selector/pseudo.dart @@ -170,7 +170,7 @@ final class PseudoSelector extends SimpleSelector { for (var simple in compound) { if (simple case PseudoSelector(isElement: true)) { // A given compound selector may only contain one pseudo element. If - // [compound] has a different one than [this], unification fails. + // [compound] has a different one than `this`, unification fails. if (isElement) return null; // Otherwise, this is a pseudo selector and should come before pseudo diff --git a/lib/src/ast/selector/simple.dart b/lib/src/ast/selector/simple.dart index d8ae7864d..423ad794c 100644 --- a/lib/src/ast/selector/simple.dart +++ b/lib/src/ast/selector/simple.dart @@ -49,7 +49,7 @@ abstract base class SimpleSelector extends Selector { url: url, logger: logger, allowParent: allowParent) .parseSimpleSelector(); - /// Returns a new [SimpleSelector] based on [this], as though it had been + /// Returns a new [SimpleSelector] based on `this`, as though it had been /// written with [suffix] at the end. /// /// Assumes [suffix] is a valid identifier suffix. If this wouldn't produce a diff --git a/lib/src/async_environment.dart b/lib/src/async_environment.dart index 96cbecc18..5313d8c45 100644 --- a/lib/src/async_environment.dart +++ b/lib/src/async_environment.dart @@ -790,7 +790,7 @@ final class AsyncEnvironment { return Configuration.implicit(configuration); } - /// Returns a module that represents the top-level members defined in [this], + /// Returns a module that represents the top-level members defined in `this`, /// that contains [css] and [preModuleComments] as its CSS, which can be /// extended using [extensionStore]. Module toModule( @@ -802,7 +802,7 @@ final class AsyncEnvironment { forwarded: _forwardedModules.andThen((modules) => MapKeySet(modules))); } - /// Returns a module with the same members and upstream modules as [this], but + /// Returns a module with the same members and upstream modules as `this`, but /// an empty stylesheet and extension store. /// /// This is used when resolving imports, since they need to inject forwarded diff --git a/lib/src/environment.dart b/lib/src/environment.dart index 623f67828..3aa0fa45d 100644 --- a/lib/src/environment.dart +++ b/lib/src/environment.dart @@ -5,7 +5,7 @@ // DO NOT EDIT. This file was generated from async_environment.dart. // See tool/grind/synchronize.dart for details. // -// Checksum: f7172be68e0a19c4dc2d2ad04fc32a843a98a6bd +// Checksum: e1beeae58a4d5b97cd7d4f01c7d46b0586b508b9 // // ignore_for_file: unused_import @@ -796,7 +796,7 @@ final class Environment { return Configuration.implicit(configuration); } - /// Returns a module that represents the top-level members defined in [this], + /// Returns a module that represents the top-level members defined in `this`, /// that contains [css] and [preModuleComments] as its CSS, which can be /// extended using [extensionStore]. Module toModule( @@ -808,7 +808,7 @@ final class Environment { forwarded: _forwardedModules.andThen((modules) => MapKeySet(modules))); } - /// Returns a module with the same members and upstream modules as [this], but + /// Returns a module with the same members and upstream modules as `this`, but /// an empty stylesheet and extension store. /// /// This is used when resolving imports, since they need to inject forwarded diff --git a/lib/src/extend/extension_store.dart b/lib/src/extend/extension_store.dart index 3637b5aac..5f760005d 100644 --- a/lib/src/extend/extension_store.dart +++ b/lib/src/extend/extension_store.dart @@ -386,12 +386,12 @@ class ExtensionStore { } } - /// Extends [this] with all the extensions in [extensions]. + /// Extends `this` with all the extensions in [extensions]. /// - /// These extensions will extend all selectors already in [this], but they + /// These extensions will extend all selectors already in `this`, but they /// will *not* extend other extensions from [extensionStores]. void addExtensions(Iterable extensionStores) { - // Extensions already in [this] whose extenders are extended by + // Extensions already in `this` whose extenders are extended by // [extensions], and thus which need to be updated. List? extensionsToExtend; @@ -973,8 +973,8 @@ class ExtensionStore { return specificity; } - /// Returns a copy of [this] that extends new selectors, as well as a map - /// (with reference equality) from the selectors extended by [this] to the + /// Returns a copy of `this` that extends new selectors, as well as a map + /// (with reference equality) from the selectors extended by `this` to the /// selectors extended by the new [ExtensionStore]. (ExtensionStore, Map>) clone() { var newSelectors = >>{}; diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index 9a0b6af01..9c4467453 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -106,6 +106,12 @@ final global = UnmodifiableListView([ var color = arguments[0].assertColor("color"); var degrees = _angleValue(arguments[1], "degrees"); + if (!color.isLegacy) { + throw SassScriptException( + "adjust-hue() is only supported for legacy colors. Please use " + "color.adjust() instead with an explicit \$space argument."); + } + var suggestedValue = SassNumber(degrees, 'deg'); warnForDeprecation( "adjust-hue() is deprecated. Suggestion:\n" @@ -121,9 +127,15 @@ final global = UnmodifiableListView([ _function("lighten", r"$color, $amount", (arguments) { var color = arguments[0].assertColor("color"); var amount = arguments[1].assertNumber("amount"); + if (!color.isLegacy) { + throw SassScriptException( + "lighten() is only supported for legacy colors. Please use " + "color.adjust() instead with an explicit \$space argument."); + } + var result = color.changeHsl( - lightness: (color.lightness + amount.valueInRange(0, 100, "amount")) - .clamp(0, 100)); + lightness: clampLikeCss( + color.lightness + amount.valueInRange(0, 100, "amount"), 0, 100)); warnForDeprecation( "lighten() is deprecated. " @@ -137,9 +149,15 @@ final global = UnmodifiableListView([ _function("darken", r"$color, $amount", (arguments) { var color = arguments[0].assertColor("color"); var amount = arguments[1].assertNumber("amount"); + if (!color.isLegacy) { + throw SassScriptException( + "darken() is only supported for legacy colors. Please use " + "color.adjust() instead with an explicit \$space argument."); + } + var result = color.changeHsl( - lightness: (color.lightness - amount.valueInRange(0, 100, "amount")) - .clamp(0, 100)); + lightness: clampLikeCss( + color.lightness - amount.valueInRange(0, 100, "amount"), 0, 100)); warnForDeprecation( "darken() is deprecated. " @@ -162,9 +180,17 @@ final global = UnmodifiableListView([ r"$color, $amount": (arguments) { var color = arguments[0].assertColor("color"); var amount = arguments[1].assertNumber("amount"); + if (!color.isLegacy) { + throw SassScriptException( + "saturate() is only supported for legacy colors. Please use " + "color.adjust() instead with an explicit \$space argument."); + } + var result = color.changeHsl( - saturation: (color.saturation + amount.valueInRange(0, 100, "amount")) - .clamp(0, 100)); + saturation: clampLikeCss( + color.saturation + amount.valueInRange(0, 100, "amount"), + 0, + 100)); warnForDeprecation( "saturate() is deprecated. " @@ -179,9 +205,15 @@ final global = UnmodifiableListView([ _function("desaturate", r"$color, $amount", (arguments) { var color = arguments[0].assertColor("color"); var amount = arguments[1].assertNumber("amount"); + if (!color.isLegacy) { + throw SassScriptException( + "desaturate() is only supported for legacy colors. Please use " + "color.adjust() instead with an explicit \$space argument."); + } + var result = color.changeHsl( - saturation: (color.saturation - amount.valueInRange(0, 100, "amount")) - .clamp(0, 100)); + saturation: clampLikeCss( + color.saturation - amount.valueInRange(0, 100, "amount"), 0, 100)); warnForDeprecation( "desaturate() is deprecated. " @@ -203,18 +235,16 @@ final global = UnmodifiableListView([ (arguments) => _transparentize("fade-out", arguments)), BuiltInCallable.overloadedFunction("alpha", { - r"$color": (arguments) { - var argument = arguments[0]; - if (argument is SassString && - !argument.hasQuotes && - argument.text.contains(_microsoftFilterStart)) { - // Support the proprietary Microsoft alpha() function. - return _functionString("alpha", arguments); - } - - var color = argument.assertColor("color"); - return SassNumber(color.alpha); - }, + r"$color": (arguments) => switch (arguments[0]) { + // Support the proprietary Microsoft alpha() function. + SassString(hasQuotes: false, :var text) + when text.contains(_microsoftFilterStart) => + _functionString("alpha", arguments), + SassColor(isLegacy: false) => throw SassScriptException( + "alpha() is only supported for legacy colors. Please use " + "color.channel() instead."), + var argument => SassNumber(argument.assertColor("color").alpha) + }, r"$args...": (arguments) { var argList = arguments[0].asList; if (argList.isNotEmpty && @@ -364,21 +394,26 @@ final module = BuiltInModule("color", functions: [ BuiltInCallable.overloadedFunction("alpha", { r"$color": (arguments) { - var argument = arguments[0]; - if (argument is SassString && - !argument.hasQuotes && - argument.text.contains(_microsoftFilterStart)) { - var result = _functionString("alpha", arguments); - warnForDeprecation( - "Using color.alpha() for a Microsoft filter is deprecated.\n" - "\n" - "Recommendation: $result", - Deprecation.colorModuleCompat); - return result; - } + switch (arguments[0]) { + // Support the proprietary Microsoft alpha() function. + case SassString(hasQuotes: false, :var text) + when text.contains(_microsoftFilterStart): + var result = _functionString("alpha", arguments); + warnForDeprecation( + "Using color.alpha() for a Microsoft filter is deprecated.\n" + "\n" + "Recommendation: $result", + Deprecation.colorModuleCompat); + return result; - var color = argument.assertColor("color"); - return SassNumber(color.alpha); + case SassColor(isLegacy: false): + throw SassScriptException( + "color.alpha() is only supported for legacy colors. Please use " + "color.channel() instead."); + + case var argument: + return SassNumber(argument.assertColor("color").alpha); + } }, r"$args...": (arguments) { if (arguments[0].asList.every((argument) => @@ -424,21 +459,14 @@ final module = BuiltInModule("color", functions: [ (arguments) => SassString(arguments.first.assertColor("color").space.name, quotes: false)), - _function("to-space", r"$color, $space", (arguments) { - var converted = _colorInSpace(arguments[0], arguments[1]); - // `color.to-space()` never returns missing channels for legacy color - // spaces because they're less compatible and users are probably using a - // legacy space because they want a highly compatible color. - return converted.isLegacy && - (converted.isChannel0Missing || - converted.isChannel1Missing || - converted.isChannel2Missing || - converted.isAlphaMissing) && - converted.space != (arguments[0] as SassColor).space - ? SassColor.forSpaceInternal(converted.space, converted.channel0, - converted.channel1, converted.channel2, converted.alpha) - : converted; - }), + // `color.to-space()` never returns missing channels for legacy color spaces + // because they're less compatible and users are probably using a legacy space + // because they want a highly compatible color. + _function( + "to-space", + r"$color, $space", + (arguments) => + _colorInSpace(arguments[0], arguments[1], legacyMissing: false)), _function("is-legacy", r"$color", (arguments) => SassBoolean(arguments[0].assertColor("color").isLegacy)), @@ -475,7 +503,10 @@ final module = BuiltInModule("color", functions: [ (arguments[2].assertString("method")..assertUnquoted("method")).text); if (!space.isBounded) return color; - return color.toSpace(space).toGamut(method).toSpace(color.space); + return color + .toSpace(space) + .toGamut(method) + .toSpace(color.space, legacyMissing: false); }), _function("channel", r"$color, $channel, $space: null", (arguments) { @@ -562,7 +593,8 @@ final _mix = _function("mix", r"$color1, $color2, $weight: 50%, $method: null", if (arguments[3] != sassNull) { return color1.interpolate( color2, InterpolationMethod.fromValue(arguments[3], "method"), - weight: weight.valueInRangeWithUnit(0, 100, "weight", "%") / 100); + weight: weight.valueInRangeWithUnit(0, 100, "weight", "%") / 100, + legacyMissing: false); } _checkPercent(weight, "weight"); @@ -597,9 +629,24 @@ final _complement = "Color space $space doesn't have a hue channel.", 'space'); } - var inSpace = color.toSpace(space); - return inSpace.changeChannels({'hue': inSpace.channel('hue') + 180}).toSpace( - color.space); + var colorInSpace = + color.toSpace(space, legacyMissing: arguments[1] != sassNull); + return (space.isLegacy + ? SassColor.forSpaceInternal( + space, + _adjustChannel(colorInSpace, space.channels[0], + colorInSpace.channel0OrNull, SassNumber(180)), + colorInSpace.channel1OrNull, + colorInSpace.channel2OrNull, + colorInSpace.alphaOrNull) + : SassColor.forSpaceInternal( + space, + colorInSpace.channel0OrNull, + colorInSpace.channel1OrNull, + _adjustChannel(colorInSpace, space.channels[2], + colorInSpace.channel2OrNull, SassNumber(180)), + colorInSpace.alphaOrNull)) + .toSpace(color.space, legacyMissing: false); }); /// The implementation of the `invert()` function. @@ -629,9 +676,13 @@ Value _invert(List arguments, {bool global = false}) { _checkPercent(weightNumber, "weight"); var rgb = color.toSpace(ColorSpace.rgb); + var [channel0, channel1, channel2] = ColorSpace.rgb.channels; return _mixLegacy( - SassColor.rgb(255.0 - rgb.channel0, 255.0 - rgb.channel1, - 255.0 - rgb.channel2, color.alphaOrNull), + SassColor.rgb( + _invertChannel(rgb, channel0, rgb.channel0OrNull), + _invertChannel(rgb, channel1, rgb.channel1OrNull), + _invertChannel(rgb, channel2, rgb.channel2OrNull), + color.alphaOrNull), color, weightNumber) .toSpace(color.space); @@ -645,38 +696,46 @@ Value _invert(List arguments, {bool global = false}) { var inSpace = color.toSpace(space); var inverted = switch (space) { - ColorSpace.hwb => SassColor.hwb((inSpace.channel0 + 180) % 360, - inSpace.channel2, inSpace.channel1, inSpace.alpha), - ColorSpace.hsl => SassColor.hsl((inSpace.channel0 + 180) % 360, - inSpace.channel1, 100 - inSpace.channel2, inSpace.alpha), - ColorSpace.lch => SassColor.lch(100 - inSpace.channel0, inSpace.channel1, - (inSpace.channel2 + 180) % 360, inSpace.alpha), - ColorSpace.oklch => SassColor.oklch(1 - inSpace.channel0, inSpace.channel1, - (inSpace.channel2 + 180) % 360, inSpace.alpha), - ColorSpace( - channels: [ - LinearChannel channel0, - LinearChannel channel1, - LinearChannel channel2 - ] - ) => + ColorSpace.hwb => SassColor.hwb( + _invertChannel(inSpace, space.channels[0], inSpace.channel0OrNull), + inSpace.channel2OrNull, + inSpace.channel1OrNull, + inSpace.alpha), + ColorSpace.hsl || + ColorSpace.lch || + ColorSpace.oklch => SassColor.forSpaceInternal( space, - _invertChannel(channel0, inSpace.channel0), - _invertChannel(channel1, inSpace.channel1), - _invertChannel(channel2, inSpace.channel2), + _invertChannel(inSpace, space.channels[0], inSpace.channel0OrNull), + inSpace.channel1OrNull, + _invertChannel(inSpace, space.channels[2], inSpace.channel2OrNull), + inSpace.alpha), + ColorSpace(channels: [var channel0, var channel1, var channel2]) => + SassColor.forSpaceInternal( + space, + _invertChannel(inSpace, channel0, inSpace.channel0OrNull), + _invertChannel(inSpace, channel1, inSpace.channel1OrNull), + _invertChannel(inSpace, channel2, inSpace.channel2OrNull), inSpace.alpha), _ => throw UnsupportedError("Unknown color space $space.") }; - if (fuzzyEquals(weight, 1)) return inverted.toSpace(color.space); - return color.interpolate(inverted, InterpolationMethod(space), - weight: 1 - weight); + return fuzzyEquals(weight, 1) + ? inverted.toSpace(color.space, legacyMissing: false) + : color.interpolate(inverted, InterpolationMethod(space), + weight: 1 - weight, legacyMissing: false); } /// Returns the inverse of the given [value] in a linear color channel. -double _invertChannel(LinearChannel channel, double value) => - channel.min < 0 ? -value : channel.max - value; +double _invertChannel(SassColor color, ColorChannel channel, double? value) { + if (value == null) _missingChannelError(color, channel.name); + return switch (channel) { + LinearChannel(min: < 0) => -value, + LinearChannel(min: 0, :var max) => max - value, + ColorChannel(isPolarAngle: true) => (value + 180) % 360, + _ => throw UnsupportedError("Unknown channel $channel.") + }; +} /// The implementation of the `grayscale()` function, without any logic for the /// plain-CSS `grayscale()` syntax. @@ -685,11 +744,12 @@ Value _grayscale(Value colorArg) { if (color.isLegacy) { var hsl = color.toSpace(ColorSpace.hsl); - return SassColor.hsl(hsl.channel0, 0, hsl.channel2, hsl.alpha) - .toSpace(color.space); + return SassColor.hsl(hsl.channel0OrNull, 0, hsl.channel2OrNull, hsl.alpha) + .toSpace(color.space, legacyMissing: false); } else { var oklch = color.toSpace(ColorSpace.oklch); - return SassColor.oklch(oklch.channel0, 0, oklch.channel2, oklch.alpha) + return SassColor.oklch( + oklch.channel0OrNull, 0, oklch.channel2OrNull, oklch.alpha) .toSpace(color.space); } } @@ -741,12 +801,14 @@ SassColor _updateComponents(List arguments, var alphaArg = keywords.remove('alpha'); // For backwards-compatibility, we allow legacy colors to modify channels in - // any legacy color space. - var color = - spaceKeyword == null && originalColor.isLegacy && keywords.isNotEmpty - ? _sniffLegacyColorSpace(keywords).andThen(originalColor.toSpace) ?? - originalColor - : _colorInSpace(originalColor, spaceKeyword ?? sassNull); + // any legacy color space and we their powerless channels as 0. + var color = spaceKeyword == null && + originalColor.isLegacy && + keywords.isNotEmpty + ? _sniffLegacyColorSpace(keywords).andThen( + (space) => originalColor.toSpace(space, legacyMissing: false)) ?? + originalColor + : _colorInSpace(originalColor, spaceKeyword ?? sassNull); var oldChannels = color.channels; var channelArgs = List.filled(oldChannels.length, null); @@ -776,7 +838,7 @@ SassColor _updateComponents(List arguments, : _adjustColor(color, channelNumbers, alphaNumber); } - return result.toSpace(originalColor.space); + return result.toSpace(originalColor.space, legacyMissing: false); } /// Returns a copy of [color] with its channel values replaced by those in @@ -839,20 +901,25 @@ SassColor _scaleColor( SassColor color, List channelArgs, SassNumber? alphaArg) => SassColor.forSpaceInternal( color.space, - _scaleChannel(color.space.channels[0], color.channel0, channelArgs[0]), - _scaleChannel(color.space.channels[1], color.channel1, channelArgs[1]), - _scaleChannel(color.space.channels[2], color.channel2, channelArgs[2]), - _scaleChannel(ColorChannel.alpha, color.alpha, alphaArg)); + _scaleChannel(color, color.space.channels[0], color.channel0OrNull, + channelArgs[0]), + _scaleChannel(color, color.space.channels[1], color.channel1OrNull, + channelArgs[1]), + _scaleChannel(color, color.space.channels[2], color.channel2OrNull, + channelArgs[2]), + _scaleChannel(color, ColorChannel.alpha, color.alphaOrNull, alphaArg)); /// Returns [oldValue] scaled by [factorArg] according to the definition in /// [channel]. -double _scaleChannel( - ColorChannel channel, double oldValue, SassNumber? factorArg) { +double? _scaleChannel(SassColor color, ColorChannel channel, double? oldValue, + SassNumber? factorArg) { if (factorArg == null) return oldValue; if (channel is! LinearChannel) { throw SassScriptException("Channel isn't scalable.", channel.name); } + if (oldValue == null) _missingChannelError(color, channel.name); + var factor = (factorArg..assertUnit('%', channel.name)) .valueInRangeWithUnit(-100, 100, channel.name, '%') / 100; @@ -873,32 +940,33 @@ SassColor _adjustColor( SassColor color, List channelArgs, SassNumber? alphaArg) => SassColor.forSpaceInternal( color.space, - _adjustChannel(color.space, color.space.channels[0], color.channel0, + _adjustChannel(color, color.space.channels[0], color.channel0OrNull, channelArgs[0]), - _adjustChannel(color.space, color.space.channels[1], color.channel1, + _adjustChannel(color, color.space.channels[1], color.channel1OrNull, channelArgs[1]), - _adjustChannel(color.space, color.space.channels[2], color.channel2, + _adjustChannel(color, color.space.channels[2], color.channel2OrNull, channelArgs[2]), // The color space doesn't matter for alpha, as long as it's not // strictly bounded. - _adjustChannel( - ColorSpace.lab, ColorChannel.alpha, color.alpha, alphaArg) - .clamp(0, 1)); + _adjustChannel(color, ColorChannel.alpha, color.alphaOrNull, alphaArg) + .andThen((alpha) => clampLikeCss(alpha, 0, 1))); /// Returns [oldValue] adjusted by [adjustmentArg] according to the definition -/// in [space]'s [channel]. -double _adjustChannel(ColorSpace space, ColorChannel channel, double oldValue, +/// in [color]'s space's [channel]. +double? _adjustChannel(SassColor color, ColorChannel channel, double? oldValue, SassNumber? adjustmentArg) { if (adjustmentArg == null) return oldValue; - switch ((space, channel)) { - case (ColorSpace.hsl || ColorSpace.hwb, _) when channel is! LinearChannel: + if (oldValue == null) _missingChannelError(color, channel.name); + + switch ((color.space, channel)) { + case (ColorSpace.hsl || ColorSpace.hwb, ColorChannel(isPolarAngle: true)): // `_channelFromValue` expects all hue values to be compatible with `deg`, // but we're still in the deprecation period where we allow non-`deg` // values for HSL and HWB so we have to handle that ahead-of-time. adjustmentArg = SassNumber(_angleValue(adjustmentArg, 'hue')); - case (ColorSpace.hsl, LinearChannel()): + case (ColorSpace.hsl, LinearChannel(name: 'saturation' || 'lightness')): // `_channelFromValue` expects lightness/saturation to be `%`, but we're // still in the deprecation period where we allow non-`%` values so we // have to handle that ahead-of-time. @@ -996,9 +1064,10 @@ Value _rgb(String name, List arguments) { arguments[0].assertNumber("red"), arguments[1].assertNumber("green"), arguments[2].assertNumber("blue"), - alpha.andThen((alpha) => - _percentageOrUnitless(alpha.assertNumber("alpha"), 1, "alpha") - .clamp(0, 1)) ?? + alpha.andThen((alpha) => clampLikeCss( + _percentageOrUnitless(alpha.assertNumber("alpha"), 1, "alpha"), + 0, + 1)) ?? 1, fromRgbFunction: true); } @@ -1014,6 +1083,14 @@ Value _rgbTwoArg(String name, List arguments) { } var color = first.assertColor("color"); + if (!color.isLegacy) { + throw SassScriptException( + 'Expected $color to be in the legacy RGB, HSL, or HWB color space.\n' + '\n' + 'Recommendation: color.change($color, \$alpha: $second)', + name); + } + color.assertLegacy("color"); color = color.toSpace(ColorSpace.rgb); if (second.isSpecialNumber) { @@ -1026,8 +1103,8 @@ Value _rgbTwoArg(String name, List arguments) { } var alpha = arguments[1].assertNumber("alpha"); - return color - .changeAlpha(_percentageOrUnitless(alpha, 1, "alpha").clamp(0, 1)); + return color.changeAlpha( + clampLikeCss(_percentageOrUnitless(alpha, 1, "alpha"), 0, 1)); } /// The implementation of the three- and four-argument `hsl()` and `hsla()` @@ -1046,9 +1123,10 @@ Value _hsl(String name, List arguments) { arguments[0].assertNumber("hue"), arguments[1].assertNumber("saturation"), arguments[2].assertNumber("lightness"), - alpha.andThen((alpha) => - _percentageOrUnitless(alpha.assertNumber("alpha"), 1, "alpha") - .clamp(0, 1)) ?? + alpha.andThen((alpha) => clampLikeCss( + _percentageOrUnitless(alpha.assertNumber("alpha"), 1, "alpha"), + 0, + 1)) ?? 1); } @@ -1155,9 +1233,14 @@ SassColor _mixLegacy(SassColor color1, SassColor color2, SassNumber weight) { SassColor _opacify(String name, List arguments) { var color = arguments[0].assertColor("color"); var amount = arguments[1].assertNumber("amount"); - var result = color.changeAlpha( - (color.alpha + amount.valueInRangeWithUnit(0, 1, "amount", "")) - .clamp(0, 1)); + if (!color.isLegacy) { + throw SassScriptException( + "$name() is only supported for legacy colors. Please use " + "color.adjust() instead with an explicit \$space argument."); + } + + var result = color.changeAlpha(clampLikeCss( + (color.alpha + amount.valueInRangeWithUnit(0, 1, "amount", "")), 0, 1)); warnForDeprecation( "$name() is deprecated. " @@ -1172,9 +1255,14 @@ SassColor _opacify(String name, List arguments) { SassColor _transparentize(String name, List arguments) { var color = arguments[0].assertColor("color"); var amount = arguments[1].assertNumber("amount"); - var result = color.changeAlpha( - (color.alpha - amount.valueInRangeWithUnit(0, 1, "amount", "")) - .clamp(0, 1)); + if (!color.isLegacy) { + throw SassScriptException( + "$name() is only supported for legacy colors. Please use " + "color.adjust() instead with an explicit \$space argument."); + } + + var result = color.changeAlpha(clampLikeCss( + (color.alpha - amount.valueInRangeWithUnit(0, 1, "amount", "")), 0, 1)); warnForDeprecation( "$name() is deprecated. " @@ -1188,17 +1276,22 @@ SassColor _transparentize(String name, List arguments) { /// Returns the [colorUntyped] as a [SassColor] in the color space specified by /// [spaceUntyped]. /// +/// If [legacyMissing] is false, this will convert missing channels in legacy +/// color spaces to zero if a conversion occurs. +/// /// Throws a [SassScriptException] if either argument isn't the expected type or /// if [spaceUntyped] isn't the name of a color space. If [spaceUntyped] is /// `sassNull`, it defaults to the color's existing space. -SassColor _colorInSpace(Value colorUntyped, Value spaceUntyped) { +SassColor _colorInSpace(Value colorUntyped, Value spaceUntyped, + {bool legacyMissing = true}) { var color = colorUntyped.assertColor("color"); if (spaceUntyped == sassNull) return color; - var space = ColorSpace.fromName( - (spaceUntyped.assertString("space")..assertUnquoted("space")).text, - "space"); - return color.space == space ? color : color.toSpace(space); + return color.toSpace( + ColorSpace.fromName( + (spaceUntyped.assertString("space")..assertUnquoted("space")).text, + "space"), + legacyMissing: legacyMissing); } /// Returns the color space named by [space], or throws a [SassScriptException] @@ -1299,8 +1392,10 @@ Value _parseChannels(String functionName, Value input, var alpha = switch (alphaValue) { null => 1.0, SassString(hasQuotes: false, text: 'none') => null, - _ => _percentageOrUnitless(alphaValue.assertNumber(name), 1, 'alpha') - .clamp(0, 1) + _ => clampLikeCss( + _percentageOrUnitless(alphaValue.assertNumber(name), 1, 'alpha'), + 0, + 1) .toDouble() }; @@ -1458,10 +1553,10 @@ double? _channelFromValue(ColorChannel channel, SassNumber? value, _percentageOrUnitless(value, channel.max, channel.name), LinearChannel() when !clamp => _percentageOrUnitless(value, channel.max, channel.name), - LinearChannel(:var lowerClamped, :var upperClamped) => - _percentageOrUnitless(value, channel.max, channel.name).clamp( - lowerClamped ? channel.min : double.negativeInfinity, - upperClamped ? channel.max : double.infinity), + LinearChannel(:var lowerClamped, :var upperClamped) => clampLikeCss( + _percentageOrUnitless(value, channel.max, channel.name), + lowerClamped ? channel.min : double.negativeInfinity, + upperClamped ? channel.max : double.infinity), _ => value.coerceValueToUnit('deg', channel.name) % 360 }); @@ -1539,6 +1634,15 @@ String _suggestScaleAndAdjust( return suggestion + "color.adjust(\$color, \$$channelName: $difference)"; } +/// Throws an error indicating that a missing channel named [name] can't be +/// modified. +Never _missingChannelError(SassColor color, String channel) => + throw SassScriptException( + "Because the CSS working group is still deciding on the best behavior, " + "Sass doesn't currently support modifying missing channels (color: " + "$color).", + channel); + /// Asserts that `value` is an unquoted string and throws an error if it's not. /// /// Assumes that `value` comes from a parameter named `$channel`. diff --git a/lib/src/js/legacy/value/color.dart b/lib/src/js/legacy/value/color.dart index 6aae64426..0545e761e 100644 --- a/lib/src/js/legacy/value/color.dart +++ b/lib/src/js/legacy/value/color.dart @@ -4,6 +4,7 @@ import 'package:js/js.dart'; +import '../../../util/nullable.dart'; import '../../../util/number.dart'; import '../../../value.dart'; import '../../reflection.dart'; @@ -45,8 +46,8 @@ final JSClass legacyColorClass = createJSClass('sass.types.Color', red = redOrArgb!; } - thisArg.dartValue = SassColor.rgb( - _clamp(red), _clamp(green), _clamp(blue), alpha?.clamp(0, 1) ?? 1); + thisArg.dartValue = SassColor.rgb(_clamp(red), _clamp(green), _clamp(blue), + alpha.andThen((alpha) => clampLikeCss(alpha.toDouble(), 0, 1)) ?? 1); }) ..defineMethods({ 'getR': (_NodeSassColor thisArg) => thisArg.dartValue.red, @@ -63,10 +64,11 @@ final JSClass legacyColorClass = createJSClass('sass.types.Color', thisArg.dartValue = thisArg.dartValue.changeRgb(blue: _clamp(value)); }, 'setA': (_NodeSassColor thisArg, num value) { - thisArg.dartValue = thisArg.dartValue.changeRgb(alpha: value.clamp(0, 1)); + thisArg.dartValue = thisArg.dartValue + .changeRgb(alpha: clampLikeCss(value.toDouble(), 0, 1)); } }); /// Clamps [channel] within the range 0, 255 and rounds it to the nearest /// integer. -int _clamp(num channel) => fuzzyRound(channel.clamp(0, 255)); +int _clamp(num channel) => fuzzyRound(clampLikeCss(channel.toDouble(), 0, 255)); diff --git a/lib/src/stylesheet_graph.dart b/lib/src/stylesheet_graph.dart index 3109fc5f0..245d8b146 100644 --- a/lib/src/stylesheet_graph.dart +++ b/lib/src/stylesheet_graph.dart @@ -385,7 +385,7 @@ class StylesheetNode { _upstreamImports = newUpstreamImports; } - /// Removes [this] as an upstream and downstream node from all the nodes that + /// Removes `this` as an upstream and downstream node from all the nodes that /// import it and that it imports. void _remove() { for (var node in {...upstream.values, ...upstreamImports.values}) { diff --git a/lib/src/util/map.dart b/lib/src/util/map.dart index a61c151df..865b213bc 100644 --- a/lib/src/util/map.dart +++ b/lib/src/util/map.dart @@ -5,7 +5,7 @@ import 'option.dart'; extension MapExtensions on Map { - /// If [this] doesn't contain the given [key], sets that key to [value] and + /// If `this` doesn't contain the given [key], sets that key to [value] and /// returns it. /// /// Otherwise, calls [merge] with the existing value and [value] and sets diff --git a/lib/src/util/multi_span.dart b/lib/src/util/multi_span.dart index 24ca42b48..41121042a 100644 --- a/lib/src/util/multi_span.dart +++ b/lib/src/util/multi_span.dart @@ -70,7 +70,7 @@ class MultiSpan implements FileSpan { primaryColor: primaryColor, secondaryColor: secondaryColor); - /// Returns a copy of [this] with [newPrimary] as its primary span. + /// Returns a copy of `this` with [newPrimary] as its primary span. MultiSpan _withPrimary(FileSpan newPrimary) => MultiSpan._(newPrimary, primaryLabel, secondarySpans); } diff --git a/lib/src/util/nullable.dart b/lib/src/util/nullable.dart index ad4a8ba2f..cf24c880e 100644 --- a/lib/src/util/nullable.dart +++ b/lib/src/util/nullable.dart @@ -3,7 +3,7 @@ // https://opensource.org/licenses/MIT. extension NullableExtension on T? { - /// If [this] is `null`, returns `null`. Otherwise, runs [fn] and returns its + /// If `this` is `null`, returns `null`. Otherwise, runs [fn] and returns its /// result. /// /// Based on Rust's `Option.and_then`. diff --git a/lib/src/util/number.dart b/lib/src/util/number.dart index 9eca12928..8aad45581 100644 --- a/lib/src/util/number.dart +++ b/lib/src/util/number.dart @@ -140,6 +140,12 @@ double moduloLikeSass(double num1, double num2) { return result == 0 ? 0 : result + num2; } +//// Returns [num] clamped between [lowerBound] and [upperBound], with `NaN` +//// preferring the lower bound (unlike Dart for which it prefers the upper +//// bound). +double clampLikeCss(double number, double lowerBound, double upperBound) => + number.isNaN ? lowerBound : number.clamp(lowerBound, upperBound); + /// Returns the square root of [number]. SassNumber sqrt(SassNumber number) { number.assertNoUnits("number"); diff --git a/lib/src/util/span.dart b/lib/src/util/span.dart index bcb9b6165..6328f4aed 100644 --- a/lib/src/util/span.dart +++ b/lib/src/util/span.dart @@ -84,10 +84,10 @@ extension SpanExtensions on FileSpan { return subspan(scanner.position).trimLeft(); } - /// Whether [this] FileSpan contains the [target] FileSpan. + /// Whether this [FileSpan] contains the [target] FileSpan. /// /// Validates the FileSpans to be in the same file and for the [target] to be - /// within [this] FileSpan inclusive range [start,end]. + /// within this [FileSpan]'s inclusive range `[start,end]`. bool contains(FileSpan target) => file.url == target.file.url && start.offset <= target.start.offset && diff --git a/lib/src/value.dart b/lib/src/value.dart index ae9cb0bf0..81f4df27e 100644 --- a/lib/src/value.dart +++ b/lib/src/value.dart @@ -100,7 +100,7 @@ abstract class Value { @internal bool get isVar => false; - /// Returns Dart's `null` value if this is [sassNull], and returns [this] + /// Returns Dart's `null` value if this is [sassNull], and returns `this` /// otherwise. Value? get realNull => this; @@ -148,7 +148,7 @@ abstract class Value { return index < 0 ? lengthAsList + index : index - 1; } - /// Throws a [SassScriptException] if [this] isn't a boolean. + /// Throws a [SassScriptException] if `this` isn't a boolean. /// /// Note that generally, functions should use [isTruthy] rather than requiring /// a literal boolean. @@ -158,60 +158,60 @@ abstract class Value { SassBoolean assertBoolean([String? name]) => throw SassScriptException("$this is not a boolean.", name); - /// Throws a [SassScriptException] if [this] isn't a calculation. + /// Throws a [SassScriptException] if `this` isn't a calculation. /// /// If this came from a function argument, [name] is the argument name /// (without the `$`). It's used for error reporting. SassCalculation assertCalculation([String? name]) => throw SassScriptException("$this is not a calculation.", name); - /// Throws a [SassScriptException] if [this] isn't a color. + /// Throws a [SassScriptException] if `this` isn't a color. /// /// If this came from a function argument, [name] is the argument name /// (without the `$`). It's used for error reporting. SassColor assertColor([String? name]) => throw SassScriptException("$this is not a color.", name); - /// Throws a [SassScriptException] if [this] isn't a function reference. + /// Throws a [SassScriptException] if `this` isn't a function reference. /// /// If this came from a function argument, [name] is the argument name /// (without the `$`). It's used for error reporting. SassFunction assertFunction([String? name]) => throw SassScriptException("$this is not a function reference.", name); - /// Throws a [SassScriptException] if [this] isn't a mixin reference. + /// Throws a [SassScriptException] if `this` isn't a mixin reference. /// /// If this came from a function argument, [name] is the argument name /// (without the `$`). It's used for error reporting. SassMixin assertMixin([String? name]) => throw SassScriptException("$this is not a mixin reference.", name); - /// Throws a [SassScriptException] if [this] isn't a map. + /// Throws a [SassScriptException] if `this` isn't a map. /// /// If this came from a function argument, [name] is the argument name /// (without the `$`). It's used for error reporting. SassMap assertMap([String? name]) => throw SassScriptException("$this is not a map.", name); - /// Returns [this] as a [SassMap] if it is one (including empty lists, which + /// Returns `this` as a [SassMap] if it is one (including empty lists, which /// count as empty maps) or returns `null` if it's not. SassMap? tryMap() => null; - /// Throws a [SassScriptException] if [this] isn't a number. + /// Throws a [SassScriptException] if `this` isn't a number. /// /// If this came from a function argument, [name] is the argument name /// (without the `$`). It's used for error reporting. SassNumber assertNumber([String? name]) => throw SassScriptException("$this is not a number.", name); - /// Throws a [SassScriptException] if [this] isn't a string. + /// Throws a [SassScriptException] if `this` isn't a string. /// /// If this came from a function argument, [name] is the argument name /// (without the `$`). It's used for error reporting. SassString assertString([String? name]) => throw SassScriptException("$this is not a string.", name); - /// Throws a [SassScriptException] if [this] isn't a list of the sort commonly + /// Throws a [SassScriptException] if `this` isn't a list of the sort commonly /// used in plain CSS expression syntax: space-separated and unbracketed. /// /// If [allowSlash] is `true`, this allows slash-separated lists as well. @@ -241,7 +241,7 @@ abstract class Value { /// Converts a `selector-parse()`-style input into a string that can be /// parsed. /// - /// Throws a [SassScriptException] if [this] isn't a type or a structure that + /// Throws a [SassScriptException] if `this` isn't a type or a structure that /// can be parsed as a selector. String _selectorString([String? name]) { if (_selectorStringOrNull() case var string?) return string; @@ -255,7 +255,7 @@ abstract class Value { /// Converts a `selector-parse()`-style input into a string that can be /// parsed. /// - /// Returns `null` if [this] isn't a type or a structure that can be parsed as + /// Returns `null` if `this` isn't a type or a structure that can be parsed as /// a selector. String? _selectorStringOrNull() { var self = this; @@ -397,7 +397,7 @@ abstract class Value { @internal Value unaryNot() => sassFalse; - /// Returns a copy of [this] without [SassNumber.asSlash] set. + /// Returns a copy of `this` without [SassNumber.asSlash] set. /// /// If this isn't a [SassNumber], returns it as-is. /// @@ -405,9 +405,9 @@ abstract class Value { @internal Value withoutSlash() => this; - /// Returns a valid CSS representation of [this]. + /// Returns a valid CSS representation of `this`. /// - /// Throws a [SassScriptException] if [this] can't be represented in plain + /// Throws a [SassScriptException] if `this` can't be represented in plain /// CSS. Use [toString] instead to get a string representation even if this /// isn't valid CSS. // @@ -416,11 +416,11 @@ abstract class Value { String toCssString({@internal bool quote = true}) => serializeValue(this, quote: quote); - /// Returns a string representation of [this]. + /// Returns a string representation of `this`. /// /// Note that this is equivalent to calling `inspect()` on the value, and thus /// won't reflect the user's output settings. [toCssString] should be used - /// instead to convert [this] to CSS. + /// instead to convert `this` to CSS. String toString() => serializeValue(this, inspect: true); } @@ -431,7 +431,7 @@ abstract class Value { /// /// {@category Value} extension SassApiValue on Value { - /// Parses [this] as a selector list, in the same manner as the + /// Parses `this` as a selector list, in the same manner as the /// `selector-parse()` function. /// /// Throws a [SassScriptException] if this isn't a type that can be parsed as a @@ -455,7 +455,7 @@ extension SassApiValue on Value { } } - /// Parses [this] as a simple selector, in the same manner as the + /// Parses `this` as a simple selector, in the same manner as the /// `selector-parse()` function. /// /// Throws a [SassScriptException] if this isn't a type that can be parsed as a @@ -480,7 +480,7 @@ extension SassApiValue on Value { } } - /// Parses [this] as a compound selector, in the same manner as the + /// Parses `this` as a compound selector, in the same manner as the /// `selector-parse()` function. /// /// Throws a [SassScriptException] if this isn't a type that can be parsed as a @@ -505,7 +505,7 @@ extension SassApiValue on Value { } } - /// Parses [this] as a complex selector, in the same manner as the + /// Parses `this` as a complex selector, in the same manner as the /// `selector-parse()` function. /// /// Throws a [SassScriptException] if this isn't a type that can be parsed as a diff --git a/lib/src/value/calculation.dart b/lib/src/value/calculation.dart index cbb8b92e6..261400dc2 100644 --- a/lib/src/value/calculation.dart +++ b/lib/src/value/calculation.dart @@ -906,13 +906,13 @@ enum CalculationOperator { /// The division operator. dividedBy('divided by', '/', 2); - /// The English name of [this]. + /// The English name of `this`. final String name; - /// The CSS syntax for [this]. + /// The CSS syntax for `this`. final String operator; - /// The precedence of [this]. + /// The precedence of `this`. /// /// An operator with higher precedence binds tighter. /// diff --git a/lib/src/value/color.dart b/lib/src/value/color.dart index 9a61f91eb..8843aed27 100644 --- a/lib/src/value/color.dart +++ b/lib/src/value/color.dart @@ -523,22 +523,6 @@ class SassColor extends Value { alpha.andThen((alpha) => fuzzyAssertRange(alpha, 0, 1, "alpha")) { assert(format == null || _space == ColorSpace.rgb); assert(space != ColorSpace.lms); - - _checkChannel(channel0OrNull, space.channels[0].name); - _checkChannel(channel1OrNull, space.channels[1].name); - _checkChannel(channel2OrNull, space.channels[2].name); - } - - /// Throws a [RangeError] if [channel] isn't a finite number. - void _checkChannel(double? channel, String name) { - switch (channel) { - case null: - return; - case double(isNaN: true): - throw RangeError.value(channel, name, 'must be a number.'); - case double(isFinite: false): - throw RangeError.value(channel, name, 'must be finite.'); - } } /// If [hue] isn't null, normalizes it to the range `[0, 360)`. @@ -640,16 +624,24 @@ class SassColor extends Value { /// Converts this color to [space]. /// - /// If this came from a function argument, [name] is the argument name for - /// this color (without the `$`). It's used for error reporting. - /// - /// This currently can't produce an error, but it will likely do so in the - /// future when Sass adds support for color spaces that don't support - /// automatic conversions. - SassColor toSpace(ColorSpace space) => this.space == space - ? this - : this.space.convert( - space, channel0OrNull, channel1OrNull, channel2OrNull, alpha); + /// If [legacyMissing] is false, this will convert missing channels in + /// legacy color spaces to zero if a conversion occurs. + SassColor toSpace(ColorSpace space, {bool legacyMissing = true}) { + if (this.space == space) return this; + + var converted = this + .space + .convert(space, channel0OrNull, channel1OrNull, channel2OrNull, alpha); + return !legacyMissing && + converted.isLegacy && + (converted.isChannel0Missing || + converted.isChannel1Missing || + converted.isChannel2Missing || + converted.isAlphaMissing) + ? SassColor.forSpaceInternal(converted.space, converted.channel0, + converted.channel1, converted.channel2, converted.alpha) + : converted; + } /// Returns a copy of this color that's in-gamut in the current color space. SassColor toGamut(GamutMapMethod method) => @@ -724,12 +716,7 @@ class SassColor extends Value { /// name (without the `$`). This is used for error reporting. SassColor changeChannels(Map newValues, {ColorSpace? space, String? colorName}) { - if (newValues.isEmpty) { - // If space conversion produces an error, we still want to expose that - // error even if there's nothing to change. - if (space != null && space != this.space) toSpace(space); - return this; - } + if (newValues.isEmpty) return this; if (space != null && space != this.space) { return toSpace(space) @@ -800,15 +787,18 @@ class SassColor extends Value { new1 ?? channel1OrNull, new2 ?? channel2OrNull, alpha ?? alphaOrNull); } - /// Returns a color partway between [this] and [other] according to [method], + /// Returns a color partway between `this` and [other] according to [method], /// as defined by the CSS Color 4 [color interpolation] procedure. /// /// [color interpolation]: https://www.w3.org/TR/css-color-4/#interpolation /// - /// The [weight] is a number between 0 and 1 that indicates how much of [this] + /// The [weight] is a number between 0 and 1 that indicates how much of `this` /// should be in the resulting color. It defaults to 0.5. + /// + /// If [legacyMissing] is false, this will convert missing channels in legacy + /// color spaces to zero if a conversion occurs. SassColor interpolate(SassColor other, InterpolationMethod method, - {double? weight}) { + {double? weight, bool legacyMissing = true}) { weight ??= 0.5; if (fuzzyEquals(weight, 0)) return other; @@ -877,7 +867,7 @@ class SassColor extends Value { _ => SassColor.forSpaceInternal( method.space, mixed0, mixed1, mixed2, mixedAlpha) } - .toSpace(space); + .toSpace(space, legacyMissing: legacyMissing); } /// Returns whether [output], which was converted to its color space from diff --git a/lib/src/value/color/gamut_map_method/clip.dart b/lib/src/value/color/gamut_map_method/clip.dart index 363f59374..14420dea3 100644 --- a/lib/src/value/color/gamut_map_method/clip.dart +++ b/lib/src/value/color/gamut_map_method/clip.dart @@ -4,6 +4,7 @@ import 'package:meta/meta.dart'; +import '../../../util/number.dart'; import '../../color.dart'; /// Gamut mapping by clipping individual channels. @@ -24,7 +25,7 @@ final class ClipGamutMap extends GamutMapMethod { double? _clampChannel(double? value, ColorChannel channel) => value == null ? null : switch (channel) { - LinearChannel(:var min, :var max) => value.clamp(min, max), + LinearChannel(:var min, :var max) => clampLikeCss(value, min, max), _ => value }; } diff --git a/lib/src/value/number.dart b/lib/src/value/number.dart index 154367554..f2527ad0b 100644 --- a/lib/src/value/number.dart +++ b/lib/src/value/number.dart @@ -190,7 +190,7 @@ abstract class SassNumber extends Value { /// The value of this number. /// - /// Note that Sass stores all numbers as [double]s even if if [this] + /// Note that Sass stores all numbers as [double]s even if if `this` /// represents an integer from Sass's perspective. Use [isInt] to determine /// whether this is an integer, [asInt] to get its integer value, or /// [assertInt] to do both at once. @@ -209,14 +209,14 @@ abstract class SassNumber extends Value { /// This number's denominator units. List get denominatorUnits; - /// Whether [this] has any units. + /// Whether `this` has any units. /// /// If a function expects a number to have no units, it should use /// [assertNoUnits]. If it expects the number to have a particular unit, it /// should use [assertUnit]. bool get hasUnits; - /// Whether [this] has more than one numerator unit, or any denominator units. + /// Whether `this` has more than one numerator unit, or any denominator units. /// /// This is `true` for numbers whose units make them unrepresentable as CSS /// lengths. @@ -229,7 +229,7 @@ abstract class SassNumber extends Value { @internal final (SassNumber, SassNumber)? asSlash; - /// Whether [this] is an integer, according to [fuzzyEquals]. + /// Whether `this` is an integer, according to [fuzzyEquals]. /// /// The [int] value can be accessed using [asInt] or [assertInt]. Note that /// this may return `false` for very large doubles even though they may be @@ -237,7 +237,7 @@ abstract class SassNumber extends Value { /// representation for integers that large. bool get isInt => fuzzyIsInt(value); - /// If [this] is an integer according to [isInt], returns [value] as an [int]. + /// If `this` is an integer according to [isInt], returns [value] as an [int]. /// /// Otherwise, returns `null`. int? get asInt => fuzzyAsInt(value); @@ -304,20 +304,20 @@ abstract class SassNumber extends Value { T accept(ValueVisitor visitor) => visitor.visitNumber(this); - /// Returns a number with the same units as [this] but with [value] as its + /// Returns a number with the same units as `this` but with [value] as its /// value. /// /// @nodoc @protected SassNumber withValue(num value); - /// Returns a copy of [this] without [asSlash] set. + /// Returns a copy of `this` without [asSlash] set. /// /// @nodoc @internal SassNumber withoutSlash() => asSlash == null ? this : withValue(value); - /// Returns a copy of [this] with [asSlash] set to a pair containing + /// Returns a copy of `this` with [asSlash] set to a pair containing /// [numerator] and [denominator]. /// /// @nodoc @@ -365,10 +365,10 @@ abstract class SassNumber extends Value { "Expected $this to be within $min$unit and $max$unit.", name); } - /// Returns whether [this] has [unit] as its only unit (and as a numerator). + /// Returns whether `this` has [unit] as its only unit (and as a numerator). bool hasUnit(String unit); - /// Returns whether [this] has units that are compatible with [other]. + /// Returns whether `this` has units that are compatible with [other]. /// /// Unlike [isComparableTo], unitless numbers are only considered compatible /// with other unitless numbers. @@ -378,17 +378,17 @@ abstract class SassNumber extends Value { return isComparableTo(other); } - /// Returns whether [this] has units that are possibly-compatible with + /// Returns whether `this` has units that are possibly-compatible with /// [other], as defined by the Sass spec. @internal bool hasPossiblyCompatibleUnits(SassNumber other); - /// Returns whether [this] can be coerced to the given [unit]. + /// Returns whether `this` can be coerced to the given [unit]. /// /// This always returns `true` for a unitless number. bool compatibleWithUnit(String unit); - /// Throws a [SassScriptException] unless [this] has [unit] as its only unit + /// Throws a [SassScriptException] unless `this` has [unit] as its only unit /// (and as a numerator). /// /// If this came from a function argument, [name] is the argument name @@ -398,7 +398,7 @@ abstract class SassNumber extends Value { throw SassScriptException('Expected $this to have unit "$unit".', name); } - /// Throws a [SassScriptException] unless [this] has no units. + /// Throws a [SassScriptException] unless `this` has no units. /// /// If this came from a function argument, [name] is the argument name /// (without the `$`). It's used for error reporting. @@ -574,7 +574,7 @@ abstract class SassNumber extends Value { /// /// If [other] is passed, it should be the number from which [newNumerators] /// and [newDenominators] are derived. The [name] and [otherName] are the Sass - /// function parameter names of [this] and [other], respectively, used for + /// function parameter names of `this` and [other], respectively, used for /// error reporting. double _coerceOrConvertValue( List newNumerators, List newDenominators, @@ -778,7 +778,7 @@ abstract class SassNumber extends Value { return operation(value, other.coerceValueToMatch(this)); } on SassScriptException { // If the conversion fails, re-run it in the other direction. This will - // generate an error message that prints [this] before [other], which is + // generate an error message that prints `this` before [other], which is // more readable. coerceValueToMatch(other); rethrow; // This should be unreachable. diff --git a/lib/src/visitor/serialize.dart b/lib/src/visitor/serialize.dart index ae70415c3..dec68b275 100644 --- a/lib/src/visitor/serialize.dart +++ b/lib/src/visitor/serialize.dart @@ -597,14 +597,11 @@ final class _SerializeVisitor _buffer ..write(value.space) ..writeCharCode($lparen); - _writeChannel(value.channel0OrNull); - if (!_isCompressed && !value.isChannel0Missing) _buffer.write('deg'); + _writeChannel(value.channel0OrNull, _isCompressed ? null : 'deg'); _buffer.writeCharCode($space); - _writeChannel(value.channel1OrNull); - if (!value.isChannel1Missing) _buffer.writeCharCode($percent); + _writeChannel(value.channel1OrNull, '%'); _buffer.writeCharCode($space); - _writeChannel(value.channel2OrNull); - if (!value.isChannel2Missing) _buffer.writeCharCode($percent); + _writeChannel(value.channel2OrNull, '%'); _maybeWriteSlashAlpha(value); _buffer.writeCharCode($rparen); @@ -672,10 +669,8 @@ final class _SerializeVisitor _buffer.writeCharCode($space); _writeChannel(value.channel1OrNull); _buffer.writeCharCode($space); - _writeChannel(value.channel2OrNull); - if (!_isCompressed && !value.isChannel2Missing && polar) { - _buffer.write('deg'); - } + _writeChannel( + value.channel2OrNull, polar && !_isCompressed ? 'deg' : null); _maybeWriteSlashAlpha(value); _buffer.writeCharCode($rparen); @@ -685,11 +680,14 @@ final class _SerializeVisitor } /// Writes a [channel] which may be missing. - void _writeChannel(double? channel) { + void _writeChannel(double? channel, [String? unit]) { if (channel == null) { _buffer.write('none'); - } else { + } else if (channel.isFinite) { _writeNumber(channel); + if (unit != null) _buffer.write(unit); + } else { + visitNumber(SassNumber(channel, unit)); } } @@ -866,13 +864,11 @@ final class _SerializeVisitor var opaque = fuzzyEquals(color.alpha, 1); var hsl = color.toSpace(ColorSpace.hsl); _buffer.write(opaque ? "hsl(" : "hsla("); - _writeNumber(hsl.channel('hue')); + _writeChannel(hsl.channel('hue')); _buffer.write(_commaSeparator); - _writeNumber(hsl.channel('saturation')); - _buffer.writeCharCode($percent); + _writeChannel(hsl.channel('saturation'), '%'); _buffer.write(_commaSeparator); - _writeNumber(hsl.channel('lightness')); - _buffer.writeCharCode($percent); + _writeChannel(hsl.channel('lightness'), '%'); if (!opaque) { _buffer.write(_commaSeparator); @@ -948,11 +944,7 @@ final class _SerializeVisitor _writeOptionalSpace(); _buffer.writeCharCode($slash); _writeOptionalSpace(); - if (color.isAlphaMissing) { - _buffer.write('none'); - } else { - _writeNumber(color.alpha); - } + _writeChannel(color.alphaOrNull); } void visitFunction(SassFunction function) { From 422f037ebd95b30e33e95fd1da3752c235b6347d Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Fri, 13 Sep 2024 13:03:37 -0700 Subject: [PATCH 54/56] Fix a typo --- lib/src/value/color/channel.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/value/color/channel.dart b/lib/src/value/color/channel.dart index eb07f8ff1..63e279da7 100644 --- a/lib/src/value/color/channel.dart +++ b/lib/src/value/color/channel.dart @@ -42,7 +42,7 @@ class ColorChannel { /// [analogous]: https://www.w3.org/TR/css-color-4/#interpolation-missing bool isAnalogous(ColorChannel other) => switch ((name, other.name)) { ("red" || "x", "red" || "x") || - ("green" || "y", "gren" || "y") || + ("green" || "y", "green" || "y") || ("blue" || "z", "blue" || "z") || ("chroma" || "saturation", "chroma" || "saturation") || ("lightness", "lightness") || From 34f98c703b248c28d060cffbd70bd3905759f79a Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Fri, 13 Sep 2024 14:03:31 -0700 Subject: [PATCH 55/56] Update color API tests --- lib/src/value/color.dart | 8 +- test/dart_api/value/color_test.dart | 421 +++++++++++++++++----------- 2 files changed, 262 insertions(+), 167 deletions(-) diff --git a/lib/src/value/color.dart b/lib/src/value/color.dart index 8843aed27..d68f465a0 100644 --- a/lib/src/value/color.dart +++ b/lib/src/value/color.dart @@ -83,6 +83,7 @@ class SassColor extends Value { /// [missing]: https://www.w3.org/TR/css-color-4/#missing /// /// @nodoc + @internal final double? channel0OrNull; /// This color's second channel. @@ -118,6 +119,7 @@ class SassColor extends Value { /// [missing]: https://www.w3.org/TR/css-color-4/#missing /// /// @nodoc + @internal final double? channel1OrNull; /// Returns whether this color's third channel is [missing]. @@ -156,6 +158,7 @@ class SassColor extends Value { /// [missing]: https://www.w3.org/TR/css-color-4/#missing /// /// @nodoc + @internal final double? channel2OrNull; /// The format in which this color was originally written and should be @@ -624,8 +627,9 @@ class SassColor extends Value { /// Converts this color to [space]. /// - /// If [legacyMissing] is false, this will convert missing channels in - /// legacy color spaces to zero if a conversion occurs. + /// If [legacyMissing] is false, this will convert missing channels in legacy + /// color spaces to zero if a conversion occurs. Otherwise, they remain + /// missing after the conversion. SassColor toSpace(ColorSpace space, {bool legacyMissing = true}) { if (this.space == space) return this; diff --git a/test/dart_api/value/color_test.dart b/test/dart_api/value/color_test.dart index aa637e93d..e0a6f3028 100644 --- a/test/dart_api/value/color_test.dart +++ b/test/dart_api/value/color_test.dart @@ -2,7 +2,6 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -@Skip("TODO(nweiz): Update these for the new Color API") @TestOn('vm') library; @@ -39,14 +38,116 @@ void main() { expect(value.alpha, equals(1)); }); + test("has a named alpha channel", () { + expect(value.channel("alpha"), equals(1)); + }); + + group("channel()", () { + test("returns RGB channels", () { + expect(value.channel("red"), equals(0x12)); + expect(value.channel("green"), equals(0x34)); + expect(value.channel("blue"), equals(0x56)); + }); + + test("returns alpha", () { + expect(value.channel("alpha"), equals(1)); + }); + + test("throws for a channel not in this space", () { + expect(() => value.channel("hue"), throwsSassScriptException); + }); + }); + + test("isChannelMissing() throws for a channel not in this space", () { + expect(() => value.channel("hue"), throwsSassScriptException); + }); + + test("isChannelPowerless() throws for a channel not in this space", () { + expect(() => value.channel("hue"), throwsSassScriptException); + }); + + test("has a space", () { + expect(value.space, equals(ColorSpace.rgb)); + }); + + test("is a legacy color", () { + expect(value.isLegacy, isTrue); + }); + test("equals the same color", () { expect(value, equalsWithHash(SassColor.rgb(0x12, 0x34, 0x56))); + }); + + test("equals an equivalent legacy color", () { expect( value, equalsWithHash( SassColor.hsl(210, 65.3846153846154, 20.392156862745097))); }); + test("does not equal an equivalent non-legacy color", () { + expect(value, isNot(equals(SassColor.srgb(0x12, 0x34, 0x56)))); + }); + + group("isInGamut", () { + test("returns true if the color is in the RGB gamut", () { + expect(value.isInGamut, isTrue); + }); + + test("returns false if the color is outside the RGB gamut", () { + expect(value.changeChannels({"red": 0x100}).isInGamut, isFalse); + }); + }); + + group("toSpace", () { + test("converts the color to a given space", () { + expect( + value.toSpace(ColorSpace.lab), + equals(SassColor.lab( + 20.675469453386192, -2.276792630515417, -24.59314874484676))); + }); + + test("with legacyMissing: true, makes a powerless channel missing", () { + expect( + SassColor.rgb(0, 0, 0) + .toSpace(ColorSpace.hsl) + .isChannelMissing("hue"), + isTrue); + }); + + test("with legacyMissing: false, makes a powerless channel zero", () { + var result = SassColor.rgb(0, 0, 0) + .toSpace(ColorSpace.hsl, legacyMissing: false); + expect(result.isChannelMissing("hue"), isFalse); + expect(result.channel("hue"), equals(0)); + }); + + test( + "even with legacyMissing: false, preserves missing channels for same " + "space", () { + expect( + SassColor.rgb(0, null, 0) + .toSpace(ColorSpace.rgb, legacyMissing: false) + .isChannelMissing("green"), + isTrue); + }); + }); + + group("toGamut() brings the color into its gamut", () { + setUp(() => value = parseValue("rgb(300 200 100)") as SassColor); + + test("with clip", () { + expect(value.toGamut(GamutMapMethod.clip), + equals(SassColor.rgb(255, 200, 100))); + }); + + test("with localMinde", () { + // TODO: update + expect(value.toGamut(GamutMapMethod.localMinde), + equals(SassColor.rgb(255, 200, 100))); + }); + }); + group("changeRgb()", () { test("changes RGB values", () { expect(value.changeRgb(red: 0xAA), @@ -61,102 +162,85 @@ void main() { equals(SassColor.rgb(0xAA, 0xAA, 0xAA, 0.5))); }); - test("allows valid values", () { - expect(value.changeRgb(red: 0).red, equals(0)); - expect(value.changeRgb(red: 0xFF).red, equals(0xFF)); - expect(value.changeRgb(green: 0).green, equals(0)); - expect(value.changeRgb(green: 0xFF).green, equals(0xFF)); - expect(value.changeRgb(blue: 0).blue, equals(0)); - expect(value.changeRgb(blue: 0xFF).blue, equals(0xFF)); - expect(value.changeRgb(alpha: 0).alpha, equals(0)); + test("allows in-gamut alpha", () { expect(value.changeRgb(alpha: 1).alpha, equals(1)); + expect(value.changeRgb(alpha: 0).alpha, equals(0)); + }); + + test("allows out-of-gamut values", () { + expect(value.changeRgb(red: -1).red, equals(-1)); + expect(value.changeRgb(red: 0x100).red, equals(0x100)); }); - test("disallows invalid values", () { - expect(() => value.changeRgb(red: -1), throwsRangeError); - expect(() => value.changeRgb(red: 0x100), throwsRangeError); - expect(() => value.changeRgb(green: -1), throwsRangeError); - expect(() => value.changeRgb(green: 0x100), throwsRangeError); - expect(() => value.changeRgb(blue: -1), throwsRangeError); - expect(() => value.changeRgb(blue: 0x100), throwsRangeError); + test("disallows out-of-gamut alpha", () { expect(() => value.changeRgb(alpha: -0.1), throwsRangeError); expect(() => value.changeRgb(alpha: 1.1), throwsRangeError); }); }); - group("changeHsl()", () { - test("changes HSL values", () { - expect(value.changeHsl(hue: 120), - equals(SassColor.hsl(120, 65.3846153846154, 20.392156862745097))); - expect(value.changeHsl(saturation: 42), - equals(SassColor.hsl(210, 42, 20.392156862745097))); - expect(value.changeHsl(lightness: 42), - equals(SassColor.hsl(210, 65.3846153846154, 42))); - expect( - value.changeHsl(alpha: 0.5), - equals( - SassColor.hsl(210, 65.3846153846154, 20.392156862745097, 0.5))); - expect( - value.changeHsl( - hue: 120, saturation: 42, lightness: 42, alpha: 0.5), - equals(SassColor.hsl(120, 42, 42, 0.5))); - }); - - test("allows valid values", () { - expect(value.changeHsl(saturation: 0).saturation, equals(0)); - expect(value.changeHsl(saturation: 100).saturation, equals(100)); - expect(value.changeHsl(lightness: 0).lightness, equals(0)); - expect(value.changeHsl(lightness: 100).lightness, equals(100)); - expect(value.changeHsl(alpha: 0).alpha, equals(0)); - expect(value.changeHsl(alpha: 1).alpha, equals(1)); - }); + test("changeHsl() changes HSL values", () { + expect(value.changeHsl(hue: 120), + equals(SassColor.hsl(120, 65.3846153846154, 20.392156862745097))); + expect(value.changeHsl(saturation: 42), + equals(SassColor.hsl(210, 42, 20.392156862745097))); + expect(value.changeHsl(lightness: 42), + equals(SassColor.hsl(210, 65.3846153846154, 42))); + expect( + value.changeHsl(alpha: 0.5), + equals( + SassColor.hsl(210, 65.3846153846154, 20.392156862745097, 0.5))); + expect( + value.changeHsl(hue: 120, saturation: 42, lightness: 42, alpha: 0.5), + equals(SassColor.hsl(120, 42, 42, 0.5))); + }); - test("disallows invalid values", () { - expect(() => value.changeHsl(saturation: -0.1), throwsRangeError); - expect(() => value.changeHsl(saturation: 100.1), throwsRangeError); - expect(() => value.changeHsl(lightness: -0.1), throwsRangeError); - expect(() => value.changeHsl(lightness: 100.1), throwsRangeError); - expect(() => value.changeHsl(alpha: -0.1), throwsRangeError); - expect(() => value.changeHsl(alpha: 1.1), throwsRangeError); - }); + test("changeHwb() changes HWB values", () { + expect(value.changeHwb(hue: 120), + equals(SassColor.hwb(120, 7.0588235294117645, 66.27450980392157))); + expect(value.changeHwb(whiteness: 20), + equals(SassColor.hwb(210, 20, 66.27450980392157))); + expect(value.changeHwb(blackness: 42), + equals(SassColor.hwb(210, 7.0588235294117645, 42))); + expect( + value.changeHwb(alpha: 0.5), + equals( + SassColor.hwb(210, 7.0588235294117645, 66.27450980392157, 0.5))); + expect( + value.changeHwb(hue: 120, whiteness: 42, blackness: 42, alpha: 0.5), + equals(SassColor.hwb(120, 42, 42, 0.5))); + expect(value.changeHwb(whiteness: 50), + equals(SassColor.hwb(210, 43.0016863406408, 56.9983136593592))); }); - group("changeHwb()", () { - test("changes HWB values", () { - expect(value.changeHwb(hue: 120), - equals(SassColor.hwb(120, 7.0588235294117645, 66.27450980392157))); - expect(value.changeHwb(whiteness: 20), - equals(SassColor.hwb(210, 20, 66.27450980392157))); - expect(value.changeHwb(blackness: 42), - equals(SassColor.hwb(210, 7.0588235294117645, 42))); - expect( - value.changeHwb(alpha: 0.5), - equals(SassColor.hwb( - 210, 7.0588235294117645, 66.27450980392157, 0.5))); - expect( - value.changeHwb(hue: 120, whiteness: 42, blackness: 42, alpha: 0.5), - equals(SassColor.hwb(120, 42, 42, 0.5))); + group("changeChannels()", () { + test("changes RGB values", () { + expect(value.changeChannels({"red": 0xAA}), + equals(SassColor.rgb(0xAA, 0x34, 0x56))); + expect(value.changeChannels({"green": 0xAA}), + equals(SassColor.rgb(0x12, 0xAA, 0x56))); + expect(value.changeChannels({"blue": 0xAA}), + equals(SassColor.rgb(0x12, 0x34, 0xAA))); + expect(value.changeChannels({"alpha": 0.5}), + equals(SassColor.rgb(0x12, 0x34, 0x56, 0.5))); expect( - value.changeHwb(whiteness: 50), equals(SassColor.hwb(210, 43, 57))); + value.changeChannels( + {"red": 0xAA, "green": 0xAA, "blue": 0xAA, "alpha": 0.5}), + equals(SassColor.rgb(0xAA, 0xAA, 0xAA, 0.5))); }); - test("allows valid values", () { - expect(value.changeHwb(whiteness: 0).whiteness, equals(0)); - expect(value.changeHwb(whiteness: 100).whiteness, equals(60.0)); - expect(value.changeHwb(blackness: 0).blackness, equals(0)); - expect(value.changeHwb(blackness: 100).blackness, - equals(93.33333333333333)); - expect(value.changeHwb(alpha: 0).alpha, equals(0)); - expect(value.changeHwb(alpha: 1).alpha, equals(1)); + test("allows in-gamut alpha", () { + expect(value.changeChannels({"alpha": 1}).alpha, equals(1)); + expect(value.changeChannels({"alpha": 0}).alpha, equals(0)); }); - test("disallows invalid values", () { - expect(() => value.changeHwb(whiteness: -0.1), throwsRangeError); - expect(() => value.changeHwb(whiteness: 100.1), throwsRangeError); - expect(() => value.changeHwb(blackness: -0.1), throwsRangeError); - expect(() => value.changeHwb(blackness: 100.1), throwsRangeError); - expect(() => value.changeHwb(alpha: -0.1), throwsRangeError); - expect(() => value.changeHwb(alpha: 1.1), throwsRangeError); + test("allows out-of-gamut values", () { + expect(value.changeChannels({"red": -1}).red, equals(-1)); + expect(value.changeChannels({"red": 0x100}).red, equals(0x100)); + }); + + test("disallows out-of-gamut alpha", () { + expect(() => value.changeChannels({"alpha": -0.1}), throwsRangeError); + expect(() => value.changeChannels({"alpha": 1.1}), throwsRangeError); }); }); @@ -192,126 +276,133 @@ void main() { }); }); - group("an HSL color", () { + group("a color with a missing channel", () { late SassColor value; - setUp(() => value = parseValue("hsl(120, 42%, 42%)") as SassColor); + setUp(() => + value = parseValue("color(display-p3 0.3 0.4 none)") as SassColor); - test("has RGB channels", () { - expect(value.red, equals(0x3E)); - expect(value.green, equals(0x98)); - expect(value.blue, equals(0x3E)); + test("reports present channels as present", () { + expect(value.isChannelMissing("red"), isFalse); + expect(value.isChannelMissing("green"), isFalse); + expect(value.isChannelMissing("alpha"), isFalse); }); - test("has HSL channels", () { - expect(value.hue, equals(120)); - expect(value.saturation, equals(42)); - expect(value.lightness, equals(42)); + test("reports the missing channel as missing", () { + expect(value.isChannelMissing("blue"), isTrue); }); - test("has HWB channels", () { - expect(value.whiteness, equals(24.313725490196077)); - expect(value.blackness, equals(40.3921568627451)); - }); - - test("has an alpha channel", () { - expect(value.alpha, equals(1)); + test("reports the missing channel's value as 0", () { + expect(value.channel("blue"), equals(0)); }); - test("equals the same color", () { - expect(value, equalsWithHash(SassColor.rgb(0x3E, 0x98, 0x3E))); - expect(value, equalsWithHash(SassColor.hsl(120, 42, 42))); - expect( - value, - equalsWithHash( - SassColor.hwb(120, 24.313725490196077, 40.3921568627451))); + test("does not report the missing channel as powerless", () { + expect(value.isChannelPowerless("blue"), isFalse); }); }); - test("an RGBA color has an alpha channel", () { - var color = parseValue("rgba(10, 20, 30, 0.7)") as SassColor; - expect(color.alpha, closeTo(0.7, 1e-11)); - }); + group("a color with a powerless channel", () { + late SassColor value; + setUp(() => value = parseValue("hsl(120 0% 50%)") as SassColor); - group("new SassColor.rgb()", () { - test("allows valid values", () { - expect(SassColor.rgb(0, 0, 0, 0), equals(parseValue("rgba(0, 0, 0, 0)"))); - expect(SassColor.rgb(0xFF, 0xFF, 0xFF, 1), equals(parseValue("#fff"))); + test("reports powerful channels as powerful", () { + expect(value.isChannelPowerless("saturation"), isFalse); + expect(value.isChannelPowerless("lightness"), isFalse); + expect(value.isChannelPowerless("alpha"), isFalse); }); - test("disallows invalid values", () { - expect(() => SassColor.rgb(-1, 0, 0, 0), throwsRangeError); - expect(() => SassColor.rgb(0, -1, 0, 0), throwsRangeError); - expect(() => SassColor.rgb(0, 0, -1, 0), throwsRangeError); - expect(() => SassColor.rgb(0, 0, 0, -0.1), throwsRangeError); - expect(() => SassColor.rgb(0x100, 0, 0, 0), throwsRangeError); - expect(() => SassColor.rgb(0, 0x100, 0, 0), throwsRangeError); - expect(() => SassColor.rgb(0, 0, 0x100, 0), throwsRangeError); - expect(() => SassColor.rgb(0, 0, 0, 1.1), throwsRangeError); + test("reports the powerless channel as powerless", () { + expect(value.isChannelPowerless("hue"), isTrue); }); - }); - group("new SassColor.hsl()", () { - test("allows valid values", () { - expect( - SassColor.hsl(0, 0, 0, 0), equals(parseValue("hsla(0, 0%, 0%, 0)"))); - expect(SassColor.hsl(4320, 100, 100, 1), - equals(parseValue("hsl(4320, 100%, 100%)"))); + test("reports the powerless channel's value", () { + expect(value.channel("hue"), 120); }); - test("disallows invalid values", () { - expect(() => SassColor.hsl(0, -0.1, 0, 0), throwsRangeError); - expect(() => SassColor.hsl(0, 0, -0.1, 0), throwsRangeError); - expect(() => SassColor.hsl(0, 0, 0, -0.1), throwsRangeError); - expect(() => SassColor.hsl(0, 100.1, 0, 0), throwsRangeError); - expect(() => SassColor.hsl(0, 0, 100.1, 0), throwsRangeError); - expect(() => SassColor.hsl(0, 0, 0, 1.1), throwsRangeError); + test("does not report the powerless channel as missing", () { + expect(value.isChannelMissing("hue"), isFalse); }); }); - group("new SassColor.hwb()", () { + group("an LCH color", () { late SassColor value; - setUp(() => value = SassColor.hwb(120, 42, 42)); - - test("has RGB channels", () { - expect(value.red, equals(0x6B)); - expect(value.green, equals(0x94)); - expect(value.blue, equals(0x6B)); + setUp(() => value = parseValue("lch(42% 42% 120)") as SassColor); + + test("throws for legacy channels", () { + expect(() => value.red, throwsSassScriptException); + expect(() => value.green, throwsSassScriptException); + expect(() => value.blue, throwsSassScriptException); + expect(() => value.hue, throwsSassScriptException); + expect(() => value.saturation, throwsSassScriptException); + expect(() => value.lightness, throwsSassScriptException); + expect(() => value.whiteness, throwsSassScriptException); + expect(() => value.blackness, throwsSassScriptException); }); - test("has HSL channels", () { - expect(value.hue, equals(120)); - expect(value.saturation, equals(16.078431372549026)); - expect(value.lightness, equals(50)); + test("has an alpha channel", () { + expect(value.alpha, equals(1)); }); - test("has HWB channels", () { - expect(value.whiteness, equals(41.96078431372549)); - expect(value.blackness, equals(41.96078431372548)); + group("channel()", () { + test("returns LCH channels", () { + expect(value.channel("lightness"), equals(42)); + expect(value.channel("chroma"), equals(63)); + expect(value.channel("hue"), equals(120)); + }); + + test("returns alpha", () { + expect(value.channel("alpha"), equals(1)); + }); + + test("throws for a channel not in this space", () { + expect(() => value.channel("red"), throwsSassScriptException); + }); }); - test("has an alpha channel", () { - expect(value.alpha, equals(1)); + test("is not a legacy color", () { + expect(value.isLegacy, isFalse); }); test("equals the same color", () { - expect(value, equalsWithHash(SassColor.rgb(0x6B, 0x94, 0x6B))); - expect(value, equalsWithHash(SassColor.hsl(120, 16, 50))); - expect(value, equalsWithHash(SassColor.hwb(120, 42, 42))); + expect(value, equalsWithHash(SassColor.lch(42, 63, 120))); }); - test("allows valid values", () { + test("doesn't equal an equivalent color", () { expect( - SassColor.hwb(0, 0, 0, 0), equals(parseValue("rgba(255, 0, 0, 0)"))); - expect(SassColor.hwb(4320, 100, 100, 1), equals(parseValue("grey"))); + value, + isNot(equals(SassColor.xyzD65(0.07461544022446227, + 0.12417002656711021, 0.011301590030256693)))); }); - test("disallows invalid values", () { - expect(() => SassColor.hwb(0, -0.1, 0, 0), throwsRangeError); - expect(() => SassColor.hwb(0, 0, -0.1, 0), throwsRangeError); - expect(() => SassColor.hwb(0, 0, 0, -0.1), throwsRangeError); - expect(() => SassColor.hwb(0, 100.1, 0, 0), throwsRangeError); - expect(() => SassColor.hwb(0, 0, 100.1, 0), throwsRangeError); - expect(() => SassColor.hwb(0, 0, 0, 1.1), throwsRangeError); + test("changeChannels() changes LCH values", () { + expect(value.changeChannels({"lightness": 30}), + equals(SassColor.lch(30, 63, 120))); + expect(value.changeChannels({"chroma": 30}), + equals(SassColor.lch(42, 30, 120))); + expect( + value.changeChannels({"hue": 80}), equals(SassColor.lch(42, 63, 80))); + expect(value.changeChannels({"alpha": 0.5}), + equals(SassColor.lch(42, 63, 120, 0.5))); + expect( + value.changeChannels( + {"lightness": 30, "chroma": 30, "hue": 30, "alpha": 0.5}), + equals(SassColor.lch(30, 30, 30, 0.5))); + }); + }); + + test("an RGBA color has an alpha channel", () { + var color = parseValue("rgba(10, 20, 30, 0.7)") as SassColor; + expect(color.alpha, closeTo(0.7, 1e-11)); + }); + + group("new SassColor.rgb()", () { + test("allows out-of-gamut values", () { + expect(SassColor.rgb(-1, 0, 0, 0).channel("red"), equals(-1)); + expect(SassColor.rgb(0, 100, 0, 0).channel("green"), equals(100)); + }); + + test("disallows out-of-gamut alpha values", () { + expect(() => SassColor.rgb(0, 0, 0, -0.1), throwsRangeError); + expect(() => SassColor.rgb(0, 0, 0, 1.1), throwsRangeError); }); }); } From de181d91922ff7bf7e05a5a474be8e67ce90c430 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Fri, 13 Sep 2024 14:41:41 -0700 Subject: [PATCH 56/56] Poke CI