w3c / csswg-drafts

CSS Working Group Editor Drafts
https://drafts.csswg.org/
Other
4.5k stars 661 forks source link

[css-color-4] Computed value and serialization of `Infinity` and `NaN` in color functions #8629

Open romainmenke opened 1 year ago

romainmenke commented 1 year ago

What are the expected values and serializations for these examples?

manually setting these values seems illogical but they can be the result of calculations and variables. e.g. calc(var(--foo) / var(--bar)) where --bar is possibly zero.


Currently browsers do very different things in some area's.

Chrome and Safari seem to agree that lab(50% calc(Infinity) 0) serializes as lab(50% Infinity 0), but that no longer roundtrips because the required calc was removed. That seems like an obvious bug, but maybe I overlooked something.

Chrome renders lab(50% calc(Infinity) 0) as white. Safari renders lab(50% calc(Infinity) 0) as black.

Chrome also sometimes renders a pink area in certain edge cases

WPT doesn't have any tests for these kinds of values.

tabatkins commented 1 year ago

Chrome and Safari seem to agree that lab(50% calc(Infinity) 0) serializes as lab(50% Infinity 0), but that no longer roundtrips because the required calc was removed.

Yes, that's clearly wrong. The arguments aren't calculations, they're either keywords (channel names) or math functions. We should get a WPT enforcing this.

Rendering is just a consequence of the "clamp it to your actual allowed range" behavior that V&U specifies for infinite (and other arbitrarily-large finite) values. I'm not sure what the actual behavior of a ginormous a value should be, but whatever it is, that's the correct rendering. (I suspect white? But I have no actual knowledge here.)

romainmenke commented 1 year ago

I'm not sure what the actual behavior of a ginormous a value should be, but whatever it is, that's the correct rendering. (I suspect white? But I have no actual knowledge here.)

Also unsure. Consistency would be nice, but I can imagine that this is very sensitive to implementation details.

In our implementation for the a/b component :

color.js seems to produce white and blue after gamut mapping. But I am never sure if I am using that library correctly.

let color = new Color("lab(50% 0 0)");
color.coords[1] = Infinity
color
color.toGamut('srgb')
Screenshot 2023-03-22 at 10 26 36
romainmenke commented 1 year ago

I've added a few tests for serialization of calc(Infinity) : https://github.com/web-platform-tests/wpt/pull/39137

I have no idea how NaN should be handled, so I skipped that for now.

cdoublev commented 1 year ago

I have no idea how NaN should be handled, so I skipped that for now.

If I am not mistaken, only nested math functions (unresolved before computed value) can produce NaN: calc(min(1em, 0px / 0) + 1px) would serialize to calc(1px + min(1em, NaN * 1px)) as a specified value.

If a top-level calculation (a math function not nested inside of another math function) would produce a value whose numeric part is NaN, it instead act as though the numeric part is 0.

https://drafts.csswg.org/css-values-4/#top-level-calculation

I think your tests should serialize Infinity to lowercase.

As usual for CSS keywords, these are ASCII case-insensitive. Thus, calc(InFiNiTy) is perfectly valid. However, NaN must be serialized with this canonical casing.

https://drafts.csswg.org/css-values-4/#calc-error-constants

Hope it helps. =)

romainmenke commented 1 year ago

Thank you @cdoublev

I've changed the tests to lowercase infinity, this indeed matches tests under css-values.

tabatkins commented 1 year ago

The behavior of NaN is specified in https://drafts.csswg.org/css-values/#top-level-calculation - it gets censored to 0 when it would escape the calculation tree. It serializes as NaN, tho, similar to infinity, at whatever level it's been able to infect to, given the simplification rules and what value stage you're serializing.

romainmenke commented 1 year ago

For lab(calc(NaN) 0 0) or lab(calc(0 / 0) 0 0):

Would that be correct?

cdoublev commented 1 year ago

It serializes as NaN [...] at whatever level it's been able to infect to

Thanks for the clarification. My understanding of the spec was that it only serializes as NaN in non-top level calculations (or NaN * 1 unit when the calculation type is not empty).

Would that be correct?

I am not in the position to give an answer but it would say yes, then.

tabatkins commented 1 year ago

@romainmenke yes

@cdoublev What part of the spec is leading you to believe that a top-level NaN doesn't serialize as such? I might have made a mistake.

