diff --git a/CHANGELOG.md b/CHANGELOG.md index 523a1a7cd..2e48ad5d2 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.1 ### 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..a73188e22 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,87 @@ 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, + "color") + .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 +456,34 @@ 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, + thisName: "color1", + otherName: "color2"); + } + + _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 +503,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, "color"); + 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, thisName: "color"); +} + +/// 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 +634,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 +652,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 +660,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 +863,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 +874,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 +924,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 +965,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 +981,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 +1028,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 +1054,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, "color"); +} + +/// 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/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 20f12fcc6..dc47d5d24 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -156,6 +156,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..d30adb531 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,490 @@ 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, [String? name]) => 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, colorName); + return this; + } + + if (space != null && space != this.space) { + return toSpace(space, colorName) + .changeChannels(newValues, colorName: colorName) + .toSpace(space, colorName); + } + + 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. + /// + /// Throws a [SassScriptException] if it's not possible to interpolate between + /// [this] and [other]. If this color came from a function argument, + /// [thisName] is the argument name (without the `$`). If [other] came from a + /// function argument, [otherName] is the argument name (without the `$`). + /// These are used for error reporting. + SassColor interpolate(SassColor other, InterpolationMethod method, + {double? weight, String? thisName, String? otherName}) { + weight ??= 0.5; + + if (fuzzyEquals(weight, 0)) return other; + if (fuzzyEquals(weight, 1)) return this; + + var color1 = toSpace(method.space, thisName); + var color2 = other.toSpace(method.space, otherName); + + 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 +963,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; - } + operator ==(Object other) { + if (other is! SassColor) return false; - var lightness = _lightness = 50 * (max + min); + if (isLegacy) { + if (!other.isLegacy) return false; + if (!fuzzyEquals(alpha, other.alpha)) return false; + other = other.toSpace(space); - if (max == min) { - _saturation = 0; - } else if (lightness < 50) { - _saturation = 100 * delta / (max + min); - } else { - _saturation = 100 * delta / (2 - max - min); + // TODO BEFORE COMMIT: Should we convert both to RGB and round to the + // nearest integer for backwards-compatibility? + return fuzzyEquals(channel0, other.channel0) && + fuzzyEquals(channel1, other.channel1) && + fuzzyEquals(channel2, other.channel2); } - } - /// 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 +1011,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..357bc74a1 --- /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.mix]. +/// +/// {@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..8bffe3e15 --- /dev/null +++ b/lib/src/value/color/space.dart @@ -0,0 +1,323 @@ +// 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-d65 + static const ColorSpace xyzD65 = XyzD65ColorSpace(); + + /// The xyz-d50 color space. + /// + /// https://www.w3.org/TR/css-color-4/#predefined-xyz-d50 + static const ColorSpace xyzD50 = XyzD50ColorSpace(); + + static const ColorSpace lab = LabColorSpace(); + + 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(); + + static const ColorSpace oklab = OklabColorSpace(); + + 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..d1bd07362 100644 --- a/lib/src/visitor/async_evaluate.dart +++ b/lib/src/visitor/async_evaluate.dart @@ -1645,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/evaluate.dart b/lib/src/visitor/evaluate.dart index e45ad4b97..508e96e29 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: 2307bacb74031d29940a0e228c83ed879b2029fa // // ignore_for_file: unused_import @@ -1643,7 +1643,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 1155f85eb..ab26e26ca 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.1 * No user-visible changes. @@ -8,8 +59,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 8a3e401ed..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.1 +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.1 + 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 b20562378..b43780c81 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: sass -version: 1.56.1 +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';