From 440430d862c1f1da881057e3d84253597ce420d3 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Fri, 19 Apr 2024 16:18:26 -0700 Subject: [PATCH] Add a parameter to determine how to gamut-map a color (#2222) --- lib/src/functions/color.dart | 36 ++++--- lib/src/js/value/color.dart | 19 +++- lib/src/util/number.dart | 10 -- lib/src/value/color.dart | 70 +------------- lib/src/value/color/gamut_map_method.dart | 65 +++++++++++++ .../value/color/gamut_map_method/clip.dart | 30 ++++++ .../color/gamut_map_method/local_minde.dart | 94 +++++++++++++++++++ 7 files changed, 228 insertions(+), 96 deletions(-) create mode 100644 lib/src/value/color/gamut_map_method.dart create mode 100644 lib/src/value/color/gamut_map_method/clip.dart create mode 100644 lib/src/value/color/gamut_map_method/local_minde.dart diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index c9c12cb38..1a3dcbfb5 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -457,17 +457,25 @@ final module = BuiltInModule("color", functions: [ (arguments) => SassBoolean(_colorInSpace(arguments[0], arguments[1]).isInGamut)), - _function("to-gamut", r"$color, $space: null", (arguments) { + _function("to-gamut", r"$color, $space: null, $method: null", (arguments) { var color = arguments[0].assertColor("color"); var space = _spaceOrDefault(color, arguments[1], "space"); + if (arguments[2] == sassNull) { + throw SassScriptException( + "color.to-gamut() requires a \$method argument for forwards-" + "compatibility with changes in the CSS spec. Suggestion:\n" + "\n" + "\$method: local-minde", + "method"); + } + + // Assign this before checking [space.isBounded] so that invalid method + // names consistently produce errors. + var method = GamutMapMethod.fromName( + (arguments[2].assertString("method")..assertUnquoted("method")).text); if (!space.isBounded) return color; - return color - .toSpace(space == ColorSpace.hsl || space == ColorSpace.hwb - ? ColorSpace.srgb - : space) - .toGamut() - .toSpace(color.space); + return color.toSpace(space).toGamut(method).toSpace(color.space); }), _function("channel", r"$color, $channel, $space: null", (arguments) { @@ -671,8 +679,10 @@ final _change = _function("change", r"$color, $kwargs...", (arguments) => _updateComponents(arguments, change: true)); final _ieHexStr = _function("ie-hex-str", r"$color", (arguments) { - var color = - arguments[0].assertColor("color").toSpace(ColorSpace.rgb).toGamut(); + var color = arguments[0] + .assertColor("color") + .toSpace(ColorSpace.rgb) + .toGamut(GamutMapMethod.localMinde); String hexString(double component) => fuzzyRound(component).toRadixString(16).padLeft(2, '0').toUpperCase(); return SassString( @@ -841,11 +851,9 @@ SassColor _adjustColor( channelArgs[2]), // The color space doesn't matter for alpha, as long as it's not // strictly bounded. - fuzzyClamp( - _adjustChannel( - ColorSpace.lab, ColorChannel.alpha, color.alpha, alphaArg), - 0, - 1)); + _adjustChannel( + ColorSpace.lab, ColorChannel.alpha, color.alpha, alphaArg) + .clamp(0, 1)); /// Returns [oldValue] adjusted by [adjustmentArg] according to the definition /// in [space]'s [channel]. diff --git a/lib/src/js/value/color.dart b/lib/src/js/value/color.dart index 43d01400c..8d2a34743 100644 --- a/lib/src/js/value/color.dart +++ b/lib/src/js/value/color.dart @@ -87,9 +87,11 @@ final JSClass colorClass = () { 'toSpace': (SassColor self, String space) => _toSpace(self, space), 'isInGamut': (SassColor self, [String? space]) => _toSpace(self, space).isInGamut, - 'toGamut': (SassColor self, [String? space]) { + 'toGamut': (SassColor self, _ToGamutOptions options) { var originalSpace = self.space; - return _toSpace(self, space).toGamut().toSpace(originalSpace); + return _toSpace(self, options.space) + .toGamut(GamutMapMethod.fromName(options.method)) + .toSpace(originalSpace); }, 'channel': (SassColor self, String channel, [_ChannelOptions? options]) => _toSpace(self, options?.space).channel(channel), @@ -460,12 +462,19 @@ class _ConstructionOptions extends _Channels { @JS() @anonymous class _ChannelOptions { - String? space; + external String? get space; +} + +@JS() +@anonymous +class _ToGamutOptions { + external String? get space; + external String get method; } @JS() @anonymous class _InterpolationOptions { - external double? weight; - external String? method; + external double? get weight; + external String? get method; } diff --git a/lib/src/util/number.dart b/lib/src/util/number.dart index 83138e9d1..0cd82fb70 100644 --- a/lib/src/util/number.dart +++ b/lib/src/util/number.dart @@ -83,16 +83,6 @@ int fuzzyRound(num number) { } } -/// Returns [number], clamped to be within [min] and [max]. -/// -/// If [number] is [fuzzyEquals] to [min] or [max], it's clamped to the -/// appropriate value. -double fuzzyClamp(double number, double min, double max) { - if (fuzzyLessThanOrEquals(number, min)) return min; - if (fuzzyGreaterThanOrEquals(number, max)) return max; - return number; -} - /// Returns whether [number] is within [min] and [max] inclusive, using fuzzy /// equality. bool fuzzyInRange(double number, num min, num max) => diff --git a/lib/src/value/color.dart b/lib/src/value/color.dart index f398e5077..0768c052d 100644 --- a/lib/src/value/color.dart +++ b/lib/src/value/color.dart @@ -2,8 +2,6 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import 'dart:math' as math; - import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; @@ -14,6 +12,7 @@ import '../util/number.dart'; import '../value.dart'; import '../visitor/interface/value.dart'; +export 'color/gamut_map_method.dart'; export 'color/interpolation_method.dart'; export 'color/channel.dart'; export 'color/space.dart'; @@ -646,71 +645,8 @@ class SassColor extends Value { space, channel0OrNull, channel1OrNull, channel2OrNull, alpha); /// Returns a copy of this color that's in-gamut in the current color space. - SassColor toGamut() { - if (isInGamut) return this; - - // Algorithm from https://www.w3.org/TR/css-color-4/#css-gamut-mapping-algorithm - var originOklch = toSpace(ColorSpace.oklch); - - if (fuzzyGreaterThanOrEquals(originOklch.channel0, 1)) { - return space == ColorSpace.rgb - ? SassColor.rgb(255, 255, 255, alphaOrNull) - : SassColor.forSpaceInternal(space, 1, 1, 1, alphaOrNull); - } else if (fuzzyLessThanOrEquals(originOklch.channel0, 0)) { - return SassColor.forSpaceInternal(space, 0, 0, 0, alphaOrNull); - } - - // Always target RGB for legacy colors because HSL and HWB can't even - // represent out-of-gamut colors. - var targetSpace = isLegacy ? ColorSpace.rgb : space; - - var min = 0.0; - var max = originOklch.channel1; - while (true) { - var chroma = (min + max) / 2; - // Never null because [targetSpace] can't be HSL or HWB. - var current = ColorSpace.oklch.convert(targetSpace, originOklch.channel0, - chroma, originOklch.channel2, originOklch.alpha); - if (current.isInGamut) { - min = chroma; - continue; - } - - var clipped = _clip(current); - if (_deltaEOK(clipped, current) < 0.02) return clipped; - max = chroma; - } - } - - /// Returns [current] clipped into its space's gamut. - SassColor _clip(SassColor current) { - assert(!current.isInGamut); - assert(current.space == space); - - return space == ColorSpace.rgb - ? SassColor.rgb( - fuzzyClamp(current.channel0, 0, 255), - fuzzyClamp(current.channel1, 0, 255), - fuzzyClamp(current.channel2, 0, 255), - current.alphaOrNull) - : SassColor.forSpaceInternal( - space, - fuzzyClamp(current.channel0, 0, 1), - fuzzyClamp(current.channel1, 0, 1), - fuzzyClamp(current.channel2, 0, 1), - current.alphaOrNull); - } - - /// Returns the ΔEOK measure between [color1] and [color2]. - double _deltaEOK(SassColor color1, SassColor color2) { - // Algorithm from https://www.w3.org/TR/css-color-4/#color-difference-OK - var lab1 = color1.toSpace(ColorSpace.oklab); - var lab2 = color2.toSpace(ColorSpace.oklab); - - return math.sqrt(math.pow(lab1.channel0 - lab2.channel0, 2) + - math.pow(lab1.channel1 - lab2.channel1, 2) + - math.pow(lab1.channel2 - lab2.channel2, 2)); - } + SassColor toGamut(GamutMapMethod method) => + isInGamut ? this : method.map(this); /// Changes one or more of this color's RGB channels and returns the result. @Deprecated('Use changeChannels() instead.') diff --git a/lib/src/value/color/gamut_map_method.dart b/lib/src/value/color/gamut_map_method.dart new file mode 100644 index 000000000..f934d5940 --- /dev/null +++ b/lib/src/value/color/gamut_map_method.dart @@ -0,0 +1,65 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'package:meta/meta.dart'; + +import '../../exception.dart'; +import '../color.dart'; +import 'gamut_map_method/clip.dart'; +import 'gamut_map_method/local_minde.dart'; + +/// Different algorithms that can be used to map an out-of-gamut Sass color into +/// the gamut for its color space. +/// +/// {@category Value} +@sealed +abstract base class GamutMapMethod { + /// Clamp each color channel that's outside the gamut to the minimum or + /// maximum value for that channel. + /// + /// This algorithm will produce poor visual results, but it may be useful to + /// match the behavior of other situations in which a color can be clipped. + static const GamutMapMethod clip = ClipGamutMap(); + + /// The algorithm specified in [the original Color Level 4 candidate + /// recommendation]. + /// + /// This maps in the Oklch color space, using the [deltaEOK] color difference + /// formula and the [local-MINDE] improvement. + /// + /// [the original Color Level 4 candidate recommendation]: https://www.w3.org/TR/2024/CRD-css-color-4-20240213/#css-gamut-mapping + /// [deltaEOK]: https://www.w3.org/TR/2024/CRD-css-color-4-20240213/#color-difference-OK + /// [local-MINDE]: https://www.w3.org/TR/2024/CRD-css-color-4-20240213/#GM-chroma-local-MINDE + static const GamutMapMethod localMinde = LocalMindeGamutMap(); + + /// The Sass name of the gamut-mapping algorithm. + final String name; + + /// @nodoc + @internal + const GamutMapMethod(this.name); + + /// Parses a [GamutMapMethod] from its Sass name. + /// + /// Throws a [SassScriptException] if there is no method with the given + /// [name]. If this came from a function argument, [argumentName] is the + /// argument name (without the `$`). This is used for error reporting. + factory GamutMapMethod.fromName(String name, [String? argumentName]) => + switch (name) { + 'clip' => GamutMapMethod.clip, + 'local-minde' => GamutMapMethod.localMinde, + _ => throw SassScriptException( + 'Unknown gamut map method "$name".', argumentName) + }; + + /// Maps [color] to its gamut using this method's algorithm. + /// + /// Callers should use [SassColor.toGamut] instead of this method. + /// + /// @nodoc + @internal + SassColor map(SassColor color); + + String toString() => name; +} diff --git a/lib/src/value/color/gamut_map_method/clip.dart b/lib/src/value/color/gamut_map_method/clip.dart new file mode 100644 index 000000000..363f59374 --- /dev/null +++ b/lib/src/value/color/gamut_map_method/clip.dart @@ -0,0 +1,30 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'package:meta/meta.dart'; + +import '../../color.dart'; + +/// Gamut mapping by clipping individual channels. +/// +/// @nodoc +@internal +final class ClipGamutMap extends GamutMapMethod { + const ClipGamutMap() : super("clip"); + + SassColor map(SassColor color) => SassColor.forSpaceInternal( + color.space, + _clampChannel(color.channel0OrNull, color.space.channels[0]), + _clampChannel(color.channel1OrNull, color.space.channels[1]), + _clampChannel(color.channel2OrNull, color.space.channels[2]), + color.alphaOrNull); + + /// Clamps the channel value [value] within the bounds given by [channel]. + double? _clampChannel(double? value, ColorChannel channel) => value == null + ? null + : switch (channel) { + LinearChannel(:var min, :var max) => value.clamp(min, max), + _ => value + }; +} diff --git a/lib/src/value/color/gamut_map_method/local_minde.dart b/lib/src/value/color/gamut_map_method/local_minde.dart new file mode 100644 index 000000000..b444cdf55 --- /dev/null +++ b/lib/src/value/color/gamut_map_method/local_minde.dart @@ -0,0 +1,94 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'dart:math' as math; + +import 'package:meta/meta.dart'; + +import '../../../util/number.dart'; +import '../../color.dart'; + +/// Gamut mapping using the deltaEOK difference formula and the local-MINDE +/// improvement. +/// +/// @nodoc +@internal +final class LocalMindeGamutMap extends GamutMapMethod { + /// A constant from the gamut-mapping algorithm. + static const _jnd = 0.02; + + /// A constant from the gamut-mapping algorithm. + static const _epsilon = 0.0001; + + const LocalMindeGamutMap() : super("local-minde"); + + SassColor map(SassColor color) { + // Algorithm from https://www.w3.org/TR/2022/CRD-css-color-4-20221101/#css-gamut-mapping-algorithm + var originOklch = color.toSpace(ColorSpace.oklch); + + // The channel equivalents to `current` in the Color 4 algorithm. + var lightness = originOklch.channel0OrNull; + var hue = originOklch.channel2OrNull; + var alpha = originOklch.alphaOrNull; + + if (fuzzyGreaterThanOrEquals(lightness ?? 0, 1)) { + return color.space == ColorSpace.rgb + ? SassColor.rgb(255, 255, 255, color.alphaOrNull) + : SassColor.forSpaceInternal(color.space, 1, 1, 1, color.alphaOrNull); + } else if (fuzzyLessThanOrEquals(lightness ?? 0, 0)) { + return SassColor.forSpaceInternal( + color.space, 0, 0, 0, color.alphaOrNull); + } + + var clipped = color.toGamut(GamutMapMethod.clip); + if (_deltaEOK(clipped, color) < _jnd) return clipped; + + var min = 0.0; + var max = originOklch.channel1; + var minInGamut = true; + while (max - min > _epsilon) { + var chroma = (min + max) / 2; + + // In the Color 4 algorithm `current` is in Oklch, but all its actual uses + // other than modifying chroma convert it to `color.space` first so we + // just store it in that space to begin with. + var current = + ColorSpace.oklch.convert(color.space, lightness, chroma, hue, alpha); + + // Per [this comment], the intention of the algorithm is to fall through + // this clause if `minInGamut = false` without checking + // `current.isInGamut` at all, even though that's unclear from the + // pseudocode. `minInGamut = false` *should* imply `current.isInGamut = + // false`. + // + // [this comment]: https://github.com/w3c/csswg-drafts/issues/10226#issuecomment-2065534713 + if (minInGamut && current.isInGamut) { + min = chroma; + continue; + } + + clipped = current.toGamut(GamutMapMethod.clip); + var e = _deltaEOK(clipped, current); + if (e < _jnd) { + if (_jnd - e < _epsilon) return clipped; + minInGamut = false; + min = chroma; + } else { + max = chroma; + } + } + return clipped; + } + + /// Returns the ΔEOK measure between [color1] and [color2]. + double _deltaEOK(SassColor color1, SassColor color2) { + // Algorithm from https://www.w3.org/TR/css-color-4/#color-difference-OK + var lab1 = color1.toSpace(ColorSpace.oklab); + var lab2 = color2.toSpace(ColorSpace.oklab); + + return math.sqrt(math.pow(lab1.channel0 - lab2.channel0, 2) + + math.pow(lab1.channel1 - lab2.channel1, 2) + + math.pow(lab1.channel2 - lab2.channel2, 2)); + } +}