Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4aa7b12
Color: separate alpha from hex
MKoroteev-HORIS May 1, 2026
816ce6c
Fix alpha/opacity propagation in geom rendering pipeline
MKoroteev-HORIS May 1, 2026
9349a41
Propagate fill-opacity through SVG text and CSS style
MKoroteev-HORIS May 1, 2026
28d6317
Add alpha_opacity.ipynb dev notebook
MKoroteev-HORIS May 1, 2026
555e942
Fix test names
MKoroteev-HORIS May 1, 2026
637060e
Clean up resolveColor() interface
MKoroteev-HORIS May 1, 2026
42f62cb
Refactor alpha/opacity handling
MKoroteev-HORIS May 1, 2026
dae3aa4
Clean up manual alpha conversions
MKoroteev-HORIS May 1, 2026
726a4b4
Update tests
MKoroteev-HORIS May 1, 2026
801f145
Fix hint style
MKoroteev-HORIS May 1, 2026
8e3c372
Make use of precalculated opacity values via OPACITY_TABLE
MKoroteev-HORIS May 2, 2026
e11b8b0
Code cleanup
MKoroteev-HORIS May 3, 2026
9cc3215
Merge branch 'master' into alpha-opacity
MKoroteev-HORIS May 3, 2026
95bfd81
Clean up code
MKoroteev-HORIS May 4, 2026
f107e8b
Add test for alpha in Batik since it does not support #rrggbbaa
MKoroteev-HORIS May 4, 2026
cc780a4
Fix import
MKoroteev-HORIS May 4, 2026
105d8f4
Clean up code
MKoroteev-HORIS May 4, 2026
c364b1c
Clean up code; reduce duplication
MKoroteev-HORIS May 4, 2026
12391c5
Clean up code
MKoroteev-HORIS May 4, 2026
6ba1108
Refactoring TextUtil
MKoroteev-HORIS May 4, 2026
9069ce1
Improve parseRGB: better validation and error messages
MKoroteev-HORIS May 5, 2026
c3d0f95
Clean up code
MKoroteev-HORIS May 5, 2026
370cbd2
Add cookbook color_alpha.ipynb
MKoroteev-HORIS May 5, 2026
46d53a2
Update dev notebook alpha_opacity.ipynb
MKoroteev-HORIS May 5, 2026
c8f5528
Update future_changes.md
MKoroteev-HORIS May 5, 2026
fbbacfc
Mention #RRGGBBAA support
MKoroteev-HORIS May 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@ fun rgbFromHcl(hcl: HCL, alpha: Double = 1.0): Color {
val luv = luvFromHcl(hcl)
val xyz = xyzFromLuv(luv)
val rgb = rgbFromXyz(xyz)
return rgb.changeAlpha((255 * alpha).roundToInt())
return rgb.changeAlpha(alpha)
}


fun rgbFromLab(lab: LAB, alpha: Double = 1.0): Color {
val xyz = xyzFromLab(lab)
val rgb = rgbFromXyz(xyz)
return rgb.changeAlpha((255 * alpha).roundToInt())
return rgb.changeAlpha(alpha)
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,5 +77,5 @@ fun rgbFromHsl(hsl: HSL, alpha: Double = 1.0): Color {
((g1 + m) * 255).roundToInt(),
((b1 + m) * 255).roundToInt(),
(255 * 1.0).roundToInt()
).changeAlpha((255 * alpha).roundToInt())
).changeAlpha(alpha)
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,13 @@ class Color @JvmOverloads constructor(
}
}

fun toHexColorNoAlpha(): String {
return "#" + toColorPart(red) + toColorPart(green) + toColorPart(blue)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sense to rename 'toColorPart()' -> 'toHexColorPart()' for consistency.

}

fun toHexColor(): String {
val rgb = "#" + toColorPart(red) + toColorPart(green) + toColorPart(blue)
if (alpha == 255) {
return rgb
} else {
return rgb + toColorPart(alpha)
}
val rgb = toHexColorNoAlpha()
return if (alpha == 255) rgb else rgb + toColorPart(alpha)
}

