diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a72869..cccdc64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/colormath/api/colormath.api b/colormath/api/colormath.api index c86ff28..970a529 100644 --- a/colormath/api/colormath.api +++ b/colormath/api/colormath.api @@ -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 { @@ -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 { diff --git a/colormath/src/commonMain/kotlin/com/github/ajalt/colormath/CssParse.kt b/colormath/src/commonMain/kotlin/com/github/ajalt/colormath/CssParse.kt index d26c681..99c3ff7 100644 --- a/colormath/src/commonMain/kotlin/com/github/ajalt/colormath/CssParse.kt +++ b/colormath/src/commonMain/kotlin/com/github/ajalt/colormath/CssParse.kt @@ -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)?" @@ -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) diff --git a/colormath/src/commonMain/kotlin/com/github/ajalt/colormath/CssRender.kt b/colormath/src/commonMain/kotlin/com/github/ajalt/colormath/CssRender.kt index f0d448b..5a5cdd6 100644 --- a/colormath/src/commonMain/kotlin/com/github/ajalt/colormath/CssRender.kt +++ b/colormath/src/commonMain/kotlin/com/github/ajalt/colormath/CssRender.kt @@ -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, @@ -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 "" diff --git a/test/src/commonTest/kotlin/com/github/ajalt/colormath/CssParseTest.kt b/test/src/commonTest/kotlin/com/github/ajalt/colormath/CssParseTest.kt index 862655a..79ae738 100644 --- a/test/src/commonTest/kotlin/com/github/ajalt/colormath/CssParseTest.kt +++ b/test/src/commonTest/kotlin/com/github/ajalt/colormath/CssParseTest.kt @@ -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 @@ -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)) } @@ -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) diff --git a/test/src/commonTest/kotlin/com/github/ajalt/colormath/CssRenderTest.kt b/test/src/commonTest/kotlin/com/github/ajalt/colormath/CssRenderTest.kt index 075688a..4911517 100644 --- a/test/src/commonTest/kotlin/com/github/ajalt/colormath/CssRenderTest.kt +++ b/test/src/commonTest/kotlin/com/github/ajalt/colormath/CssRenderTest.kt @@ -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 } @@ -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)