Bug 1847503 - Map colors into gamut limits before rendering. r=layout-reviewers,emilio

When colors are converted to sRGB to render onto the display, make sure
that they are within sRGB gamut limits.

Gamut mapping is implemented according to:
https://drafts.csswg.org/css-color-4/#gamut-mapping

The color-mix-non-srgb-001 test is checking the expected result in
sRGB, which happens to be out of gamut limits, but because the test
is for color-mix and not gamut mapping, I changed the expected
results to the color space of the mix.

The svg reftest now has an increased fuzzy to allow for the final colors
to be gamut mapped. Gamut mapping is dependent of available hardware, so
we can't pin down exact colors for the result.

Differential Revision: https://phabricator.services.mozilla.com/D191083
This commit is contained in:
Tiaan Louw 2023-11-01 00:06:11 +00:00
parent aa4b953382
commit ef59084b47
8 changed files with 271 additions and 38 deletions

View File

@ -46,16 +46,14 @@ fuzzy(0-1,0-580) == anim-css-fill-3-from-by-ident-ident.svg anim-css-fill-3-ref.
fuzzy(0-1,0-580) == anim-css-fill-3-from-by-rgb-ident.svg anim-css-fill-3-ref.svg
# check handling of overflowing color values
# NOTE: Some of the tests below fail in Gecko because we compute
# "from + by" as the animation end-point, and we clamp that final color value
# (due to bug 515919) and use the clamped value for interpolation.
# That's earlier than the SVG spec wants us to clamp -- we're only supposed to
# clamp *final presentation values*.
# Gamut mapping is now available and presentation colors are not clipped
# any more. Because the result is dependent on hardware, we use a fairly high
# fuzzy for now.
# (Reference: SVG 1.1 Appendix F.4)
== anim-css-fill-overflow-1-by.svg anim-css-fill-overflow-1-ref.svg
== anim-css-fill-overflow-1-from-by.svg anim-css-fill-overflow-1-ref.svg # bug 515919
== anim-css-stopcolor-overflow-1-from-by.svg anim-css-stopcolor-overflow-1-ref.svg # bug 515919
== anim-css-floodcolor-overflow-1-from-by.svg anim-css-floodcolor-overflow-1-ref.svg # bug 515919
fuzzy(0-19,0-900) == anim-css-fill-overflow-1-by.svg anim-css-fill-overflow-1-ref.svg
fuzzy(0-19,0-900) == anim-css-fill-overflow-1-from-by.svg anim-css-fill-overflow-1-ref.svg
fuzzy(0-19,0-900) == anim-css-stopcolor-overflow-1-from-by.svg anim-css-stopcolor-overflow-1-ref.svg
fuzzy(0-19,0-900) == anim-css-floodcolor-overflow-1-from-by.svg anim-css-floodcolor-overflow-1-ref.svg
# 'fill-opacity' property
fuzzy(0-1,0-885) == anim-css-fillopacity-1-by.svg anim-css-fillopacity-1-ref.svg

View File