romainmenke commented 1 year ago

I've added some test for NaN : https://github.com/web-platform-tests/wpt/pull/39137/commits/789c766a0f30486e6f77f869d08f495b1240707b

cdoublev commented 1 year ago

What part of the spec is leading you to believe that a top-level NaN doesn't serialize as such? I might have made a mistake.

The part that we both referred to (edited for clarity):

  • If a top-level calculation would produce a value whose numeric part is NaN, it instead act as though the numeric part is 0.
  • If a top-level calculation would produce a value whose numeric part is 0⁻, it instead acts as though the numeric part is the standard "unsigned" zero.

Taking Example 38 following this part:

calc(-5 * 0) produces an unsigned zero — the calculation resolves to 0⁻, but as it’s a top-level calculation, it’s then censored to an unsigned zero.

calc(0 / 0) produces 0 — the calculation resolves to NaN, but as it’s a top-level calculation, it’s then censored to zero.

Basically my understanding meant that NaN could only appear in the serialization of unresolved nested calculations.


I am also missing why Chrome currently serializes the next example to calc(infinity) instead of calc(-infinity).

On the other hand, calc(1 / calc(-5 * 0)) produces −∞ [...] the inner calc resolves to 0⁻, and as it’s not a top-level calculation, it passes it up unchanged to the outer calc to produce −∞

tabatkins commented 1 year ago

The part that we both referred to (edited for clarity):

That's about how the value actually acts in the property it's used in, when the final result is a NaN. The simplification and serialization of the calculation itself is well-defined by the simplification and serialization algos, and they preserve NaNs at whatever level they show up in.

I am also missing why Chrome currently serializes the next example

Looks like a bug.

romainmenke commented 1 year ago

The tests were merged : https://github.com/web-platform-tests/wpt/pull/39137


That only leaves the open question of how to handle enormous values :

Rendering is just a consequence of the "clamp it to your actual allowed range" behavior that V&U specifies for infinite (and other arbitrarily-large finite) values. I'm not sure what the actual behavior of a ginormous a value should be, but whatever it is, that's the correct rendering. (I suspect white? But I have no actual knowledge here.)

I suspect the right value will just fall out of implementations that are free of bugs but maybe good to have a few examples/tests?

@svgeesus Do you know or have an opinion on how something like lab(50% calc(Infinity) 0) which has an absurdly large a should behave?

Anyone writing lab(50% calc(Infinity) 0) manually should expect bogus results, but infinity can be the result of a calculation.

svgeesus commented 1 year ago

@svgeesus Do you know or have an opinion on how something like lab(50% calc(Infinity) 0) which has an absurdly large a should behave?

