Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a parameter to determine how to gamut-map a color #2222

Merged
merged 6 commits into from Apr 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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));
}
}