Skip to content

Commit

Permalink
Implement HSV/HSL color decoding and use Colormath lib for hex decoding
Browse files Browse the repository at this point in the history
  • Loading branch information
iamgio committed Sep 18, 2024
1 parent af5b3b5 commit 19e4977
Show file tree
Hide file tree
Showing 9 changed files with 128 additions and 33 deletions.
1 change: 1 addition & 0 deletions core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
9 changes: 8 additions & 1 deletion core/src/main/kotlin/eu/iamgio/quarkdown/misc/color/Color.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,22 @@ 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 <T> accept(visitor: RenderRepresentableVisitor<T>) = visitor.visit(this)

/**
* @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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) }
Original file line number Diff line number Diff line change
@@ -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(),
)
}
Original file line number Diff line number Diff line change
@@ -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

/**
Expand All @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<java.awt.Color>(raw)
return awtColor?.let(::fromAWT)
return awtColor?.let { Color.from(it) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
21 changes: 14 additions & 7 deletions core/src/test/kotlin/eu/iamgio/quarkdown/ValueFactoryTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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") }
}
Expand Down Expand Up @@ -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<IllegalRawValueException> { ValueFactory.color("abc") }
assertFailsWith<IllegalRawValueException> { ValueFactory.color("#hello") }
assertFailsWith<IllegalRawValueException> { ValueFactory.color("rgb(300, 200, 200)") }
assertFailsWith<IllegalRawValueException> { ValueFactory.color("rgb(300, 200, 200, 0.8)") }
assertFailsWith<IllegalRawValueException> { ValueFactory.color("rgba(100, 200, 200, 1.5)") }
assertFailsWith<IllegalRawValueException> { ValueFactory.color("hsl(120, 105, 20)") }
assertFailsWith<IllegalRawValueException> { ValueFactory.color("hsv(120, 10,200)") }
assertFailsWith<IllegalRawValueException> { ValueFactory.color("hsv(20, 10, 50, 10)") }
}

@Test
fun enum() {
@Suppress("UNCHECKED_CAST")
val values = Size.Unit.values() as Array<Enum<*>>
val values = Size.Unit.entries.toTypedArray() as Array<Enum<*>>

assertEquals(Size.Unit.PX, ValueFactory.enum("px", values)!!.unwrappedValue)
assertEquals(Size.Unit.CM, ValueFactory.enum("CM", values)!!.unwrappedValue)
Expand Down

0 comments on commit 19e4977

Please sign in to comment.