override fun hashCode(): Int {
Expand Down Expand Up @@ -243,43 +243,33 @@ class Color @JvmOverloads constructor(
}

fun parseRGB(text: String): Color {
val firstParen = findNext(text, "(", 0)
val prefix = text.substring(0, firstParen)

val firstComma = findNext(text, ",", firstParen + 1)
val secondComma = findNext(text, ",", firstComma + 1)

var thirdComma = -1

when {
prefix == RGBA -> thirdComma = findNext(text, ",", secondComma + 1)
prefix == COLOR -> thirdComma = text.indexOf(",", secondComma + 1)
prefix != RGB -> throw IllegalArgumentException(text)
val firstParen = text.indexOf("(")
val lastParen = text.lastIndexOf(")")
if (firstParen == -1 || lastParen == -1 || lastParen < firstParen) {
throw IllegalArgumentException("Invalid color value: $text")
}

val lastParen = findNext(text, ")", thirdComma + 1)
val red = text.substring(firstParen + 1, firstComma).trim { it <= ' ' }.toInt()
val green = text.substring(firstComma + 1, secondComma).trim { it <= ' ' }.toInt()
val prefix = text.substring(0, firstParen)
val components = text.substring(firstParen + 1, lastParen).split(",").map(String::trim)

val blue: Int
val alpha: Int
if (thirdComma == -1) {
blue = text.substring(secondComma + 1, lastParen).trim { it <= ' ' }.toInt()
alpha = 255
} else {
blue = text.substring(secondComma + 1, thirdComma).trim { it <= ' ' }.toInt()
alpha = (text.substring(thirdComma + 1, lastParen).trim { it <= ' ' }.toFloat() * 255).roundToInt()
when (prefix) {
RGB -> require(components.size == 3) { "RGB color format requires exactly 3 components: $text" }
RGBA -> require(components.size == 4) { "RGBA color format requires exactly 4 components: $text" }
COLOR -> require(components.size in 3..4) { "'color()' format requires 3 or 4 components: $text" }
else -> throw IllegalArgumentException("Unsupported RGB color format: $text")
}

return Color(red, green, blue, alpha)
}
return try {
val red = components[0].toInt()
val green = components[1].toInt()
val blue = components[2].toInt()
val opacity = components.getOrNull(3)?.toFloat() ?: 1f
val alpha = (opacity * 255).roundToInt()

private fun findNext(s: String, what: String, from: Int): Int {
val result = s.indexOf(what, from)
if (result == -1) {
throw IllegalArgumentException("text=$s what=$what from=$from")
Color(red, green, blue, alpha)
} catch (e: NumberFormatException) {
throw IllegalArgumentException("Invalid color value: $text", e)
}
return result
}

fun parseHex(hexColor: String): Color {
Expand Down Expand Up @@ -328,4 +318,4 @@ class Color @JvmOverloads constructor(
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,9 @@ object Colors {
* - rgba(r, g, b, a)
* - color(r, g, b, a)
* - #rrggbb
* - #rrggbbaa
* - #rgb
* - #rgba
* - white, green, etc.
*/
fun parseColor(c: String): Color {
Expand Down Expand Up @@ -313,10 +315,6 @@ object Colors {
return Color(red, green, blue)
}

fun withOpacity(c: Color, opacity: Double): Color {
return c.changeAlpha(max(0, min(255, round(255 * opacity).toInt())))
}

fun contrast(color: Color, other: Color): Double {
return (luminance(color) + .05) / (luminance(other) + .05)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ class ColorTest {
assertEquals("#11223344", Color(0x11, 0x22, 0x33, 0x44).toHexColor())
}

@Test
fun toHexColorNoAlpha() {
assertEquals("#112233", Color(0x11, 0x22, 0x33, 0x44).toHexColorNoAlpha())
}

@Test
fun changeAlphaDoubleRoundsToNearestByte() {
assertEquals(128, Color.RED.changeAlpha(0.5).alpha)
}

@Test
fun parseRGB() {
assertEquals(Color.RED, Color.parseRGB("rgb(255,0,0)"))
Expand All @@ -46,6 +56,24 @@ class ColorTest {
assertEquals(Color.RED, Color.parseRGB("rgba(255,0,0,1.0)"))
}

@Test
fun rgbaRequiresAlpha() {
val e = assertFailsWith<IllegalArgumentException> {
Color.parseRGB("rgba(220, 240, 255)")
}

assertEquals("RGBA color format requires exactly 4 components: rgba(220, 240, 255)", e.message)
}

@Test
fun rgbRejectsExtraAlpha() {
val e = assertFailsWith<IllegalArgumentException> {
Color.parseRGB("rgb(220, 240, 255, 0.5)")
}

assertEquals("RGB color format requires exactly 3 components: rgb(220, 240, 255, 0.5)", e.message)
}

@Test
fun parseColRGB() {
assertEquals(Color.BLUE, Color.parseRGB("color(0,0,255)"))
Expand All @@ -56,6 +84,15 @@ class ColorTest {
assertEquals(Color.BLUE, Color.parseRGB("color(0,0,255,1.0)"))
}

@Test
fun colorRejectsWrongComponentCount() {
val e = assertFailsWith<IllegalArgumentException> {
Color.parseRGB("color(0,0)")
}

assertEquals("'color()' format requires 3 or 4 components: color(0,0)", e.message)
}

@Test
fun parseRgbWithSpaces() {
assertEquals(Color.RED, Color.parseRGB("rgb(255, 0, 0)"))
Expand All @@ -75,4 +112,4 @@ class ColorTest {
Color.parseRGB("rbg(255, 0, )")
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ class ColorsTest {
assertEquals(Color.RED, Colors.parseColor(Color.RED.toHexColor()))
}

@Test
fun parseHexWithAlpha() {
assertEquals(Color(0, 255, 0, 128), Colors.parseColor("#00ff0080"))
}

@Test
fun parseRGB() {
assertEquals(Color.RED, Colors.parseColor("rgb(255,0,0)"))
Expand Down Expand Up @@ -112,6 +117,13 @@ class ColorsTest {
assertColors(Color(0, 0, 128), HSL(240.0, 1.0, 0.25)) // navy
}

@Test
fun `color space conversions apply alpha`() {
assertEquals(128, rgbFromHsl(HSL(0.0, 1.0, 0.5), alpha = 0.5).alpha)
assertEquals(128, rgbFromHcl(HCL(15.0, 100.0, 65.0), alpha = 0.5).alpha)
assertEquals(128, rgbFromLab(LAB(l = 43.579, a = 45.164, b = 36.823), alpha = 0.5).alpha)
}

@Test
fun hcl() {
fun assertHclToRgb(hcl: HCL, hexRgb: String) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ fun SvgSlimGroup.slimLine(
config: SvgSlimShape.() -> Unit = {},
): SvgSlimShape {
val el = SvgSlimElements.line(x1, y1, x2, y2)
stroke?.let { el.setStroke(it, 1.0) }
stroke?.let { el.setStroke(it) }
strokeWidth?.let { el.setStrokeWidth(it.toDouble()) }
el.apply(config)
el.appendTo(this)
Expand All @@ -282,8 +282,8 @@ fun SvgSlimGroup.slimRect(
config: SvgSlimShape.() -> Unit = {},
): SvgSlimShape {
val el = SvgSlimElements.rect(x.toDouble(), y.toDouble(), width.toDouble(), height.toDouble())
stroke?.let { el.setStroke(it, 1.0) }
fill?.let { el.setFill(it, 1.0) }
stroke?.let { el.setStroke(it) }
fill?.let { el.setFill(it) }
strokeWidth?.let { el.setStrokeWidth(it.toDouble()) }

el.apply(config)
Expand All @@ -301,8 +301,8 @@ fun SvgSlimGroup.slimCircle(
config: SvgSlimShape.() -> Unit = {},
): SvgSlimShape {
val el = SvgSlimElements.circle(cx.toDouble(), cy.toDouble(), r.toDouble())
stroke?.let { el.setStroke(it, 1.0) }
fill?.let { el.setFill(it, 1.0) }
stroke?.let { el.setStroke(it) }
fill?.let { el.setFill(it) }
strokeWidth?.let { el.setStrokeWidth(it.toDouble()) }

el.apply(config)
Expand All @@ -318,8 +318,8 @@ fun SvgSlimGroup.slimPath(
config: SvgSlimShape.() -> Unit = {},
): SvgSlimShape {
val el = SvgSlimElements.path(pathData)
stroke?.let { el.setStroke(it, 1.0) }
fill?.let { el.setFill(it, 1.0) }
stroke?.let { el.setStroke(it) }
fill?.let { el.setFill(it) }
strokeWidth?.let { el.setStrokeWidth(it.toDouble()) }
el.apply(config)
el.appendTo(this)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,40 +9,38 @@ import org.jetbrains.letsPlot.commons.geometry.DoubleVector
import org.jetbrains.letsPlot.commons.intern.observable.property.Property
import org.jetbrains.letsPlot.commons.intern.observable.property.WritableProperty
import org.jetbrains.letsPlot.commons.values.Color
import kotlin.math.max
import kotlin.math.min

object SvgUtils {
private val OPACITY_TABLE: DoubleArray = DoubleArray(256)

init {
for (alpha in 0..255) {
OPACITY_TABLE[alpha] = alpha / 255.0
}
}
private val OPACITY_TABLE: DoubleArray = DoubleArray(256) { alpha -> alpha / 255.0 }
private val OPACITY_STRING_TABLE: Array<String> = Array(256) { alpha -> OPACITY_TABLE[alpha].toString() }

fun opacity(c: Color): Double {
return OPACITY_TABLE[c.alpha]
}

fun alpha2opacity(colorAlpha: Int): Double {
return OPACITY_TABLE[colorAlpha]
private fun opacityString(c: Color): String {
return OPACITY_STRING_TABLE[c.alpha]
}

fun toARGB(c: Color): Int {
return toARGB(c.red, c.green, c.blue, c.alpha)
fun splitColorAndOpacity(color: Color): Pair<String, String?> {
return color.toHexColorNoAlpha() to if (color.alpha < 255) opacityString(color) else null
}

fun toARGB(c: Color, alpha: Double): Int {
return toARGB(
c.red,
c.green,
c.blue,
max(0.0, min(255.0, alpha * 255)).toInt()
)
fun fillAndOpacityStyle(color: Color, separator: String = ""): String {
val (fill, fillOpacity) = splitColorAndOpacity(color)
return buildString {
append("fill:$fill;$separator")
if (fillOpacity != null) {
append("fill-opacity:$fillOpacity;$separator")
}
}
}

fun toARGB(c: Color): Int {
return toARGB(c.red, c.green, c.blue, c.alpha)
}

fun toARGB(r: Int, g: Int, b: Int, alpha: Int): Int {
private fun toARGB(r: Int, g: Int, b: Int, alpha: Int): Int {
val rgb = (r shl 16) + (g shl 8) + b
return (alpha shl 24) + rgb
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
package org.jetbrains.letsPlot.datamodel.svg.dom.slim

import org.jetbrains.letsPlot.commons.values.Color
import org.jetbrains.letsPlot.datamodel.svg.dom.SvgUtils
import org.jetbrains.letsPlot.datamodel.svg.dom.SvgTransform

internal abstract class SlimBase protected constructor(val elementName: String) :
Expand Down Expand Up @@ -41,18 +42,16 @@ internal abstract class SlimBase protected constructor(val elementName: String)
internal val ATTR_COUNT = ATTR_KEYS.size
}

override fun setFill(c: Color, alpha: Double) {
setAttribute(fill, c.toHexColor())
if (alpha < 1.0) {
setAttribute(fillOpacity, alpha.toString())
}
override fun setFill(c: Color) {
val (color, opacity) = SvgUtils.splitColorAndOpacity(c)
setAttribute(fill, color)
opacity?.let { setAttribute(fillOpacity, it) }
}

override fun setStroke(c: Color, alpha: Double) {
setAttribute(stroke, c.toHexColor())
if (alpha < 1.0) {
setAttribute(strokeOpacity, alpha.toString())
}
override fun setStroke(c: Color) {
val (color, opacity) = SvgUtils.splitColorAndOpacity(c)
setAttribute(stroke, color)
opacity?.let { setAttribute(strokeOpacity, it) }
}

override fun setStrokeWidth(v: Double) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import org.jetbrains.letsPlot.commons.values.Color
import org.jetbrains.letsPlot.datamodel.svg.dom.SvgTransform

interface SvgSlimShape : SvgSlimObject {
fun setFill(c: Color, alpha: Double)
fun setStroke(c: Color, alpha: Double)
fun setFill(c: Color)
fun setStroke(c: Color)
fun setStrokeWidth(v: Double)
fun setStrokeDashArray(v: String)
fun strokeDashOffset(v: Double)
Expand Down
Loading