@ -7,8 +7,7 @@
#include "mozilla/StyleColorInlines.h"
#include "mozilla/ComputedStyle.h"
#include "mozilla/ComputedStyleInlines.h"
#include "mozilla/dom/BindingDeclarations.h"
#include "mozilla/StaticPrefs_layout.h"
#include "nsIFrame.h"
#include "nsStyleStruct.h"
@ -65,16 +64,33 @@ StyleAbsoluteColor StyleAbsoluteColor::ToColorSpace(
nscolor StyleAbsoluteColor::ToColor() const {
auto srgb = ToColorSpace(StyleColorSpace::Srgb);
// TODO(tlouw): Needs gamut mapping here. Right now we just hard clip the
// components to [0..1], which will yield invalid colors.
// https://bugzilla.mozilla.org/show_bug.cgi?id=1626624
auto red = std::clamp(srgb.components._0, 0.0f, 1.0f);
auto green = std::clamp(srgb.components._1, 0.0f, 1.0f);
auto blue = std::clamp(srgb.components._2, 0.0f, 1.0f);
constexpr float MIN = 0.0f;
constexpr float MAX = 1.0f;
return NS_RGBA(nsStyleUtil::FloatToColorComponent(red),
nsStyleUtil::FloatToColorComponent(green),
nsStyleUtil::FloatToColorComponent(blue),
// We KNOW the values are in srgb so we can do a quick gamut limit check
// here and avoid calling into Servo_MapColorIntoGamutLimits and let it
// return early anyway.
auto isColorInGamut =
(srgb.components._0 >= MIN && srgb.components._0 <= MAX &&
srgb.components._1 >= MIN && srgb.components._1 <= MAX &&
srgb.components._2 >= MIN && srgb.components._2 <= MAX);
if (!isColorInGamut) {
if (StaticPrefs::layout_css_gamut_map_for_rendering_enabled()) {
srgb = Servo_MapColorIntoGamutLimits(&srgb);
} else {
// If gamut mapping is not enabled, we just naively clip the colors at
// sRGB gamut limits. This will go away completely when gamut mapping is
// enabled.
srgb.components._0 = std::clamp(srgb.components._0, 0.0f, 1.0f);
srgb.components._1 = std::clamp(srgb.components._1, 0.0f, 1.0f);
srgb.components._2 = std::clamp(srgb.components._2, 0.0f, 1.0f);
}
}
return NS_RGBA(nsStyleUtil::FloatToColorComponent(srgb.components._0),
nsStyleUtil::FloatToColorComponent(srgb.components._1),
nsStyleUtil::FloatToColorComponent(srgb.components._2),
nsStyleUtil::FloatToColorComponent(srgb.alpha));
}

View File

@ -8407,6 +8407,12 @@
value: 3
mirror: always
# Should colors that are out of gamut for sRGB mapped to within limits?
- name: layout.css.gamut-map-for-rendering.enabled
type: bool
value: @IS_NIGHTLY_BUILD@
mirror: always
# Is support for GeometryUtils.getBoxQuads enabled?
- name: layout.css.getBoxQuads.enabled
type: bool

View File

@ -0,0 +1,196 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
//! Gamut mapping.
//! <https://drafts.csswg.org/css-color-4/#gamut-mapping>
use super::{AbsoluteColor, ColorSpace};
impl AbsoluteColor {
/// Map the components of this color into it's gamut limits if needed.
/// <https://drafts.csswg.org/css-color-4/#binsearch>
pub fn map_into_gamut_limits(&self) -> Self {
// 1. if destination has no gamut limits (XYZ-D65, XYZ-D50, Lab, LCH,
// Oklab, Oklch) return origin.
if matches!(
self.color_space,
ColorSpace::Lab |
ColorSpace::Lch |
ColorSpace::Oklab |
ColorSpace::Oklch |
ColorSpace::XyzD50 |
ColorSpace::XyzD65
) {
return self.clone();
}
// If the color does have gamut limits, then we can check it here and
// possibly skip the binary search.
if self.in_gamut() {
return self.clone();
}
// 2. let origin_Oklch be origin converted from origin color space to
// the Oklch color space.
let origin_oklch = self.to_color_space(ColorSpace::Oklch);
// 3. if the Lightness of origin_Oklch is greater than or equal to
// 100%, return { 1 1 1 origin.alpha } in destination.
if origin_oklch.components.1 >= 1.0 {
return AbsoluteColor::new(self.color_space, 1.0, 1.0, 1.0, self.alpha);
}
// 4. if the Lightness of origin_Oklch is less than than or equal to
// 0%, return { 0 0 0 origin.alpha } in destination.
if origin_oklch.components.1 <= 0.0 {
return AbsoluteColor::new(self.color_space, 0.0, 0.0, 0.0, self.alpha);
}
// 5. let inGamut(color) be a function which returns true if, when
// passed a color, that color is inside the gamut of destination.
// For HSL and HWB, it returns true if the color is inside the gamut
// of sRGB.
// See [`Color::in_gamut`].
// 6. if inGamut(origin_Oklch) is true, convert origin_Oklch to
// destination and return it as the gamut mapped color.
// We already checked if the color is in gamut limits above.
// 7. otherwise, let delta(one, two) be a function which returns the
// deltaEOK of color one compared to color two.
// See the [`delta_eok`] function.
// 8. let JND be 0.02
const JND: f32 = 0.02;
// 9. let epsilon be 0.0001
const EPSILON: f32 = 0.0001;
// 10. let clip(color) be a function which converts color to
// destination, converts all negative components to zero, converts
// all components greater that one to one, and returns the result.
// See [`Color::clip`].
// 11. set min to zero
let mut min = 0.0;
// 12. set max to the Oklch chroma of origin_Oklch.
let mut max = origin_oklch.components.1;
// 13. let min_inGamut be a boolean that represents when min is still
// in gamut, and set it to true
let mut min_in_gamut = true;
let mut current = origin_oklch.clone();
let mut current_in_space = self.clone();
// If we are already within the JND threshold, return the clipped color
// and skip the binary search.
let clipped = current_in_space.clip();
if delta_eok(&clipped, &current) < JND {
return clipped;
}
// 14. while (max - min is greater than epsilon) repeat the following
// steps.
while max - min > EPSILON {
// 14.1. set chroma to (min + max) / 2
let chroma = (min + max) / 2.0;
// 14.2. set current to origin_Oklch and then set the chroma
// component to chroma
current.components.1 = chroma;
current_in_space = current.to_color_space(self.color_space);
// 14.3. if min_inGamut is true and also if inGamut(current) is
// true, set min to chroma and continue to repeat these steps.
if min_in_gamut && current_in_space.in_gamut() {
min = chroma;
continue;
}
// 14.4. otherwise, if inGamut(current) is false carry out these
// steps:
// 14.4.1. set clipped to clip(current)
let clipped = current_in_space.clip();
// 14.4.2. set E to delta(clipped, current)
let e = delta_eok(&clipped, &current);
// 14.4.3. if E < JND
if e < JND {
// 14.4.3.1. if (JND - E < epsilon) return clipped as the gamut
// mapped color
if JND - e < EPSILON {
return clipped;
}
// 14.4.3.2. otherwise
// 14.4.3.2.1. set min_inGamut to false
min_in_gamut = false;
// 14.4.3.2.2. set min to chroma
min = chroma;
} else {
// 14.4.4. otherwise, set max to chroma and continue to repeat
// these steps
max = chroma;
}
}
// 15. return current as the gamut mapped color current
current_in_space
}
/// Clamp this color to within the [0..1] range.
fn clip(&self) -> Self {
let mut result = self.clone();
result.components = result.components.map(|c| c.clamp(0.0, 1.0));
result
}
/// Returns true if this color is within its gamut limits.
fn in_gamut(&self) -> bool {
macro_rules! in_range {
($c:expr) => {{
$c >= 0.0 && $c <= 1.0
}};
}
match self.color_space {
ColorSpace::Hsl | ColorSpace::Hwb => self.to_color_space(ColorSpace::Srgb).in_gamut(),
ColorSpace::Srgb |
ColorSpace::SrgbLinear |
ColorSpace::DisplayP3 |
ColorSpace::A98Rgb |
ColorSpace::ProphotoRgb |
ColorSpace::Rec2020 => {
in_range!(self.components.0) &&
in_range!(self.components.1) &&
in_range!(self.components.2)
},
ColorSpace::Lab |
ColorSpace::Lch |
ColorSpace::Oklab |
ColorSpace::Oklch |
ColorSpace::XyzD50 |
ColorSpace::XyzD65 => true,
}
}
}
/// Calculate deltaE OK (simple root sum of squares).
/// <https://drafts.csswg.org/css-color-4/#color-difference-OK>
fn delta_eok(reference: &AbsoluteColor, sample: &AbsoluteColor) -> f32 {
// Delta is calculated in the oklab color space.
let reference = reference.to_color_space(ColorSpace::Oklab);
let sample = sample.to_color_space(ColorSpace::Oklab);
let diff = reference.components - sample.components;
let diff = diff * diff;
(diff.0 + diff.1 + diff.2).sqrt()
}

View File

@ -9,6 +9,8 @@ pub mod convert;
pub mod mix;
pub mod parsing;
mod gamut;
use cssparser::color::PredefinedColorSpace;
use std::fmt::{self, Write};
use style_traits::{CssWriter, ToCss};
@ -26,6 +28,14 @@ impl ColorComponents {
}
}
impl std::ops::Sub for ColorComponents {
type Output = Self;
fn sub(self, rhs: Self) -> Self::Output {
Self(self.0 - rhs.0, self.1 - rhs.1, self.2 - rhs.2)
}
}
impl std::ops::Mul for ColorComponents {
type Output = Self;

View File

@ -7855,6 +7855,13 @@ pub extern "C" fn Servo_ConvertColorSpace(
color.to_color_space(color_space)
}
#[no_mangle]
pub extern "C" fn Servo_MapColorIntoGamutLimits(
color: &AbsoluteColor
) -> AbsoluteColor {
color.map_into_gamut_limits()
}
#[no_mangle]
pub unsafe extern "C" fn Servo_IntersectionObserverRootMargin_Parse(
value: &nsACString,

View File

@ -9,15 +9,15 @@ div { color: black; }
<body>
<script>
const TEST_CASES = [
["red", "green", "xyz", "rgb(188, 92, 0)"],
["red", "green", "lab", "rgb(161, 108, 0)"],
["red", "green", "lch", "rgb(145, 116, 0)"],
["red", "green 90%", "xyz", "rgb(89, 122, 0)"],
["red", "green 90%", "lab", "rgb(65, 126, 0)"],
["red", "green 90%", "lch", "rgb(49, 128, 0)"],
["red 90%", "green", "xyz", "rgb(243, 40, 0)"],
["red 90%", "green", "lab", "rgb(237, 55, 0)"],
["red 90%", "green", "lch", "rgb(235, 59, 0)"],
["red", "green", "xyz", "color(xyz 0.24479 0.183508 0.0225301)"],
["red", "green", "lab", "lab(50.2841 16.6263 59.2386)"],
["red", "green", "lch", "lch(50.2841 87.4108 87.6208)"],
["red", "green 90%", "xyz", "color(xyz 0.110709 0.160203 0.0250896)"],
["red", "green 90%", "lab", "lab(47.079 -34.7167 50.7168)"],
["red", "green 90%", "lch", "lch(47.079 71.8696 125.031)"],
["red 90%", "green", "xyz", "color(xyz 0.378871 0.206813 0.0199707)"],
["red 90%", "green", "lab", "lab(53.4893 67.9692 67.7605)"],
["red 90%", "green", "lch", "lch(53.4893 102.952 50.2103)"],
];
for (let [from, to, space, expected] of TEST_CASES) {

View File

@ -12,15 +12,15 @@ div { color: black; }
<body>
<script>
const TEST_CASES = [
["red", "green", "xyz", "rgb(188, 92, 0)"],
["red", "green", "lab", "rgb(161, 108, 0)"],
["red", "green", "lch", "rgb(145, 116, 0)"],
["red", "green 90%", "xyz", "rgb(89, 122, 0)"],
["red", "green 90%", "lab", "rgb(65, 126, 0)"],
["red", "green 90%", "lch", "rgb(49, 128, 0)"],
["red 90%", "green", "xyz", "rgb(243, 40, 0)"],
["red 90%", "green", "lab", "rgb(237, 55, 0)"],
["red 90%", "green", "lch", "rgb(235, 59, 0)"],
["red", "green", "xyz", "color(xyz 0.24479 0.183508 0.0225301)"],
["red", "green", "lab", "lab(50.2841 16.6263 59.2386)"],
["red", "green", "lch", "lch(50.2841 87.4108 87.6208)"],
["red", "green 90%", "xyz", "color(xyz 0.110709 0.160203 0.0250896)"],
["red", "green 90%", "lab", "lab(47.079 -34.7167 50.7168)"],
["red", "green 90%", "lch", "lch(47.079 71.8696 125.031)"],
["red 90%", "green", "xyz", "color(xyz 0.378871 0.206813 0.0199707)"],
["red 90%", "green", "lab", "lab(53.4893 67.9692 67.7605)"],
["red 90%", "green", "lch", "lch(53.4893 102.952 50.2103)"],
];
for (let [from, to, space, expected] of TEST_CASES) {