diff --git a/CHANGELOG.md b/CHANGELOG.md index 147f4bcec..03a033cbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,199 @@ ## 1.79.0 +* **Breaking change**: Passing a number with unit `%` to the `$alpha` parameter + of `color.change()`, `color.adjust()`, `change-color()`, and `adjust-color()` + is now interpreted as a percentage, instead of ignoring the unit. For example, + `color.change(red, $alpha: 50%)` now returns `rgb(255 0 0 / 0.5)`. + +* **Potentially breaking compatibility fix**: Sass no longer rounds RGB channels + to the nearest integer. This means that, for example, `rgb(0 0 1) != rgb(0 0 + 0.6)`. This matches the latest version of the CSS spec and browser behavior. + +* **Potentially breaking compatibility fix**: Passing large positive or negative + values to `color.adjust()` can now cause a color's channels to go outside that + color's gamut. In most cases this will currently be clipped by the browser and + end up showing the same color as before, but once browsers implement gamut + mapping it may produce a different result. + +* Add support for CSS Color Level 4 [color spaces]. Each color value now tracks + its color space along with the values of each channel in that color space. + There are two general principles to keep in mind when dealing with new color + 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 + +* The following functions are now deprecated, and uses should be replaced with + the new color-space-aware functions defined above: + + * The `color.red()`, `color.green()`, `color.blue()`, `color.hue()`, + `color.saturation()`, `color.lightness()`, `color.whiteness()`, and + `color.blackness()` functions, as well as their global counterparts, should + be replaced with calls to `color.channel()`. + + * The global `adjust-hue()`, `saturate()`, `desaturate()`, `lighten()`, + `darken()`, `transaprentize()`, `fade-out()`, `opacify()`, and `fade-in()` + functions should be replaced by `color.adjust()` or `color.scale()`. + * Add a `global-builtin` future deprecation, which can be opted-into with the `--future-deprecation` flag or the `futureDeprecations` option in the JS or Dart API. This emits warnings when any global built-in functions that are now available in `sass:` modules are called. It will become active by default in an upcoming release alongside the `@import` deprecation. +### 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. + +### JS API + +* Modify `SassColor` to accept a new `space` option, with support for all the + new color spaces defined in Color Level 4. + +* Add `SassColor.space` which returns a color's color space. + +* Add `SassColor.channels` and `.channelsOrNull` which returns a list of channel + values, with missing channels converted to 0 or exposed as null, respectively. + +* Add `SassColor.isLegacy`, `.isInGamut()`, `.channel()`, `.isChannelMissing()`, + `.isChannelPowerless()`, `.toSpace()`, `.toGamut()`, `.change()`, and + `.interpolate()` which do the same thing as the Sass functions of the + corresponding names. + +* Deprecate `SassColor.red`, `.green`, `.blue`, `.hue`, `.saturation`, + `.lightness`, `.whiteness`, and `.blackness` in favor of + `SassColor.channel()`. + +### Embedded Sass + +* Add `Color` SassScript value, with support for all the new color spaces + defined in Color Level 4. + +* Remove `RgbColor`, `HslColor` and `HwbColor` SassScript values. + ## 1.78.0 * The `meta.feature-exists` function is now deprecated. This deprecation is diff --git a/lib/sass.dart b/lib/sass.dart index 7413b8bcd..39f14f7b1 100644 --- a/lib/sass.dart +++ b/lib/sass.dart @@ -31,7 +31,12 @@ export 'src/importer.dart'; export 'src/logger.dart' show Logger; 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/deprecation.dart b/lib/src/deprecation.dart index b1626db5a..097ece323 100644 --- a/lib/src/deprecation.dart +++ b/lib/src/deprecation.dart @@ -15,7 +15,7 @@ enum Deprecation { // DO NOT EDIT. This section was generated from the language repo. // See tool/grind/generate_deprecations.dart for details. // - // Checksum: bf841a728263bf7efc2a85a091330a1f8074e067 + // Checksum: 5470e7252641d3eaa7093b072b52e423c3b77375 /// Deprecation for passing a string directly to meta.call(). callString('call-string', @@ -99,6 +99,16 @@ enum Deprecation { featureExists('feature-exists', deprecatedIn: '1.78.0', description: 'meta.feature-exists'), + /// Deprecation for certain uses of built-in sass:color functions. + color4Api('color-4-api', + deprecatedIn: '1.79.0', + description: 'Certain uses of built-in sass:color functions.'), + + /// Deprecation for using global color functions instead of sass:color. + colorFunctions('color-functions', + deprecatedIn: '1.79.0', + description: 'Using global color functions instead of sass:color.'), + /// Deprecation for @import rules. import.future('import', description: '@import rules.'), diff --git a/lib/src/embedded/protofier.dart b/lib/src/embedded/protofier.dart index c2878e5ef..f039daea2 100644 --- a/lib/src/embedded/protofier.dart +++ b/lib/src/embedded/protofier.dart @@ -53,17 +53,12 @@ final class Protofier { ..quoted = value.hasQuotes; case SassNumber(): result.number = _protofyNumber(value); - case SassColor(hasCalculatedHsl: true): - result.hslColor = Value_HslColor() - ..hue = value.hue * 1.0 - ..saturation = value.saturation * 1.0 - ..lightness = value.lightness * 1.0 - ..alpha = value.alpha * 1.0; case SassColor(): - result.rgbColor = Value_RgbColor() - ..red = value.red - ..green = value.green - ..blue = value.blue + result.color = Value_Color() + ..space = value.space.name + ..channel1 = value.channel0 + ..channel2 = value.channel1 + ..channel3 = value.channel2 ..alpha = value.alpha * 1.0; case SassArgumentList(): _argumentLists.add(value); @@ -181,17 +176,85 @@ final class Protofier { case Value_Value.number: return _deprotofyNumber(value.number); - case Value_Value.rgbColor: - return SassColor.rgb(value.rgbColor.red, value.rgbColor.green, - value.rgbColor.blue, value.rgbColor.alpha); - - case Value_Value.hslColor: - return SassColor.hsl(value.hslColor.hue, value.hslColor.saturation, - value.hslColor.lightness, value.hslColor.alpha); - - case Value_Value.hwbColor: - return SassColor.hwb(value.hwbColor.hue, value.hwbColor.whiteness, - value.hwbColor.blackness, value.hwbColor.alpha); + case Value_Value.color: + var space = ColorSpace.fromName(value.color.space); + switch (space) { + case ColorSpace.rgb: + return SassColor.rgb(value.color.channel1, value.color.channel2, + value.color.channel3, value.color.alpha); + + case ColorSpace.hsl: + return SassColor.hsl(value.color.channel1, value.color.channel2, + value.color.channel3, value.color.alpha); + + case ColorSpace.hwb: + return SassColor.hwb(value.color.channel1, value.color.channel2, + value.color.channel3, value.color.alpha); + + case ColorSpace.lab: + return SassColor.lab(value.color.channel1, value.color.channel2, + value.color.channel3, value.color.alpha); + case ColorSpace.oklab: + return SassColor.oklab(value.color.channel1, value.color.channel2, + value.color.channel3, value.color.alpha); + + case ColorSpace.lch: + return SassColor.lch(value.color.channel1, value.color.channel2, + value.color.channel3, value.color.alpha); + case ColorSpace.oklch: + return SassColor.oklch(value.color.channel1, value.color.channel2, + value.color.channel3, value.color.alpha); + + case ColorSpace.srgb: + return SassColor.srgb(value.color.channel1, value.color.channel2, + value.color.channel3, value.color.alpha); + case ColorSpace.srgbLinear: + return SassColor.srgbLinear( + value.color.channel1, + value.color.channel2, + value.color.channel3, + value.color.alpha); + case ColorSpace.displayP3: + return SassColor.displayP3( + value.color.channel1, + value.color.channel2, + value.color.channel3, + value.color.alpha); + case ColorSpace.a98Rgb: + return SassColor.a98Rgb( + value.color.channel1, + value.color.channel2, + value.color.channel3, + value.color.alpha); + case ColorSpace.prophotoRgb: + return SassColor.prophotoRgb( + value.color.channel1, + value.color.channel2, + value.color.channel3, + value.color.alpha); + case ColorSpace.rec2020: + return SassColor.rec2020( + value.color.channel1, + value.color.channel2, + value.color.channel3, + value.color.alpha); + + case ColorSpace.xyzD50: + return SassColor.xyzD50( + value.color.channel1, + value.color.channel2, + value.color.channel3, + value.color.alpha); + case ColorSpace.xyzD65: + return SassColor.xyzD65( + value.color.channel1, + value.color.channel2, + value.color.channel3, + value.color.alpha); + + default: + throw "Unreachable"; + } case Value_Value.argumentList: if (value.argumentList.id != 0) { @@ -276,10 +339,8 @@ final class Protofier { throw paramsError(error.toString()); } - if (value.whichValue() == Value_Value.rgbColor) { - name = 'RgbColor.$name'; - } else if (value.whichValue() == Value_Value.hslColor) { - name = 'HslColor.$name'; + if (value.whichValue() == Value_Value.color) { + name = 'Color.$name'; } throw paramsError( diff --git a/lib/src/evaluation_context.dart b/lib/src/evaluation_context.dart index 30b3852f9..1f831cd87 100644 --- a/lib/src/evaluation_context.dart +++ b/lib/src/evaluation_context.dart @@ -29,6 +29,16 @@ abstract interface class EvaluationContext { _ => null }; + /// The current evaluation context, or null if there isn't a Sass stylesheet + /// currently being evaluated. + static EvaluationContext? get _currentOrNull { + if (Zone.current[#_evaluationContext] case EvaluationContext context) { + return context; + } else { + return null; + } + } + /// Returns the span for the currently executing callable. /// /// For normal exception reporting, this should be avoided in favor of @@ -69,6 +79,16 @@ void warnForDeprecation(String message, Deprecation deprecation) => _ => (const Logger.stderr()).warnForDeprecation(deprecation, message) }; +/// Prints a deprecation warning with [message] of type [deprecation], +/// using stderr if there is no [EvaluationContext.current]. +void warnForDeprecationFromApi(String message, Deprecation deprecation) { + if (EvaluationContext._currentOrNull case var context?) { + context.warn(message, deprecation); + } else { + Logger.stderr().warnForDeprecation(deprecation, message); + } +} + /// Runs [callback] with [context] as [EvaluationContext.current]. /// /// This is zone-based, so if [callback] is asynchronous [warn] is set for the diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index f3d05fdac..3745f3f72 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -2,7 +2,7 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import 'dart:collection'; +import 'dart:math' as math; import 'package:collection/collection.dart'; @@ -11,6 +11,8 @@ import '../deprecation.dart'; import '../evaluation_context.dart'; import '../exception.dart'; import '../module/built_in.dart'; +import '../parse/scss.dart'; +import '../util/map.dart'; import '../util/nullable.dart'; import '../util/number.dart'; import '../utils.dart'; @@ -20,60 +22,51 @@ 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 - _red.withDeprecationWarning('color'), _green.withDeprecationWarning('color'), - _blue.withDeprecationWarning('color'), _mix.withDeprecationWarning('color'), + _channelFunction("red", (color) => color.red, global: true) + .withDeprecationWarning("color"), + _channelFunction("green", (color) => color.green, global: true) + .withDeprecationWarning("color"), + _channelFunction("blue", (color) => color.blue, global: true) + .withDeprecationWarning("color"), + _mix.withDeprecationWarning("color"), BuiltInCallable.overloadedFunction("rgb", { 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 || arguments[0].isSpecialNumber) { - if (weight.value != 100 || !weight.hasUnit("%")) { - throw "Only one argument may be passed to the plain-CSS invert() " - "function."; - } - - // Use the native CSS `invert` filter 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); - }).withDeprecationWarning('color'), + _function("invert", r"$color, $weight: 100%, $space: null", + (arguments) => _invert(arguments, global: true)) + .withDeprecationWarning("color"), // ### HSL - _hue.withDeprecationWarning('color'), - _saturation.withDeprecationWarning('color'), - _lightness.withDeprecationWarning('color'), - _complement.withDeprecationWarning('color'), + _channelFunction("hue", (color) => color.hue, unit: 'deg', global: true) + .withDeprecationWarning("color"), + _channelFunction("saturation", (color) => color.saturation, + unit: '%', global: true) + .withDeprecationWarning("color"), + _channelFunction("lightness", (color) => color.lightness, + unit: '%', global: true) + .withDeprecationWarning("color"), BuiltInCallable.overloadedFunction("hsl", { r"$hue, $saturation, $lightness, $alpha": (arguments) => @@ -88,11 +81,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", { @@ -106,45 +96,85 @@ 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 || arguments[0].isSpecialNumber) { // Use the native CSS `grayscale` filter function. return _functionString('grayscale', arguments); + } else { + warnForGlobalBuiltIn('color', 'grayscale'); + + return _grayscale(arguments[0]); } - warnForGlobalBuiltIn('color', 'grayscale'); - var color = arguments[0].assertColor("color"); - return color.changeHsl(saturation: 0); }), _function("adjust-hue", r"$color, $degrees", (arguments) { var color = arguments[0].assertColor("color"); var degrees = _angleValue(arguments[1], "degrees"); + + if (!color.isLegacy) { + throw SassScriptException( + "adjust-hue() is only supported for legacy colors. Please use " + "color.adjust() instead with an explicit \$space argument."); + } + + var suggestedValue = SassNumber(degrees, 'deg'); + warnForDeprecation( + "adjust-hue() is deprecated. Suggestion:\n" + "\n" + "color.adjust(\$color, \$hue: $suggestedValue)\n" + "\n" + "More info: https://sass-lang.com/d/color-functions", + Deprecation.colorFunctions); + return color.changeHsl(hue: color.hue + degrees); }).withDeprecationWarning('color', 'adjust'), _function("lighten", r"$color, $amount", (arguments) { var color = arguments[0].assertColor("color"); var amount = arguments[1].assertNumber("amount"); - return color.changeHsl( - lightness: (color.lightness + amount.valueInRange(0, 100, "amount")) - .clamp(0, 100)); + if (!color.isLegacy) { + throw SassScriptException( + "lighten() is only supported for legacy colors. Please use " + "color.adjust() instead with an explicit \$space argument."); + } + + var result = color.changeHsl( + lightness: clampLikeCss( + color.lightness + amount.valueInRange(0, 100, "amount"), 0, 100)); + + warnForDeprecation( + "lighten() is deprecated. " + "${_suggestScaleAndAdjust(color, amount.value, 'lightness')}\n" + "\n" + "More info: https://sass-lang.com/d/color-functions", + Deprecation.colorFunctions); + return result; }).withDeprecationWarning('color', 'adjust'), _function("darken", r"$color, $amount", (arguments) { var color = arguments[0].assertColor("color"); var amount = arguments[1].assertNumber("amount"); - return color.changeHsl( - lightness: (color.lightness - amount.valueInRange(0, 100, "amount")) - .clamp(0, 100)); + if (!color.isLegacy) { + throw SassScriptException( + "darken() is only supported for legacy colors. Please use " + "color.adjust() instead with an explicit \$space argument."); + } + + var result = color.changeHsl( + lightness: clampLikeCss( + color.lightness - amount.valueInRange(0, 100, "amount"), 0, 100)); + + warnForDeprecation( + "darken() is deprecated. " + "${_suggestScaleAndAdjust(color, -amount.value, 'lightness')}\n" + "\n" + "More info: https://sass-lang.com/d/color-functions", + Deprecation.colorFunctions); + return result; }).withDeprecationWarning('color', 'adjust'), BuiltInCallable.overloadedFunction("saturate", { @@ -160,43 +190,79 @@ final global = UnmodifiableListView([ warnForGlobalBuiltIn('color', 'adjust'); var color = arguments[0].assertColor("color"); var amount = arguments[1].assertNumber("amount"); - return color.changeHsl( - saturation: (color.saturation + amount.valueInRange(0, 100, "amount")) - .clamp(0, 100)); + if (!color.isLegacy) { + throw SassScriptException( + "saturate() is only supported for legacy colors. Please use " + "color.adjust() instead with an explicit \$space argument."); + } + + var result = color.changeHsl( + saturation: clampLikeCss( + color.saturation + amount.valueInRange(0, 100, "amount"), + 0, + 100)); + + warnForDeprecation( + "saturate() is deprecated. " + "${_suggestScaleAndAdjust(color, amount.value, 'saturation')}\n" + "\n" + "More info: https://sass-lang.com/d/color-functions", + Deprecation.colorFunctions); + return result; } }), _function("desaturate", r"$color, $amount", (arguments) { var color = arguments[0].assertColor("color"); var amount = arguments[1].assertNumber("amount"); - return color.changeHsl( - saturation: (color.saturation - amount.valueInRange(0, 100, "amount")) - .clamp(0, 100)); + if (!color.isLegacy) { + throw SassScriptException( + "desaturate() is only supported for legacy colors. Please use " + "color.adjust() instead with an explicit \$space argument."); + } + + var result = color.changeHsl( + saturation: clampLikeCss( + color.saturation - amount.valueInRange(0, 100, "amount"), 0, 100)); + + warnForDeprecation( + "desaturate() is deprecated. " + "${_suggestScaleAndAdjust(color, -amount.value, 'saturation')}\n" + "\n" + "More info: https://sass-lang.com/d/color-functions", + Deprecation.colorFunctions); + return result; }).withDeprecationWarning('color', 'adjust'), // ### Opacity - _function("opacify", r"$color, $amount", _opacify) + _function("opacify", r"$color, $amount", + (arguments) => _opacify("opacify", arguments)) .withDeprecationWarning('color', 'adjust'), - _function("fade-in", r"$color, $amount", _opacify) + _function("fade-in", r"$color, $amount", + (arguments) => _opacify("fade-in", arguments)) .withDeprecationWarning('color', 'adjust'), - _function("transparentize", r"$color, $amount", _transparentize) + _function("transparentize", r"$color, $amount", + (arguments) => _transparentize("transparentize", arguments)) .withDeprecationWarning('color', 'adjust'), - _function("fade-out", r"$color, $amount", _transparentize) + _function("fade-out", r"$color, $amount", + (arguments) => _transparentize("fade-out", arguments)) .withDeprecationWarning('color', 'adjust'), BuiltInCallable.overloadedFunction("alpha", { r"$color": (arguments) { - var argument = arguments[0]; - if (argument is SassString && - !argument.hasQuotes && - argument.text.contains(_microsoftFilterStart)) { + switch (arguments[0]) { // Support the proprietary Microsoft alpha() function. - return _functionString("alpha", arguments); + case SassString(hasQuotes: false, :var text) + when text.contains(_microsoftFilterStart): + return _functionString("alpha", arguments); + case SassColor(isLegacy: false): + throw SassScriptException( + "alpha() is only supported for legacy colors. Please use " + "color.channel() instead."); + case var argument: + warnForGlobalBuiltIn('color', 'alpha'); + return SassNumber(argument.assertColor("color").alpha); } - - warnForGlobalBuiltIn('color', 'alpha'); - var color = argument.assertColor("color"); - return SassNumber(color.alpha); }, r"$args...": (arguments) { var argList = arguments[0].asList; @@ -230,6 +296,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.withDeprecationWarning("color"), + // ### Miscellaneous _ieHexStr, _adjust.withDeprecationWarning('color').withName("adjust-color"), @@ -240,35 +346,28 @@ final global = UnmodifiableListView([ /// The Sass color module. 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)); + _channelFunction("red", (color) => color.red), + _channelFunction("green", (color) => color.green), + _channelFunction("blue", (color) => color.blue), + _mix, + + _function("invert", r"$color, $weight: 100%, $space: null", (arguments) { + var result = _invert(arguments); + if (result is SassString) { warnForDeprecation( "Passing a number (${arguments[0]}) to color.invert() is " "deprecated.\n" "\n" "Recommendation: $result", Deprecation.colorModuleCompat); - 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, + _channelFunction("hue", (color) => color.hue, unit: 'deg'), + _channelFunction("saturation", (color) => color.saturation, unit: '%'), + _channelFunction("lightness", (color) => color.lightness, unit: '%'), _removedColorFunction("adjust-hue", "hue"), _removedColorFunction("lighten", "lightness"), _removedColorFunction("darken", "lightness", negative: true), @@ -287,37 +386,25 @@ 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( - "whiteness", - r"$color", - (arguments) => - SassNumber(arguments.first.assertColor("color").whiteness, "%")), - - _function( - "blackness", - r"$color", - (arguments) => - SassNumber(arguments.first.assertColor("color").blackness, "%")), + _channelFunction("whiteness", (color) => color.whiteness, unit: '%'), + _channelFunction("blackness", (color) => color.blackness, unit: '%'), // ### Opacity _removedColorFunction("opacify", "alpha"), @@ -327,21 +414,26 @@ final module = BuiltInModule("color", functions: [ BuiltInCallable.overloadedFunction("alpha", { r"$color": (arguments) { - var argument = arguments[0]; - if (argument is SassString && - !argument.hasQuotes && - argument.text.contains(_microsoftFilterStart)) { - var result = _functionString("alpha", arguments); - warnForDeprecation( - "Using color.alpha() for a Microsoft filter is deprecated.\n" - "\n" - "Recommendation: $result", - Deprecation.colorModuleCompat); - return result; + switch (arguments[0]) { + // Support the proprietary Microsoft alpha() function. + case SassString(hasQuotes: false, :var text) + when text.contains(_microsoftFilterStart): + var result = _functionString("alpha", arguments); + warnForDeprecation( + "Using color.alpha() for a Microsoft filter is deprecated.\n" + "\n" + "Recommendation: $result", + Deprecation.colorModuleCompat); + return result; + + case SassColor(isLegacy: false): + throw SassScriptException( + "color.alpha() is only supported for legacy colors. Please use " + "color.channel() instead."); + + case var argument: + return SassNumber(argument.assertColor("color").alpha); } - - var color = argument.assertColor("color"); - return SassNumber(color.alpha); }, r"$args...": (arguments) { if (arguments[0].asList.every((argument) => @@ -380,53 +472,308 @@ 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)), + + // `color.to-space()` never returns missing channels for legacy color spaces + // because they're less compatible and users are probably using a legacy space + // because they want a highly compatible color. + _function( + "to-space", + r"$color, $space", + (arguments) => + _colorInSpace(arguments[0], arguments[1], legacyMissing: false)), + + _function("is-legacy", r"$color", + (arguments) => SassBoolean(arguments[0].assertColor("color").isLegacy)), + + _function( + "is-missing", + r"$color, $channel", + (arguments) => SassBoolean(arguments[0] + .assertColor("color") + .isChannelMissing(_channelName(arguments[1]), + colorName: "color", channelName: "channel"))), + + _function( + "is-in-gamut", + r"$color, $space: null", + (arguments) => + SassBoolean(_colorInSpace(arguments[0], arguments[1]).isInGamut)), + + _function("to-gamut", r"$color, $space: null, $method: null", (arguments) { + var color = arguments[0].assertColor("color"); + var space = _spaceOrDefault(color, arguments[1], "space"); + if (arguments[2] == sassNull) { + throw SassScriptException( + "color.to-gamut() requires a \$method argument for forwards-" + "compatibility with changes in the CSS spec. Suggestion:\n" + "\n" + "\$method: local-minde", + "method"); + } + + // Assign this before checking [space.isBounded] so that invalid method + // names consistently produce errors. + var method = GamutMapMethod.fromName( + (arguments[2].assertString("method")..assertUnquoted("method")).text); + if (!space.isBounded) return color; + + return color + .toSpace(space) + .toGamut(method) + .toSpace(color.space, legacyMissing: false); + }), + + _function("channel", r"$color, $channel, $space: null", (arguments) { + var color = _colorInSpace(arguments[0], arguments[2]); + var channelName = _channelName(arguments[1]); + 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]; + var unit = channelInfo.associatedUnit; + if (unit == '%') { + channelValue = channelValue * 100 / (channelInfo as LinearChannel).max; + } + + return SassNumber(channelValue, unit); + }), + + _function("same", r"$color1, $color2", (arguments) { + var color1 = arguments[0].assertColor('color1'); + var color2 = arguments[1].assertColor('color2'); + + /// Converts [color] to the xyz-d65 space without any mising channels. + SassColor toXyzNoMissing(SassColor color) => switch (color) { + SassColor(space: ColorSpace.xyzD65, hasMissingChannel: false) => + color, + SassColor( + space: ColorSpace.xyzD65, + :var channel0, + :var channel1, + :var channel2, + :var alpha + ) => + SassColor.xyzD65(channel0, channel1, channel2, alpha), + SassColor( + :var space, + :var channel0, + :var channel1, + :var channel2, + :var alpha + ) => + // Use [ColorSpace.convert] manually so that we can convert missing + // channels to 0 without having to create new intermediate color + // objects. + space.convert( + ColorSpace.xyzD65, channel0, channel1, channel2, alpha) + }; + + return SassBoolean(color1.space == color2.space + ? fuzzyEquals(color1.channel0, color2.channel0) && + fuzzyEquals(color1.channel1, color2.channel1) && + fuzzyEquals(color1.channel2, color2.channel2) && + fuzzyEquals(color1.alpha, color2.alpha) + : toXyzNoMissing(color1) == toXyzNoMissing(color2)); + }), + + _function( + "is-powerless", + r"$color, $channel, $space: null", + (arguments) => SassBoolean(_colorInSpace(arguments[0], arguments[2]) + .isChannelPowerless(_channelName(arguments[1]), + colorName: "color", channelName: "channel"))), + + _complement, + // Miscellaneous _adjust, _scale, _change, _ieHexStr ]); // ### RGB -final _red = _function("red", r"$color", (arguments) { - return SassNumber(arguments.first.assertColor("color").red); -}); - -final _green = _function("green", r"$color", (arguments) { - return SassNumber(arguments.first.assertColor("color").green); -}); - -final _blue = _function("blue", r"$color", (arguments) { - return SassNumber(arguments.first.assertColor("color").blue); -}); - -final _mix = _function("mix", r"$color1, $color2, $weight: 50%", (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); -}); -// ### HSL + if (arguments[3] != sassNull) { + return color1.interpolate( + color2, InterpolationMethod.fromValue(arguments[3], "method"), + weight: weight.valueInRangeWithUnit(0, 100, "weight", "%") / 100, + legacyMissing: false); + } -final _hue = _function("hue", r"$color", - (arguments) => SassNumber(arguments.first.assertColor("color").hue, "deg")); + _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 $color2, you must provide a " + "\$method.", + "color2"); + } -final _saturation = _function( - "saturation", - r"$color", - (arguments) => - SassNumber(arguments.first.assertColor("color").saturation, "%")); + return _mixLegacy(color1, color2, weight); +}); -final _lightness = _function( - "lightness", - r"$color", - (arguments) => - SassNumber(arguments.first.assertColor("color").lightness, "%")); +// ### Color Spaces -final _complement = _function("complement", r"$color", (arguments) { +final _complement = + _function("complement", r"$color, $space: null", (arguments) { var color = arguments[0].assertColor("color"); - return color.changeHsl(hue: color.hue + 180); + var space = color.isLegacy && arguments[1] == sassNull + ? ColorSpace.hsl + : ColorSpace.fromName( + (arguments[1].assertString("space")..assertUnquoted("space")).text, + "space"); + + if (!space.isPolar) { + throw SassScriptException( + "Color space $space doesn't have a hue channel.", 'space'); + } + + var colorInSpace = + color.toSpace(space, legacyMissing: arguments[1] != sassNull); + return (space.isLegacy + ? SassColor.forSpaceInternal( + space, + _adjustChannel(colorInSpace, space.channels[0], + colorInSpace.channel0OrNull, SassNumber(180)), + colorInSpace.channel1OrNull, + colorInSpace.channel2OrNull, + colorInSpace.alphaOrNull) + : SassColor.forSpaceInternal( + space, + colorInSpace.channel0OrNull, + colorInSpace.channel1OrNull, + _adjustChannel(colorInSpace, space.channels[2], + colorInSpace.channel2OrNull, SassNumber(180)), + colorInSpace.alphaOrNull)) + .toSpace(color.space, legacyMissing: false); }); +/// The implementation of the `invert()` function. +/// +/// If [global] is true, that indicates that this is being called from the +/// global `invert()` function. +Value _invert(List arguments, {bool global = false}) { + var weightNumber = arguments[1].assertNumber("weight"); + if (arguments[0] is SassNumber || (global && arguments[0].isSpecialNumber)) { + if (weightNumber.value != 100 || !weightNumber.hasUnit("%")) { + throw "Only one argument may be passed to the plain-CSS invert() " + "function."; + } + + // Use the native CSS `invert` filter function. + return _functionString("invert", arguments.take(1)); + } + + 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); + var [channel0, channel1, channel2] = ColorSpace.rgb.channels; + return _mixLegacy( + SassColor.rgb( + _invertChannel(rgb, channel0, rgb.channel0OrNull), + _invertChannel(rgb, channel1, rgb.channel1OrNull), + _invertChannel(rgb, channel2, rgb.channel2OrNull), + color.alphaOrNull), + color, + weightNumber) + .toSpace(color.space); + } + + 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); + var inverted = switch (space) { + ColorSpace.hwb => SassColor.hwb( + _invertChannel(inSpace, space.channels[0], inSpace.channel0OrNull), + inSpace.channel2OrNull, + inSpace.channel1OrNull, + inSpace.alpha), + ColorSpace.hsl || + ColorSpace.lch || + ColorSpace.oklch => + SassColor.forSpaceInternal( + space, + _invertChannel(inSpace, space.channels[0], inSpace.channel0OrNull), + inSpace.channel1OrNull, + _invertChannel(inSpace, space.channels[2], inSpace.channel2OrNull), + inSpace.alpha), + ColorSpace(channels: [var channel0, var channel1, var channel2]) => + SassColor.forSpaceInternal( + space, + _invertChannel(inSpace, channel0, inSpace.channel0OrNull), + _invertChannel(inSpace, channel1, inSpace.channel1OrNull), + _invertChannel(inSpace, channel2, inSpace.channel2OrNull), + inSpace.alpha), + _ => throw UnsupportedError("Unknown color space $space.") + }; + + return fuzzyEquals(weight, 1) + ? inverted.toSpace(color.space, legacyMissing: false) + : color.interpolate(inverted, InterpolationMethod(space), + weight: 1 - weight, legacyMissing: false); +} + +/// Returns the inverse of the given [value] in a linear color channel. +double _invertChannel(SassColor color, ColorChannel channel, double? value) { + if (value == null) _missingChannelError(color, channel.name); + return switch (channel) { + LinearChannel(min: < 0) => -value, + LinearChannel(min: 0, :var max) => max - value, + ColorChannel(isPolarAngle: true) => (value + 180) % 360, + _ => throw UnsupportedError("Unknown channel $channel.") + }; +} + +/// The implementation of the `grayscale()` function, without any logic for the +/// plain-CSS `grayscale()` syntax. +Value _grayscale(Value colorArg) { + var color = colorArg.assertColor("color"); + + if (color.isLegacy) { + var hsl = color.toSpace(ColorSpace.hsl); + return SassColor.hsl(hsl.channel0OrNull, 0, hsl.channel2OrNull, hsl.alpha) + .toSpace(color.space, legacyMissing: false); + } else { + var oklch = color.toSpace(ColorSpace.oklch); + return SassColor.oklch( + oklch.channel0OrNull, 0, oklch.channel2OrNull, oklch.alpha) + .toSpace(color.space); + } +} + // Miscellaneous final _adjust = _function("adjust", r"$color, $kwargs...", @@ -439,12 +786,15 @@ 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(GamutMapMethod.localMinde); + 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); }); @@ -456,7 +806,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( @@ -465,106 +814,232 @@ 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) { - warnForDeprecation( - "\$$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.functionUnits); - } + var originalColor = arguments[0].assertColor("color"); + var spaceKeyword = keywords.remove("space")?.assertString("space") + ?..assertUnquoted("space"); + + var alphaArg = keywords.remove('alpha'); + + // For backwards-compatibility, we allow legacy colors to modify channels in + // any legacy color space and we their powerless channels as 0. + var color = spaceKeyword == null && + originalColor.isLegacy && + keywords.isNotEmpty + ? _sniffLegacyColorSpace(keywords).andThen( + (space) => originalColor.toSpace(space, legacyMissing: false)) ?? + originalColor + : _colorInSpace(originalColor, spaceKeyword ?? sassNull); + + var oldChannels = color.channels; + var channelArgs = List.filled(oldChannels.length, null); + var channelInfo = color.space.channels; + for (var (name, value) in keywords.pairs) { + var channelIndex = channelInfo.indexWhere((info) => name == info.name); + if (channelIndex == -1) { + throw SassScriptException( + "Color space ${color.space} doesn't have a channel with this name.", + name); } - 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] = value; } - var alpha = getParam("alpha", 1, checkUnitless: true); - var red = getParam("red", 255); - var green = getParam("green", 255); - var blue = getParam("blue", 255); + SassColor result; + if (change) { + result = _changeColor(color, channelArgs, alphaArg); + } else { + var channelNumbers = [ + for (var i = 0; i < channelInfo.length; i++) + channelArgs[i]?.assertNumber(channelInfo[i].name) + ]; + var alphaNumber = alphaArg?.assertNumber("alpha"); + result = scale + ? _scaleColor(color, channelNumbers, alphaNumber) + : _adjustColor(color, channelNumbers, alphaNumber); + } - var hue = scale - ? null - : keywords.remove("hue").andThen((hue) => _angleValue(hue, "hue")); + return result.toSpace(originalColor.space, legacyMissing: false); +} - 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, Value? alphaArg) => + _colorFromChannels( + color.space, + _channelForChange(channelArgs[0], color, 0), + _channelForChange(channelArgs[1], color, 1), + _channelForChange(channelArgs[2], color, 2), + switch (alphaArg) { + null => color.alpha, + _ when _isNone(alphaArg) => null, + SassNumber(hasUnits: false) => alphaArg.valueInRange(0, 1, "alpha"), + SassNumber() when alphaArg.hasUnit('%') => + alphaArg.valueInRangeWithUnit(0, 100, "alpha", "%") / 100, + SassNumber() => () { + warnForDeprecation( + "\$alpha: Passing a unit other than % ($alphaArg) is " + "deprecated.\n" + "\n" + "To preserve current behavior: " + "${alphaArg.unitSuggestion('alpha')}\n" + "\n" + "See https://sass-lang.com/d/function-units", + Deprecation.functionUnits); + return alphaArg.valueInRange(0, 1, "alpha"); + }(), + _ => throw SassScriptException( + '$alphaArg is not a number or unquoted "none".', 'alpha') + }, + clamp: false); + +/// Returns the value for a single channel in `color.change()`. +/// +/// The [channelArg] is the argument passed in by the user, if one exists. If no +/// argument is passed, the channel at [index] in [color] is used instead. +SassNumber? _channelForChange(Value? channelArg, SassColor color, int channel) { + if (channelArg == null) { + return switch (color.channelsOrNull[channel]) { + var value? => SassNumber( + value, + (color.space == ColorSpace.hsl || color.space == ColorSpace.hwb) && + channel > 0 + ? '%' + : null), + _ => null + }; + } + if (_isNone(channelArg)) return null; + if (channelArg is SassNumber) return channelArg; + throw SassScriptException('$channelArg is not a number or unquoted "none".', + color.space.channels[channel].name); +} - 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, color.space.channels[0], color.channel0OrNull, + channelArgs[0]), + _scaleChannel(color, color.space.channels[1], color.channel1OrNull, + channelArgs[1]), + _scaleChannel(color, color.space.channels[2], color.channel2OrNull, + channelArgs[2]), + _scaleChannel(color, ColorChannel.alpha, color.alphaOrNull, alphaArg)); + +/// Returns [oldValue] scaled by [factorArg] according to the definition in +/// [channel]. +double? _scaleChannel(SassColor color, ColorChannel channel, double? oldValue, + SassNumber? factorArg) { + if (factorArg == null) return oldValue; + if (channel is! LinearChannel) { + throw SassScriptException("Channel isn't scalable.", channel.name); } - var hasRgb = red != null || green != null || blue != null; - var hasSL = saturation != null || lightness != null; - var hasWB = whiteness != null || blackness != null; + if (oldValue == null) _missingChannelError(color, channel.name); + + var factor = (factorArg..assertUnit('%', channel.name)) + .valueInRangeWithUnit(-100, 100, channel.name, '%') / + 100; + return switch (factor) { + 0 => oldValue, + > 0 => oldValue >= channel.max + ? oldValue + : oldValue + (channel.max - oldValue) * factor, + _ => oldValue <= channel.min + ? oldValue + : oldValue + (oldValue - channel.min) * factor + }; +} - if (hasRgb && (hasSL || hasWB || hue != null)) { - throw SassScriptException("RGB parameters may not be passed along with " - "${hasWB ? 'HWB' : 'HSL'} 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, color.space.channels[0], color.channel0OrNull, + channelArgs[0]), + _adjustChannel(color, color.space.channels[1], color.channel1OrNull, + channelArgs[1]), + _adjustChannel(color, color.space.channels[2], color.channel2OrNull, + channelArgs[2]), + // The color space doesn't matter for alpha, as long as it's not + // strictly bounded. + _adjustChannel(color, ColorChannel.alpha, color.alphaOrNull, alphaArg) + .andThen((alpha) => clampLikeCss(alpha, 0, 1))); + +/// Returns [oldValue] adjusted by [adjustmentArg] according to the definition +/// in [color]'s space's [channel]. +double? _adjustChannel(SassColor color, ColorChannel channel, double? oldValue, + SassNumber? adjustmentArg) { + if (adjustmentArg == null) return oldValue; + + if (oldValue == null) _missingChannelError(color, channel.name); + + switch ((color.space, channel)) { + case (ColorSpace.hsl || ColorSpace.hwb, ColorChannel(isPolarAngle: true)): + // `_channelFromValue` expects all hue values to be compatible with `deg`, + // but we're still in the deprecation period where we allow non-`deg` + // values for HSL and HWB so we have to handle that ahead-of-time. + adjustmentArg = SassNumber(_angleValue(adjustmentArg, 'hue')); + + case (ColorSpace.hsl, LinearChannel(name: 'saturation' || 'lightness')): + // `_channelFromValue` expects lightness/saturation to be `%`, but we're + // still in the deprecation period where we allow non-`%` values so we + // have to handle that ahead-of-time. + _checkPercent(adjustmentArg, channel.name); + adjustmentArg = SassNumber(adjustmentArg.value, '%'); + + case (_, ColorChannel.alpha) when adjustmentArg.hasUnits: + // `_channelFromValue` expects alpha to be unitless or `%`, but we're + // still in the deprecation period where we allow other values (and + // interpret `%` as unitless) so we have to handle that ahead-of-time. + warnForDeprecation( + "\$alpha: Passing a number with unit ${adjustmentArg.unitString} is " + "deprecated.\n" + "\n" + "To preserve current behavior: " + "${adjustmentArg.unitSuggestion('alpha')}\n" + "\n" + "More info: https://sass-lang.com/d/function-units", + Deprecation.functionUnits); + adjustmentArg = SassNumber(adjustmentArg.value); } - if (hasSL && hasWB) { - throw SassScriptException( - "HSL parameters may not be passed along with HWB parameters."); - } + var result = + oldValue + _channelFromValue(channel, adjustmentArg, clamp: false)!; + return switch (channel) { + LinearChannel(lowerClamped: true, :var min) when result < min => + oldValue < min ? math.max(oldValue, result) : min, + LinearChannel(upperClamped: true, :var max) when result > max => + oldValue > max ? math.min(oldValue, result) : max, + _ => result + }; +} - /// 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); +/// 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" || "green" || "blue": + return ColorSpace.rgb; + + case "saturation" || "lightness": + return ColorSpace.hsl; + + case "whiteness" || "blackness": + return ColorSpace.hwb; + } } - 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; - } + return keywords.containsKey("hue") ? ColorSpace.hsl : null; } /// Returns a string representation of [name] called with [arguments], as though @@ -593,6 +1068,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 || @@ -602,39 +1079,56 @@ 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")) ?? + return _colorFromChannels( + ColorSpace.rgb, + arguments[0].assertNumber("red"), + arguments[1].assertNumber("green"), + arguments[2].assertNumber("blue"), + alpha.andThen((alpha) => clampLikeCss( + _percentageOrUnitless(alpha.assertNumber("alpha"), 1, "alpha"), + 0, + 1)) ?? 1, - ColorFormat.rgbFunction); + 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 || - (arguments[0] is! SassColor && arguments[1].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].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"); + if (!color.isLegacy) { + throw SassScriptException( + 'Expected $color to be in the legacy RGB, HSL, or HWB color space.\n' + '\n' + 'Recommendation: color.change($color, \$alpha: $second)', + name); + } + + color.assertLegacy("color"); + color = color.toSpace(ColorSpace.rgb); + if (second.isSpecialNumber) { + 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( + clampLikeCss(_percentageOrUnitless(alpha, 1, "alpha"), 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 || @@ -644,21 +1138,16 @@ 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")) ?? - 1, - ColorFormat.hslFunction); + return _colorFromChannels( + ColorSpace.hsl, + arguments[0].assertNumber("hue"), + arguments[1].assertNumber("saturation"), + arguments[2].assertNumber("lightness"), + alpha.andThen((alpha) => clampLikeCss( + _percentageOrUnitless(alpha.assertNumber("alpha"), 1, "alpha"), + 0, + 1)) ?? + 1); } /// Asserts that [angle] is a number and returns its value in degrees. @@ -691,112 +1180,15 @@ void _checkPercent(SassNumber number, String name) { Deprecation.functionUnits); } -/// 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")) ?? - 1); -} - -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 case [SassString(:var text, hasQuotes: false), _, ...] - when equalsIgnoreCase(text, "from")) { - return _functionString(name, [originalChannels]); - } - - 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]; - - return switch (list[2]) { - SassNumber(asSlash: (var channel3, var alpha)) => [ - list[0], - list[1], - channel3, - alpha - ], - SassString(hasQuotes: false, :var text) when text.contains("/") => - _functionString(name, [channels]), - _ => 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; @@ -804,15 +1196,20 @@ double _percentageOrUnitless(SassNumber number, num max, String name) { value = max * number.value / 100; } else { throw SassScriptException( - '\$$name: Expected $number to have unit "%" or no units.'); + 'Expected $number to have unit "%" or no units.', 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 @@ -846,32 +1243,432 @@ SassColor _mixColors(SassColor color1, SassColor color2, SassNumber weight) { var weight2 = 1 - weight1; return SassColor.rgb( - fuzzyRound(color1.red * weight1 + color2.red * weight2), - fuzzyRound(color1.green * weight1 + color2.green * weight2), - fuzzyRound(color1.blue * weight1 + color2.blue * weight2), - color1.alpha * weightScale + color2.alpha * (1 - weightScale)); + rgb1.channel0 * weight1 + rgb2.channel0 * weight2, + rgb1.channel1 * weight1 + rgb2.channel1 * weight2, + rgb1.channel2 * weight1 + rgb2.channel2 * weight2, + rgb1.alpha * weightScale + rgb2.alpha * (1 - weightScale)); } /// The definition of the `opacify()` and `fade-in()` functions. -SassColor _opacify(List arguments) { +SassColor _opacify(String name, List arguments) { var color = arguments[0].assertColor("color"); var amount = arguments[1].assertNumber("amount"); + if (!color.isLegacy) { + throw SassScriptException( + "$name() is only supported for legacy colors. Please use " + "color.adjust() instead with an explicit \$space argument."); + } - return color.changeAlpha( - (color.alpha + amount.valueInRangeWithUnit(0, 1, "amount", "")) - .clamp(0, 1)); + var result = color.changeAlpha(clampLikeCss( + (color.alpha + amount.valueInRangeWithUnit(0, 1, "amount", "")), 0, 1)); + + warnForDeprecation( + "$name() is deprecated. " + "${_suggestScaleAndAdjust(color, amount.value, 'alpha')}\n" + "\n" + "More info: https://sass-lang.com/d/color-functions", + Deprecation.colorFunctions); + return result; } /// The definition of the `transparentize()` and `fade-out()` functions. -SassColor _transparentize(List arguments) { +SassColor _transparentize(String name, List arguments) { var color = arguments[0].assertColor("color"); var amount = arguments[1].assertNumber("amount"); + if (!color.isLegacy) { + throw SassScriptException( + "$name() is only supported for legacy colors. Please use " + "color.adjust() instead with an explicit \$space argument."); + } - return color.changeAlpha( - (color.alpha - amount.valueInRangeWithUnit(0, 1, "amount", "")) - .clamp(0, 1)); + var result = color.changeAlpha(clampLikeCss( + (color.alpha - amount.valueInRangeWithUnit(0, 1, "amount", "")), 0, 1)); + + warnForDeprecation( + "$name() is deprecated. " + "${_suggestScaleAndAdjust(color, -amount.value, 'alpha')}\n" + "\n" + "More info: https://sass-lang.com/d/color-functions", + Deprecation.colorFunctions); + return result; } +/// Returns the [colorUntyped] as a [SassColor] in the color space specified by +/// [spaceUntyped]. +/// +/// If [legacyMissing] is false, this will convert missing channels in legacy +/// color spaces to zero if a conversion occurs. +/// +/// Throws a [SassScriptException] if either argument isn't the expected type or +/// if [spaceUntyped] isn't the name of a color space. If [spaceUntyped] is +/// `sassNull`, it defaults to the color's existing space. +SassColor _colorInSpace(Value colorUntyped, Value spaceUntyped, + {bool legacyMissing = true}) { + var color = colorUntyped.assertColor("color"); + if (spaceUntyped == sassNull) return color; + + return color.toSpace( + ColorSpace.fromName( + (spaceUntyped.assertString("space")..assertUnquoted("space")).text, + "space"), + legacyMissing: legacyMissing); +} + +/// Returns the color space named by [space], or throws a [SassScriptException] +/// 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 parsedSlash = _parseSlashChannels(input, name: name); + if (parsedSlash == null) return _functionString(functionName, [input]); + var (components, alphaValue) = parsedSlash; + + List channels; + SassString? spaceName; + switch (components.assertCommonListStyle(name, allowSlash: false)) { + case []: + throw SassScriptException('Color component list may not be empty.', name); + + case [SassString(:var text, hasQuotes: false), ...] + when text.toLowerCase() == "from": + return _functionString(functionName, [input]); + + case _ when components.isVar: + channels = [components]; + + case [var first, ...var rest] && var componentList: + if (space == null) { + spaceName = first.assertString(name)..assertUnquoted(name); + space = + spaceName.isVar ? null : ColorSpace.fromName(spaceName.text, name); + channels = rest; + + if (space + case ColorSpace.rgb || + ColorSpace.hsl || + ColorSpace.hwb || + ColorSpace.lab || + ColorSpace.lch || + ColorSpace.oklab || + ColorSpace.oklch) { + throw SassScriptException( + "The color() function doesn't support the color space $space. Use " + "the $space() function instead.", + name); + } + } else { + channels = componentList; + } + + for (var i = 0; i < channels.length; i++) { + var channel = channels[i]; + if (!channel.isSpecialNumber && + channel is! SassNumber && + !_isNone(channel)) { + var channelName = space?.channels + .elementAtOrNull(i) + ?.name + .andThen((name) => '$name channel') ?? + 'channel ${i + 1}'; + throw SassScriptException( + 'Expected $channelName to be a number, was $channel.', name); + } + } + + // dart-lang/sdk#51926 + case _: + throw "unreachable"; + } + + if (alphaValue?.isSpecialNumber ?? false) { + return channels.length == 3 && _specialCommaSpaces.contains(space) + ? _functionString(functionName, [...channels, alphaValue!]) + : _functionString(functionName, [input]); + } + + var alpha = switch (alphaValue) { + null => 1.0, + SassString(hasQuotes: false, text: 'none') => null, + _ => clampLikeCss( + _percentageOrUnitless(alphaValue.assertNumber(name), 1, 'alpha'), + 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); +} + +/// Parses [input]'s slash-separated third number and alpha value, if one +/// exists. +/// +/// Returns a single value that contains the space-separated list of components, +/// and an alpha value if one was specified. If this channel set couldn't be +/// parsed and should be returned as-is, returns null. +/// +/// Throws a [SassScriptException] if [input] is invalid. If [input] came from a +/// function argument, [name] is the argument name (without the `$`). It's used +/// for error reporting. +(Value components, Value? alpha)? _parseSlashChannels(Value input, + {String? name}) => + switch (input.assertCommonListStyle(name, allowSlash: true)) { + [var components, var alphaValue] + when input.separator == ListSeparator.slash => + (components, alphaValue), + var inputList when input.separator == ListSeparator.slash => + throw SassScriptException( + "Only 2 slash-separated elements allowed, but ${inputList.length} " + "${pluralize('was', inputList.length, plural: 'were')} passed.", + name), + [...var initial, SassString(hasQuotes: false, :var text)] => switch ( + text.split('/')) { + [_] => (input, null), + [var channel3, var alpha] => ( + SassList([...initial, _parseNumberOrString(channel3)], + ListSeparator.space), + _parseNumberOrString(alpha) + ), + _ => null + }, + [...var initial, SassNumber(asSlash: (var before, var after))] => ( + SassList([...initial, before], ListSeparator.space), + after + ), + _ => (input, null) + }; + +/// Parses [text] as either a Sass number or an unquoted Sass string. +Value _parseNumberOrString(String text) { + try { + return ScssParser(text).parseNumber(); + } on SassFormatException { + return SassString(text, quotes: false); + } +} + +/// Creates a [SassColor] for the given [space] from the given channel values, +/// or throws a [SassScriptException] if the channel values are invalid. +/// +/// If [clamp] is true, this will clamp any clamped channels. +SassColor _colorFromChannels(ColorSpace space, SassNumber? channel0, + SassNumber? channel1, SassNumber? channel2, double? alpha, + {bool clamp = true, bool fromRgbFunction = false}) { + switch (space) { + case ColorSpace.hsl: + if (channel1 != null) _checkPercent(channel1, 'saturation'); + if (channel2 != null) _checkPercent(channel2, 'lightness'); + return SassColor.hsl( + channel0.andThen((channel0) => _angleValue(channel0, 'hue')), + _channelFromValue(space.channels[1], _forcePercent(channel1), + clamp: clamp), + _channelFromValue(space.channels[2], _forcePercent(channel2), + clamp: clamp), + alpha); + + case ColorSpace.hwb: + channel1?.assertUnit('%', 'whiteness'); + channel2?.assertUnit('%', 'blackness'); + var whiteness = channel1?.value.toDouble(); + var blackness = channel2?.value.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, clamp: clamp), + _channelFromValue(space.channels[1], channel1, clamp: clamp), + _channelFromValue(space.channels[2], channel2, clamp: clamp), + alpha, + fromRgbFunction ? ColorFormat.rgbFunction : null); + + default: + return SassColor.forSpaceInternal( + space, + _channelFromValue(space.channels[0], channel0, clamp: clamp), + _channelFromValue(space.channels[1], channel1, clamp: clamp), + _channelFromValue(space.channels[2], channel2, clamp: clamp), + alpha); + } +} + +/// Returns [number] with unit `'%'` regardless of its original unit. +SassNumber? _forcePercent(SassNumber? number) => switch (number) { + null => null, + SassNumber(numeratorUnits: ['%'], denominatorUnits: []) => number, + _ => SassNumber(number.value, '%') + }; + +/// Converts a channel value from a [SassNumber] into a [double] according to +/// [channel]. +/// +/// If [clamp] is true, this clamps [value] according to [channel]'s clamping +/// rules. +double? _channelFromValue(ColorChannel channel, SassNumber? value, + {bool clamp = true}) => + value.andThen((value) => switch (channel) { + LinearChannel(requiresPercent: true) when !value.hasUnit('%') => + throw SassScriptException( + 'Expected $value to have unit "%".', channel.name), + LinearChannel(lowerClamped: false, upperClamped: false) => + _percentageOrUnitless(value, channel.max, channel.name), + LinearChannel() when !clamp => + _percentageOrUnitless(value, channel.max, channel.name), + LinearChannel(:var lowerClamped, :var upperClamped) => clampLikeCss( + _percentageOrUnitless(value, channel.max, channel.name), + lowerClamped ? channel.min : double.negativeInfinity, + upperClamped ? channel.max : double.infinity), + _ => value.coerceValueToUnit('deg', channel.name) % 360 + }); + +/// 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'; + +/// Returns the implementation of a deprecated function that returns the value +/// of the channel named [name], implemented with [getter]. +/// +/// If [unit] is passed, the channel is returned with that unit. The [global] +/// parameter indicates whether this was called using the legacy global syntax. +BuiltInCallable _channelFunction( + String name, num Function(SassColor color) getter, + {String? unit, bool global = false}) { + return _function(name, r"$color", (arguments) { + var result = SassNumber(getter(arguments.first.assertColor("color")), unit); + + warnForDeprecation( + "${global ? '' : 'color.'}$name() is deprecated. Suggestion:\n" + "\n" + 'color.channel(\$color, $name)\n' + "\n" + "More info: https://sass-lang.com/d/color-functions", + Deprecation.colorFunctions); + + return result; + }); +} + +/// Returns suggested translations for deprecated color modification functions +/// in terms of both `color.scale()` and `color.adjust()`. +/// +/// [original] is the color that was passed in, [adjustment] is the requested +/// change, and [channelName] is the name of the modified channel. +String _suggestScaleAndAdjust( + SassColor original, double adjustment, String channelName) { + assert(original.isLegacy); + var channel = channelName == 'alpha' + ? ColorChannel.alpha + : ColorSpace.hsl.channels + .firstWhere((channel) => channel.name == channelName) + as LinearChannel; + + var oldValue = channel == ColorChannel.alpha + ? original.alpha + : original.toSpace(ColorSpace.hsl).channel(channelName); + var newValue = oldValue + adjustment; + + var suggestion = "Suggestion"; + if (adjustment != 0) { + late double factor; + if (newValue > channel.max) { + factor = 1; + } else if (newValue < channel.min) { + factor = -1; + } else if (adjustment > 0) { + factor = adjustment / (channel.max - oldValue); + } else { + factor = (newValue - oldValue) / (oldValue - channel.min); + } + var factorNumber = SassNumber(factor * 100, '%'); + suggestion += "s:\n" + "\n" + "color.scale(\$color, \$$channelName: $factorNumber)\n"; + } else { + suggestion += ":\n\n"; + } + + var difference = + SassNumber(adjustment, channel == ColorChannel.alpha ? null : '%'); + return suggestion + "color.adjust(\$color, \$$channelName: $difference)"; +} + +/// Throws an error indicating that a missing channel named [name] can't be +/// modified. +Never _missingChannelError(SassColor color, String channel) => + throw SassScriptException( + "Because the CSS working group is still deciding on the best behavior, " + "Sass doesn't currently support modifying missing channels (color: " + "$color).", + channel); + +/// Asserts that `value` is an unquoted string and throws an error if it's not. +/// +/// Assumes that `value` comes from a parameter named `$channel`. +String _channelName(Value value) => + (value.assertString("channel")..assertQuoted("channel")).text; + /// 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 1f0a473b6..03cfe4c87 100644 --- a/lib/src/functions/meta.dart +++ b/lib/src/functions/meta.dart @@ -12,6 +12,7 @@ import '../deprecation.dart'; import '../evaluation_context.dart'; import '../util/map.dart'; import '../value.dart'; +import '../visitor/serialize.dart'; /// Feature names supported by Dart sass. final _features = { @@ -39,8 +40,13 @@ final _shared = UnmodifiableListView([ var feature = arguments[0].assertString("feature"); 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", diff --git a/lib/src/js/legacy/value/color.dart b/lib/src/js/legacy/value/color.dart index 6aae64426..0545e761e 100644 --- a/lib/src/js/legacy/value/color.dart +++ b/lib/src/js/legacy/value/color.dart @@ -4,6 +4,7 @@ import 'package:js/js.dart'; +import '../../../util/nullable.dart'; import '../../../util/number.dart'; import '../../../value.dart'; import '../../reflection.dart'; @@ -45,8 +46,8 @@ final JSClass legacyColorClass = createJSClass('sass.types.Color', red = redOrArgb!; } - thisArg.dartValue = SassColor.rgb( - _clamp(red), _clamp(green), _clamp(blue), alpha?.clamp(0, 1) ?? 1); + thisArg.dartValue = SassColor.rgb(_clamp(red), _clamp(green), _clamp(blue), + alpha.andThen((alpha) => clampLikeCss(alpha.toDouble(), 0, 1)) ?? 1); }) ..defineMethods({ 'getR': (_NodeSassColor thisArg) => thisArg.dartValue.red, @@ -63,10 +64,11 @@ final JSClass legacyColorClass = createJSClass('sass.types.Color', thisArg.dartValue = thisArg.dartValue.changeRgb(blue: _clamp(value)); }, 'setA': (_NodeSassColor thisArg, num value) { - thisArg.dartValue = thisArg.dartValue.changeRgb(alpha: value.clamp(0, 1)); + thisArg.dartValue = thisArg.dartValue + .changeRgb(alpha: clampLikeCss(value.toDouble(), 0, 1)); } }); /// Clamps [channel] within the range 0, 255 and rounds it to the nearest /// integer. -int _clamp(num channel) => fuzzyRound(channel.clamp(0, 255)); +int _clamp(num channel) => fuzzyRound(clampLikeCss(channel.toDouble(), 0, 255)); diff --git a/lib/src/js/utils.dart b/lib/src/js/utils.dart index a6b269782..e291054b5 100644 --- a/lib/src/js/utils.dart +++ b/lib/src/js/utils.dart @@ -28,6 +28,11 @@ bool isUndefined(Object? value) => _isUndefined.call(value) as bool; final _isUndefined = JSFunction("value", "return value === undefined;"); +/// Returns whether or not [value] is the JS `null` value. +bool isNull(Object? value) => _isNull.call(value) as bool; + +final _isNull = JSFunction("value", "return value === null;"); + @JS("Error") external JSClass get jsErrorClass; diff --git a/lib/src/js/value/color.dart b/lib/src/js/value/color.dart index 51987f47d..8d2a34743 100644 --- a/lib/src/js/value/color.dart +++ b/lib/src/js/value/color.dart @@ -2,67 +2,366 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'dart:js_util'; + import 'package:js/js.dart'; +import 'package:node_interop/js.dart'; -import '../../util/nullable.dart'; -import '../../util/number.dart'; +import '../../deprecation.dart'; +import '../../evaluation_context.dart'; import '../../value.dart'; +import '../immutable.dart'; import '../reflection.dart'; import '../utils.dart'; /// The JavaScript `SassColor` class. final JSClass colorClass = () { - var jsClass = createJSClass('sass.SassColor', (Object self, _Channels color) { - if (color.red != null) { - return SassColor.rgb(fuzzyRound(color.red!), fuzzyRound(color.green!), - fuzzyRound(color.blue!), _handleNullAlpha(color.alpha)); - } else if (color.saturation != null) { - return SassColor.hsl(color.hue!, color.saturation!, color.lightness!, - _handleNullAlpha(color.alpha)); - } else { - return SassColor.hwb(color.hue!, color.whiteness!, color.blackness!, - _handleNullAlpha(color.alpha)); + var jsClass = createJSClass('sass.SassColor', + (Object self, _ConstructionOptions options) { + var constructionSpace = _constructionSpace(options); + switch (constructionSpace) { + case ColorSpace.rgb: + _checkNullAlphaDeprecation(options); + return SassColor.rgb(options.red, options.green, options.blue, + _handleUndefinedAlpha(options.alpha)); + + case ColorSpace.hsl: + _checkNullAlphaDeprecation(options); + return SassColor.hsl(options.hue, options.saturation, options.lightness, + _handleUndefinedAlpha(options.alpha)); + + case ColorSpace.hwb: + _checkNullAlphaDeprecation(options); + return SassColor.hwb(options.hue, options.whiteness, options.blackness, + _handleUndefinedAlpha(options.alpha)); + + case ColorSpace.lab: + return SassColor.lab(options.lightness, options.a, options.b, + _handleUndefinedAlpha(options.alpha)); + case ColorSpace.oklab: + return SassColor.oklab(options.lightness, options.a, options.b, + _handleUndefinedAlpha(options.alpha)); + + case ColorSpace.lch: + return SassColor.lch(options.lightness, options.chroma, options.hue, + _handleUndefinedAlpha(options.alpha)); + case ColorSpace.oklch: + return SassColor.oklch(options.lightness, options.chroma, options.hue, + _handleUndefinedAlpha(options.alpha)); + + case ColorSpace.srgb: + return SassColor.srgb(options.red, options.green, options.blue, + _handleUndefinedAlpha(options.alpha)); + case ColorSpace.srgbLinear: + return SassColor.srgbLinear(options.red, options.green, options.blue, + _handleUndefinedAlpha(options.alpha)); + case ColorSpace.displayP3: + return SassColor.displayP3(options.red, options.green, options.blue, + _handleUndefinedAlpha(options.alpha)); + case ColorSpace.a98Rgb: + return SassColor.a98Rgb(options.red, options.green, options.blue, + _handleUndefinedAlpha(options.alpha)); + case ColorSpace.prophotoRgb: + return SassColor.prophotoRgb(options.red, options.green, options.blue, + _handleUndefinedAlpha(options.alpha)); + case ColorSpace.rec2020: + return SassColor.rec2020(options.red, options.green, options.blue, + _handleUndefinedAlpha(options.alpha)); + + // `xyz` name is mapped to `xyzD65` space. + case ColorSpace.xyzD50: + return SassColor.xyzD50(options.x, options.y, options.z, + _handleUndefinedAlpha(options.alpha)); + case ColorSpace.xyzD65: + return SassColor.xyzD65(options.x, options.y, options.z, + _handleUndefinedAlpha(options.alpha)); + + default: + throw "Unreachable"; } }); - jsClass.defineMethod('change', (SassColor self, _Channels options) { - if (options.whiteness != null || options.blackness != null) { - return self.changeHwb( - hue: options.hue ?? self.hue, - whiteness: options.whiteness ?? self.whiteness, - blackness: options.blackness ?? self.blackness, - alpha: options.alpha ?? self.alpha); - } else if (options.hue != null || - options.saturation != null || - options.lightness != null) { - return self.changeHsl( - hue: options.hue ?? self.hue, - saturation: options.saturation ?? self.saturation, - lightness: options.lightness ?? self.lightness, - alpha: options.alpha ?? self.alpha); - } else if (options.red != null || - options.green != null || - options.blue != null) { - return self.changeRgb( - red: options.red.andThen(fuzzyRound) ?? self.red, - green: options.green.andThen(fuzzyRound) ?? self.green, - blue: options.blue.andThen(fuzzyRound) ?? self.blue, - alpha: options.alpha ?? self.alpha); - } else { - return self.changeAlpha(options.alpha ?? self.alpha); + jsClass.defineMethods({ + 'equals': (SassColor self, Object other) => self == other, + 'hashCode': (SassColor self) => self.hashCode, + 'toSpace': (SassColor self, String space) => _toSpace(self, space), + 'isInGamut': (SassColor self, [String? space]) => + _toSpace(self, space).isInGamut, + 'toGamut': (SassColor self, _ToGamutOptions options) { + var originalSpace = self.space; + return _toSpace(self, options.space) + .toGamut(GamutMapMethod.fromName(options.method)) + .toSpace(originalSpace); + }, + 'channel': (SassColor self, String channel, [_ChannelOptions? options]) => + _toSpace(self, options?.space).channel(channel), + 'isChannelMissing': (SassColor self, String channel) => + self.isChannelMissing(channel), + 'isChannelPowerless': (SassColor self, String channel, + [_ChannelOptions? options]) => + _toSpace(self, options?.space).isChannelPowerless(channel), + 'change': (SassColor self, _ConstructionOptions options) { + var spaceSetExplicitly = options.space != null; + var space = + spaceSetExplicitly ? ColorSpace.fromName(options.space!) : self.space; + + if (self.isLegacy && !spaceSetExplicitly) { + if (hasProperty(options, 'whiteness') || + hasProperty(options, 'blackness')) { + space = ColorSpace.hwb; + } else if (hasProperty(options, 'hue') && + self.space == ColorSpace.hwb) { + space = ColorSpace.hwb; + } else if (hasProperty(options, 'hue') || + hasProperty(options, 'saturation') || + hasProperty(options, 'lightness')) { + space = ColorSpace.hsl; + } else if (hasProperty(options, 'red')) { + space = ColorSpace.rgb; + } + if (space != self.space) { + warnForDeprecationFromApi( + "Changing a channel not in this color's space without explicitly specifying " + "the `space` option is deprecated." + "\n" + "More info: https://sass-lang.com/d/color-4-api", + Deprecation.color4Api); + } + } + + for (final key in objectKeys(options)) { + if (['alpha', 'space'].contains(key)) continue; + if (!space.channels.any((channel) => channel.name == key)) { + jsThrow(JsError("`$key` is not a valid channel in `$space`.")); + } + } + + var color = self.toSpace(space); + + SassColor changedColor; + + double? changedValue(String channel) { + return _changeComponentValue(color, channel, options); + } + + switch (space) { + case ColorSpace.hsl when spaceSetExplicitly: + changedColor = SassColor.hsl( + changedValue('hue'), + changedValue('saturation'), + changedValue('lightness'), + changedValue('alpha')); + break; + + case ColorSpace.hsl: + if (isNull(options.hue)) { + _emitColor4ApiNullDeprecation('hue'); + } else if (isNull(options.saturation)) { + _emitColor4ApiNullDeprecation('saturation'); + } else if (isNull(options.lightness)) { + _emitColor4ApiNullDeprecation('lightness'); + } + if (isNull(options.alpha)) { + _emitNullAlphaDeprecation(); + } + changedColor = SassColor.hsl( + options.hue ?? color.channel('hue'), + options.saturation ?? color.channel('saturation'), + options.lightness ?? color.channel('lightness'), + options.alpha ?? color.channel('alpha')); + break; + + case ColorSpace.hwb when spaceSetExplicitly: + changedColor = SassColor.hwb( + changedValue('hue'), + changedValue('whiteness'), + changedValue('blackness'), + changedValue('alpha')); + break; + + case ColorSpace.hwb: + if (isNull(options.hue)) { + _emitColor4ApiNullDeprecation('hue'); + } else if (isNull(options.whiteness)) { + _emitColor4ApiNullDeprecation('whiteness'); + } else if (isNull(options.blackness)) { + _emitColor4ApiNullDeprecation('blackness'); + } + if (isNull(options.alpha)) _emitNullAlphaDeprecation(); + changedColor = SassColor.hwb( + options.hue ?? color.channel('hue'), + options.whiteness ?? color.channel('whiteness'), + options.blackness ?? color.channel('blackness'), + options.alpha ?? color.channel('alpha')); + + break; + + case ColorSpace.rgb when spaceSetExplicitly: + changedColor = SassColor.rgb( + changedValue('red'), + changedValue('green'), + changedValue('blue'), + changedValue('alpha')); + break; + + case ColorSpace.rgb: + if (isNull(options.red)) { + _emitColor4ApiNullDeprecation('red'); + } else if (isNull(options.green)) { + _emitColor4ApiNullDeprecation('green'); + } else if (isNull(options.blue)) { + _emitColor4ApiNullDeprecation('blue'); + } + if (isNull(options.alpha)) { + _emitNullAlphaDeprecation(); + } + changedColor = SassColor.rgb( + options.red ?? color.channel('red'), + options.green ?? color.channel('green'), + options.blue ?? color.channel('blue'), + options.alpha ?? color.channel('alpha')); + break; + + case ColorSpace.lab: + changedColor = SassColor.lab(changedValue('lightness'), + changedValue('a'), changedValue('b'), changedValue('alpha')); + break; + + case ColorSpace.oklab: + changedColor = SassColor.oklab(changedValue('lightness'), + changedValue('a'), changedValue('b'), changedValue('alpha')); + break; + + case ColorSpace.lch: + changedColor = SassColor.lch( + changedValue('lightness'), + changedValue('chroma'), + changedValue('hue'), + changedValue('alpha')); + break; + case ColorSpace.oklch: + changedColor = SassColor.oklch( + changedValue('lightness'), + changedValue('chroma'), + changedValue('hue'), + changedValue('alpha')); + break; + + case ColorSpace.a98Rgb: + changedColor = SassColor.a98Rgb( + changedValue('red'), + changedValue('green'), + changedValue('blue'), + changedValue('alpha')); + break; + case ColorSpace.displayP3: + changedColor = SassColor.displayP3( + changedValue('red'), + changedValue('green'), + changedValue('blue'), + changedValue('alpha')); + break; + case ColorSpace.prophotoRgb: + changedColor = SassColor.prophotoRgb( + changedValue('red'), + changedValue('green'), + changedValue('blue'), + changedValue('alpha')); + break; + case ColorSpace.rec2020: + changedColor = SassColor.rec2020( + changedValue('red'), + changedValue('green'), + changedValue('blue'), + changedValue('alpha')); + break; + case ColorSpace.srgb: + changedColor = SassColor.srgb( + changedValue('red'), + changedValue('green'), + changedValue('blue'), + changedValue('alpha')); + break; + case ColorSpace.srgbLinear: + changedColor = SassColor.srgbLinear( + changedValue('red'), + changedValue('green'), + changedValue('blue'), + changedValue('alpha')); + break; + + case ColorSpace.xyzD50: + changedColor = SassColor.forSpaceInternal(space, changedValue('x'), + changedValue('y'), changedValue('z'), changedValue('alpha')); + break; + case ColorSpace.xyzD65: + changedColor = SassColor.forSpaceInternal(space, changedValue('x'), + changedValue('y'), changedValue('z'), changedValue('alpha')); + break; + + default: + throw "No space set"; + } + + return changedColor.toSpace(self.space); + }, + 'interpolate': + (SassColor self, SassColor color2, _InterpolationOptions options) { + InterpolationMethod interpolationMethod; + + if (options.method case var method?) { + var hue = HueInterpolationMethod.values.byName(method); + interpolationMethod = InterpolationMethod(self.space, hue); + } else if (!self.space.isPolar) { + interpolationMethod = InterpolationMethod(self.space); + } else { + interpolationMethod = + InterpolationMethod(self.space, HueInterpolationMethod.shorter); + } + + return self.interpolate(color2, interpolationMethod, + weight: options.weight); } }); jsClass.defineGetters({ - 'red': (SassColor self) => self.red, - 'green': (SassColor self) => self.green, - 'blue': (SassColor self) => self.blue, - 'hue': (SassColor self) => self.hue, - 'saturation': (SassColor self) => self.saturation, - 'lightness': (SassColor self) => self.lightness, - 'whiteness': (SassColor self) => self.whiteness, - 'blackness': (SassColor self) => self.blackness, + 'red': (SassColor self) { + _emitColor4ApiChannelDeprecation('red'); + return self.red; + }, + 'green': (SassColor self) { + _emitColor4ApiChannelDeprecation('green'); + return self.green; + }, + 'blue': (SassColor self) { + _emitColor4ApiChannelDeprecation('blue'); + return self.blue; + }, + 'hue': (SassColor self) { + _emitColor4ApiChannelDeprecation('hue'); + return self.hue; + }, + 'saturation': (SassColor self) { + _emitColor4ApiChannelDeprecation('saturation'); + return self.saturation; + }, + 'lightness': (SassColor self) { + _emitColor4ApiChannelDeprecation('lightness'); + return self.lightness; + }, + 'whiteness': (SassColor self) { + _emitColor4ApiChannelDeprecation('whiteness'); + return self.whiteness; + }, + 'blackness': (SassColor self) { + _emitColor4ApiChannelDeprecation('blackness'); + return self.blackness; + }, 'alpha': (SassColor self) => self.alpha, + 'space': (SassColor self) => self.space.name, + 'isLegacy': (SassColor self) => self.isLegacy, + 'channelsOrNull': (SassColor self) => ImmutableList(self.channelsOrNull), + 'channels': (SassColor self) => ImmutableList(self.channels) }); getJSClass(SassColor.rgb(0, 0, 0)).injectSuperclass(jsClass); @@ -71,20 +370,111 @@ final JSClass colorClass = () { /// Converts an undefined [alpha] to 1. /// -/// This ensures that an explicitly null alpha will produce a deprecation -/// warning when passed to the Dart API. -num? _handleNullAlpha(num? alpha) => isUndefined(alpha) ? 1 : alpha; +/// This ensures that an explicitly null alpha will be treated as a missing +/// component. +double? _handleUndefinedAlpha(double? alpha) => isUndefined(alpha) ? 1 : alpha; + +/// This procedure takes a `channel` name, an object `changes` and a SassColor +/// `initial` and returns the result of applying the change for `channel` to +/// `initial`. +double? _changeComponentValue( + SassColor initial, String channel, _ConstructionOptions changes) => + hasProperty(changes, channel) && !isUndefined(getProperty(changes, channel)) + ? getProperty(changes, channel) + : initial.channel(channel); + +/// Determines the construction space based on the provided options. +ColorSpace _constructionSpace(_ConstructionOptions options) { + if (options.space != null) return ColorSpace.fromName(options.space!); + if (options.red != null) return ColorSpace.rgb; + if (options.saturation != null) return ColorSpace.hsl; + if (options.whiteness != null) return ColorSpace.hwb; + throw "No color space found"; +} + +// Return a SassColor in a named space, or in its original space. +SassColor _toSpace(SassColor self, String? space) { + return self.toSpace(ColorSpace.fromName(space ?? self.space.name)); +} + +// If alpha is explicitly null and space is not set, emit deprecation. +void _checkNullAlphaDeprecation(_ConstructionOptions options) { + if (!isUndefined(options.alpha) && + identical(options.alpha, null) && + identical(options.space, null)) { + _emitNullAlphaDeprecation(); + } +} + +// Warn users about null-alpha deprecation. +void _emitNullAlphaDeprecation() { + warnForDeprecationFromApi( + "Passing `alpha: null` without setting `space` is deprecated." + "\n" + "More info: https://sass-lang.com/d/null-alpha", + Deprecation.nullAlpha); +} + +// Warn users about `null` channel values without setting `space`. +void _emitColor4ApiNullDeprecation(String name) { + warnForDeprecationFromApi( + "Passing `$name: null` without setting `space` is deprecated." + "\n" + "More info: https://sass-lang.com/d/color-4-api", + Deprecation.color4Api); +} + +// Warn users about legacy color channel getters. +void _emitColor4ApiChannelDeprecation(String name) { + warnForDeprecationFromApi( + "$name is deprecated, use `channel` instead." + "\n" + "More info: https://sass-lang.com/d/color-4-api", + Deprecation.color4Api); +} @JS() @anonymous class _Channels { - external num? get red; - external num? get green; - external num? get blue; - external num? get hue; - external num? get saturation; - external num? get lightness; - external num? get whiteness; - external num? get blackness; - external num? get alpha; + external double? get red; + external double? get green; + external double? get blue; + external double? get hue; + external double? get saturation; + external double? get lightness; + external double? get whiteness; + external double? get blackness; + external double? get alpha; + external double? get a; + external double? get b; + external double? get x; + external double? get y; + external double? get z; + external double? get chroma; +} + +@JS() +@anonymous +class _ConstructionOptions extends _Channels { + external String? get space; +} + +@JS() +@anonymous +class _ChannelOptions { + external String? get space; +} + +@JS() +@anonymous +class _ToGamutOptions { + external String? get space; + external String get method; +} + +@JS() +@anonymous +class _InterpolationOptions { + external double? get weight; + external String? get method; } diff --git a/lib/src/parse/stylesheet.dart b/lib/src/parse/stylesheet.dart index 2ebdf412a..b619949b0 100644 --- a/lib/src/parse/stylesheet.dart +++ b/lib/src/parse/stylesheet.dart @@ -121,6 +121,11 @@ abstract class StylesheetParser extends Parser { Expression parseExpression() => _parseSingleProduction(_expression); + SassNumber parseNumber() { + var expression = _parseSingleProduction(_number); + return SassNumber(expression.value, expression.unit); + } + VariableDeclaration parseVariableDeclaration() => _parseSingleProduction(() => lookingAtIdentifier() ? _variableDeclarationWithNamespace() diff --git a/lib/src/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 8df7beb1a..8aad45581 100644 --- a/lib/src/util/number.dart +++ b/lib/src/util/number.dart @@ -30,6 +30,17 @@ bool fuzzyEquals(num number1, num number2) { (number2 * _inverseEpsilon).round(); } +/// Like [fuzzyEquals], but allows null values for [number1] and [number2]. +/// +/// null values are only equal to one another. +bool fuzzyEqualsNullable(num? number1, num? number2) { + if (number1 == number2) return true; + if (number1 == null || number2 == null) return false; + return (number1 - number2).abs() <= _epsilon && + (number1 * _inverseEpsilon).round() == + (number2 * _inverseEpsilon).round(); +} + /// Returns a hash code for [number] that matches [fuzzyEquals]. int fuzzyHashCode(double number) { if (!number.isFinite) return number.hashCode; @@ -83,6 +94,11 @@ int fuzzyRound(num number) { } } +/// Returns whether [number] is within [min] and [max] inclusive, using fuzzy +/// equality. +bool fuzzyInRange(double number, num min, num max) => + fuzzyGreaterThanOrEquals(number, min) && fuzzyLessThanOrEquals(number, max); + /// Returns [number] if it's within [min] and [max], or `null` if it's not. /// /// If [number] is [fuzzyEquals] to [min] or [max], it's clamped to the @@ -124,6 +140,12 @@ double moduloLikeSass(double num1, double num2) { return result == 0 ? 0 : result + num2; } +//// Returns [num] clamped between [lowerBound] and [upperBound], with `NaN` +//// preferring the lower bound (unlike Dart for which it prefers the upper +//// bound). +double clampLikeCss(double number, double lowerBound, double upperBound) => + number.isNaN ? lowerBound : number.clamp(lowerBound, upperBound); + /// Returns the square root of [number]. SassNumber sqrt(SassNumber number) { number.assertNoUnits("number"); diff --git a/lib/src/utils.dart b/lib/src/utils.dart index abd7834cc..51e88a839 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -155,6 +155,9 @@ List flattenVertically(Iterable> iterable) { return result; } +/// 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 1161ebbbe..81f4df27e 100644 --- a/lib/src/value.dart +++ b/lib/src/value.dart @@ -211,6 +211,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 8df328564..d68f465a0 100644 --- a/lib/src/value/color.dart +++ b/lib/src/value/color.dart @@ -2,207 +2,539 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import 'dart:math' as math; - -import 'package:cli_pkg/js.dart'; +import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; -import '../deprecation.dart'; -import '../evaluation_context.dart'; import '../exception.dart'; +import '../util/nullable.dart'; import '../util/number.dart'; import '../value.dart'; import '../visitor/interface/value.dart'; +export 'color/gamut_map_method.dart'; +export 'color/interpolation_method.dart'; +export 'color/channel.dart'; +export 'color/space.dart'; + /// 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) { + ColorSpace.hsl => fuzzyEquals(channel1, 0), + ColorSpace.hwb => fuzzyGreaterThanOrEquals(channel1 + channel2, 100), + _ => false + }; - double? _hue; + /// 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 + /// + /// @nodoc + @internal + final double? channel0OrNull; - /// This color's saturation, a percentage between `0` and `100`. - double get saturation { - if (_saturation == null) _rgbToHsl(); - return _saturation!; - } + /// 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? _saturation; + /// 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 lightness, a percentage between `0` and `100`. - double get lightness { - if (_lightness == null) _rgbToHsl(); - return _lightness!; - } + /// Returns whether this color's second channel is [powerless]. + /// + /// [powerless]: https://www.w3.org/TR/css-color-4/#powerless + /// + /// @nodoc + @internal + final bool isChannel1Powerless = false; - double? _lightness; + /// 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 + /// + /// @nodoc + @internal + final double? channel1OrNull; - /// 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 third channel is [missing]. + /// + /// [missing]: https://www.w3.org/TR/css-color-4/#missing + /// + /// @nodoc + @internal + bool get isChannel2Missing => channel2OrNull == null; - /// 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; - } + /// 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) { + ColorSpace.lch || ColorSpace.oklch => fuzzyEquals(channel1, 0), + _ => 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 + /// + /// @nodoc + @internal + 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 => alphaOrNull ?? 0; + + /// This color's alpha channel. /// - /// Passing `null` to [alpha] is deprecated, and will change behavior in - /// future versions of Dart Sass to represent a [missing component] instead of - /// being equivalent to `1`. Callers who want to create opaque colors should - /// explicitly pass `1` or not pass [alpha] at all. + /// If this is `null`, that indicates a [missing] component. /// - /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// [missing]: https://www.w3.org/TR/css-color-4/#missing /// - /// 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 = 1]) - : this.rgbInternal(red, green, blue, _handleNullAlpha(alpha)); + /// @nodoc + final double? alphaOrNull; - /// Like [SassColor.rgb], but also takes a [format] parameter. + /// Returns whether this color's alpha channel is [missing]. + /// + /// [missing]: https://www.w3.org/TR/css-color-4/#missing /// /// @nodoc @internal - SassColor.rgbInternal(this._red, this._green, this._blue, - [num alpha = 1, this.format]) - : _alpha = fuzzyAssertRange(alpha.toDouble(), 0, 1, "alpha") { - RangeError.checkValueInInterval(red, 0, 255, "red"); - RangeError.checkValueInInterval(green, 0, 255, "green"); - RangeError.checkValueInInterval(blue, 0, 255, "blue"); + bool get isAlphaMissing => alphaOrNull == null; + + /// Whether this is a legacy color—that is, a color defined using + /// pre-color-spaces syntax that preserves comaptibility with old color + /// behavior and semantics. + bool get isLegacy => space.isLegacy; + + /// Whether this color is in-gamut for its color space. + bool get isInGamut { + if (!space.isBounded) return true; + + // There aren't (currently) any color spaces that are bounded but not + // STRICTLY bounded, and have polar-angle channels. + return _isChannelInGamut(channel0, space.channels[0]) && + _isChannelInGamut(channel1, space.channels[1]) && + _isChannelInGamut(channel2, space.channels[2]); } - /// Creates an HSL color. + /// Returns whether [value] is in-gamut for the given [channel]. + bool _isChannelInGamut(double value, ColorChannel channel) => + switch (channel) { + LinearChannel(:var min, :var max) => + fuzzyLessThanOrEquals(value, max) && + fuzzyGreaterThanOrEquals(value, min), + _ => true + }; + + /// Whether this color has any missing channels. /// - /// Passing `null` to [alpha] is deprecated, and will change behavior in - /// future versions of Dart Sass to represent a [missing component] instead of - /// being equivalent to `1`. Callers who want to create opaque colors should - /// explicitly pass `1` or not pass [alpha] at all. + /// @nodoc + @internal + bool get hasMissingChannel => + isChannel0Missing || + isChannel1Missing || + isChannel2Missing || + isAlphaMissing; + + /// This color's red channel, between `0` and `255`. + /// + /// **Note:** This is rounded to the nearest integer, which may be lossy. Use + /// [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'); + + /// 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]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. /// /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components /// - /// 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 = 1]) - : this.hslInternal(hue, saturation, lightness, _handleNullAlpha(alpha)); + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.rgb(num? red, num? green, num? blue, [num? alpha = 1]) => + SassColor.rgbInternal(red, green, blue, alpha); - /// Like [SassColor.hsl], but also takes a [format] parameter. + /// Like [SassColor.rgb], but also takes a [format] parameter. /// /// @nodoc @internal - SassColor.hslInternal(num hue, num saturation, num lightness, - [num alpha = 1, this.format]) - : _hue = hue % 360, - _saturation = - fuzzyAssertRange(saturation.toDouble(), 0, 100, "saturation"), - _lightness = - fuzzyAssertRange(lightness.toDouble(), 0, 100, "lightness"), - _alpha = 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 = 1]) { - // 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; - } + factory SassColor.rgbInternal(num? red, num? green, num? blue, + [num? alpha = 1, ColorFormat? format]) => + SassColor._forSpace(ColorSpace.rgb, red?.toDouble(), green?.toDouble(), + blue?.toDouble(), alpha?.toDouble(), format); - 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.hsl]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.hsl(num? hue, num? saturation, num? lightness, + [num? alpha = 1]) => + SassColor.forSpaceInternal(ColorSpace.hsl, hue?.toDouble(), + saturation?.toDouble(), lightness?.toDouble(), alpha?.toDouble()); - // 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); - } + /// Creates a color in [ColorSpace.hwb]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.hwb(num? hue, num? whiteness, num? blackness, + [num? alpha = 1]) => + SassColor.forSpaceInternal(ColorSpace.hwb, hue?.toDouble(), + whiteness?.toDouble(), blackness?.toDouble(), alpha?.toDouble()); + + /// Creates a color in [ColorSpace.srgb]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.srgb(double? red, double? green, double? blue, + [double? alpha = 1]) => + SassColor._forSpace(ColorSpace.srgb, red, green, blue, alpha); + + /// Creates a color in [ColorSpace.srgbLinear]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.srgbLinear(double? red, double? green, double? blue, + [double? alpha = 1]) => + SassColor._forSpace(ColorSpace.srgbLinear, red, green, blue, alpha); + + /// Creates a color in [ColorSpace.displayP3]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.displayP3(double? red, double? green, double? blue, + [double? alpha = 1]) => + SassColor._forSpace(ColorSpace.displayP3, red, green, blue, alpha); + + /// Creates a color in [ColorSpace.a98Rgb]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.a98Rgb(double? red, double? green, double? blue, + [double? alpha = 1]) => + SassColor._forSpace(ColorSpace.a98Rgb, red, green, blue, alpha); + + /// Creates a color in [ColorSpace.prophotoRgb]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.prophotoRgb(double? red, double? green, double? blue, + [double? alpha = 1]) => + SassColor._forSpace(ColorSpace.prophotoRgb, red, green, blue, alpha); + + /// Creates a color in [ColorSpace.rec2020]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.rec2020(double? red, double? green, double? blue, + [double? alpha = 1]) => + SassColor._forSpace(ColorSpace.rec2020, red, green, blue, alpha); - /// Prints a deprecation warning if [alpha] is explicitly `null`. - static num _handleNullAlpha(num? alpha) { - if (alpha != null) return alpha; - - warnForDeprecation( - 'Passing null for alpha in the ${isJS ? 'JS' : 'Dart'} API is ' - 'deprecated.\n' - 'To preserve current behavior, pass 1${isJS ? ' or undefined' : ''} ' - 'instead.' - '\n' - 'More info: https://sass-lang.com/d/null-alpha', - Deprecation.nullAlpha); - return 1; + /// Creates a color in [ColorSpace.xyzD50]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.xyzD50(double? x, double? y, double? z, + [double? alpha = 1]) => + SassColor._forSpace(ColorSpace.xyzD50, x, y, z, alpha); + + /// Creates a color in [ColorSpace.xyzD65]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.xyzD65(double? x, double? y, double? z, + [double? alpha = 1]) => + SassColor._forSpace(ColorSpace.xyzD65, x, y, z, alpha); + + /// Creates a color in [ColorSpace.lab]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.lab(double? lightness, double? a, double? b, + [double? alpha = 1]) => + SassColor._forSpace(ColorSpace.lab, lightness, a, b, alpha); + + /// Creates a color in [ColorSpace.lch]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.lch(double? lightness, double? chroma, double? hue, + [double? alpha = 1]) => + SassColor.forSpaceInternal(ColorSpace.lch, lightness, chroma, hue, alpha); + + /// Creates a color in [ColorSpace.oklab]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.oklab(double? lightness, double? a, double? b, + [double? alpha = 1]) => + SassColor._forSpace(ColorSpace.oklab, lightness, a, b, alpha); + + /// Creates a color in [ColorSpace.oklch]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.oklch(double? lightness, double? chroma, double? hue, + [double? alpha = 1]) => + SassColor.forSpaceInternal( + ColorSpace.oklch, lightness, chroma, hue, alpha); + + /// Creates a color in the color space named [space]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1` or if + /// [channels] is the wrong length for [space]. + factory SassColor.forSpace(ColorSpace space, List channels, + [double? alpha = 1]) => + channels.length == space.channels.length + ? SassColor.forSpaceInternal( + space, channels[0], channels[1], channels[2], alpha) + : throw RangeError.value(channels.length, "channels.length", + 'must be exactly ${space.channels.length} for color space "$space"'); + + /// Like [forSpace], but takes three channels explicitly rather than wrapping + /// and unwrapping them in an array. + /// + /// @nodoc + factory SassColor.forSpaceInternal(ColorSpace space, double? channel0, + double? channel1, double? channel2, + [double? alpha = 1]) => + switch (space) { + ColorSpace.hsl => SassColor._forSpace( + space, + _normalizeHue(channel0, + invert: channel1 != null && fuzzyLessThan(channel1, 0)), + channel1?.abs(), + channel2, + alpha), + ColorSpace.hwb => SassColor._forSpace(space, + _normalizeHue(channel0, invert: false), channel1, channel2, alpha), + ColorSpace.lch || ColorSpace.oklch => SassColor._forSpace( + space, + channel0, + channel1?.abs(), + _normalizeHue(channel2, + invert: channel1 != null && fuzzyLessThan(channel1, 0)), + alpha), + _ => SassColor._forSpace(space, channel0, channel1, channel2, alpha) + }; + + /// Like [forSpaceInternal], but doesn't do _any_ pre-processing of any + /// channels. + SassColor._forSpace(this._space, this.channel0OrNull, this.channel1OrNull, + this.channel2OrNull, double? alpha, [this.format]) + : alphaOrNull = + alpha.andThen((alpha) => fuzzyAssertRange(alpha, 0, 1, "alpha")) { + assert(format == null || _space == ColorSpace.rgb); + assert(space != ColorSpace.lms); } - SassColor._(this._red, this._green, this._blue, this._hue, this._saturation, - this._lightness, this._alpha) - : format = null; + /// If [hue] isn't null, normalizes it to the range `[0, 360)`. + /// + /// If [invert] is true, this returns the hue 180deg offset from the original value. + static double? _normalizeHue(double? hue, {required bool invert}) { + if (hue == null) return hue; + return (hue % 360 + 360 + (invert ? 180 : 0)) % 360; + } /// @nodoc @internal @@ -210,31 +542,392 @@ 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}) { + 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; + if (channel == 'alpha') return alpha; + + 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}) { + 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; + if (channel == 'alpha') return isAlphaMissing; + + 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}) { + 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; + if (channel == 'alpha') return false; + + 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 [legacyMissing] is false, this will convert missing channels in legacy + /// color spaces to zero if a conversion occurs. Otherwise, they remain + /// missing after the conversion. + SassColor toSpace(ColorSpace space, {bool legacyMissing = true}) { + if (this.space == space) return this; + + var converted = this + .space + .convert(space, channel0OrNull, channel1OrNull, channel2OrNull, alpha); + return !legacyMissing && + converted.isLegacy && + (converted.isChannel0Missing || + converted.isChannel1Missing || + converted.isChannel2Missing || + converted.isAlphaMissing) + ? SassColor.forSpaceInternal(converted.space, converted.channel0, + converted.channel1, converted.channel2, converted.alpha) + : converted; + } + + /// Returns a copy of this color that's in-gamut in the current color space. + SassColor toGamut(GamutMapMethod method) => + isInGamut ? this : method.map(this); + /// 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) return this; + + if (space != null && space != this.space) { + return toSpace(space) + .changeChannels(newValues, colorName: colorName) + .toSpace(this.space); + } + + double? new0; + double? new1; + double? new2; + double? alpha; + var channels = this.space.channels; + + void setChannel0(double value) { + if (new0 != null) { + throw SassScriptException( + 'Multiple values supplied for "${channels[0]}": $new0 and ' + '$value.', + colorName); + } + new0 = value; + } + + void setChannel1(double value) { + if (new1 != null) { + throw SassScriptException( + 'Multiple values supplied for "${channels[1]}": $new1 and ' + '$value.', + colorName); + } + new1 = value; + } + + void setChannel2(double value) { + if (new2 != null) { + throw SassScriptException( + 'Multiple values supplied for "${channels[2]}": $new2 and ' + '$value.', + colorName); + } + new2 = value; + } + + for (var entry in newValues.entries) { + var channel = entry.key; + 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, new0 ?? channel0OrNull, + new1 ?? channel1OrNull, new2 ?? channel2OrNull, alpha ?? alphaOrNull); + } + + /// 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. + /// + /// If [legacyMissing] is false, this will convert missing channels in legacy + /// color spaces to zero if a conversion occurs. + SassColor interpolate(SassColor other, InterpolationMethod method, + {double? weight, bool legacyMissing = true}) { + weight ??= 0.5; + + if (fuzzyEquals(weight, 0)) return other; + if (fuzzyEquals(weight, 1)) return this; + + var color1 = toSpace(method.space); + var color2 = other.toSpace(method.space); + + if (weight < 0 || weight > 1) { + throw RangeError.range(weight, 0, 1, 'weight'); + } + + // If either color is missing a channel _and_ that channel is analogous with + // one in the output space, then the output channel should take on the other + // color's value. + var missing1_0 = _isAnalogousChannelMissing(this, color1, 0); + var missing1_1 = _isAnalogousChannelMissing(this, color1, 1); + var missing1_2 = _isAnalogousChannelMissing(this, color1, 2); + var missing2_0 = _isAnalogousChannelMissing(other, color2, 0); + var missing2_1 = _isAnalogousChannelMissing(other, color2, 1); + var missing2_2 = _isAnalogousChannelMissing(other, color2, 2); + var channel1_0 = (missing1_0 ? color2 : color1).channel0; + var channel1_1 = (missing1_1 ? color2 : color1).channel1; + var channel1_2 = (missing1_2 ? color2 : color1).channel2; + var channel2_0 = (missing2_0 ? color1 : color2).channel0; + var channel2_1 = (missing2_1 ? color1 : color2).channel1; + var channel2_2 = (missing2_2 ? color1 : color2).channel2; + var alpha1 = alphaOrNull ?? other.alpha; + var alpha2 = other.alphaOrNull ?? alpha; + + var thisMultiplier = (alphaOrNull ?? 1) * weight; + var otherMultiplier = (other.alphaOrNull ?? 1) * (1 - weight); + var mixedAlpha = isAlphaMissing && other.isAlphaMissing + ? null + : alpha1 * weight + alpha2 * (1 - weight); + var mixed0 = missing1_0 && missing2_0 + ? null + : (channel1_0 * thisMultiplier + channel2_0 * otherMultiplier) / + (mixedAlpha ?? 1); + var mixed1 = missing1_1 && missing2_1 + ? null + : (channel1_1 * thisMultiplier + channel2_1 * otherMultiplier) / + (mixedAlpha ?? 1); + var mixed2 = missing1_2 && missing2_2 + ? null + : (channel1_2 * thisMultiplier + channel2_2 * otherMultiplier) / + (mixedAlpha ?? 1); + + return switch (method.space) { + ColorSpace.hsl || ColorSpace.hwb => SassColor.forSpaceInternal( + method.space, + missing1_0 && missing2_0 + ? null + : _interpolateHues(channel1_0, channel2_0, method.hue!, weight), + mixed1, + mixed2, + mixedAlpha), + ColorSpace.lch || ColorSpace.oklch => SassColor.forSpaceInternal( + method.space, + mixed0, + mixed1, + missing1_2 && missing2_2 + ? null + : _interpolateHues(channel1_2, channel2_2, method.hue!, weight), + mixedAlpha), + _ => SassColor.forSpaceInternal( + method.space, mixed0, mixed1, mixed2, mixedAlpha) + } + .toSpace(space, legacyMissing: legacyMissing); + } + + /// 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 + switch (method) { + case HueInterpolationMethod.shorter: + switch (hue2 - hue1) { + case > 180: + hue1 += 360; + case < -180: + hue2 += 360; + } + + case HueInterpolationMethod.longer: + switch (hue2 - hue1) { + case > 0 && < 180: + hue2 += 360; + case > -180 && <= 0: + hue1 += 360; + } + + case HueInterpolationMethod.increasing when hue2 < hue1: + hue2 += 360; + + case HueInterpolationMethod.decreasing when hue1 < hue2: + hue1 += 360; + + case _: // do nothing + } + + return hue1 * weight + hue2 * (1 - weight); + } /// @nodoc @internal @@ -259,126 +952,45 @@ class SassColor extends Value { throw SassScriptException('Undefined operation "$this / $other".'); } - bool operator ==(Object other) => - other is SassColor && - other.red == red && - other.green == green && - other.blue == blue && - other.alpha == alpha; - - int get hashCode => - red.hashCode ^ green.hashCode ^ blue.hashCode ^ alpha.hashCode; - - /// Computes [_hue], [_saturation], and [_value] based on [red], [green], and - /// [blue]. - void _rgbToHsl() { - // Algorithm from https://en.wikipedia.org/wiki/HSL_and_HSV#RGB_to_HSL_and_HSV - var scaledRed = red / 255; - var scaledGreen = green / 255; - var scaledBlue = blue / 255; - - var max = math.max(math.max(scaledRed, scaledGreen), scaledBlue); - var min = math.min(math.min(scaledRed, scaledGreen), scaledBlue); - var delta = max - min; - - if (max == min) { - _hue = 0; - } else if (max == scaledRed) { - _hue = (60 * (scaledGreen - scaledBlue) / delta) % 360; - } else if (max == scaledGreen) { - _hue = (120 + 60 * (scaledBlue - scaledRed) / delta) % 360; - } else if (max == scaledBlue) { - _hue = (240 + 60 * (scaledRed - scaledGreen) / delta) % 360; - } - - var lightness = _lightness = 50 * (max + min); - - if (max == min) { - _saturation = 0; - } else if (lightness < 50) { - _saturation = 100 * delta / (max + min); - } else { - _saturation = 100 * delta / (2 - max - min); + operator ==(Object other) { + if (other is! SassColor) return false; + + if (isLegacy) { + if (!other.isLegacy) return false; + if (!fuzzyEqualsNullable(alphaOrNull, other.alphaOrNull)) return false; + if (space == other.space) { + return fuzzyEqualsNullable(channel0OrNull, other.channel0OrNull) && + fuzzyEqualsNullable(channel1OrNull, other.channel1OrNull) && + fuzzyEqualsNullable(channel2OrNull, other.channel2OrNull); + } else { + return toSpace(ColorSpace.rgb) == other.toSpace(ColorSpace.rgb); + } } - } - /// 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 && + fuzzyEqualsNullable(channel0OrNull, other.channel0OrNull) && + fuzzyEqualsNullable(channel1OrNull, other.channel1OrNull) && + fuzzyEqualsNullable(channel2OrNull, other.channel2OrNull) && + fuzzyEqualsNullable(alphaOrNull, other.alphaOrNull); } - /// An algorithm from the CSS3 spec: - /// https://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; - - return switch (hue) { - < 1 / 6 => m1 + (m2 - m1) * hue * 6, - < 1 / 2 => m2, - < 2 / 3 => m1 + (m2 - m1) * (2 / 3 - hue) * 6, - _ => 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)}"); + int get hashCode { + if (isLegacy) { + var rgb = toSpace(ColorSpace.rgb); + return fuzzyHashCode(rgb.channel0) ^ + fuzzyHashCode(rgb.channel1) ^ + fuzzyHashCode(rgb.channel2) ^ + fuzzyHashCode(alpha); + } else { + 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. /// @@ -388,9 +1000,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..63e279da7 --- /dev/null +++ b/lib/src/value/color/channel.dart @@ -0,0 +1,106 @@ +// 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; + + /// The unit that's associated with this channel. + /// + /// Some channels are typically written without units, while others have a + /// specific unit that is conventionally applied to their values. Although any + /// compatible unit or unitless value will work for input¹, this unit is used + /// when the value is serialized or returned from a Sass function. + /// + /// 1: Unless [LinearChannel.requiresPercent] is set, in which case unitless + /// values are not allowed. + final String? associatedUnit; + + /// @nodoc + @internal + const ColorChannel(this.name, + {required this.isPolarAngle, this.associatedUnit}); + + /// 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, other.name)) { + ("red" || "x", "red" || "x") || + ("green" || "y", "green" || "y") || + ("blue" || "z", "blue" || "z") || + ("chroma" || "saturation", "chroma" || "saturation") || + ("lightness", "lightness") || + ("hue", "hue") => + true, + _ => false + }; +} + +/// Metadata about a color channel with a linear (as opposed to polar) value. +/// +/// {@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; + + /// Whether the lower bound of this channel is clamped when the color is + /// created using the global function syntax. + final bool lowerClamped; + + /// Whether the upper bound of this channel is clamped when the color is + /// created using the global function syntax. + final bool upperClamped; + + /// Creates a linear color channel. + /// + /// By default, [ColorChannel.associatedUnit] is set to `%` if and only if + /// [min] is 0 and [max] is 100. However, if [conventionallyPercent] is + /// true, it's set to `%`, and if it's false, it's set to null. + /// + /// @nodoc + @internal + const LinearChannel(super.name, this.min, this.max, + {this.requiresPercent = false, + this.lowerClamped = false, + this.upperClamped = false, + bool? conventionallyPercent}) + : super( + isPolarAngle: false, + associatedUnit: (conventionallyPercent ?? (min == 0 && max == 100)) + ? '%' + : null); +} 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/gamut_map_method.dart b/lib/src/value/color/gamut_map_method.dart new file mode 100644 index 000000000..f934d5940 --- /dev/null +++ b/lib/src/value/color/gamut_map_method.dart @@ -0,0 +1,65 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'package:meta/meta.dart'; + +import '../../exception.dart'; +import '../color.dart'; +import 'gamut_map_method/clip.dart'; +import 'gamut_map_method/local_minde.dart'; + +/// Different algorithms that can be used to map an out-of-gamut Sass color into +/// the gamut for its color space. +/// +/// {@category Value} +@sealed +abstract base class GamutMapMethod { + /// Clamp each color channel that's outside the gamut to the minimum or + /// maximum value for that channel. + /// + /// This algorithm will produce poor visual results, but it may be useful to + /// match the behavior of other situations in which a color can be clipped. + static const GamutMapMethod clip = ClipGamutMap(); + + /// The algorithm specified in [the original Color Level 4 candidate + /// recommendation]. + /// + /// This maps in the Oklch color space, using the [deltaEOK] color difference + /// formula and the [local-MINDE] improvement. + /// + /// [the original Color Level 4 candidate recommendation]: https://www.w3.org/TR/2024/CRD-css-color-4-20240213/#css-gamut-mapping + /// [deltaEOK]: https://www.w3.org/TR/2024/CRD-css-color-4-20240213/#color-difference-OK + /// [local-MINDE]: https://www.w3.org/TR/2024/CRD-css-color-4-20240213/#GM-chroma-local-MINDE + static const GamutMapMethod localMinde = LocalMindeGamutMap(); + + /// The Sass name of the gamut-mapping algorithm. + final String name; + + /// @nodoc + @internal + const GamutMapMethod(this.name); + + /// Parses a [GamutMapMethod] from its Sass name. + /// + /// Throws a [SassScriptException] if there is no method with the given + /// [name]. If this came from a function argument, [argumentName] is the + /// argument name (without the `$`). This is used for error reporting. + factory GamutMapMethod.fromName(String name, [String? argumentName]) => + switch (name) { + 'clip' => GamutMapMethod.clip, + 'local-minde' => GamutMapMethod.localMinde, + _ => throw SassScriptException( + 'Unknown gamut map method "$name".', argumentName) + }; + + /// Maps [color] to its gamut using this method's algorithm. + /// + /// Callers should use [SassColor.toGamut] instead of this method. + /// + /// @nodoc + @internal + SassColor map(SassColor color); + + String toString() => name; +} diff --git a/lib/src/value/color/gamut_map_method/clip.dart b/lib/src/value/color/gamut_map_method/clip.dart new file mode 100644 index 000000000..14420dea3 --- /dev/null +++ b/lib/src/value/color/gamut_map_method/clip.dart @@ -0,0 +1,31 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'package:meta/meta.dart'; + +import '../../../util/number.dart'; +import '../../color.dart'; + +/// Gamut mapping by clipping individual channels. +/// +/// @nodoc +@internal +final class ClipGamutMap extends GamutMapMethod { + const ClipGamutMap() : super("clip"); + + SassColor map(SassColor color) => SassColor.forSpaceInternal( + color.space, + _clampChannel(color.channel0OrNull, color.space.channels[0]), + _clampChannel(color.channel1OrNull, color.space.channels[1]), + _clampChannel(color.channel2OrNull, color.space.channels[2]), + color.alphaOrNull); + + /// Clamps the channel value [value] within the bounds given by [channel]. + double? _clampChannel(double? value, ColorChannel channel) => value == null + ? null + : switch (channel) { + LinearChannel(:var min, :var max) => clampLikeCss(value, min, max), + _ => value + }; +} diff --git a/lib/src/value/color/gamut_map_method/local_minde.dart b/lib/src/value/color/gamut_map_method/local_minde.dart new file mode 100644 index 000000000..a8b896d21 --- /dev/null +++ b/lib/src/value/color/gamut_map_method/local_minde.dart @@ -0,0 +1,93 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'dart:math' as math; + +import 'package:meta/meta.dart'; + +import '../../../util/number.dart'; +import '../../color.dart'; + +/// Gamut mapping using the deltaEOK difference formula and the local-MINDE +/// improvement. +/// +/// @nodoc +@internal +final class LocalMindeGamutMap extends GamutMapMethod { + /// A constant from the gamut-mapping algorithm. + static const _jnd = 0.02; + + /// A constant from the gamut-mapping algorithm. + static const _epsilon = 0.0001; + + const LocalMindeGamutMap() : super("local-minde"); + + SassColor map(SassColor color) { + // Algorithm from https://www.w3.org/TR/2022/CRD-css-color-4-20221101/#css-gamut-mapping-algorithm + var originOklch = color.toSpace(ColorSpace.oklch); + + // The channel equivalents to `current` in the Color 4 algorithm. + var lightness = originOklch.channel0OrNull; + var hue = originOklch.channel2OrNull; + var alpha = originOklch.alphaOrNull; + + if (fuzzyGreaterThanOrEquals(lightness ?? 0, 1)) { + return color.isLegacy + ? SassColor.rgb(255, 255, 255, color.alphaOrNull).toSpace(color.space) + : SassColor.forSpaceInternal(color.space, 1, 1, 1, color.alphaOrNull); + } else if (fuzzyLessThanOrEquals(lightness ?? 0, 0)) { + return SassColor.rgb(0, 0, 0, color.alphaOrNull).toSpace(color.space); + } + + var clipped = color.toGamut(GamutMapMethod.clip); + if (_deltaEOK(clipped, color) < _jnd) return clipped; + + var min = 0.0; + var max = originOklch.channel1; + var minInGamut = true; + while (max - min > _epsilon) { + var chroma = (min + max) / 2; + + // In the Color 4 algorithm `current` is in Oklch, but all its actual uses + // other than modifying chroma convert it to `color.space` first so we + // just store it in that space to begin with. + var current = + ColorSpace.oklch.convert(color.space, lightness, chroma, hue, alpha); + + // Per [this comment], the intention of the algorithm is to fall through + // this clause if `minInGamut = false` without checking + // `current.isInGamut` at all, even though that's unclear from the + // pseudocode. `minInGamut = false` *should* imply `current.isInGamut = + // false`. + // + // [this comment]: https://github.com/w3c/csswg-drafts/issues/10226#issuecomment-2065534713 + if (minInGamut && current.isInGamut) { + min = chroma; + continue; + } + + clipped = current.toGamut(GamutMapMethod.clip); + var e = _deltaEOK(clipped, current); + if (e < _jnd) { + if (_jnd - e < _epsilon) return clipped; + minInGamut = false; + min = chroma; + } else { + max = chroma; + } + } + return clipped; + } + + /// Returns the ΔEOK measure between [color1] and [color2]. + double _deltaEOK(SassColor color1, SassColor color2) { + // Algorithm from https://www.w3.org/TR/css-color-4/#color-difference-OK + var lab1 = color1.toSpace(ColorSpace.oklab); + var lab2 = color2.toSpace(ColorSpace.oklab); + + return math.sqrt(math.pow(lab1.channel0 - lab2.channel0, 2) + + math.pow(lab1.channel1 - lab2.channel1, 2) + + math.pow(lab1.channel2 - lab2.channel2, 2)); + } +} diff --git a/lib/src/value/color/interpolation_method.dart b/lib/src/value/color/interpolation_method.dart new file mode 100644 index 000000000..6a4a67535 --- /dev/null +++ b/lib/src/value/color/interpolation_method.dart @@ -0,0 +1,117 @@ +// 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 '../../exception.dart'; +import '../../value.dart'; + +/// The method by which two colors are interpolated to find a color in the +/// middle. +/// +/// Used by [SassColor.interpolate]. +/// +/// {@category Value} +class InterpolationMethod { + /// The 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 (!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 (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 > 3) { + 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; + + /// 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]) => + switch ((value.assertString(name)..assertUnquoted()).text.toLowerCase()) { + 'shorter' => HueInterpolationMethod.shorter, + 'longer' => HueInterpolationMethod.longer, + 'increasing' => HueInterpolationMethod.increasing, + 'decreasing' => HueInterpolationMethod.decreasing, + _ => throw SassScriptException( + 'Unknown hue interpolation method $value.', name) + }; +} diff --git a/lib/src/value/color/space.dart b/lib/src/value/color/space.dart new file mode 100644 index 000000000..1d96ae705 --- /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'; + +/// A color space whose channel names and semantics Sass knows. +/// +/// {@category Value} +@sealed +abstract base class ColorSpace { + /// The legacy RGB color space. + static const ColorSpace rgb = RgbColorSpace(); + + /// The legacy HSL color space. + static const ColorSpace hsl = HslColorSpace(); + + /// The legacy HWB color space. + static const ColorSpace hwb = HwbColorSpace(); + + /// The sRGB color space. + /// + /// https://www.w3.org/TR/css-color-4/#predefined-sRGB + static const ColorSpace srgb = SrgbColorSpace(); + + /// The linear-light sRGB color space. + /// + /// https://www.w3.org/TR/css-color-4/#predefined-sRGB-linear + static const ColorSpace srgbLinear = SrgbLinearColorSpace(); + + /// The display-p3 color space. + /// + /// https://www.w3.org/TR/css-color-4/#predefined-display-p3 + static const ColorSpace displayP3 = DisplayP3ColorSpace(); + + /// The a98-rgb color space. + /// + /// https://www.w3.org/TR/css-color-4/#predefined-a98-rgb + static const ColorSpace a98Rgb = A98RgbColorSpace(); + + /// The prophoto-rgb color space. + /// + /// https://www.w3.org/TR/css-color-4/#predefined-prophoto-rgb + static const ColorSpace prophotoRgb = ProphotoRgbColorSpace(); + + /// The rec2020 color space. + /// + /// https://www.w3.org/TR/css-color-4/#predefined-rec2020 + static const ColorSpace rec2020 = Rec2020ColorSpace(); + + /// The xyz-d65 color space. + /// + /// https://www.w3.org/TR/css-color-4/#predefined-xyz + static const ColorSpace xyzD65 = XyzD65ColorSpace(); + + /// The xyz-d50 color space. + /// + /// https://www.w3.org/TR/css-color-4/#predefined-xyz + static const ColorSpace xyzD50 = XyzD50ColorSpace(); + + /// The CIE Lab color space. + /// + /// https://www.w3.org/TR/css-color-4/#cie-lab + static const ColorSpace lab = LabColorSpace(); + + /// The CIE LCH color space. + /// + /// https://www.w3.org/TR/css-color-4/#cie-lab + static const ColorSpace lch = LchColorSpace(); + + /// The internal LMS color space. + /// + /// This only used as an intermediate space for conversions to and from OKLab + /// and OKLCH. It's never used in a real color value and isn't returned by + /// [fromName]. + /// + /// @nodoc + @internal + static const ColorSpace lms = LmsColorSpace(); + + /// The Oklab color space. + /// + /// https://www.w3.org/TR/css-color-4/#ok-lab + static const ColorSpace oklab = OklabColorSpace(); + + /// The Oklch color space. + /// + /// https://www.w3.org/TR/css-color-4/#ok-lab + static const ColorSpace oklch = OklchColorSpace(); + + /// The CSS name of the color space. + final String name; + + /// See [SassApiColorSpace.channels]. + final List _channels; + + /// See [SassApiColorSpace.isBounded]. + /// + /// @nodoc + @internal + bool get isBoundedInternal; + + /// See [SassApiColorSpace.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()) { + 'rgb' => rgb, + 'hwb' => hwb, + 'hsl' => hsl, + 'srgb' => srgb, + 'srgb-linear' => srgbLinear, + 'display-p3' => displayP3, + 'a98-rgb' => a98Rgb, + 'prophoto-rgb' => prophotoRgb, + 'rec2020' => rec2020, + 'xyz' || 'xyz-d65' => xyzD65, + 'xyz-d50' => xyzD50, + 'lab' => lab, + 'lch' => lch, + 'oklab' => oklab, + 'oklch' => oklch, + _ => throw SassScriptException( + 'Unknown color space "$name".', argumentName) + }; + + /// Converts a color with the given channels from this color space to [dest]. + /// + /// 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) => + convertLinear(dest, channel0, channel1, channel2, alpha); + + /// The default implementation of [convert], which always starts with a linear + /// transformation from RGB or XYZ channels to a linear destination space, and + /// may then further convert to a polar space. + /// + /// @nodoc + @internal + @protected + @nonVirtual + SassColor convertLinear( + ColorSpace dest, double? red, double? green, double? blue, double? alpha, + {bool missingLightness = false, + bool missingChroma = false, + bool missingHue = false, + bool missingA = false, + bool missingB = false}) { + var linearDest = switch (dest) { + ColorSpace.hsl || ColorSpace.hwb => const SrgbColorSpace(), + ColorSpace.lab || ColorSpace.lch => const XyzD50ColorSpace(), + ColorSpace.oklab || ColorSpace.oklch => const LmsColorSpace(), + _ => dest + }; + + double? transformedRed; + double? transformedGreen; + double? transformedBlue; + if (linearDest == this) { + transformedRed = red; + transformedGreen = green; + transformedBlue = blue; + } else { + var linearRed = toLinear(red ?? 0); + var linearGreen = toLinear(green ?? 0); + var linearBlue = toLinear(blue ?? 0); + var matrix = transformationMatrix(linearDest); + + // (matrix * [linearRed, linearGreen, linearBlue]).map(linearDest.fromLinear) + transformedRed = linearDest.fromLinear(matrix[0] * linearRed + + matrix[1] * linearGreen + + matrix[2] * linearBlue); + transformedGreen = linearDest.fromLinear(matrix[3] * linearRed + + matrix[4] * linearGreen + + matrix[5] * linearBlue); + transformedBlue = linearDest.fromLinear(matrix[6] * linearRed + + matrix[7] * linearGreen + + matrix[8] * linearBlue); + } + + return switch (dest) { + ColorSpace.hsl || ColorSpace.hwb => const SrgbColorSpace().convert( + dest, transformedRed, transformedGreen, transformedBlue, alpha, + missingLightness: missingLightness, + missingChroma: missingChroma, + missingHue: missingHue), + ColorSpace.lab || ColorSpace.lch => const XyzD50ColorSpace().convert( + dest, transformedRed, transformedGreen, transformedBlue, alpha, + missingLightness: missingLightness, + missingChroma: missingChroma, + missingHue: missingHue, + missingA: missingA, + missingB: missingB), + ColorSpace.oklab || ColorSpace.oklch => const LmsColorSpace().convert( + dest, transformedRed, transformedGreen, transformedBlue, alpha, + missingLightness: missingLightness, + missingChroma: missingChroma, + missingHue: missingHue, + missingA: missingA, + missingB: missingB), + _ => SassColor.forSpaceInternal( + dest, + red == null ? null : transformedRed, + green == null ? null : transformedGreen, + blue == null ? null : transformedBlue, + 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 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..df61a6d50 --- /dev/null +++ b/lib/src/value/color/space/a98_rgb.dart @@ -0,0 +1,49 @@ +// 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 +final 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) { + ColorSpace.srgbLinear || + ColorSpace.srgb || + ColorSpace.rgb => + linearA98RgbToLinearSrgb, + ColorSpace.displayP3 => linearA98RgbToLinearDisplayP3, + ColorSpace.prophotoRgb => linearA98RgbToLinearProphotoRgb, + ColorSpace.rec2020 => linearA98RgbToLinearRec2020, + ColorSpace.xyzD65 => linearA98RgbToXyzD65, + ColorSpace.xyzD50 => linearA98RgbToXyzD50, + ColorSpace.lms => linearA98RgbToLms, + _ => super.transformationMatrix(dest) + }; +} diff --git a/lib/src/value/color/space/display_p3.dart b/lib/src/value/color/space/display_p3.dart new file mode 100644 index 000000000..b1c56df5d --- /dev/null +++ b/lib/src/value/color/space/display_p3.dart @@ -0,0 +1,44 @@ +// 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 +final 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) { + ColorSpace.srgbLinear || + ColorSpace.srgb || + ColorSpace.rgb => + linearDisplayP3ToLinearSrgb, + ColorSpace.a98Rgb => linearDisplayP3ToLinearA98Rgb, + ColorSpace.prophotoRgb => linearDisplayP3ToLinearProphotoRgb, + ColorSpace.rec2020 => linearDisplayP3ToLinearRec2020, + ColorSpace.xyzD65 => linearDisplayP3ToXyzD65, + ColorSpace.xyzD50 => linearDisplayP3ToXyzD50, + ColorSpace.lms => linearDisplayP3ToLms, + _ => super.transformationMatrix(dest) + }; +} diff --git a/lib/src/value/color/space/hsl.dart b/lib/src/value/color/space/hsl.dart new file mode 100644 index 000000000..bc4a02164 --- /dev/null +++ b/lib/src/value/color/space/hsl.dart @@ -0,0 +1,54 @@ +// 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 'srgb.dart'; +import 'utils.dart'; + +/// The legacy HSL color space. +/// +/// @nodoc +@internal +final class HslColorSpace extends ColorSpace { + bool get isBoundedInternal => true; + bool get isLegacyInternal => true; + bool get isPolarInternal => true; + + const HslColorSpace() + : super('hsl', const [ + hueChannel, + LinearChannel('saturation', 0, 100, + requiresPercent: true, lowerClamped: 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 ?? 0) / 360) % 1; + var scaledSaturation = (saturation ?? 0) / 100; + var scaledLightness = (lightness ?? 0) / 100; + + var m2 = scaledLightness <= 0.5 + ? scaledLightness * (scaledSaturation + 1) + : scaledLightness + + scaledSaturation - + scaledLightness * scaledSaturation; + var m1 = scaledLightness * 2 - m2; + + return const SrgbColorSpace().convert( + dest, + hueToRgb(m1, m2, scaledHue + 1 / 3), + hueToRgb(m1, m2, scaledHue), + hueToRgb(m1, m2, scaledHue - 1 / 3), + alpha, + missingLightness: lightness == null, + missingChroma: saturation == null, + missingHue: hue == null); + } +} diff --git a/lib/src/value/color/space/hwb.dart b/lib/src/value/color/space/hwb.dart new file mode 100644 index 000000000..956768b19 --- /dev/null +++ b/lib/src/value/color/space/hwb.dart @@ -0,0 +1,51 @@ +// 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 'srgb.dart'; +import 'utils.dart'; + +/// The legacy HWB color space. +/// +/// @nodoc +@internal +final class HwbColorSpace extends ColorSpace { + bool get isBoundedInternal => 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 ?? 0) % 360 / 360; + var scaledWhiteness = (whiteness ?? 0) / 100; + var scaledBlackness = (blackness ?? 0) / 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 const SrgbColorSpace().convert(dest, toRgb(scaledHue + 1 / 3), + toRgb(scaledHue), toRgb(scaledHue - 1 / 3), alpha, + missingHue: hue == null); + } +} diff --git a/lib/src/value/color/space/lab.dart b/lib/src/value/color/space/lab.dart new file mode 100644 index 000000000..7766e706d --- /dev/null +++ b/lib/src/value/color/space/lab.dart @@ -0,0 +1,75 @@ +// 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'; +import 'xyz_d50.dart'; + +/// The Lab color space. +/// +/// https://www.w3.org/TR/css-color-4/#specifying-lab-lch +/// +/// @nodoc +@internal +final class LabColorSpace extends ColorSpace { + bool get isBoundedInternal => false; + + const LabColorSpace() + : super('lab', const [ + LinearChannel('lightness', 0, 100, + lowerClamped: true, upperClamped: true), + LinearChannel('a', -125, 125), + LinearChannel('b', -125, 125) + ]); + + SassColor convert( + ColorSpace dest, double? lightness, double? a, double? b, double? alpha, + {bool missingChroma = false, bool missingHue = false}) { + switch (dest) { + case ColorSpace.lab: + var powerlessAB = lightness == null || fuzzyEquals(lightness, 0); + return SassColor.lab(lightness, a == null || powerlessAB ? null : a, + b == null || powerlessAB ? null : b, alpha); + + case ColorSpace.lch: + return labToLch(dest, lightness, a, b, alpha); + + default: + var missingLightness = lightness == null; + lightness ??= 0; + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + // and http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html + var f1 = (lightness + 16) / 116; + + return const XyzD50ColorSpace().convert( + dest, + _convertFToXorZ((a ?? 0) / 500 + f1) * d50[0], + (lightness > labKappa * labEpsilon + ? math.pow((lightness + 16) / 116, 3) * 1.0 + : lightness / labKappa) * + d50[1], + _convertFToXorZ(f1 - (b ?? 0) / 200) * d50[2], + alpha, + missingLightness: missingLightness, + missingChroma: missingChroma, + missingHue: missingHue, + missingA: a == null, + missingB: b == null); + } + } + + /// 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..095babeef --- /dev/null +++ b/lib/src/value/color/space/lch.dart @@ -0,0 +1,45 @@ +// 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 'lab.dart'; +import 'utils.dart'; + +/// The LCH color space. +/// +/// https://www.w3.org/TR/css-color-4/#specifying-lab-lch +/// +/// @nodoc +@internal +final class LchColorSpace extends ColorSpace { + bool get isBoundedInternal => false; + bool get isPolarInternal => true; + + const LchColorSpace() + : super('lch', const [ + LinearChannel('lightness', 0, 100, + lowerClamped: true, upperClamped: true), + LinearChannel('chroma', 0, 150, lowerClamped: true), + hueChannel + ]); + + SassColor convert(ColorSpace dest, double? lightness, double? chroma, + double? hue, double? alpha) { + var hueRadians = (hue ?? 0) * math.pi / 180; + return const LabColorSpace().convert( + dest, + lightness, + (chroma ?? 0) * math.cos(hueRadians), + (chroma ?? 0) * math.sin(hueRadians), + alpha, + missingChroma: chroma == null, + missingHue: hue == null); + } +} diff --git a/lib/src/value/color/space/lms.dart b/lib/src/value/color/space/lms.dart new file mode 100644 index 000000000..0ea82eb01 --- /dev/null +++ b/lib/src/value/color/space/lms.dart @@ -0,0 +1,124 @@ +// 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 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 +final 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, + {bool missingLightness = false, + bool missingChroma = false, + bool missingHue = false, + bool missingA = false, + bool missingB = false}) { + switch (dest) { + case ColorSpace.oklab: + // Algorithm from https://drafts.csswg.org/css-color-4/#color-conversion-code + var longScaled = _cubeRootPreservingSign(long ?? 0); + var mediumScaled = _cubeRootPreservingSign(medium ?? 0); + var shortScaled = _cubeRootPreservingSign(short ?? 0); + var lightness = lmsToOklab[0] * longScaled + + lmsToOklab[1] * mediumScaled + + lmsToOklab[2] * shortScaled; + + return SassColor.oklab( + missingLightness ? null : lightness, + missingA + ? null + : lmsToOklab[3] * longScaled + + lmsToOklab[4] * mediumScaled + + lmsToOklab[5] * shortScaled, + missingB + ? 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 = _cubeRootPreservingSign(long ?? 0); + var mediumScaled = _cubeRootPreservingSign(medium ?? 0); + var shortScaled = _cubeRootPreservingSign(short ?? 0); + return labToLch( + dest, + missingLightness + ? null + : lmsToOklab[0] * longScaled + + lmsToOklab[1] * mediumScaled + + lmsToOklab[2] * shortScaled, + lmsToOklab[3] * longScaled + + lmsToOklab[4] * mediumScaled + + lmsToOklab[5] * shortScaled, + lmsToOklab[6] * longScaled + + lmsToOklab[7] * mediumScaled + + lmsToOklab[8] * shortScaled, + alpha, + missingChroma: missingChroma, + missingHue: missingHue); + + default: + return super.convertLinear(dest, long, medium, short, alpha, + missingLightness: missingLightness, + missingChroma: missingChroma, + missingHue: missingHue, + missingA: missingA, + missingB: missingB); + } + } + + /// Returns the cube root of the absolute value of [number] with the same sign + /// as [number]. + double _cubeRootPreservingSign(double number) => + math.pow(number.abs(), 1 / 3) * number.sign; + + @protected + double toLinear(double channel) => channel; + + @protected + double fromLinear(double channel) => channel; + + @protected + Float64List transformationMatrix(ColorSpace dest) => switch (dest) { + ColorSpace.srgbLinear || + ColorSpace.srgb || + ColorSpace.rgb => + lmsToLinearSrgb, + ColorSpace.a98Rgb => lmsToLinearA98Rgb, + ColorSpace.prophotoRgb => lmsToLinearProphotoRgb, + ColorSpace.displayP3 => lmsToLinearDisplayP3, + ColorSpace.rec2020 => lmsToLinearRec2020, + ColorSpace.xyzD65 => lmsToXyzD65, + ColorSpace.xyzD50 => lmsToXyzD50, + _ => super.transformationMatrix(dest) + }; +} diff --git a/lib/src/value/color/space/oklab.dart b/lib/src/value/color/space/oklab.dart new file mode 100644 index 000000000..c24806635 --- /dev/null +++ b/lib/src/value/color/space/oklab.dart @@ -0,0 +1,77 @@ +// 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 'lms.dart'; +import 'utils.dart'; + +/// The OKLab color space. +/// +/// https://www.w3.org/TR/css-color-4/#specifying-oklab-oklch +/// +/// @nodoc +@internal +final class OklabColorSpace extends ColorSpace { + bool get isBoundedInternal => false; + + const OklabColorSpace() + : super('oklab', const [ + LinearChannel('lightness', 0, 1, + conventionallyPercent: true, + lowerClamped: true, + upperClamped: true), + LinearChannel('a', -0.4, 0.4), + LinearChannel('b', -0.4, 0.4) + ]); + + SassColor convert( + ColorSpace dest, double? lightness, double? a, double? b, double? alpha, + {bool missingChroma = false, bool missingHue = false}) { + if (dest == ColorSpace.oklch) { + return labToLch(dest, lightness, a, b, alpha, + missingChroma: missingChroma, missingHue: missingHue); + } + + var missingLightness = lightness == null; + var missingA = a == null; + var missingB = b == null; + lightness ??= 0; + a ??= 0; + b ??= 0; + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + return const LmsColorSpace().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, + missingLightness: missingLightness, + missingChroma: missingChroma, + missingHue: missingHue, + missingA: missingA, + missingB: missingB); + } +} diff --git a/lib/src/value/color/space/oklch.dart b/lib/src/value/color/space/oklch.dart new file mode 100644 index 000000000..bdf7fea65 --- /dev/null +++ b/lib/src/value/color/space/oklch.dart @@ -0,0 +1,47 @@ +// 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 'oklab.dart'; +import 'utils.dart'; + +/// The OKLCH color space. +/// +/// https://www.w3.org/TR/css-color-4/#specifying-oklab-oklch +/// +/// @nodoc +@internal +final class OklchColorSpace extends ColorSpace { + bool get isBoundedInternal => false; + bool get isPolarInternal => true; + + const OklchColorSpace() + : super('oklch', const [ + LinearChannel('lightness', 0, 1, + conventionallyPercent: true, + lowerClamped: true, + upperClamped: true), + LinearChannel('chroma', 0, 0.4, lowerClamped: true), + hueChannel + ]); + + SassColor convert(ColorSpace dest, double? lightness, double? chroma, + double? hue, double? alpha) { + var hueRadians = (hue ?? 0) * math.pi / 180; + return const OklabColorSpace().convert( + dest, + lightness, + (chroma ?? 0) * math.cos(hueRadians), + (chroma ?? 0) * math.sin(hueRadians), + alpha, + missingChroma: chroma == null, + missingHue: hue == null); + } +} diff --git a/lib/src/value/color/space/prophoto_rgb.dart b/lib/src/value/color/space/prophoto_rgb.dart new file mode 100644 index 000000000..0de23ada9 --- /dev/null +++ b/lib/src/value/color/space/prophoto_rgb.dart @@ -0,0 +1,55 @@ +// 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 +final 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) { + ColorSpace.srgbLinear || + ColorSpace.srgb || + ColorSpace.rgb => + linearProphotoRgbToLinearSrgb, + ColorSpace.a98Rgb => linearProphotoRgbToLinearA98Rgb, + ColorSpace.displayP3 => linearProphotoRgbToLinearDisplayP3, + ColorSpace.rec2020 => linearProphotoRgbToLinearRec2020, + ColorSpace.xyzD65 => linearProphotoRgbToXyzD65, + ColorSpace.xyzD50 => linearProphotoRgbToXyzD50, + ColorSpace.lms => linearProphotoRgbToLms, + _ => super.transformationMatrix(dest) + }; +} diff --git a/lib/src/value/color/space/rec2020.dart b/lib/src/value/color/space/rec2020.dart new file mode 100644 index 000000000..ca5dcf0e5 --- /dev/null +++ b/lib/src/value/color/space/rec2020.dart @@ -0,0 +1,63 @@ +// 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 +final 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) { + ColorSpace.srgbLinear || + ColorSpace.srgb || + ColorSpace.rgb => + linearRec2020ToLinearSrgb, + ColorSpace.a98Rgb => linearRec2020ToLinearA98Rgb, + ColorSpace.displayP3 => linearRec2020ToLinearDisplayP3, + ColorSpace.prophotoRgb => linearRec2020ToLinearProphotoRgb, + ColorSpace.xyzD65 => linearRec2020ToXyzD65, + ColorSpace.xyzD50 => linearRec2020ToXyzD50, + ColorSpace.lms => linearRec2020ToLms, + _ => super.transformationMatrix(dest), + }; +} diff --git a/lib/src/value/color/space/rgb.dart b/lib/src/value/color/space/rgb.dart new file mode 100644 index 000000000..b12fd40bd --- /dev/null +++ b/lib/src/value/color/space/rgb.dart @@ -0,0 +1,43 @@ +// 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 +final class RgbColorSpace extends ColorSpace { + bool get isBoundedInternal => true; + bool get isLegacyInternal => true; + + const RgbColorSpace() + : super('rgb', const [ + LinearChannel('red', 0, 255, lowerClamped: true, upperClamped: true), + LinearChannel('green', 0, 255, + lowerClamped: true, upperClamped: true), + LinearChannel('blue', 0, 255, lowerClamped: true, upperClamped: true) + ]); + + SassColor convert(ColorSpace dest, double? red, double? green, double? blue, + double? alpha) => + ColorSpace.srgb.convert( + dest, + red == null ? null : red / 255, + green == null ? null : green / 255, + blue == null ? null : blue / 255, + alpha); + + @protected + double toLinear(double channel) => srgbAndDisplayP3ToLinear(channel / 255); + + @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..963cdf733 --- /dev/null +++ b/lib/src/value/color/space/srgb.dart @@ -0,0 +1,124 @@ +// 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 +final class SrgbColorSpace extends ColorSpace { + bool get isBoundedInternal => true; + + const SrgbColorSpace() : super('srgb', rgbChannels); + + SassColor convert( + ColorSpace dest, double? red, double? green, double? blue, double? alpha, + {bool missingLightness = false, + bool missingChroma = false, + bool missingHue = false}) { + switch (dest) { + case ColorSpace.hsl || ColorSpace.hwb: + red ??= 0; + green ??= 0; + blue ??= 0; + + // Algorithm from https://drafts.csswg.org/css-color-4/#rgb-to-hsl + var max = math.max(math.max(red, green), blue); + var min = math.min(math.min(red, green), blue); + var delta = max - min; + + double hue; + if (max == min) { + hue = 0; + } else if (max == red) { + hue = 60 * (green - blue) / delta + 360; + } else if (max == green) { + hue = 60 * (blue - red) / delta + 120; + } else { + // max == blue + hue = 60 * (red - green) / delta + 240; + } + + if (dest == ColorSpace.hsl) { + var lightness = (min + max) / 2; + + var saturation = lightness == 0 || lightness == 1 + ? 0.0 + : 100 * (max - lightness) / math.min(lightness, 1 - lightness); + if (saturation < 0) { + hue += 180; + saturation = saturation.abs(); + } + + return SassColor.forSpaceInternal( + dest, + missingHue || fuzzyEquals(saturation, 0) ? null : hue % 360, + missingChroma ? null : saturation, + missingLightness ? null : lightness * 100, + alpha); + } else { + var whiteness = min * 100; + var blackness = 100 - max * 100; + return SassColor.forSpaceInternal( + dest, + missingHue || fuzzyGreaterThanOrEquals(whiteness + blackness, 100) + ? null + : hue % 360, + whiteness, + blackness, + alpha); + } + + case ColorSpace.rgb: + return SassColor.rgb( + red == null ? null : red * 255, + green == null ? null : green * 255, + blue == null ? null : blue * 255, + alpha); + + case ColorSpace.srgbLinear: + return SassColor.forSpaceInternal(dest, red.andThen(toLinear), + green.andThen(toLinear), blue.andThen(toLinear), alpha); + + default: + return super.convertLinear(dest, red, green, blue, alpha, + missingLightness: missingLightness, + missingChroma: missingChroma, + missingHue: missingHue); + } + } + + @protected + double toLinear(double channel) => srgbAndDisplayP3ToLinear(channel); + + @protected + double fromLinear(double channel) => srgbAndDisplayP3FromLinear(channel); + + @protected + Float64List transformationMatrix(ColorSpace dest) => switch (dest) { + ColorSpace.displayP3 => linearSrgbToLinearDisplayP3, + ColorSpace.a98Rgb => linearSrgbToLinearA98Rgb, + ColorSpace.prophotoRgb => linearSrgbToLinearProphotoRgb, + ColorSpace.rec2020 => linearSrgbToLinearRec2020, + ColorSpace.xyzD65 => linearSrgbToXyzD65, + ColorSpace.xyzD50 => linearSrgbToXyzD50, + ColorSpace.lms => linearSrgbToLms, + _ => super.transformationMatrix(dest), + }; +} diff --git a/lib/src/value/color/space/srgb_linear.dart b/lib/src/value/color/space/srgb_linear.dart new file mode 100644 index 000000000..3e5151da4 --- /dev/null +++ b/lib/src/value/color/space/srgb_linear.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:typed_data'; + +import 'package:meta/meta.dart'; + +import '../../../util/nullable.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 +final class SrgbLinearColorSpace extends ColorSpace { + bool get isBoundedInternal => true; + + const SrgbLinearColorSpace() : super('srgb-linear', rgbChannels); + + SassColor convert(ColorSpace dest, double? red, double? green, double? blue, + double? alpha) => + switch (dest) { + ColorSpace.rgb || + ColorSpace.hsl || + ColorSpace.hwb || + ColorSpace.srgb => + ColorSpace.srgb.convert( + dest, + red.andThen(srgbAndDisplayP3FromLinear), + green.andThen(srgbAndDisplayP3FromLinear), + blue.andThen(srgbAndDisplayP3FromLinear), + alpha), + _ => 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) { + ColorSpace.displayP3 => linearSrgbToLinearDisplayP3, + ColorSpace.a98Rgb => linearSrgbToLinearA98Rgb, + ColorSpace.prophotoRgb => linearSrgbToLinearProphotoRgb, + ColorSpace.rec2020 => linearSrgbToLinearRec2020, + ColorSpace.xyzD65 => linearSrgbToXyzD65, + ColorSpace.xyzD50 => linearSrgbToXyzD50, + ColorSpace.lms => linearSrgbToLms, + _ => super.transformationMatrix(dest) + }; +} diff --git a/lib/src/value/color/space/utils.dart b/lib/src/value/color/space/utils.dart new file mode 100644 index 000000000..d5ecf2eb4 --- /dev/null +++ b/lib/src/value/color/space/utils.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 '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, associatedUnit: 'deg'); + +/// 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; + + return switch (hue) { + < 1 / 6 => m1 + (m2 - m1) * hue * 6, + < 1 / 2 => m2, + < 2 / 3 => m1 + (m2 - m1) * (2 / 3 - hue) * 6, + _ => m1 + }; +} + +/// The algorithm for converting a single `srgb` or `display-p3` channel to +/// 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. +/// +/// The [missingChroma] and [missingHue] arguments indicate whether this came +/// from a color that was missing its chroma or hue channels, respectively. +SassColor labToLch( + ColorSpace dest, double? lightness, double? a, double? b, double? alpha, + {bool missingChroma = false, bool missingHue = false}) { + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + var chroma = math.sqrt(math.pow(a ?? 0, 2) + math.pow(b ?? 0, 2)); + var hue = missingHue || fuzzyEquals(chroma, 0) + ? null + : math.atan2(b ?? 0, a ?? 0) * 180 / math.pi; + + return SassColor.forSpaceInternal( + dest, + lightness, + missingChroma ? null : chroma, + hue == null || hue >= 0 ? hue : hue + 360, + alpha); +} diff --git a/lib/src/value/color/space/xyz_d50.dart b/lib/src/value/color/space/xyz_d50.dart new file mode 100644 index 000000000..fab064cb9 --- /dev/null +++ b/lib/src/value/color/space/xyz_d50.dart @@ -0,0 +1,86 @@ +// 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 +final class XyzD50ColorSpace extends ColorSpace { + bool get isBoundedInternal => false; + + const XyzD50ColorSpace() : super('xyz-d50', xyzChannels); + + SassColor convert( + ColorSpace dest, double? x, double? y, double? z, double? alpha, + {bool missingLightness = false, + bool missingChroma = false, + bool missingHue = false, + bool missingA = false, + bool missingB = false}) { + switch (dest) { + case ColorSpace.lab || ColorSpace.lch: + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + // and http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html + var f0 = _convertComponentToLabF((x ?? 0) / d50[0]); + var f1 = _convertComponentToLabF((y ?? 0) / d50[1]); + var f2 = _convertComponentToLabF((z ?? 0) / d50[2]); + var lightness = missingLightness ? null : (116 * f1) - 16; + var a = 500 * (f0 - f1); + var b = 200 * (f1 - f2); + + return dest == ColorSpace.lab + ? SassColor.lab( + lightness, missingA ? null : a, missingB ? null : b, alpha) + : labToLch(ColorSpace.lch, lightness, a, b, alpha, + missingChroma: missingChroma, missingHue: missingHue); + + default: + return super.convertLinear(dest, x, y, z, alpha, + missingLightness: missingLightness, + missingChroma: missingChroma, + missingHue: missingHue, + missingA: missingA, + missingB: missingB); + } + } + + /// 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) { + ColorSpace.srgbLinear || + ColorSpace.srgb || + ColorSpace.rgb => + xyzD50ToLinearSrgb, + ColorSpace.a98Rgb => xyzD50ToLinearA98Rgb, + ColorSpace.prophotoRgb => xyzD50ToLinearProphotoRgb, + ColorSpace.displayP3 => xyzD50ToLinearDisplayP3, + ColorSpace.rec2020 => xyzD50ToLinearRec2020, + ColorSpace.xyzD65 => xyzD50ToXyzD65, + ColorSpace.lms => xyzD50ToLms, + _ => super.transformationMatrix(dest) + }; +} diff --git a/lib/src/value/color/space/xyz_d65.dart b/lib/src/value/color/space/xyz_d65.dart new file mode 100644 index 000000000..4915a8cbc --- /dev/null +++ b/lib/src/value/color/space/xyz_d65.dart @@ -0,0 +1,44 @@ +// 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 +final 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) { + ColorSpace.srgbLinear || + ColorSpace.srgb || + ColorSpace.rgb => + xyzD65ToLinearSrgb, + ColorSpace.a98Rgb => xyzD65ToLinearA98Rgb, + ColorSpace.prophotoRgb => xyzD65ToLinearProphotoRgb, + ColorSpace.displayP3 => xyzD65ToLinearDisplayP3, + ColorSpace.rec2020 => xyzD65ToLinearRec2020, + ColorSpace.xyzD50 => xyzD65ToXyzD50, + ColorSpace.lms => xyzD65ToLms, + _ => super.transformationMatrix(dest) + }; +} 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 29f144ad7..f2527ad0b 100644 --- a/lib/src/value/number.dart +++ b/lib/src/value/number.dart @@ -428,8 +428,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. @@ -438,6 +437,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 9ccea8488..e5fc09814 100644 --- a/lib/src/value/number/single_unit.dart +++ b/lib/src/value/number/single_unit.dart @@ -93,6 +93,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 a6965bbf1..e3455d442 100644 --- a/lib/src/value/string.dart +++ b/lib/src/value/string.dart @@ -121,6 +121,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 5a2b5901a..eabadbc52 100644 --- a/lib/src/visitor/async_evaluate.dart +++ b/lib/src/visitor/async_evaluate.dart @@ -51,6 +51,7 @@ import 'interface/css.dart'; import 'interface/expression.dart'; import 'interface/modifiable_css.dart'; import 'interface/statement.dart'; +import 'serialize.dart'; /// A function that takes a callback with no arguments. typedef _ScopeCallback = Future Function( @@ -1181,7 +1182,8 @@ final class _EvaluateVisitor Future visitDebugRule(DebugRule node) async { var value = await node.expression.accept(this); _logger.debug( - value is SassString ? value.text : value.toString(), node.span); + value is SassString ? value.text : serializeValue(value, inspect: true), + node.span); return null; } @@ -1753,13 +1755,7 @@ final class _EvaluateVisitor } on ArgumentError catch (error, stackTrace) { throwWithTrace(_exception(error.toString()), error, stackTrace); } catch (error, stackTrace) { - String? message; - try { - message = (error as dynamic).message as String; - } catch (_) { - message = error.toString(); - } - throwWithTrace(_exception(message), error, stackTrace); + throwWithTrace(_exception(_getErrorMessage(error)), error, stackTrace); } finally { _importSpan = null; } @@ -3073,13 +3069,8 @@ final class _EvaluateVisitor } on SassException { rethrow; } catch (error, stackTrace) { - String? message; - try { - message = (error as dynamic).message as String; - } catch (_) { - message = error.toString(); - } - throwWithTrace(_exception(message, nodeWithSpan.span), error, stackTrace); + throwWithTrace(_exception(_getErrorMessage(error), nodeWithSpan.span), + error, stackTrace); } _callableNode = oldCallableNode; @@ -3925,6 +3916,18 @@ final class _EvaluateVisitor stackTrace); } } + + /// Returns the best human-readable message for [error]. + String _getErrorMessage(Object error) { + // Built-in Dart Error objects often require their full toString()s for + // full context. + if (error is Error) return error.toString(); + try { + return (error as dynamic).message as String; + } catch (_) { + return error.toString(); + } + } } /// A helper class for [_EvaluateVisitor] that adds `@import`ed CSS nodes to the diff --git a/lib/src/visitor/evaluate.dart b/lib/src/visitor/evaluate.dart index 505e91ab5..1aca4eee6 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: 548c4265a8e0aecca7a24fd52561ef17f29f34d2 +// Checksum: de25b9055a73f1c7ebe7a707139e6c789a2866dd // // ignore_for_file: unused_import @@ -60,6 +60,7 @@ import 'interface/css.dart'; import 'interface/expression.dart'; import 'interface/modifiable_css.dart'; import 'interface/statement.dart'; +import 'serialize.dart'; /// A function that takes a callback with no arguments. typedef _ScopeCallback = void Function(void Function() callback); @@ -1179,7 +1180,8 @@ final class _EvaluateVisitor Value? visitDebugRule(DebugRule node) { var value = node.expression.accept(this); _logger.debug( - value is SassString ? value.text : value.toString(), node.span); + value is SassString ? value.text : serializeValue(value, inspect: true), + node.span); return null; } @@ -1747,13 +1749,7 @@ final class _EvaluateVisitor } on ArgumentError catch (error, stackTrace) { throwWithTrace(_exception(error.toString()), error, stackTrace); } catch (error, stackTrace) { - String? message; - try { - message = (error as dynamic).message as String; - } catch (_) { - message = error.toString(); - } - throwWithTrace(_exception(message), error, stackTrace); + throwWithTrace(_exception(_getErrorMessage(error)), error, stackTrace); } finally { _importSpan = null; } @@ -3046,13 +3042,8 @@ final class _EvaluateVisitor } on SassException { rethrow; } catch (error, stackTrace) { - String? message; - try { - message = (error as dynamic).message as String; - } catch (_) { - message = error.toString(); - } - throwWithTrace(_exception(message, nodeWithSpan.span), error, stackTrace); + throwWithTrace(_exception(_getErrorMessage(error), nodeWithSpan.span), + error, stackTrace); } _callableNode = oldCallableNode; @@ -3870,6 +3861,18 @@ final class _EvaluateVisitor stackTrace); } } + + /// Returns the best human-readable message for [error]. + String _getErrorMessage(Object error) { + // Built-in Dart Error objects often require their full toString()s for + // full context. + if (error is Error) return error.toString(); + try { + return (error as dynamic).message as String; + } catch (_) { + return error.toString(); + } + } } /// A helper class for [_EvaluateVisitor] that adds `@import`ed CSS nodes to the diff --git a/lib/src/visitor/serialize.dart b/lib/src/visitor/serialize.dart index 75f1213a2..4d036e6e3 100644 --- a/lib/src/visitor/serialize.dart +++ b/lib/src/visitor/serialize.dart @@ -575,103 +575,361 @@ final class _SerializeVisitor }; void visitColor(SassColor value) { + switch (value.space) { + case ColorSpace.rgb || ColorSpace.hsl || ColorSpace.hwb + when !value.isChannel0Missing && + !value.isChannel1Missing && + !value.isChannel2Missing && + !value.isAlphaMissing: + _writeLegacyColor(value); + + case ColorSpace.rgb: + _buffer.write('rgb('); + _writeChannel(value.channel0OrNull); + _buffer.writeCharCode($space); + _writeChannel(value.channel1OrNull); + _buffer.writeCharCode($space); + _writeChannel(value.channel2OrNull); + _maybeWriteSlashAlpha(value); + _buffer.writeCharCode($rparen); + + case ColorSpace.hsl || ColorSpace.hwb: + _buffer + ..write(value.space) + ..writeCharCode($lparen); + _writeChannel(value.channel0OrNull, _isCompressed ? null : 'deg'); + _buffer.writeCharCode($space); + _writeChannel(value.channel1OrNull, '%'); + _buffer.writeCharCode($space); + _writeChannel(value.channel2OrNull, '%'); + _maybeWriteSlashAlpha(value); + _buffer.writeCharCode($rparen); + + case ColorSpace.lab || ColorSpace.lch + when !_inspect && + !fuzzyInRange(value.channel0, 0, 100) && + !value.isChannel1Missing && + !value.isChannel2Missing: + case ColorSpace.oklab || ColorSpace.oklch + when !_inspect && + !fuzzyInRange(value.channel0, 0, 1) && + !value.isChannel1Missing && + !value.isChannel2Missing: + case ColorSpace.lch || ColorSpace.oklch + when !_inspect && + fuzzyLessThan(value.channel1, 0) && + !value.isChannel0Missing && + !value.isChannel1Missing: + // color-mix() is currently more widely supported than relative color + // syntax, so we use it to serialize out-of-gamut colors in a way that + // maintains the color space defined in Sass while (per spec) not + // clamping their values. In practice, all browsers clamp out-of-gamut + // values, but there's not much we can do about that at time of writing. + _buffer.write('color-mix(in '); + _buffer.write(value.space); + _buffer.write(_commaSeparator); + // The XYZ space has no gamut restrictions, so we use it to represent + // the out-of-gamut color before converting into the target space. + _writeColorFunction(value.toSpace(ColorSpace.xyzD65)); + _writeOptionalSpace(); + _buffer.write('100%'); + _buffer.write(_commaSeparator); + _buffer.write(_isCompressed ? 'red' : 'black'); + _buffer.writeCharCode($rparen); + + case ColorSpace.lab || + ColorSpace.oklab || + ColorSpace.lch || + ColorSpace.oklch: + _buffer + ..write(value.space) + ..writeCharCode($lparen); + + // color-mix() can't represent out-of-bounds colors with missing + // channels, so in this case we use the less-supported but + // more-expressive relative color syntax instead. Relative color syntax + // never clamps channels. + var polar = value.space.channels[2].isPolarAngle; + if (!_inspect && + (!fuzzyInRange(value.channel0, 0, 100) || + (polar && fuzzyLessThan(value.channel1, 0)))) { + _buffer + ..write('from ') + ..write(_isCompressed ? 'red' : 'black') + ..writeCharCode($space); + } + + if (!_isCompressed && !value.isChannel0Missing) { + var max = (value.space.channels[0] as LinearChannel).max; + _writeNumber(value.channel0 * 100 / max); + _buffer.writeCharCode($percent); + } else { + _writeChannel(value.channel0OrNull); + } + _buffer.writeCharCode($space); + _writeChannel(value.channel1OrNull); + _buffer.writeCharCode($space); + _writeChannel( + value.channel2OrNull, polar && !_isCompressed ? 'deg' : null); + _maybeWriteSlashAlpha(value); + _buffer.writeCharCode($rparen); + + case _: + _writeColorFunction(value); + } + } + + /// Writes a [channel] which may be missing. + void _writeChannel(double? channel, [String? unit]) { + if (channel == null) { + _buffer.write('none'); + } else if (channel.isFinite) { + _writeNumber(channel); + if (unit != null) _buffer.write(unit); + } else { + visitNumber(SassNumber(channel, unit)); + } + } + + /// 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); + + // Out-of-gamut colors can _only_ be represented accurately as HSL, because + // only HSL isn't clamped at parse time (except negative saturation which + // isn't necessary anyway). + if (!color.isInGamut && !_inspect) { + _writeHsl(color); + return; + } + // In compressed mode, emit colors in the shortest representation possible. if (_isCompressed) { - 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 hexLength = _canUseShortHex(value) ? 4 : 7; - if (namesByColor[value] case var name? when 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 { - if (value.format case var format?) { - switch (format) { - case ColorFormat.rgbFunction: - _writeRgb(value); - case ColorFormat.hslFunction: - _writeHsl(value); - case SpanColorFormat(): - _buffer.write(format.original); - case _: - assert(false, "unknown format"); - } - } else if (namesByColor[value] case var name? - // Always emit generated transparent colors in rgba format. This works - // around an IE bug. See sass/sass#1782. - when !fuzzyEquals(value.alpha, 0)) { + if (!opaque) { + _buffer.writeCharCode($comma); + _writeNumber(color.alpha); + } + _buffer.writeCharCode($rparen); + return; + } + + if (color.space == ColorSpace.hsl) { + _writeHsl(color); + return; + } else if (_inspect && color.space == ColorSpace.hwb) { + _writeHwb(color); + return; + } + + switch (color.format) { + case ColorFormat.rgbFunction: + _writeRgb(color); + return; + + case SpanColorFormat format: + _buffer.write(format.original); + return; + } + + // Always emit generated transparent colors in rgba format. This works + // around an IE bug. See sass/sass#1782. + if (opaque) { + var rgb = color.toSpace(ColorSpace.rgb); + if (namesByColor[rgb] case var name?) { _buffer.write(name); - } else if (fuzzyEquals(value.alpha, 1)) { + return; + } + + if (_canUseHex(rgb)) { _buffer.writeCharCode($hash); - _writeHexComponent(value.red); - _writeHexComponent(value.green); - _writeHexComponent(value.blue); - } else { - _writeRgb(value); + _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 shortHex = _canUseShortHex(redInt, greenInt, blueInt); + if (namesByColor[rgb] case var name? + when 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); + _writeChannel(hsl.channel('hue')); _buffer.write(_commaSeparator); - _writeNumber(value.saturation); - _buffer.writeCharCode($percent); + _writeChannel(hsl.channel('saturation'), '%'); _buffer.write(_commaSeparator); - _writeNumber(value.lightness); - _buffer.writeCharCode($percent); + _writeChannel(hsl.channel('lightness'), '%'); if (!opaque) { _buffer.write(_commaSeparator); - _writeNumber(value.alpha); + _writeNumber(color.alpha); + } + + _buffer.writeCharCode($rparen); + } + + /// Writes [value] as an `hwb()` function. + /// + /// This is only used in inspect mode, and so only supports the new color syntax. + void _writeHwb(SassColor color) { + _buffer.write("hwb("); + var hwb = color.toSpace(ColorSpace.hwb); + _writeNumber(hwb.channel('hue')); + _buffer.writeCharCode($space); + _writeNumber(hwb.channel('whiteness')); + _buffer.writeCharCode($percent); + _buffer.writeCharCode($space); + _writeNumber(hwb.channel('blackness')); + _buffer.writeCharCode($percent); + + if (!fuzzyEquals(color.alpha, 1)) { + _buffer.write(' / '); + _writeNumber(color.alpha); } _buffer.writeCharCode($rparen); } + /// Writes [color] using the `color()` function syntax. + void _writeColorFunction(SassColor color) { + assert(!{ + ColorSpace.rgb, + ColorSpace.hsl, + ColorSpace.hwb, + ColorSpace.lab, + ColorSpace.oklab, + ColorSpace.lch, + ColorSpace.oklch + }.contains(color.space)); + _buffer + ..write('color(') + ..write(color.space) + ..writeCharCode($space); + _writeBetween(color.channelsOrNull, ' ', _writeChannel); + _maybeWriteSlashAlpha(color); + _buffer.writeCharCode($rparen); + } + /// Returns whether [color]'s hex pair representation is symmetrical (e.g. /// `FF`). bool _isSymmetricalHex(int color) => color & 0xF == color >> 4; /// 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) { @@ -680,6 +938,15 @@ final class _SerializeVisitor _buffer.writeCharCode(hexCharFor(color & 0xF)); } + /// Writes the alpha component of [color] if it isn't 1. + void _maybeWriteSlashAlpha(SassColor color) { + if (fuzzyEquals(color.alpha, 1)) return; + _writeOptionalSpace(); + _buffer.writeCharCode($slash); + _writeOptionalSpace(); + _writeChannel(color.alphaOrNull); + } + void visitFunction(SassFunction function) { if (!_inspect) { throw SassScriptException("$function isn't a valid CSS value."); @@ -818,15 +1085,27 @@ final 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. if (fuzzyAsInt(number) case var integer?) { // 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; } @@ -839,11 +1118,11 @@ final 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 @@ -906,7 +1185,7 @@ final 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.'); @@ -914,7 +1193,7 @@ final 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; } @@ -935,7 +1214,7 @@ final class _SerializeVisitor if (textIndex == text.length) { // If we get here, [text] has no decimal point. It definitely doesn't // need to be rounded; we can write it as-is. - _buffer.write(text); + buffer.write(text); return; } @@ -950,7 +1229,7 @@ final class _SerializeVisitor // truncation is needed. var indexAfterPrecision = textIndex + SassNumber.precision; if (indexAfterPrecision >= text.length) { - _buffer.write(text); + buffer.write(text); return; } @@ -988,11 +1267,11 @@ final 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 @@ -1003,13 +1282,13 @@ final 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 1b46987a4..6731f41e5 100644 --- a/pkg/sass_api/CHANGELOG.md +++ b/pkg/sass_api/CHANGELOG.md @@ -1,3 +1,54 @@ +## 12.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. + ## 11.1.0 * Loud comments in the Sass syntax no longer automatically inject ` */` to the @@ -298,8 +349,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 9e0c5d977..26ae0c0bd 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: 11.1.0 +version: 12.0.0 description: Additional APIs for Dart Sass. homepage: https://github.com/sass/dart-sass diff --git a/pubspec.yaml b/pubspec.yaml index 44853687b..f9e04c1c3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: sass -version: 1.79.0-dev +version: 1.79.0 description: A Sass implementation in Dart. homepage: https://github.com/sass/dart-sass diff --git a/test/dart_api/value/color_test.dart b/test/dart_api/value/color_test.dart index dfa1b9d3a..e0a6f3028 100644 --- a/test/dart_api/value/color_test.dart +++ b/test/dart_api/value/color_test.dart @@ -38,14 +38,116 @@ void main() { expect(value.alpha, equals(1)); }); + test("has a named alpha channel", () { + expect(value.channel("alpha"), equals(1)); + }); + + group("channel()", () { + test("returns RGB channels", () { + expect(value.channel("red"), equals(0x12)); + expect(value.channel("green"), equals(0x34)); + expect(value.channel("blue"), equals(0x56)); + }); + + test("returns alpha", () { + expect(value.channel("alpha"), equals(1)); + }); + + test("throws for a channel not in this space", () { + expect(() => value.channel("hue"), throwsSassScriptException); + }); + }); + + test("isChannelMissing() throws for a channel not in this space", () { + expect(() => value.channel("hue"), throwsSassScriptException); + }); + + test("isChannelPowerless() throws for a channel not in this space", () { + expect(() => value.channel("hue"), throwsSassScriptException); + }); + + test("has a space", () { + expect(value.space, equals(ColorSpace.rgb)); + }); + + test("is a legacy color", () { + expect(value.isLegacy, isTrue); + }); + test("equals the same color", () { expect(value, equalsWithHash(SassColor.rgb(0x12, 0x34, 0x56))); + }); + + test("equals an equivalent legacy color", () { expect( value, equalsWithHash( SassColor.hsl(210, 65.3846153846154, 20.392156862745097))); }); + test("does not equal an equivalent non-legacy color", () { + expect(value, isNot(equals(SassColor.srgb(0x12, 0x34, 0x56)))); + }); + + group("isInGamut", () { + test("returns true if the color is in the RGB gamut", () { + expect(value.isInGamut, isTrue); + }); + + test("returns false if the color is outside the RGB gamut", () { + expect(value.changeChannels({"red": 0x100}).isInGamut, isFalse); + }); + }); + + group("toSpace", () { + test("converts the color to a given space", () { + expect( + value.toSpace(ColorSpace.lab), + equals(SassColor.lab( + 20.675469453386192, -2.276792630515417, -24.59314874484676))); + }); + + test("with legacyMissing: true, makes a powerless channel missing", () { + expect( + SassColor.rgb(0, 0, 0) + .toSpace(ColorSpace.hsl) + .isChannelMissing("hue"), + isTrue); + }); + + test("with legacyMissing: false, makes a powerless channel zero", () { + var result = SassColor.rgb(0, 0, 0) + .toSpace(ColorSpace.hsl, legacyMissing: false); + expect(result.isChannelMissing("hue"), isFalse); + expect(result.channel("hue"), equals(0)); + }); + + test( + "even with legacyMissing: false, preserves missing channels for same " + "space", () { + expect( + SassColor.rgb(0, null, 0) + .toSpace(ColorSpace.rgb, legacyMissing: false) + .isChannelMissing("green"), + isTrue); + }); + }); + + group("toGamut() brings the color into its gamut", () { + setUp(() => value = parseValue("rgb(300 200 100)") as SassColor); + + test("with clip", () { + expect(value.toGamut(GamutMapMethod.clip), + equals(SassColor.rgb(255, 200, 100))); + }); + + test("with localMinde", () { + // TODO: update + expect(value.toGamut(GamutMapMethod.localMinde), + equals(SassColor.rgb(255, 200, 100))); + }); + }); + group("changeRgb()", () { test("changes RGB values", () { expect(value.changeRgb(red: 0xAA), @@ -60,102 +162,85 @@ void main() { equals(SassColor.rgb(0xAA, 0xAA, 0xAA, 0.5))); }); - test("allows valid values", () { - expect(value.changeRgb(red: 0).red, equals(0)); - expect(value.changeRgb(red: 0xFF).red, equals(0xFF)); - expect(value.changeRgb(green: 0).green, equals(0)); - expect(value.changeRgb(green: 0xFF).green, equals(0xFF)); - expect(value.changeRgb(blue: 0).blue, equals(0)); - expect(value.changeRgb(blue: 0xFF).blue, equals(0xFF)); - expect(value.changeRgb(alpha: 0).alpha, equals(0)); + test("allows in-gamut alpha", () { expect(value.changeRgb(alpha: 1).alpha, equals(1)); + expect(value.changeRgb(alpha: 0).alpha, equals(0)); + }); + + test("allows out-of-gamut values", () { + expect(value.changeRgb(red: -1).red, equals(-1)); + expect(value.changeRgb(red: 0x100).red, equals(0x100)); }); - test("disallows invalid values", () { - expect(() => value.changeRgb(red: -1), throwsRangeError); - expect(() => value.changeRgb(red: 0x100), throwsRangeError); - expect(() => value.changeRgb(green: -1), throwsRangeError); - expect(() => value.changeRgb(green: 0x100), throwsRangeError); - expect(() => value.changeRgb(blue: -1), throwsRangeError); - expect(() => value.changeRgb(blue: 0x100), throwsRangeError); + test("disallows out-of-gamut alpha", () { expect(() => value.changeRgb(alpha: -0.1), throwsRangeError); expect(() => value.changeRgb(alpha: 1.1), throwsRangeError); }); }); - group("changeHsl()", () { - test("changes HSL values", () { - expect(value.changeHsl(hue: 120), - equals(SassColor.hsl(120, 65.3846153846154, 20.392156862745097))); - expect(value.changeHsl(saturation: 42), - equals(SassColor.hsl(210, 42, 20.392156862745097))); - expect(value.changeHsl(lightness: 42), - equals(SassColor.hsl(210, 65.3846153846154, 42))); - expect( - value.changeHsl(alpha: 0.5), - equals( - SassColor.hsl(210, 65.3846153846154, 20.392156862745097, 0.5))); - expect( - value.changeHsl( - hue: 120, saturation: 42, lightness: 42, alpha: 0.5), - equals(SassColor.hsl(120, 42, 42, 0.5))); - }); - - test("allows valid values", () { - expect(value.changeHsl(saturation: 0).saturation, equals(0)); - expect(value.changeHsl(saturation: 100).saturation, equals(100)); - expect(value.changeHsl(lightness: 0).lightness, equals(0)); - expect(value.changeHsl(lightness: 100).lightness, equals(100)); - expect(value.changeHsl(alpha: 0).alpha, equals(0)); - expect(value.changeHsl(alpha: 1).alpha, equals(1)); - }); + test("changeHsl() changes HSL values", () { + expect(value.changeHsl(hue: 120), + equals(SassColor.hsl(120, 65.3846153846154, 20.392156862745097))); + expect(value.changeHsl(saturation: 42), + equals(SassColor.hsl(210, 42, 20.392156862745097))); + expect(value.changeHsl(lightness: 42), + equals(SassColor.hsl(210, 65.3846153846154, 42))); + expect( + value.changeHsl(alpha: 0.5), + equals( + SassColor.hsl(210, 65.3846153846154, 20.392156862745097, 0.5))); + expect( + value.changeHsl(hue: 120, saturation: 42, lightness: 42, alpha: 0.5), + equals(SassColor.hsl(120, 42, 42, 0.5))); + }); - test("disallows invalid values", () { - expect(() => value.changeHsl(saturation: -0.1), throwsRangeError); - expect(() => value.changeHsl(saturation: 100.1), throwsRangeError); - expect(() => value.changeHsl(lightness: -0.1), throwsRangeError); - expect(() => value.changeHsl(lightness: 100.1), throwsRangeError); - expect(() => value.changeHsl(alpha: -0.1), throwsRangeError); - expect(() => value.changeHsl(alpha: 1.1), throwsRangeError); - }); + test("changeHwb() changes HWB values", () { + expect(value.changeHwb(hue: 120), + equals(SassColor.hwb(120, 7.0588235294117645, 66.27450980392157))); + expect(value.changeHwb(whiteness: 20), + equals(SassColor.hwb(210, 20, 66.27450980392157))); + expect(value.changeHwb(blackness: 42), + equals(SassColor.hwb(210, 7.0588235294117645, 42))); + expect( + value.changeHwb(alpha: 0.5), + equals( + SassColor.hwb(210, 7.0588235294117645, 66.27450980392157, 0.5))); + expect( + value.changeHwb(hue: 120, whiteness: 42, blackness: 42, alpha: 0.5), + equals(SassColor.hwb(120, 42, 42, 0.5))); + expect(value.changeHwb(whiteness: 50), + equals(SassColor.hwb(210, 43.0016863406408, 56.9983136593592))); }); - group("changeHwb()", () { - test("changes HWB values", () { - expect(value.changeHwb(hue: 120), - equals(SassColor.hwb(120, 7.0588235294117645, 66.27450980392157))); - expect(value.changeHwb(whiteness: 20), - equals(SassColor.hwb(210, 20, 66.27450980392157))); - expect(value.changeHwb(blackness: 42), - equals(SassColor.hwb(210, 7.0588235294117645, 42))); - expect( - value.changeHwb(alpha: 0.5), - equals(SassColor.hwb( - 210, 7.0588235294117645, 66.27450980392157, 0.5))); - expect( - value.changeHwb(hue: 120, whiteness: 42, blackness: 42, alpha: 0.5), - equals(SassColor.hwb(120, 42, 42, 0.5))); + group("changeChannels()", () { + test("changes RGB values", () { + expect(value.changeChannels({"red": 0xAA}), + equals(SassColor.rgb(0xAA, 0x34, 0x56))); + expect(value.changeChannels({"green": 0xAA}), + equals(SassColor.rgb(0x12, 0xAA, 0x56))); + expect(value.changeChannels({"blue": 0xAA}), + equals(SassColor.rgb(0x12, 0x34, 0xAA))); + expect(value.changeChannels({"alpha": 0.5}), + equals(SassColor.rgb(0x12, 0x34, 0x56, 0.5))); expect( - value.changeHwb(whiteness: 50), equals(SassColor.hwb(210, 43, 57))); + value.changeChannels( + {"red": 0xAA, "green": 0xAA, "blue": 0xAA, "alpha": 0.5}), + equals(SassColor.rgb(0xAA, 0xAA, 0xAA, 0.5))); }); - test("allows valid values", () { - expect(value.changeHwb(whiteness: 0).whiteness, equals(0)); - expect(value.changeHwb(whiteness: 100).whiteness, equals(60.0)); - expect(value.changeHwb(blackness: 0).blackness, equals(0)); - expect(value.changeHwb(blackness: 100).blackness, - equals(93.33333333333333)); - expect(value.changeHwb(alpha: 0).alpha, equals(0)); - expect(value.changeHwb(alpha: 1).alpha, equals(1)); + test("allows in-gamut alpha", () { + expect(value.changeChannels({"alpha": 1}).alpha, equals(1)); + expect(value.changeChannels({"alpha": 0}).alpha, equals(0)); }); - test("disallows invalid values", () { - expect(() => value.changeHwb(whiteness: -0.1), throwsRangeError); - expect(() => value.changeHwb(whiteness: 100.1), throwsRangeError); - expect(() => value.changeHwb(blackness: -0.1), throwsRangeError); - expect(() => value.changeHwb(blackness: 100.1), throwsRangeError); - expect(() => value.changeHwb(alpha: -0.1), throwsRangeError); - expect(() => value.changeHwb(alpha: 1.1), throwsRangeError); + test("allows out-of-gamut values", () { + expect(value.changeChannels({"red": -1}).red, equals(-1)); + expect(value.changeChannels({"red": 0x100}).red, equals(0x100)); + }); + + test("disallows out-of-gamut alpha", () { + expect(() => value.changeChannels({"alpha": -0.1}), throwsRangeError); + expect(() => value.changeChannels({"alpha": 1.1}), throwsRangeError); }); }); @@ -191,126 +276,133 @@ void main() { }); }); - group("an HSL color", () { + group("a color with a missing channel", () { late SassColor value; - setUp(() => value = parseValue("hsl(120, 42%, 42%)") as SassColor); + setUp(() => + value = parseValue("color(display-p3 0.3 0.4 none)") as SassColor); - test("has RGB channels", () { - expect(value.red, equals(0x3E)); - expect(value.green, equals(0x98)); - expect(value.blue, equals(0x3E)); + test("reports present channels as present", () { + expect(value.isChannelMissing("red"), isFalse); + expect(value.isChannelMissing("green"), isFalse); + expect(value.isChannelMissing("alpha"), isFalse); }); - test("has HSL channels", () { - expect(value.hue, equals(120)); - expect(value.saturation, equals(42)); - expect(value.lightness, equals(42)); + test("reports the missing channel as missing", () { + expect(value.isChannelMissing("blue"), isTrue); }); - test("has HWB channels", () { - expect(value.whiteness, equals(24.313725490196077)); - expect(value.blackness, equals(40.3921568627451)); - }); - - test("has an alpha channel", () { - expect(value.alpha, equals(1)); + test("reports the missing channel's value as 0", () { + expect(value.channel("blue"), equals(0)); }); - test("equals the same color", () { - expect(value, equalsWithHash(SassColor.rgb(0x3E, 0x98, 0x3E))); - expect(value, equalsWithHash(SassColor.hsl(120, 42, 42))); - expect( - value, - equalsWithHash( - SassColor.hwb(120, 24.313725490196077, 40.3921568627451))); + test("does not report the missing channel as powerless", () { + expect(value.isChannelPowerless("blue"), isFalse); }); }); - test("an RGBA color has an alpha channel", () { - var color = parseValue("rgba(10, 20, 30, 0.7)") as SassColor; - expect(color.alpha, closeTo(0.7, 1e-11)); - }); + group("a color with a powerless channel", () { + late SassColor value; + setUp(() => value = parseValue("hsl(120 0% 50%)") as SassColor); - group("new SassColor.rgb()", () { - test("allows valid values", () { - expect(SassColor.rgb(0, 0, 0, 0), equals(parseValue("rgba(0, 0, 0, 0)"))); - expect(SassColor.rgb(0xFF, 0xFF, 0xFF, 1), equals(parseValue("#fff"))); + test("reports powerful channels as powerful", () { + expect(value.isChannelPowerless("saturation"), isFalse); + expect(value.isChannelPowerless("lightness"), isFalse); + expect(value.isChannelPowerless("alpha"), isFalse); }); - test("disallows invalid values", () { - expect(() => SassColor.rgb(-1, 0, 0, 0), throwsRangeError); - expect(() => SassColor.rgb(0, -1, 0, 0), throwsRangeError); - expect(() => SassColor.rgb(0, 0, -1, 0), throwsRangeError); - expect(() => SassColor.rgb(0, 0, 0, -0.1), throwsRangeError); - expect(() => SassColor.rgb(0x100, 0, 0, 0), throwsRangeError); - expect(() => SassColor.rgb(0, 0x100, 0, 0), throwsRangeError); - expect(() => SassColor.rgb(0, 0, 0x100, 0), throwsRangeError); - expect(() => SassColor.rgb(0, 0, 0, 1.1), throwsRangeError); + test("reports the powerless channel as powerless", () { + expect(value.isChannelPowerless("hue"), isTrue); }); - }); - group("new SassColor.hsl()", () { - test("allows valid values", () { - expect( - SassColor.hsl(0, 0, 0, 0), equals(parseValue("hsla(0, 0%, 0%, 0)"))); - expect(SassColor.hsl(4320, 100, 100, 1), - equals(parseValue("hsl(4320, 100%, 100%)"))); + test("reports the powerless channel's value", () { + expect(value.channel("hue"), 120); }); - test("disallows invalid values", () { - expect(() => SassColor.hsl(0, -0.1, 0, 0), throwsRangeError); - expect(() => SassColor.hsl(0, 0, -0.1, 0), throwsRangeError); - expect(() => SassColor.hsl(0, 0, 0, -0.1), throwsRangeError); - expect(() => SassColor.hsl(0, 100.1, 0, 0), throwsRangeError); - expect(() => SassColor.hsl(0, 0, 100.1, 0), throwsRangeError); - expect(() => SassColor.hsl(0, 0, 0, 1.1), throwsRangeError); + test("does not report the powerless channel as missing", () { + expect(value.isChannelMissing("hue"), isFalse); }); }); - group("new SassColor.hwb()", () { + group("an LCH color", () { late SassColor value; - setUp(() => value = SassColor.hwb(120, 42, 42)); - - test("has RGB channels", () { - expect(value.red, equals(0x6B)); - expect(value.green, equals(0x94)); - expect(value.blue, equals(0x6B)); + setUp(() => value = parseValue("lch(42% 42% 120)") as SassColor); + + test("throws for legacy channels", () { + expect(() => value.red, throwsSassScriptException); + expect(() => value.green, throwsSassScriptException); + expect(() => value.blue, throwsSassScriptException); + expect(() => value.hue, throwsSassScriptException); + expect(() => value.saturation, throwsSassScriptException); + expect(() => value.lightness, throwsSassScriptException); + expect(() => value.whiteness, throwsSassScriptException); + expect(() => value.blackness, throwsSassScriptException); }); - test("has HSL channels", () { - expect(value.hue, equals(120)); - expect(value.saturation, equals(16.078431372549026)); - expect(value.lightness, equals(50)); + test("has an alpha channel", () { + expect(value.alpha, equals(1)); }); - test("has HWB channels", () { - expect(value.whiteness, equals(41.96078431372549)); - expect(value.blackness, equals(41.96078431372548)); + group("channel()", () { + test("returns LCH channels", () { + expect(value.channel("lightness"), equals(42)); + expect(value.channel("chroma"), equals(63)); + expect(value.channel("hue"), equals(120)); + }); + + test("returns alpha", () { + expect(value.channel("alpha"), equals(1)); + }); + + test("throws for a channel not in this space", () { + expect(() => value.channel("red"), throwsSassScriptException); + }); }); - test("has an alpha channel", () { - expect(value.alpha, equals(1)); + test("is not a legacy color", () { + expect(value.isLegacy, isFalse); }); test("equals the same color", () { - expect(value, equalsWithHash(SassColor.rgb(0x6B, 0x94, 0x6B))); - expect(value, equalsWithHash(SassColor.hsl(120, 16, 50))); - expect(value, equalsWithHash(SassColor.hwb(120, 42, 42))); + expect(value, equalsWithHash(SassColor.lch(42, 63, 120))); }); - test("allows valid values", () { + test("doesn't equal an equivalent color", () { expect( - SassColor.hwb(0, 0, 0, 0), equals(parseValue("rgba(255, 0, 0, 0)"))); - expect(SassColor.hwb(4320, 100, 100, 1), equals(parseValue("grey"))); + value, + isNot(equals(SassColor.xyzD65(0.07461544022446227, + 0.12417002656711021, 0.011301590030256693)))); }); - test("disallows invalid values", () { - expect(() => SassColor.hwb(0, -0.1, 0, 0), throwsRangeError); - expect(() => SassColor.hwb(0, 0, -0.1, 0), throwsRangeError); - expect(() => SassColor.hwb(0, 0, 0, -0.1), throwsRangeError); - expect(() => SassColor.hwb(0, 100.1, 0, 0), throwsRangeError); - expect(() => SassColor.hwb(0, 0, 100.1, 0), throwsRangeError); - expect(() => SassColor.hwb(0, 0, 0, 1.1), throwsRangeError); + test("changeChannels() changes LCH values", () { + expect(value.changeChannels({"lightness": 30}), + equals(SassColor.lch(30, 63, 120))); + expect(value.changeChannels({"chroma": 30}), + equals(SassColor.lch(42, 30, 120))); + expect( + value.changeChannels({"hue": 80}), equals(SassColor.lch(42, 63, 80))); + expect(value.changeChannels({"alpha": 0.5}), + equals(SassColor.lch(42, 63, 120, 0.5))); + expect( + value.changeChannels( + {"lightness": 30, "chroma": 30, "hue": 30, "alpha": 0.5}), + equals(SassColor.lch(30, 30, 30, 0.5))); + }); + }); + + test("an RGBA color has an alpha channel", () { + var color = parseValue("rgba(10, 20, 30, 0.7)") as SassColor; + expect(color.alpha, closeTo(0.7, 1e-11)); + }); + + group("new SassColor.rgb()", () { + test("allows out-of-gamut values", () { + expect(SassColor.rgb(-1, 0, 0, 0).channel("red"), equals(-1)); + expect(SassColor.rgb(0, 100, 0, 0).channel("green"), equals(100)); + }); + + test("disallows out-of-gamut alpha values", () { + expect(() => SassColor.rgb(0, 0, 0, -0.1), throwsRangeError); + expect(() => SassColor.rgb(0, 0, 0, 1.1), throwsRangeError); }); }); } diff --git a/test/deprecations_test.dart b/test/deprecations_test.dart index 7f70213ff..9b3594c09 100644 --- a/test/deprecations_test.dart +++ b/test/deprecations_test.dart @@ -99,12 +99,6 @@ void main() { _expectDeprecation("a {b: hsl(10deg, 0%, 0)}", Deprecation.functionUnits); }); - test("an alpha value with a percent unit", () { - _expectDeprecation( - r"@use 'sass:color'; a {b: color.change(red, $alpha: 1%)}", - Deprecation.functionUnits); - }); - test("an alpha value with a non-percent unit", () { _expectDeprecation( r"@use 'sass:color'; a {b: color.change(red, $alpha: 1px)}", diff --git a/test/embedded/function_test.dart b/test/embedded/function_test.dart index e76cda9f8..ceb6ec929 100644 --- a/test/embedded/function_test.dart +++ b/test/embedded/function_test.dart @@ -966,6 +966,21 @@ void main() { expect(await _deprotofy(_rgb(0xaa, 0xbb, 0xcc, 1.0)), equals('#aabbcc')); }); + + test("with red above 255", () async { + expect(await _deprotofy(_rgb(256, 0, 0, 1.0)), + equals('hsl(0, 100.7874015748%, 50.1960784314%)')); + }); + + test("with green above 255", () async { + expect(await _deprotofy(_rgb(0, 256, 0, 1.0)), + equals('hsl(120, 100.7874015748%, 50.1960784314%)')); + }); + + test("with blue above 255", () async { + expect(await _deprotofy(_rgb(0, 0, 256, 1.0)), + equals('hsl(240, 100.7874015748%, 50.1960784314%)')); + }); }); group("with alpha", () { @@ -985,64 +1000,73 @@ void main() { group("without alpha:", () { group("hue", () { test("0", () async { - expect(await _deprotofy(_hsl(0, 50, 50, 1.0)), "#bf4040"); + expect( + await _deprotofy(_hsl(0, 50, 50, 1.0)), "hsl(0, 50%, 50%)"); }); test("360", () async { - expect(await _deprotofy(_hsl(360, 50, 50, 1.0)), "#bf4040"); + expect( + await _deprotofy(_hsl(360, 50, 50, 1.0)), "hsl(0, 50%, 50%)"); }); test("below 0", () async { - expect(await _deprotofy(_hsl(-100, 50, 50, 1.0)), "#6a40bf"); + expect(await _deprotofy(_hsl(-100, 50, 50, 1.0)), + "hsl(260, 50%, 50%)"); }); test("between 0 and 360", () async { - expect(await _deprotofy(_hsl(100, 50, 50, 1.0)), "#6abf40"); + expect(await _deprotofy(_hsl(100, 50, 50, 1.0)), + "hsl(100, 50%, 50%)"); }); test("above 360", () async { - expect(await _deprotofy(_hsl(560, 50, 50, 1.0)), "#4095bf"); + expect(await _deprotofy(_hsl(560, 50, 50, 1.0)), + "hsl(200, 50%, 50%)"); }); }); group("saturation", () { test("0", () async { - expect(await _deprotofy(_hsl(0, 0, 50, 1.0)), "gray"); + expect(await _deprotofy(_hsl(0, 0, 50, 1.0)), "hsl(0, 0%, 50%)"); }); test("100", () async { - expect(await _deprotofy(_hsl(0, 100, 50, 1.0)), "red"); + expect( + await _deprotofy(_hsl(0, 100, 50, 1.0)), "hsl(0, 100%, 50%)"); }); test("in the middle", () async { - expect(await _deprotofy(_hsl(0, 42, 50, 1.0)), "#b54a4a"); + expect( + await _deprotofy(_hsl(0, 42, 50, 1.0)), "hsl(0, 42%, 50%)"); }); }); group("lightness", () { test("0", () async { - expect(await _deprotofy(_hsl(0, 50, 0, 1.0)), "black"); + expect(await _deprotofy(_hsl(0, 50, 0, 1.0)), "hsl(0, 50%, 0%)"); }); test("100", () async { - expect(await _deprotofy(_hsl(0, 50, 100, 1.0)), "white"); + expect( + await _deprotofy(_hsl(0, 50, 100, 1.0)), "hsl(0, 50%, 100%)"); }); test("in the middle", () async { - expect(await _deprotofy(_hsl(0, 50, 42, 1.0)), "#a13636"); + expect( + await _deprotofy(_hsl(0, 50, 42, 1.0)), "hsl(0, 50%, 42%)"); }); }); }); group("with alpha", () { test("0", () async { - expect( - await _deprotofy(_hsl(10, 20, 30, 0.0)), "rgba(92, 66, 61, 0)"); + expect(await _deprotofy(_hsl(10, 20, 30, 0.0)), + "hsla(10, 20%, 30%, 0)"); }); test("between 0 and 1", () async { expect(await _deprotofy(_hsl(10, 20, 30, 0.123)), - "rgba(92, 66, 61, 0.123)"); + "hsla(10, 20%, 30%, 0.123)"); }); }); }); @@ -1595,63 +1619,24 @@ void main() { group("and rejects", () { group("a color", () { - test("with red above 255", () async { - await _expectDeprotofyError(_rgb(256, 0, 0, 1.0), - "RgbColor.red must be between 0 and 255, was 256"); - }); - - test("with green above 255", () async { - await _expectDeprotofyError(_rgb(0, 256, 0, 1.0), - "RgbColor.green must be between 0 and 255, was 256"); - }); - - test("with blue above 255", () async { - await _expectDeprotofyError(_rgb(0, 0, 256, 1.0), - "RgbColor.blue must be between 0 and 255, was 256"); - }); - test("with RGB alpha below 0", () async { await _expectDeprotofyError(_rgb(0, 0, 0, -0.1), - "RgbColor.alpha must be between 0 and 1, was -0.1"); + "Color.alpha must be between 0 and 1, was -0.1"); }); test("with RGB alpha above 1", () async { await _expectDeprotofyError(_rgb(0, 0, 0, 1.1), - "RgbColor.alpha must be between 0 and 1, was 1.1"); - }); - - test("with saturation below 0", () async { - await _expectDeprotofyError(_hsl(0, -0.1, 0, 1.0), - "HslColor.saturation must be between 0 and 100, was -0.1"); - }); - - test("with saturation above 100", () async { - await _expectDeprotofyError( - _hsl(0, 100.1, 0, 1.0), - "HslColor.saturation must be between 0 and 100, was " - "100.1"); - }); - - test("with lightness below 0", () async { - await _expectDeprotofyError(_hsl(0, 0, -0.1, 1.0), - "HslColor.lightness must be between 0 and 100, was -0.1"); - }); - - test("with lightness above 100", () async { - await _expectDeprotofyError( - _hsl(0, 0, 100.1, 1.0), - "HslColor.lightness must be between 0 and 100, was " - "100.1"); + "Color.alpha must be between 0 and 1, was 1.1"); }); test("with HSL alpha below 0", () async { await _expectDeprotofyError(_hsl(0, 0, 0, -0.1), - "HslColor.alpha must be between 0 and 1, was -0.1"); + "Color.alpha must be between 0 and 1, was -0.1"); }); test("with HSL alpha above 1", () async { await _expectDeprotofyError(_hsl(0, 0, 0, 1.1), - "HslColor.alpha must be between 0 and 1, was 1.1"); + "Color.alpha must be between 0 and 1, was 1.1"); }); }); @@ -1915,18 +1900,20 @@ Future _roundTrip(Value value) async { /// Returns a [Value] that's an RGB color with the given fields. Value _rgb(int red, int green, int blue, double alpha) => Value() - ..rgbColor = (Value_RgbColor() - ..red = red - ..green = green - ..blue = blue + ..color = (Value_Color() + ..space = 'rgb' + ..channel1 = red * 1.0 + ..channel2 = green * 1.0 + ..channel3 = blue * 1.0 ..alpha = alpha); /// Returns a [Value] that's an HSL color with the given fields. Value _hsl(num hue, num saturation, num lightness, double alpha) => Value() - ..hslColor = (Value_HslColor() - ..hue = hue * 1.0 - ..saturation = saturation * 1.0 - ..lightness = lightness * 1.0 + ..color = (Value_Color() + ..space = 'hsl' + ..channel1 = hue * 1.0 + ..channel2 = saturation * 1.0 + ..channel3 = lightness * 1.0 ..alpha = alpha); /// Asserts that [process] emits a [CompileFailure] result with the given diff --git a/tool/grind.dart b/tool/grind.dart index 8e95575ed..0cea7264b 100644 --- a/tool/grind.dart +++ b/tool/grind.dart @@ -215,7 +215,7 @@ String _readAndResolveMarkdown(String path) => File(path) return included.substring(headerMatch.end, sectionEnd).trim(); }); -/// Returns a map from JS type declaration file names to their contnets. +/// Returns a map from JS type declaration file names to their contents. Map _fetchJSTypes() { updateLanguageRepo();