Skip to content

Commit

Permalink
handle nan for hues
Browse files Browse the repository at this point in the history
  • Loading branch information
ajalt committed Mar 24, 2024
1 parent a2b0eda commit e401329
Show file tree
Hide file tree
Showing 6 changed files with 95 additions and 140 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
### Added
- Publish artifacts for the `JS` and `wasmJs` targets for the jetpack-compose extensions.
- Added support to `formatCssString` and `Color.parse` for color spaces added in recent updates to the CSS color spec: `oklab`, `oklch`, `srgb-linear`, `xyz-d50` and `xyz-d65`.
- Added `customColorSpaces` for `Color.parse` and `formatCssString` to allow non-standard color spaces to be used in color strings.
- Added `customColorSpaces` for `Color.parse` and `Color.formatCssString` to allow non-standard color spaces to be used in color strings.

### Changed
- `Color.parse` now parses `lch()` and `lab()` functions with the with D50 white points instead of D65 in order to comply with the CSS color spec.

### Fixed
- `ColorSpace.equals` will now properly return true when comparing color companions with the space they represent e.g. `XYZ == XYZ65`
- Support the CSS "none" keyword for `NaN` values in `Color.parse` and `Color.formatCssString`

## 3.4.0
### Added
Expand Down
16 changes: 8 additions & 8 deletions colormath/api/colormath.api
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,11 @@ public abstract interface class com/github/ajalt/colormath/ColorSpace {

public final class com/github/ajalt/colormath/CssParseKt {
public static final fun parse (Lcom/github/ajalt/colormath/Color$Companion;Ljava/lang/String;)Lcom/github/ajalt/colormath/Color;
public static final fun parse (Lcom/github/ajalt/colormath/Color$Companion;Ljava/lang/String;Ljava/util/List;)Lcom/github/ajalt/colormath/Color;
public static synthetic fun parse$default (Lcom/github/ajalt/colormath/Color$Companion;Ljava/lang/String;Ljava/util/List;ILjava/lang/Object;)Lcom/github/ajalt/colormath/Color;
public static final fun parse (Lcom/github/ajalt/colormath/Color$Companion;Ljava/lang/String;Ljava/util/Map;)Lcom/github/ajalt/colormath/Color;
public static synthetic fun parse$default (Lcom/github/ajalt/colormath/Color$Companion;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)Lcom/github/ajalt/colormath/Color;
public static final fun parseOrNull (Lcom/github/ajalt/colormath/Color$Companion;Ljava/lang/String;)Lcom/github/ajalt/colormath/Color;
public static final fun parseOrNull (Lcom/github/ajalt/colormath/Color$Companion;Ljava/lang/String;Ljava/util/List;)Lcom/github/ajalt/colormath/Color;
public static synthetic fun parseOrNull$default (Lcom/github/ajalt/colormath/Color$Companion;Ljava/lang/String;Ljava/util/List;ILjava/lang/Object;)Lcom/github/ajalt/colormath/Color;
public static final fun parseOrNull (Lcom/github/ajalt/colormath/Color$Companion;Ljava/lang/String;Ljava/util/Map;)Lcom/github/ajalt/colormath/Color;
public static synthetic fun parseOrNull$default (Lcom/github/ajalt/colormath/Color$Companion;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)Lcom/github/ajalt/colormath/Color;
}

public final class com/github/ajalt/colormath/CssRenderKt {
Expand All @@ -93,10 +93,10 @@ public final class com/github/ajalt/colormath/CssRenderKt {
public static final fun formatCssString (Lcom/github/ajalt/colormath/Color;Lcom/github/ajalt/colormath/AngleUnit;Lcom/github/ajalt/colormath/RenderCondition;ZZ)Ljava/lang/String;
public static final fun formatCssString (Lcom/github/ajalt/colormath/Color;Lcom/github/ajalt/colormath/AngleUnit;Lcom/github/ajalt/colormath/RenderCondition;ZZZ)Ljava/lang/String;
public static final fun formatCssString (Lcom/github/ajalt/colormath/Color;Lcom/github/ajalt/colormath/AngleUnit;Lcom/github/ajalt/colormath/RenderCondition;ZZZZ)Ljava/lang/String;
public static final fun formatCssString (Lcom/github/ajalt/colormath/Color;Lcom/github/ajalt/colormath/AngleUnit;Lcom/github/ajalt/colormath/RenderCondition;ZZZZLjava/util/List;)Ljava/lang/String;
public static synthetic fun formatCssString$default (Lcom/github/ajalt/colormath/Color;Lcom/github/ajalt/colormath/AngleUnit;Lcom/github/ajalt/colormath/RenderCondition;ZZZZLjava/util/List;ILjava/lang/Object;)Ljava/lang/String;
public static final fun formatCssStringOrNull (Lcom/github/ajalt/colormath/Color;Lcom/github/ajalt/colormath/AngleUnit;Lcom/github/ajalt/colormath/RenderCondition;ZZZZLjava/util/List;)Ljava/lang/String;
public static synthetic fun formatCssStringOrNull$default (Lcom/github/ajalt/colormath/Color;Lcom/github/ajalt/colormath/AngleUnit;Lcom/github/ajalt/colormath/RenderCondition;ZZZZLjava/util/List;ILjava/lang/Object;)Ljava/lang/String;
public static final fun formatCssString (Lcom/github/ajalt/colormath/Color;Lcom/github/ajalt/colormath/AngleUnit;Lcom/github/ajalt/colormath/RenderCondition;ZZZZLjava/util/Map;)Ljava/lang/String;
public static synthetic fun formatCssString$default (Lcom/github/ajalt/colormath/Color;Lcom/github/ajalt/colormath/AngleUnit;Lcom/github/ajalt/colormath/RenderCondition;ZZZZLjava/util/Map;ILjava/lang/Object;)Ljava/lang/String;
public static final fun formatCssStringOrNull (Lcom/github/ajalt/colormath/Color;Lcom/github/ajalt/colormath/AngleUnit;Lcom/github/ajalt/colormath/RenderCondition;ZZZZLjava/util/Map;)Ljava/lang/String;
public static synthetic fun formatCssStringOrNull$default (Lcom/github/ajalt/colormath/Color;Lcom/github/ajalt/colormath/AngleUnit;Lcom/github/ajalt/colormath/RenderCondition;ZZZZLjava/util/Map;ILjava/lang/Object;)Ljava/lang/String;
}

public abstract interface class com/github/ajalt/colormath/HueColor : com/github/ajalt/colormath/Color {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,12 @@ fun Color.Companion.parseOrNull(


// https://www.w3.org/TR/css-color-4/#color-syntax
@Suppress("RegExpUnnecessaryNonCapturingGroup")
private object PATTERNS {
private const val NUMBER = """[+-]?(?:\d+|\d*\.\d+)(?:[eE][+-]?\d+)?"""
private const val PERCENT = "$NUMBER%"
private const val NUMBER_OR_PERCENT = "$NUMBER%?"
private const val FLOAT = """[+-]?(?:\d+|\d*\.\d+)(?:[eE][+-]?\d+)?"""
private const val NUMBER = """(?:none|$FLOAT)"""
private const val PERCENT = "$FLOAT%"
private const val NUMBER_OR_PERCENT = "(?:none|$FLOAT%?)"
private const val SLASH_ALPHA = """\s*(?:/\s*($NUMBER_OR_PERCENT))?\s*"""
private const val COMMA_ALPHA = """(?:\s*,\s*($NUMBER_OR_PERCENT))?\s*"""
private const val HUE = "$NUMBER(?:deg|grad|rad|turn)?"
Expand Down Expand Up @@ -182,21 +184,21 @@ private fun oklch(match: MatchResult): Color {
}


// CSS uses the "none" keyword for NaN https://www.w3.org/TR/css-color-4/#missing
private fun number(str: String) = if(str == "none") Float.NaN else str.toFloat()
private fun percent(str: String) = str.dropLast(1).toFloat() / 100f
private fun number(str: String) = str.toFloat()
private fun percentOrNumber(str: String) = if (str.endsWith("%")) percent(str) else number(str)
private fun alpha(str: String) = (if (str.isEmpty()) 1f else percentOrNumber(str)).clampF()

/** return degrees in [0, 360] */
private fun hue(str: String): Float {
val deg = when {
return when {
str.endsWith("deg") -> str.dropLast(3).toFloat()
str.endsWith("grad") -> str.dropLast(4).toFloat().gradToDeg()
str.endsWith("rad") -> str.dropLast(3).toFloat().radToDeg()
str.endsWith("turn") -> str.dropLast(4).toFloat().turnToDeg()
else -> str.toFloat()
}
return deg.normalizeDeg()
else -> number(str)
}.normalizeDeg()
}

private fun Float.clampInt(min: Int = 0, max: Int = 255) = roundToInt().coerceIn(min, max)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,13 +308,14 @@ private fun Color.renderColorFn(
}


private fun HueColor.renderHue(hueUnit: AngleUnit): String = when (hueUnit) {
AngleUnit.AUTO -> h.render()
AngleUnit.DEGREES -> "${h.render()}deg"
AngleUnit.RADIANS -> "${hueAsRad().render()}rad"
AngleUnit.GRADIANS -> "${hueAsGrad().render()}grad"
AngleUnit.TURNS -> "${hueAsTurns().render()}turn"
}
private fun HueColor.renderHue(hueUnit: AngleUnit): String = if (h.isNaN()) "none" else
when (hueUnit) {
AngleUnit.AUTO -> h.render()
AngleUnit.DEGREES -> "${h.render()}deg"
AngleUnit.RADIANS -> "${hueAsRad().render()}rad"
AngleUnit.GRADIANS -> "${hueAsGrad().render()}grad"
AngleUnit.TURNS -> "${hueAsTurns().render()}turn"
}

private fun Color.renderFn(
name: String,
Expand Down Expand Up @@ -343,9 +344,10 @@ private fun Color.renderAlpha(
}
}

private fun Float.render(percent: Boolean = false, precision: Int = 4): String = when (percent) {
true -> "${(this * 100).roundToInt()}%"
false -> {
private fun Float.render(percent: Boolean = false, precision: Int = 4): String = when {
isNaN() -> "none"
percent -> "${(this * 100).roundToInt()}%"
else -> {
val abs = absoluteValue
val i = abs.toInt()
val sgn = if (this < 0) "-" else ""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import io.kotest.assertions.throwables.shouldThrow
import io.kotest.data.blocking.forAll
import io.kotest.data.row
import io.kotest.matchers.shouldBe
import kotlin.Float.Companion.NaN
import kotlin.js.JsName
import kotlin.test.Test

Expand Down Expand Up @@ -182,7 +183,8 @@ class CssParseTest {
row("0deg", 0),
row("0grad", 0),
row("0turn", 0),
row("0rad", 0)
row("0rad", 0),
row("none", NaN),
) { angle, degrees ->
Color.parse("hsl($angle, 0%, 0%)").shouldEqualColor(HSL(degrees, 0, 0))
}
Expand Down Expand Up @@ -249,6 +251,7 @@ class CssParseTest {
row("oklch(0.65125 0.13138 104.097)", Oklch(0.65125, 0.13138, 104.097)),
row("oklch(0.66016 0.15546 134.231)", Oklch(0.66016, 0.15546, 134.231)),
row("oklch(72.322% 0.12403 247.996)", Oklch(0.72322, 0.12403, 247.996)),
row("oklch(0% 0 none)", Oklch(0, 0, NaN)),
// row("oklch(42.1% 48.25% 328.4)", Oklch()), TODO: c percentage scaling
) { str, lch ->
Color.parse(str).shouldEqualColor(lch)
Expand Down
171 changes: 59 additions & 112 deletions test/src/commonTest/kotlin/com/github/ajalt/colormath/CssRenderTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,104 +21,57 @@ import io.kotest.matchers.shouldBe
import kotlin.js.JsName
import kotlin.test.Test

class CssRenderTest {
private data class R(
val r: Int,
val g: Int,
val b: Int,
val a: Float = 1f,
val commas: Boolean = false,
val namedRgba: Boolean = false,
val rgbPercent: Boolean = false,
val alphaPercent: Boolean = false,
val renderAlpha: RenderCondition = AUTO,
)
private val XYZ55 = XYZColorSpace(Illuminant.D55)

private data class H(
val h: Number,
val s: Number,
val l: Number,
val a: Float = 1f,
val commas: Boolean = false,
val namedHsla: Boolean = false,
class CssRenderTest {
private data class P(
val hueUnit: AngleUnit = AngleUnit.AUTO,
val alphaPercent: Boolean = false,
val renderAlpha: RenderCondition = AUTO,
)

private data class H2(
val h: Number,
val s: Number,
val v: Number,
val a: Float = 1f,
val commas: Boolean = false,
val namedHsla: Boolean = false,
val hueUnit: AngleUnit = AngleUnit.AUTO,
val unitsPercent: Boolean = false,
val alphaPercent: Boolean = false,
val renderAlpha: RenderCondition = AUTO,
val unitsPercent: Boolean = false
)

private data class O(
val l: Number,
val c: Number,
val h: Number,
val a: Float = 1f,
val commas: Boolean = false,
val namedHsla: Boolean = false,
val hueUnit: AngleUnit = AngleUnit.AUTO,
val alphaPercent: Boolean = false,
val renderAlpha: RenderCondition = AUTO,
val unitsPercent: Boolean = false
val legacyName: Boolean = false,
val legacyFormat: Boolean = false,
)

@Test
fun formatCssRgb() = forAll(
row(R(0, 0, 0), "rgb(0 0 0)"),
row(R(0, 0, 0, namedRgba = true), "rgba(0 0 0)"),
row(R(0, 0, 0, commas = true), "rgb(0, 0, 0)"),
row(R(0, 0, 0, renderAlpha = ALWAYS), "rgb(0 0 0 / 1)"),
row(R(0, 0, 0, .5f), "rgb(0 0 0 / 0.5)"),
row(R(0, 0, 0, .5f, commas = true), "rgb(0, 0, 0, 0.5)"),
row(R(0, 0, 0, .5f, renderAlpha = NEVER), "rgb(0 0 0)"),
row(R(255, 128, 0, rgbPercent = true), "rgb(100% 50% 0%)"),
row(R(255, 128, 0, .5f, rgbPercent = true), "rgb(100% 50% 0% / 0.5)"),
row(R(255, 128, 0, .5f, alphaPercent = true), "rgb(255 128 0 / 50%)"),
row(R(255, 128, 0, .5f, rgbPercent = true, alphaPercent = true), "rgb(100% 50% 0% / 50%)")
) { (r, g, b, a, commas, namedRgba, rgbPercent, alphaPercent, renderAlpha), expected ->
RGB(r / 255f, g / 255f, b / 255f, a).formatCssString(
AngleUnit.AUTO,
renderAlpha,
rgbPercent,
alphaPercent,
namedRgba,
commas
) shouldBe expected
}
row(RGB(0, 0, 0), P(), "rgb(0 0 0)"),
row(RGB(0, 0, 0), P(legacyName = true), "rgba(0 0 0)"),
row(RGB(0, 0, 0), P(legacyFormat = true), "rgb(0, 0, 0)"),
row(RGB(0, 0, 0), P(renderAlpha = ALWAYS), "rgb(0 0 0 / 1)"),
row(RGB(0, 0, 0, .5f), P(), "rgb(0 0 0 / 0.5)"),
row(RGB(0, 0, 0, .5f), P(legacyFormat = true), "rgb(0, 0, 0, 0.5)"),
row(RGB(0, 0, 0, .5f), P(renderAlpha = NEVER), "rgb(0 0 0)"),
row(RGB(1, .5, 0), P(unitsPercent = true), "rgb(100% 50% 0%)"),
row(RGB(1, .5, 0, .5f), P(unitsPercent = true), "rgb(100% 50% 0% / 0.5)"),
row(RGB(1, .5, 0, .5f), P(alphaPercent = true), "rgb(255 128 0 / 50%)"),
row(
RGB(1, .5, 0, .5f),
P(unitsPercent = true, alphaPercent = true),
"rgb(100% 50% 0% / 50%)"
),
testfn = ::doParamTest,
)

@Test
fun formatCssHsl() = forAll(
row(H(0, 0, 0), "hsl(0 0% 0%)"),
row(H(0, 0, 0, namedHsla = true), "hsla(0 0% 0%)"),
row(H(0, 0, 0, .5f), "hsl(0 0% 0% / 0.5)"),
row(H(0, 0, 0, .5f, commas = true), "hsl(0, 0%, 0%, 0.5)"),
row(H(0, 0, 0, commas = true), "hsl(0, 0%, 0%)"),
row(H(0, 0, 0, .5f, alphaPercent = true), "hsl(0 0% 0% / 50%)"),
row(H(0, 0, 0, renderAlpha = ALWAYS), "hsl(0 0% 0% / 1)"),
row(H(0, 0, 0, .5f, renderAlpha = NEVER), "hsl(0 0% 0%)"),
row(H(180, .5, .5), "hsl(180 50% 50%)"),
row(H(180, .5, .5, hueUnit = DEGREES), "hsl(180deg 50% 50%)"),
row(H(180, .5, .5, hueUnit = GRADIANS), "hsl(200grad 50% 50%)"),
row(H(180, .5, .5, hueUnit = RADIANS), "hsl(3.1416rad 50% 50%)"),
row(H(180, .5, .5, hueUnit = TURNS), "hsl(0.5turn 50% 50%)"),
) { (h, s, l, a, commas, namedHsla, hueUnit, alphaPercent, renderAlpha), expected ->
HSL(h, s, l, a).formatCssString(
hueUnit,
renderAlpha,
true,
alphaPercent,
namedHsla,
commas
row(HSL(0, 0, 0), P(), "hsl(0 0% 0%)"),
row(HSL(0, 0, 0), P(legacyName = true), "hsla(0 0% 0%)"),
row(HSL(0, 0, 0, .5f), P(), "hsl(0 0% 0% / 0.5)"),
row(HSL(0, 0, 0, .5f), P(legacyFormat = true), "hsl(0, 0%, 0%, 0.5)"),
row(HSL(0, 0, 0), P(legacyFormat = true), "hsl(0, 0%, 0%)"),
row(HSL(0, 0, 0, .5f), P(alphaPercent = true), "hsl(0 0% 0% / 50%)"),
row(HSL(0, 0, 0), P(renderAlpha = ALWAYS), "hsl(0 0% 0% / 1)"),
row(HSL(0, 0, 0, .5f), P(renderAlpha = NEVER), "hsl(0 0% 0%)"),
row(HSL(180, .5, .5), P(), "hsl(180 50% 50%)"),
row(HSL(180, .5, .5), P(hueUnit = DEGREES), "hsl(180deg 50% 50%)"),
row(HSL(180, .5, .5), P(hueUnit = GRADIANS), "hsl(200grad 50% 50%)"),
row(HSL(180, .5, .5), P(hueUnit = RADIANS), "hsl(3.1416rad 50% 50%)"),
row(HSL(180, .5, .5), P(hueUnit = TURNS), "hsl(0.5turn 50% 50%)"),
row(HSL(Float.NaN, 0, 0), P(hueUnit = TURNS), "hsl(none 0% 0%)"),
) { color, p, expected ->
color.formatCssString(
p.hueUnit, p.renderAlpha, true, p.alphaPercent, p.legacyName, p.legacyFormat
) shouldBe expected
}

Expand Down Expand Up @@ -172,35 +125,29 @@ class CssRenderTest {

@Test
fun formatCssHsv() = forAll(
row(H2(0, 1, 1, unitsPercent = false), "color(--hsv 0 1 1)"),
row(H2(0, 1, 1, unitsPercent = true), "color(--hsv 0% 100% 100%)"),
row(H2(Float.NaN, 0, 1, unitsPercent = false), "color(--hsv NaN 0 1)"),
row(H2(Float.NaN, 0, 1, unitsPercent = true), "color(--hsv NaN 0% 100%)"),
) { (h, s, v, a, commas, namedHsla, hueUnit, alphaPercent, renderAlpha, unitsPercent), expected ->
HSV(h, s, v, a).formatCssString(
hueUnit,
renderAlpha,
unitsPercent,
alphaPercent,
namedHsla,
commas
) shouldBe expected
}
row(HSV(0, 1, 1), P(unitsPercent = false), "color(--hsv 0 1 1)"),
row(HSV(0, 1, 1), P(unitsPercent = true), "color(--hsv 0% 100% 100%)"),
row(HSV(Float.NaN, 0, 1), P(unitsPercent = false), "color(--hsv none 0 1)"),
row(HSV(Float.NaN, 0, 1), P(unitsPercent = true), "color(--hsv none 0% 100%)"),
testfn = ::doParamTest,
)

@Test
fun formatCssOklch() = forAll(
row(O(0, 0, Float.NaN, unitsPercent = false), "color(--oklch 0 0 NaN)"),
row(O(0, 0, Float.NaN, unitsPercent = true), "color(--oklch 0% 0% NaN)"),
) { (l, c, h, a, commas, namedHsla, hueUnit, alphaPercent, renderAlpha, unitsPercent), expected ->
Oklch(l, c, h, a).formatCssString(
hueUnit,
renderAlpha,
unitsPercent,
alphaPercent,
namedHsla,
commas
row(Oklch(.1, .2, 180), P(), "oklch(10% 0.2 180)"),
row(Oklch(0, 0, Float.NaN), P(), "oklch(0% 0 none)"),
testfn = ::doParamTest,
)

private fun doParamTest(color: Color, p: P, expected: String) {
color.formatCssString(
p.hueUnit,
p.renderAlpha,
p.unitsPercent,
p.alphaPercent,
p.legacyName,
p.legacyFormat
) shouldBe expected
}
}

private val XYZ55 = XYZColorSpace(Illuminant.D55)

0 comments on commit e401329

Please sign in to comment.