Skip to content

Commit 440430d

Browse files
authored
Add a parameter to determine how to gamut-map a color (#2222)
1 parent b9fb0ab commit 440430d

File tree

7 files changed

+228
-96
lines changed

7 files changed

+228
-96
lines changed

lib/src/functions/color.dart

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -457,17 +457,25 @@ final module = BuiltInModule("color", functions: <Callable>[
457457
(arguments) =>
458458
SassBoolean(_colorInSpace(arguments[0], arguments[1]).isInGamut)),
459459

460-
_function("to-gamut", r"$color, $space: null", (arguments) {
460+
_function("to-gamut", r"$color, $space: null, $method: null", (arguments) {
461461
var color = arguments[0].assertColor("color");
462462
var space = _spaceOrDefault(color, arguments[1], "space");
463+
if (arguments[2] == sassNull) {
464+
throw SassScriptException(
465+
"color.to-gamut() requires a \$method argument for forwards-"
466+
"compatibility with changes in the CSS spec. Suggestion:\n"
467+
"\n"
468+
"\$method: local-minde",
469+
"method");
470+
}
471+
472+
// Assign this before checking [space.isBounded] so that invalid method
473+
// names consistently produce errors.
474+
var method = GamutMapMethod.fromName(
475+
(arguments[2].assertString("method")..assertUnquoted("method")).text);
463476
if (!space.isBounded) return color;
464477

465-
return color
466-
.toSpace(space == ColorSpace.hsl || space == ColorSpace.hwb
467-
? ColorSpace.srgb
468-
: space)
469-
.toGamut()
470-
.toSpace(color.space);
478+
return color.toSpace(space).toGamut(method).toSpace(color.space);
471479
}),
472480

473481
_function("channel", r"$color, $channel, $space: null", (arguments) {
@@ -671,8 +679,10 @@ final _change = _function("change", r"$color, $kwargs...",
671679
(arguments) => _updateComponents(arguments, change: true));
672680

673681
final _ieHexStr = _function("ie-hex-str", r"$color", (arguments) {
674-
var color =
675-
arguments[0].assertColor("color").toSpace(ColorSpace.rgb).toGamut();
682+
var color = arguments[0]
683+
.assertColor("color")
684+
.toSpace(ColorSpace.rgb)
685+
.toGamut(GamutMapMethod.localMinde);
676686
String hexString(double component) =>
677687
fuzzyRound(component).toRadixString(16).padLeft(2, '0').toUpperCase();
678688
return SassString(
@@ -841,11 +851,9 @@ SassColor _adjustColor(
841851
channelArgs[2]),
842852
// The color space doesn't matter for alpha, as long as it's not
843853
// strictly bounded.
844-
fuzzyClamp(
845-
_adjustChannel(
846-
ColorSpace.lab, ColorChannel.alpha, color.alpha, alphaArg),
847-
0,
848-
1));
854+
_adjustChannel(
855+
ColorSpace.lab, ColorChannel.alpha, color.alpha, alphaArg)
856+
.clamp(0, 1));
849857

850858
/// Returns [oldValue] adjusted by [adjustmentArg] according to the definition
851859
/// in [space]'s [channel].

lib/src/js/value/color.dart

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,11 @@ final JSClass colorClass = () {
8787
'toSpace': (SassColor self, String space) => _toSpace(self, space),
8888
'isInGamut': (SassColor self, [String? space]) =>
8989
_toSpace(self, space).isInGamut,
90-
'toGamut': (SassColor self, [String? space]) {
90+
'toGamut': (SassColor self, _ToGamutOptions options) {
9191
var originalSpace = self.space;
92-
return _toSpace(self, space).toGamut().toSpace(originalSpace);
92+
return _toSpace(self, options.space)
93+
.toGamut(GamutMapMethod.fromName(options.method))
94+
.toSpace(originalSpace);
9395
},
9496
'channel': (SassColor self, String channel, [_ChannelOptions? options]) =>
9597
_toSpace(self, options?.space).channel(channel),
@@ -460,12 +462,19 @@ class _ConstructionOptions extends _Channels {
460462
@JS()
461463
@anonymous
462464
class _ChannelOptions {
463-
String? space;
465+
external String? get space;
466+
}
467+
468+
@JS()
469+
@anonymous
470+
class _ToGamutOptions {
471+
external String? get space;
472+
external String get method;
464473
}
465474

466475
@JS()
467476
@anonymous
468477
class _InterpolationOptions {
469-
external double? weight;
470-
external String? method;
478+
external double? get weight;
479+
external String? get method;
471480
}

lib/src/util/number.dart

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -83,16 +83,6 @@ int fuzzyRound(num number) {
8383
}
8484
}
8585

86-
/// Returns [number], clamped to be within [min] and [max].
87-
///
88-
/// If [number] is [fuzzyEquals] to [min] or [max], it's clamped to the
89-
/// appropriate value.
90-
double fuzzyClamp(double number, double min, double max) {
91-
if (fuzzyLessThanOrEquals(number, min)) return min;
92-
if (fuzzyGreaterThanOrEquals(number, max)) return max;
93-
return number;
94-
}
95-
9686
/// Returns whether [number] is within [min] and [max] inclusive, using fuzzy
9787
/// equality.
9888
bool fuzzyInRange(double number, num min, num max) =>

lib/src/value/color.dart

Lines changed: 3 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22
// MIT-style license that can be found in the LICENSE file or at
33
// https://opensource.org/licenses/MIT.
44

5-
import 'dart:math' as math;
6-
75
import 'package:collection/collection.dart';
86
import 'package:meta/meta.dart';
97
import 'package:source_span/source_span.dart';
@@ -14,6 +12,7 @@ import '../util/number.dart';
1412
import '../value.dart';
1513
import '../visitor/interface/value.dart';
1614

15+
export 'color/gamut_map_method.dart';
1716
export 'color/interpolation_method.dart';
1817
export 'color/channel.dart';
1918
export 'color/space.dart';
@@ -646,71 +645,8 @@ class SassColor extends Value {
646645
space, channel0OrNull, channel1OrNull, channel2OrNull, alpha);
647646

648647
/// Returns a copy of this color that's in-gamut in the current color space.
649-
SassColor toGamut() {
650-
if (isInGamut) return this;
651-
652-
// Algorithm from https://www.w3.org/TR/css-color-4/#css-gamut-mapping-algorithm
653-
var originOklch = toSpace(ColorSpace.oklch);
654-
655-
if (fuzzyGreaterThanOrEquals(originOklch.channel0, 1)) {
656-
return space == ColorSpace.rgb
657-
? SassColor.rgb(255, 255, 255, alphaOrNull)
658-
: SassColor.forSpaceInternal(space, 1, 1, 1, alphaOrNull);
659-
} else if (fuzzyLessThanOrEquals(originOklch.channel0, 0)) {
660-
return SassColor.forSpaceInternal(space, 0, 0, 0, alphaOrNull);
661-
}
662-
663-
// Always target RGB for legacy colors because HSL and HWB can't even
664-
// represent out-of-gamut colors.
665-
var targetSpace = isLegacy ? ColorSpace.rgb : space;
666-
667-
var min = 0.0;
668-
var max = originOklch.channel1;
669-
while (true) {
670-
var chroma = (min + max) / 2;
671-
// Never null because [targetSpace] can't be HSL or HWB.
672-
var current = ColorSpace.oklch.convert(targetSpace, originOklch.channel0,
673-
chroma, originOklch.channel2, originOklch.alpha);
674-
if (current.isInGamut) {
675-
min = chroma;
676-
continue;
677-
}
678-
679-
var clipped = _clip(current);
680-
if (_deltaEOK(clipped, current) < 0.02) return clipped;
681-
max = chroma;
682-
}
683-
}
684-
685-
/// Returns [current] clipped into its space's gamut.
686-
SassColor _clip(SassColor current) {
687-
assert(!current.isInGamut);
688-
assert(current.space == space);
689-
690-
return space == ColorSpace.rgb
691-
? SassColor.rgb(
692-
fuzzyClamp(current.channel0, 0, 255),
693-
fuzzyClamp(current.channel1, 0, 255),
694-
fuzzyClamp(current.channel2, 0, 255),
695-
current.alphaOrNull)
696-
: SassColor.forSpaceInternal(
697-
space,
698-
fuzzyClamp(current.channel0, 0, 1),
699-
fuzzyClamp(current.channel1, 0, 1),
700-
fuzzyClamp(current.channel2, 0, 1),
701-
current.alphaOrNull);
702-
}
703-
704-
/// Returns the ΔEOK measure between [color1] and [color2].
705-
double _deltaEOK(SassColor color1, SassColor color2) {
706-
// Algorithm from https://www.w3.org/TR/css-color-4/#color-difference-OK
707-
var lab1 = color1.toSpace(ColorSpace.oklab);
708-
var lab2 = color2.toSpace(ColorSpace.oklab);
709-
710-
return math.sqrt(math.pow(lab1.channel0 - lab2.channel0, 2) +
711-
math.pow(lab1.channel1 - lab2.channel1, 2) +
712-
math.pow(lab1.channel2 - lab2.channel2, 2));
713-
}
648+
SassColor toGamut(GamutMapMethod method) =>
649+
isInGamut ? this : method.map(this);
714650

715651
/// Changes one or more of this color's RGB channels and returns the result.
716652
@Deprecated('Use changeChannels() instead.')
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Copyright 2024 Google Inc. Use of this source code is governed by an
2+
// MIT-style license that can be found in the LICENSE file or at
3+
// https://opensource.org/licenses/MIT.
4+
5+
import 'package:meta/meta.dart';
6+
7+
import '../../exception.dart';
8+
import '../color.dart';
9+
import 'gamut_map_method/clip.dart';
10+
import 'gamut_map_method/local_minde.dart';
11+
12+
/// Different algorithms that can be used to map an out-of-gamut Sass color into
13+
/// the gamut for its color space.
14+
///
15+
/// {@category Value}
16+
@sealed
17+
abstract base class GamutMapMethod {
18+
/// Clamp each color channel that's outside the gamut to the minimum or
19+
/// maximum value for that channel.
20+
///
21+
/// This algorithm will produce poor visual results, but it may be useful to
22+
/// match the behavior of other situations in which a color can be clipped.
23+
static const GamutMapMethod clip = ClipGamutMap();
24+
25+
/// The algorithm specified in [the original Color Level 4 candidate
26+
/// recommendation].
27+
///
28+
/// This maps in the Oklch color space, using the [deltaEOK] color difference
29+
/// formula and the [local-MINDE] improvement.
30+
///
31+
/// [the original Color Level 4 candidate recommendation]: https://www.w3.org/TR/2024/CRD-css-color-4-20240213/#css-gamut-mapping
32+
/// [deltaEOK]: https://www.w3.org/TR/2024/CRD-css-color-4-20240213/#color-difference-OK
33+
/// [local-MINDE]: https://www.w3.org/TR/2024/CRD-css-color-4-20240213/#GM-chroma-local-MINDE
34+
static const GamutMapMethod localMinde = LocalMindeGamutMap();
35+
36+
/// The Sass name of the gamut-mapping algorithm.
37+
final String name;
38+
39+
/// @nodoc
40+
@internal
41+
const GamutMapMethod(this.name);
42+
43+
/// Parses a [GamutMapMethod] from its Sass name.
44+
///
45+
/// Throws a [SassScriptException] if there is no method with the given
46+
/// [name]. If this came from a function argument, [argumentName] is the
47+
/// argument name (without the `$`). This is used for error reporting.
48+
factory GamutMapMethod.fromName(String name, [String? argumentName]) =>
49+
switch (name) {
50+
'clip' => GamutMapMethod.clip,
51+
'local-minde' => GamutMapMethod.localMinde,
52+
_ => throw SassScriptException(
53+
'Unknown gamut map method "$name".', argumentName)
54+
};
55+
56+
/// Maps [color] to its gamut using this method's algorithm.
57+
///
58+
/// Callers should use [SassColor.toGamut] instead of this method.
59+
///
60+
/// @nodoc
61+
@internal
62+
SassColor map(SassColor color);
63+
64+
String toString() => name;
65+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright 2024 Google Inc. Use of this source code is governed by an
2+
// MIT-style license that can be found in the LICENSE file or at
3+
// https://opensource.org/licenses/MIT.
4+
5+
import 'package:meta/meta.dart';
6+
7+
import '../../color.dart';
8+
9+
/// Gamut mapping by clipping individual channels.
10+
///
11+
/// @nodoc
12+
@internal
13+
final class ClipGamutMap extends GamutMapMethod {
14+
const ClipGamutMap() : super("clip");
15+
16+
SassColor map(SassColor color) => SassColor.forSpaceInternal(
17+
color.space,
18+
_clampChannel(color.channel0OrNull, color.space.channels[0]),
19+
_clampChannel(color.channel1OrNull, color.space.channels[1]),
20+
_clampChannel(color.channel2OrNull, color.space.channels[2]),
21+
color.alphaOrNull);
22+
23+
/// Clamps the channel value [value] within the bounds given by [channel].
24+
double? _clampChannel(double? value, ColorChannel channel) => value == null
25+
? null
26+
: switch (channel) {
27+
LinearChannel(:var min, :var max) => value.clamp(min, max),
28+
_ => value
29+
};
30+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Copyright 2024 Google Inc. Use of this source code is governed by an
2+
// MIT-style license that can be found in the LICENSE file or at
3+
// https://opensource.org/licenses/MIT.
4+
5+
import 'dart:math' as math;
6+
7+
import 'package:meta/meta.dart';
8+
9+
import '../../../util/number.dart';
10+
import '../../color.dart';
11+
12+
/// Gamut mapping using the deltaEOK difference formula and the local-MINDE
13+
/// improvement.
14+
///
15+
/// @nodoc
16+
@internal
17+
final class LocalMindeGamutMap extends GamutMapMethod {
18+
/// A constant from the gamut-mapping algorithm.
19+
static const _jnd = 0.02;
20+
21+
/// A constant from the gamut-mapping algorithm.
22+
static const _epsilon = 0.0001;
23+
24+
const LocalMindeGamutMap() : super("local-minde");
25+
26+
SassColor map(SassColor color) {
27+
// Algorithm from https://www.w3.org/TR/2022/CRD-css-color-4-20221101/#css-gamut-mapping-algorithm
28+
var originOklch = color.toSpace(ColorSpace.oklch);
29+
30+
// The channel equivalents to `current` in the Color 4 algorithm.
31+
var lightness = originOklch.channel0OrNull;
32+
var hue = originOklch.channel2OrNull;
33+
var alpha = originOklch.alphaOrNull;
34+
35+
if (fuzzyGreaterThanOrEquals(lightness ?? 0, 1)) {
36+
return color.space == ColorSpace.rgb
37+
? SassColor.rgb(255, 255, 255, color.alphaOrNull)
38+
: SassColor.forSpaceInternal(color.space, 1, 1, 1, color.alphaOrNull);
39+
} else if (fuzzyLessThanOrEquals(lightness ?? 0, 0)) {
40+
return SassColor.forSpaceInternal(
41+
color.space, 0, 0, 0, color.alphaOrNull);
42+
}
43+
44+
var clipped = color.toGamut(GamutMapMethod.clip);
45+
if (_deltaEOK(clipped, color) < _jnd) return clipped;
46+
47+
var min = 0.0;
48+
var max = originOklch.channel1;
49+
var minInGamut = true;
50+
while (max - min > _epsilon) {
51+
var chroma = (min + max) / 2;
52+
53+
// In the Color 4 algorithm `current` is in Oklch, but all its actual uses
54+
// other than modifying chroma convert it to `color.space` first so we
55+
// just store it in that space to begin with.
56+
var current =
57+
ColorSpace.oklch.convert(color.space, lightness, chroma, hue, alpha);
58+
59+
// Per [this comment], the intention of the algorithm is to fall through
60+
// this clause if `minInGamut = false` without checking
61+
// `current.isInGamut` at all, even though that's unclear from the
62+
// pseudocode. `minInGamut = false` *should* imply `current.isInGamut =
63+
// false`.
64+
//
65+
// [this comment]: https://github.com/w3c/csswg-drafts/issues/10226#issuecomment-2065534713
66+
if (minInGamut && current.isInGamut) {
67+
min = chroma;
68+
continue;
69+
}
70+
71+
clipped = current.toGamut(GamutMapMethod.clip);
72+
var e = _deltaEOK(clipped, current);
73+
if (e < _jnd) {
74+
if (_jnd - e < _epsilon) return clipped;
75+
minInGamut = false;
76+
min = chroma;
77+
} else {
78+
max = chroma;
79+
}
80+
}
81+
return clipped;
82+
}
83+
84+
/// Returns the ΔEOK measure between [color1] and [color2].
85+
double _deltaEOK(SassColor color1, SassColor color2) {
86+
// Algorithm from https://www.w3.org/TR/css-color-4/#color-difference-OK
87+
var lab1 = color1.toSpace(ColorSpace.oklab);
88+
var lab2 = color2.toSpace(ColorSpace.oklab);
89+
90+
return math.sqrt(math.pow(lab1.channel0 - lab2.channel0, 2) +
91+
math.pow(lab1.channel1 - lab2.channel1, 2) +
92+
math.pow(lab1.channel2 - lab2.channel2, 2));
93+
}
94+
}

0 commit comments

Comments
 (0)