diff --git a/core/build.gradle.kts b/core/build.gradle.kts index a7a2a2ff..f4cf0a9f 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -7,4 +7,5 @@ dependencies { testImplementation(kotlin("test")) implementation("org.apache.logging.log4j:log4j-core:2.23.1") implementation("org.apache.commons:commons-text:1.11.0") + implementation("com.github.ajalt.colormath:colormath:3.6.0") } diff --git a/core/src/main/kotlin/eu/iamgio/quarkdown/misc/color/Color.kt b/core/src/main/kotlin/eu/iamgio/quarkdown/misc/color/Color.kt index 940bca20..7e5670d4 100644 --- a/core/src/main/kotlin/eu/iamgio/quarkdown/misc/color/Color.kt +++ b/core/src/main/kotlin/eu/iamgio/quarkdown/misc/color/Color.kt @@ -14,7 +14,7 @@ data class Color( val red: Int, val green: Int, val blue: Int, - val alpha: Double = 1.0, + val alpha: Double = MAX_ALPHA, ) : RenderRepresentable { override fun accept(visitor: RenderRepresentableVisitor) = visitor.visit(this) @@ -22,7 +22,14 @@ data class Color( * @see eu.iamgio.quarkdown.misc.color.decoder.decode */ companion object { + /** + * Maximum value for RGB components. + */ const val MAX_RGB = 255 + + /** + * Maximum value for alpha component. + */ const val MAX_ALPHA = 1.0 } } diff --git a/core/src/main/kotlin/eu/iamgio/quarkdown/misc/color/decoder/ColorDecoder.kt b/core/src/main/kotlin/eu/iamgio/quarkdown/misc/color/decoder/ColorDecoder.kt index 16a4d274..b2b2c643 100644 --- a/core/src/main/kotlin/eu/iamgio/quarkdown/misc/color/decoder/ColorDecoder.kt +++ b/core/src/main/kotlin/eu/iamgio/quarkdown/misc/color/decoder/ColorDecoder.kt @@ -5,7 +5,7 @@ import eu.iamgio.quarkdown.misc.color.Color /** * A strategy to decode a [Color] from a raw string. */ -sealed interface ColorDecoder { +interface ColorDecoder { /** * Decodes a [Color] from a raw string. * @param raw raw string @@ -22,5 +22,12 @@ sealed interface ColorDecoder { */ fun Color.Companion.decode( raw: String, - vararg decoders: ColorDecoder = arrayOf(HexColorDecoder, RgbColorDecoder, RgbaColorDecoder, ColorNameDecoder), + vararg decoders: ColorDecoder = + arrayOf( + HexColorDecoder, + RgbColorDecoder, + RgbaColorDecoder, + HsvHslColorDecoder, + NamedColorDecoder, + ), ): Color? = decoders.firstNotNullOfOrNull { it.decode(raw) } diff --git a/core/src/main/kotlin/eu/iamgio/quarkdown/misc/color/decoder/ColorDecoderUtils.kt b/core/src/main/kotlin/eu/iamgio/quarkdown/misc/color/decoder/ColorDecoderUtils.kt new file mode 100644 index 00000000..7f4063a2 --- /dev/null +++ b/core/src/main/kotlin/eu/iamgio/quarkdown/misc/color/decoder/ColorDecoderUtils.kt @@ -0,0 +1,28 @@ +package eu.iamgio.quarkdown.misc.color.decoder + +import eu.iamgio.quarkdown.misc.color.Color + +/** + * @param awtColor Java AWT color + * @return a [Color] from a Java AWT color + */ +fun Color.Companion.from(awtColor: java.awt.Color): Color = + Color( + red = awtColor.red, + green = awtColor.green, + blue = awtColor.blue, + alpha = awtColor.alpha.toDouble() / 255, + ) + +/** + * @param colormathColor Colormath color + * @return a [Color] from a Colormath color + */ +fun Color.Companion.from(colormathColor: com.github.ajalt.colormath.Color): Color = + with(colormathColor.toSRGB()) { + Color( + red = (r * MAX_RGB).toInt(), + green = (g * MAX_RGB).toInt(), + blue = (b * MAX_RGB).toInt(), + ) + } diff --git a/core/src/main/kotlin/eu/iamgio/quarkdown/misc/color/decoder/HexColorDecoder.kt b/core/src/main/kotlin/eu/iamgio/quarkdown/misc/color/decoder/HexColorDecoder.kt index b7b7da32..dff8c9cf 100644 --- a/core/src/main/kotlin/eu/iamgio/quarkdown/misc/color/decoder/HexColorDecoder.kt +++ b/core/src/main/kotlin/eu/iamgio/quarkdown/misc/color/decoder/HexColorDecoder.kt @@ -1,5 +1,6 @@ package eu.iamgio.quarkdown.misc.color.decoder +import com.github.ajalt.colormath.model.RGB import eu.iamgio.quarkdown.misc.color.Color /** @@ -8,15 +9,10 @@ import eu.iamgio.quarkdown.misc.color.Color object HexColorDecoder : ColorDecoder { override fun decode(raw: String): Color? { if (raw.firstOrNull() != '#') return null - val hex = raw.drop(1) // The '#' character is skipped. - + // Converted by Colormath. return try { - Color( - red = hex.substring(0, 2).toInt(16), - green = hex.substring(2, 4).toInt(16), - blue = hex.substring(4, 6).toInt(16), - ) - } catch (e: NumberFormatException) { + Color.from(RGB(raw)) + } catch (e: IllegalArgumentException) { null } } diff --git a/core/src/main/kotlin/eu/iamgio/quarkdown/misc/color/decoder/HsvHslColorDecoder.kt b/core/src/main/kotlin/eu/iamgio/quarkdown/misc/color/decoder/HsvHslColorDecoder.kt new file mode 100644 index 00000000..2380d2f6 --- /dev/null +++ b/core/src/main/kotlin/eu/iamgio/quarkdown/misc/color/decoder/HsvHslColorDecoder.kt @@ -0,0 +1,59 @@ +package eu.iamgio.quarkdown.misc.color.decoder + +import com.github.ajalt.colormath.model.HSL +import com.github.ajalt.colormath.model.HSV +import eu.iamgio.quarkdown.misc.color.Color + +/** + * Maximum value for the hue component. + */ +private const val MAX_HUE = 360 + +/** + * Maximum value for saturation and lightness/value components. + */ +private const val MAX_SVL = 100 + +/** + * Decodes a [Color] from an HSV or HSL string (e.g. `hsv(208, 70, 66)` or `hsl(208, 54, 43)`). + */ +object HsvHslColorDecoder : ColorDecoder { + override fun decode(raw: String): Color? { + if (!raw.startsWith("hsl(") && !raw.startsWith("hsv(")) return null + + // HSL and HSV have the same structure (a degree (0-360) and two percentages (0-100)). + for (method in arrayOf('l', 'v')) { + val match = Regex("hs$method\\((\\d{1,3}), ?(\\d{1,3}), ?(\\d{1,3})\\)").find(raw) ?: continue + val (h, s, lv) = + match.destructured.let { (h, s, lv) -> + Triple( + // Hue (0-360). + h.toFloatOrNull() + ?.rem(MAX_HUE), // e.g. 520 % 360 = 160 + // Normalized saturation (0-1). + s.toFloatOrNull() + ?.takeIf { it <= MAX_SVL } + ?.div(MAX_SVL), // [0, 100] -> [0, 1] + // Normalized lightness/value (0-1). + lv.toFloatOrNull() + ?.takeIf { it <= MAX_SVL } + ?.div(MAX_SVL), // [0, 100] -> [0, 1] + ) + } + + if (h == null || s == null || lv == null) continue + + // Colormath color to be converted. + val color = + when (method) { + 'l' -> HSL(h, s, lv) + 'v' -> HSV(h, s, lv) + else -> return null // Impossible + } + + return Color.from(color) + } + + return null + } +} diff --git a/core/src/main/kotlin/eu/iamgio/quarkdown/misc/color/decoder/ColorNameDecoder.kt b/core/src/main/kotlin/eu/iamgio/quarkdown/misc/color/decoder/NamedColorDecoder.kt similarity index 52% rename from core/src/main/kotlin/eu/iamgio/quarkdown/misc/color/decoder/ColorNameDecoder.kt rename to core/src/main/kotlin/eu/iamgio/quarkdown/misc/color/decoder/NamedColorDecoder.kt index 6e0e985d..03677125 100644 --- a/core/src/main/kotlin/eu/iamgio/quarkdown/misc/color/decoder/ColorNameDecoder.kt +++ b/core/src/main/kotlin/eu/iamgio/quarkdown/misc/color/decoder/NamedColorDecoder.kt @@ -7,22 +7,10 @@ import eu.iamgio.quarkdown.misc.color.Color * Decodes a [Color] from the name (case-insensitive) of a native [java.awt.Color] color (e.g. `red`) * @see java.awt.Color */ -object ColorNameDecoder : ColorDecoder { - /** - * @param awtColor Java AWT color - * @return a [Color] from a Java AWT color - */ - private fun fromAWT(awtColor: java.awt.Color): Color = - Color( - red = awtColor.red, - green = awtColor.green, - blue = awtColor.blue, - alpha = awtColor.alpha.toDouble() / 255, - ) - +object NamedColorDecoder : ColorDecoder { override fun decode(raw: String): Color? { // Name representation (e.g. red, GREEN, bLuE). val awtColor = ReflectionUtils.getConstantByName(raw) - return awtColor?.let(::fromAWT) + return awtColor?.let { Color.from(it) } } } diff --git a/core/src/main/kotlin/eu/iamgio/quarkdown/parser/InlineTokenParser.kt b/core/src/main/kotlin/eu/iamgio/quarkdown/parser/InlineTokenParser.kt index 425961d9..a17566cb 100644 --- a/core/src/main/kotlin/eu/iamgio/quarkdown/parser/InlineTokenParser.kt +++ b/core/src/main/kotlin/eu/iamgio/quarkdown/parser/InlineTokenParser.kt @@ -42,6 +42,7 @@ import eu.iamgio.quarkdown.lexer.tokens.TextSymbolToken import eu.iamgio.quarkdown.lexer.tokens.UrlAutolinkToken import eu.iamgio.quarkdown.misc.color.Color import eu.iamgio.quarkdown.misc.color.decoder.HexColorDecoder +import eu.iamgio.quarkdown.misc.color.decoder.HsvHslColorDecoder import eu.iamgio.quarkdown.misc.color.decoder.RgbColorDecoder import eu.iamgio.quarkdown.misc.color.decoder.RgbaColorDecoder import eu.iamgio.quarkdown.misc.color.decoder.decode @@ -228,7 +229,8 @@ class InlineTokenParser(private val context: MutableContext) : InlineTokenVisito // If null, no additional content is present. val content: CodeSpan.ContentInfo? = // Color decoding. Named colors are disabled due to performance reasons. - Color.decode(text, HexColorDecoder, RgbColorDecoder, RgbaColorDecoder)?.let(CodeSpan::ColorContent) + Color.decode(text, HexColorDecoder, RgbColorDecoder, RgbaColorDecoder, HsvHslColorDecoder) + ?.let(CodeSpan::ColorContent) return CodeSpan(text, content) } diff --git a/core/src/test/kotlin/eu/iamgio/quarkdown/ValueFactoryTest.kt b/core/src/test/kotlin/eu/iamgio/quarkdown/ValueFactoryTest.kt index c9cbd81e..b3f82399 100644 --- a/core/src/test/kotlin/eu/iamgio/quarkdown/ValueFactoryTest.kt +++ b/core/src/test/kotlin/eu/iamgio/quarkdown/ValueFactoryTest.kt @@ -13,6 +13,7 @@ import eu.iamgio.quarkdown.function.value.LambdaValue import eu.iamgio.quarkdown.function.value.NumberValue import eu.iamgio.quarkdown.function.value.StringValue import eu.iamgio.quarkdown.function.value.data.Range +import eu.iamgio.quarkdown.function.value.factory.IllegalRawValueException import eu.iamgio.quarkdown.function.value.factory.ValueFactory import eu.iamgio.quarkdown.misc.color.Color import kotlin.test.Test @@ -34,7 +35,7 @@ class ValueFactoryTest { @Test fun number() { assertEquals(NumberValue(42), ValueFactory.number("42")) - assertEquals(16.3F, ValueFactory.number("16.3")?.unwrappedValue) + assertEquals(16.3F, ValueFactory.number("16.3").unwrappedValue) assertFails { ValueFactory.number("num") } assertFails { ValueFactory.number("16.3.2") } } @@ -120,17 +121,23 @@ class ValueFactoryTest { assertEquals(Color(145, 168, 50), ValueFactory.color("rgb(145, 168, 50)").unwrappedValue) assertEquals(Color(120, 111, 93), ValueFactory.color("rgb(120,111,93)").unwrappedValue) assertEquals(Color(120, 111, 93, 0.5), ValueFactory.color("rgba(120, 111, 93, 0.5)").unwrappedValue) - assertFails { ValueFactory.color("abc") } - assertFails { ValueFactory.color("#hello") } - assertFails { ValueFactory.color("rgb(300, 200, 200)") } - assertFails { ValueFactory.color("rgb(300, 200, 200, 0.8)") } - assertFails { ValueFactory.color("rgba(100, 200, 200, 1.5)") } + assertEquals(Color(50, 113, 168), ValueFactory.color("hsv(208, 70, 66)").unwrappedValue) + assertEquals(Color(50, 113, 168), ValueFactory.color("hsv(568, 70, 66)").unwrappedValue) + assertEquals(Color(50, 113, 168), ValueFactory.color("hsl(208, 54, 43)").unwrappedValue) + assertFailsWith { ValueFactory.color("abc") } + assertFailsWith { ValueFactory.color("#hello") } + assertFailsWith { ValueFactory.color("rgb(300, 200, 200)") } + assertFailsWith { ValueFactory.color("rgb(300, 200, 200, 0.8)") } + assertFailsWith { ValueFactory.color("rgba(100, 200, 200, 1.5)") } + assertFailsWith { ValueFactory.color("hsl(120, 105, 20)") } + assertFailsWith { ValueFactory.color("hsv(120, 10,200)") } + assertFailsWith { ValueFactory.color("hsv(20, 10, 50, 10)") } } @Test fun enum() { @Suppress("UNCHECKED_CAST") - val values = Size.Unit.values() as Array> + val values = Size.Unit.entries.toTypedArray() as Array> assertEquals(Size.Unit.PX, ValueFactory.enum("px", values)!!.unwrappedValue) assertEquals(Size.Unit.CM, ValueFactory.enum("CM", values)!!.unwrappedValue)