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

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

Open
romainmenke opened this issue Mar 21, 2023 · 30 comments

Comments

@romainmenke
Copy link
Member

romainmenke commented Mar 21, 2023

What are the expected values and serializations for these examples?

  • lab(calc(Infinity) 0 0)
  • lab(50% calc(Infinity) 0)
  • lab(calc(NaN) 0 0)
  • lab(50% calc(NaN) 0)
  • ...

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
Copy link
Member

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
Copy link
Member Author

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 :

  • a very large but finite number: white
  • positive and negative infinite: black

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
Copy link
Member Author

romainmenke commented Mar 22, 2023

I've added a few tests for serialization of calc(Infinity) : web-platform-tests/wpt#39137

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

@cdoublev
Copy link
Collaborator

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
Copy link
Member Author

Thank you @cdoublev

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

@tabatkins
Copy link
Member

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
Copy link
Member Author

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?

@cdoublev
Copy link
Collaborator

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
Copy link
Member

@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
Copy link
Member Author

I've added some test for NaN : web-platform-tests/wpt@789c766

@cdoublev
Copy link
Collaborator

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
Copy link
Member

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
Copy link
Member Author

The tests were merged : web-platform-tests/wpt#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
Copy link
Contributor

@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
Copy link
Member Author

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
Copy link
Contributor

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
Copy link

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):

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

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

  • computed value lab(0 Infinity 0)
  • serializes as lab(0 Infinity 0)

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

@romainmenke
Copy link
Member Author

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.

  • lab(0 Infinity 0) -> invalid
  • lab(0 calc(Infinity) 0) -> something very pink-ish in Chrome

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

@mysteryDate
Copy link

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
Copy link
Member Author

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
Copy link

mysteryDate commented May 30, 2023

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.

@fserb fserb added the Agenda+ label May 30, 2023
@svgeesus
Copy link
Contributor

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
Copy link
Member Author

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
Copy link
Member

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
Copy link

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

  • At parse time: all things equivalent to NaN are serialized as calc(NaN), and infinity is serialized as calc(infinity) or calc(-infinity)
  • At computed value time: all NaNs become zero. Infinity remains infinity unless it expressing a clamped value, in which case it becomes the maximum possible value in that range. Negative infinity does the same, going to the minimum possible value.

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

@tabatkins
Copy link
Member

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
Copy link

mysteryDate commented Jun 7, 2023

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
Copy link
Member

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:

  • RESOLVED: No (further) change
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
Copy link
Collaborator

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
Copy link
Contributor

svgeesus commented Jun 28, 2024

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

7 participants