All colors of the form lab(50% calc((var(--n)*100) 0) where n > 0 have the same (LCH) hue, and increasing chroma. Thus, even if the color itself is imaginary, the gamut mapped used value of the color should be lab(50% x 0) where x is the greatest chroma that can be displayed for that hue and lightness. I would apply the same logic to +Infinity (and -Infinity gives the opposite hue).

Where b is not zero, as a increases the contribution of b on the resulting hue decreases, so as a tends to infinity the hue tends towards b being effectively zero. So I guess infinite values of either a or b make the other component powerless? And thus there are eight effective hues that the color tends towards, depending on the sign of the component and whether only one, or both, have Infinity values?

romainmenke commented 1 year ago

And thus there are eight effective hues that the color tends towards, depending on the sign of the component and whether only one, or both, have Infinity values?

This is specific to lab and oklab right?

For rgb or xyz color spaces an increase in value for any channel also has an effect on lightness, so any enormous value forces the color to black or white depending on the sign.

Interesting, not what I would have guessed but it makes a lot of sense!

svgeesus commented 1 year ago

This is specific to lab and oklab right?

Yes.

For rgb or xyz color spaces an increase in value for any channel also has an effect on lightness, so any enormous value forces the color to black or white depending on the sign.

Yes (Negative values in XYZ are not supposed to occur, although they are possible as a result of chromatic adaptation; large negative ones should be treated as black I guess).

mysteryDate commented 1 year ago

For lab(calc(NaN) 0 0) or lab(calc(0 / 0) 0 0):

  • computed value lab(0 0 0)
  • serializes as lab(calc(NaN) 0 0)

Would that be correct?

@tabatkins Even if this is correct, per spec, why do we want this behavior? Does it have significant use-cases? Do other CSS properties perform this way? The reason I ask is because this would invalidate a couple major assumptions that chromium's color parser makes. Namely that the result of parsing a color function will either be failure or a Color, i.e. something that can be encoded with a colorspace and 4 values, which are numbers or "None."

Tests verifying this are part of interop 2023 and all UAs are currently failing:

https://wpt.fyi/results/css/css-color/parsing/color-valid-lab.html

To pass these tests will involve restructuring things quite a bit and creating a sort of "unresolved color" type so that the color can be serialized differently at parse time than at computed time. Obviously, none of this is impossible, but I simply wanted to highlight that it is a non-trivial amount of work to support. If it's a minor use-case and other UAs are uninterested in supporting it, than I'd propose changing the spec such that:

For lab(calc(NaN) 0 0) or lab(calc(0 / 0) 0 0):

And for lab(0 calc(infinity) 0):

romainmenke commented 1 year ago

So, importantly, dropping the "calc" so that the channels are all numbers. Why is it important that they roundtrip in this context?

Part of the reason for initially opening this issue is that dropping calc "breaks" the value.

Serializing into an invalid value seems like a bug to me.

mysteryDate commented 1 year ago

But maybe lab(0 calc(Infinity) 0) should be invalid, if lab(0 Infinity 0) is invalid? Adding a calc to a single term to make something valid seems like a bug to me.

romainmenke commented 1 year ago

Adding a calc to a single term to make something valid seems like a bug to me.

This is not to make something valid, this is done to express NaN or Infinity : https://drafts.csswg.org/css-values-4/#calc-serialize

There will be other examples that don't have anything to do with color :)

mysteryDate commented 1 year ago

Thanks! I understand that now. So color(srgb 0 calc(Infinity) 0) represents a color with an infinite green color channel, whereas color(srgb 0 Infinity 0) is literally an invalid string to represent a color?

This makes sense. Now my next question would be, why are we forcing valid colors out of these inputs? Wouldn't the least surprising outcome for the user be to reject them entirely as colors?

@svgeesus Apologies if there was already a debate about this that I missed out on, but from what I currently understand I think it would be best if these were all invalid colors.

svgeesus commented 1 year ago

I think it would be best if these were all invalid colors

I certainly can't think of an actual use case for such colors.

romainmenke commented 1 year ago

Is this something already happens in other values, functions, ...?

All things that I am familiar with do not become invalid when out of range. How would this affect @supports, fallbacks, ... ?

tabatkins commented 1 year ago

There's no meaningful difference between calc(infinity) and calc(1e6). If the number has an enforced range at parse time, they'll both act as the clamped value; if it's unclamped, they'll both be essentially identical in behavior anyway. We shouldn't be treating the two cases differently.

All things that I am familiar with do not become invalid when out of range.

A number of things have ranges that are checked at parse-time. They'll be invalid if you use an out-of-range value by itself, but math functions change that behavior - see https://drafts.csswg.org/css-values/#calc-range. Instead, if a value is the result of a math function, it's treated as valid at parse time, and clamped at computed and/or used-value time to the allowed range. For example, width: -1px is invalid, but width: calc(-1px); is valid and equivalent to width: 0;. We do this because it's not always possible to tell how large a value is going to be, and even whether it'll be positive or negative, until computed or used value time. (For example, calc(16px - 1em) can be positive, negative, or zero depending on the size of the em, which isn't known until computed-value time.)

This is why the serialization algorithm has a branch for computed or later values in the first step; specified values have to preserve the fact that the value is in a math function, in case the value is outside the allowed range, so you can round-trip the value.

To pass these tests will involve restructuring things quite a bit and creating a sort of "unresolved color" type so that the color can be serialized differently at parse time than at computed time.

This is required anyway to handle cases like a color-mix() or relative color referring to currentcolor, which also isn't known until computed-value time.

mysteryDate commented 1 year ago

If we decide these are valid colors, then as far as I understand it:

Is that, in general, correct? If so, I'll start adding more test cases to wpt.

tabatkins commented 1 year ago

At parse time:

More specifically, all math functions serialize as some variety of math function when they're specified values, regardless of what's inside them. If they've been able to simplify down to a single value, they'll serialize with a calc() around them. (Browsers have, generally, not done this correctly in many places, colors being a notable example.)

At computed value time:

Yup, correct. (And note, just for completeness, that the infinity keywords have to still be serialized with a calc() wrapper, because they're only valid as keywords inside a calculation.)

mysteryDate commented 1 year ago

What about for rgb()/rgba() values? I know that there was some discussion here stating that rgb() was de-facto bounded.

Currently, Firefox does not parse non-finite inputs. Chromium and Safari do the following:

input: rgb(calc(infinity), 0, 0) parsed: rgb(255, 0, 0) computed: rgb(255, 0, 0)

Chrome and Safari also do this, which seems like definitely a bug:

input: rgb(calc(NaN), 0, 0) parsed: rgb(255, 0, 0) computed: rgb(255, 0, 0)

Should the proper behavior be:

input: rgb(calc(infinity), 0, 0) parsed: rgb(calc(infinity), 0, 0) computed: rgb(255, 0, 0) and input: rgb(calc(NaN), 0, 0) parsed: rgb(calc(NaN, 0, 0) computed: rgb(0, 0, 0)

With -infinity going to zero as well? Also, what about for alpha channels, I would assume the following:

input: rgba(0, 0, 0, calc(NaN)) parsed: rgba(0, 0, 0, calc(NaN)) computed: rgba(0, 0, 0, 0)

input: rgba(0, 0, 0, calc(infinity)) parsed: rgba(0, 0, 0, calc(infinity)) computed: rgb(0, 0, 0) (fully opaque)

input: rgba(0, 0, 0, calc(-infinity)) parsed: rgba(0, 0, 0, calc(-infinity)) computed: rgba(0, 0, 0, 0)

Safari and Chrome turn calc(NaN) into fully opaque, at parse time.

For HSL/HWB I would assume that all channels are unbounded: infinity remains infinity and NaN becomes 0 at computed value time.

If I'm correct in all this I'll add some tests to WPT and to interop 2023.

css-meeting-bot commented 1 year ago

The CSS Working Group just discussed [css-color-4] Computed value and serialization of `Infinity` and `NaN` in color functions, and agreed to the following:

The full IRC log of that discussion <fantasai> TabAtkins: Question was about if understanding of how infinity and nan showing up in color functions should be serialized at various value stages (and handled in general)
<fantasai> TabAtkins: does run into question of whether to do earlier reolution for color functions
<fantasai> TabAtkins: but aside from that
<fantasai> TabAtkins: if you put an infinite calculation into an rgba(), the behavior is well-defined: clamp to the allowed range
<fantasai> TabAtkins: I think for rgba() it's 0-255
<fantasai> TabAtkins: at at least computed value time
<fantasai> TabAtkins: specified value time is separate issue
<fantasai> TabAtkins: negative infinity clamps to zero
<fantasai> TabAtkins: and NaN becomes zero when it escapes a calculation teree
<fantasai> TabAtkins: that's all defined now
<fantasai> TabAtkins: so unless there is any disagreement on these cases, we can confirm no change
<fantasai> TabAtkins: and close the issue
<fantasai> TabAtkins: only thing left is separate issue of whether we eagerly simplify certain math functions in some cases
<fantasai> TabAtkins: but that's a separate issue
<fantasai> fantasai: separate isssue is filed?
<fantasai> TabAtkins: yes
<fantasai> Rossen_: proposed resolution is no (further) chagne
<fantasai> s/chagne/change/
<TabAtkins> (the separate issue is #8318)
<fantasai> RESOLVED: No (further) change
<TabAtkins> (#4 on the agenda this week, we skipped it because no Chris)
cdoublev commented 5 months ago

This comment above says...

[...] the "clamp it to your actual allowed range" behavior that V&U specifies for infinite (and other arbitrarily-large finite) values.

... like this code comment on WPT:

calc(infinity) resolves to the upper bound while calc(-infinity) and calc(NaN) resolves the lower bound.

But this is not what I understand from what CSS Values and Units:

[...] the value resulting from a top-level calculation must be clamped to the range allowed in the target context.

Out of range channel values are allowed in all color functions.

Therefore I think it would be great to clarify when and how calc(infinity) resolves when serializing (non-mixed) hsl() and hwb() as components of a declared value, ie. either before or after color space conversion.

svgeesus commented 4 months ago

Re-opening because arguing on the basis of one legacy function rgba() which happens to be clamped, was not sufficient.