Skip to content

Commit

Permalink
Add a parameter to determine how to gamut-map a color (#2222)
Browse files Browse the repository at this point in the history
  • Loading branch information
nex3 committed Apr 19, 2024
1 parent b9fb0ab commit 440430d
Show file tree
Hide file tree
Showing 7 changed files with 228 additions and 96 deletions.
36 changes: 22 additions & 14 deletions lib/src/functions/color.dart
Expand Up @@ -457,17 +457,25 @@ final module = BuiltInModule("color", functions: <Callable>[
(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) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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].
Expand Down
19 changes: 14 additions & 5 deletions lib/src/js/value/color.dart
Expand Up @@ -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),
Expand Down Expand Up @@ -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;
}
10 changes: 0 additions & 10 deletions lib/src/util/number.dart
Expand Up @@ -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) =>
Expand Down
70 changes: 3 additions & 67 deletions lib/src/value/color.dart
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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.')
Expand Down
65 changes: 65 additions & 0 deletions 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;
}
30 changes: 30 additions & 0 deletions 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
};
}
94 changes: 94 additions & 0 deletions 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));
}
}

0 comments on commit 440430d

Please sign in to comment.