-
Notifications
You must be signed in to change notification settings - Fork 351
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add a parameter to determine how to gamut-map a color (#2222)
- Loading branch information
Showing
7 changed files
with
228 additions
and
96 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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)); | ||
} | ||
} |