Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -108,6 +108,16 @@ object SvgUtils {
}
}

fun ensureDefaultImageRendering(imageElement: SvgImageElement, defaultStyle: String) {
if (imageElement.getAttribute(SvgImageElement.IMAGE_RENDERING).get() != null) {
return
}
if (imageElement.getAttribute(SvgConstants.SVG_STYLE_ATTRIBUTE).get() != null) {
return
}
imageElement.setAttribute(SvgConstants.SVG_STYLE_ATTRIBUTE, defaultStyle)
}

fun pngDataURI(base64EncodedPngImage: String): String {
return StringBuilder("data:image/png;base64,")
.append(base64EncodedPngImage)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ object SvgToString {
}

if (childNode is SvgImageElement) {
childNode.setAttribute(SvgConstants.SVG_STYLE_ATTRIBUTE, "image-rendering: optimizeSpeed; image-rendering: pixelated")
SvgUtils.ensureDefaultImageRendering(
childNode,
"image-rendering: optimizeSpeed; image-rendering: pixelated"
)
}

@Suppress("USELESS_CAST") // Kotlin 1.9 fails to infer correctly here
Expand Down Expand Up @@ -126,4 +129,4 @@ object SvgToString {
buffer.append(' ')
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,38 @@ class SvgToStringTest {
// There should be no spaces between <tspan> elements
assertTrue(svgString.contains("<tspan>1</tspan><tspan>2</tspan><tspan>3</tspan>"))
}

@Test
fun imageRenderingAttributeShouldNotBeOverridden() {
val svg = SvgSvgElement().apply {
children().add(
SvgImageElement(0.0, 0.0, 10.0, 10.0).apply {
href().set("data:image/png;base64,abc")
setAttribute(SvgImageElement.IMAGE_RENDERING, "optimizeQuality")
}
)
}

val svgString = SvgToString.render(svg)

assertTrue(svgString.contains("image-rendering=\"optimizeQuality\""))
assertTrue(!svgString.contains("image-rendering: pixelated"))
}

@Test
fun imageRenderingStyleShouldNotBeOverridden() {
val svg = SvgSvgElement().apply {
children().add(
SvgImageElement(0.0, 0.0, 10.0, 10.0).apply {
href().set("data:image/png;base64,abc")
setAttribute(SvgConstants.SVG_STYLE_ATTRIBUTE, "image-rendering: auto")
}
)
}

val svgString = SvgToString.render(svg)

assertTrue(svgString.contains("style=\"image-rendering: auto\""))
assertTrue(!svgString.contains("image-rendering: pixelated"))
}
}
953 changes: 953 additions & 0 deletions docs/f-26b/annotation_raster.ipynb

Large diffs are not rendered by default.

Binary file added docs/f-26b/images/blackRedCross.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ class SvgNodeMapperFactory(private val myPeer: SvgDomPeer): MapperFactory<SvgNod

val pixelated = SvgImageElement()
SvgUtils.copyAttributes(s as SvgElement, pixelated)
pixelated.setAttribute(SvgConstants.SVG_STYLE_ATTRIBUTE, "image-rendering: pixelated;image-rendering: crisp-edges;")
SvgUtils.ensureDefaultImageRendering(
pixelated,
"image-rendering: pixelated;image-rendering: crisp-edges;"
)
s = pixelated

SvgElementMapper(
Expand All @@ -50,4 +53,4 @@ class SvgNodeMapperFactory(private val myPeer: SvgDomPeer): MapperFactory<SvgNod
)
else -> throw IllegalStateException("Unsupported SvgNode ${source::class}")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,10 @@ enum class GeomKind {
LABEL_REPEL,
RASTER,
IMAGE,
ANNOTATION_RASTER,
PIE,
LOLLIPOP,
BRACKET,
BRACKET_DODGE,
BLANK,
}

Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,8 @@ object GeomMeta {
Aes.COLOR // not rendered but necessary for color legend to appear.
)

GeomKind.ANNOTATION_RASTER -> emptyList()

GeomKind.PIE -> listOf(
Aes.X,
Aes.Y,
Expand Down Expand Up @@ -566,4 +568,4 @@ object GeomMeta {
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ class AestheticsDefaults private constructor(
GeomKind.STEP,
GeomKind.RASTER,
GeomKind.IMAGE,
GeomKind.ANNOTATION_RASTER,
GeomKind.BRACKET,
GeomKind.BRACKET_DODGE,
GeomKind.LIVE_MAP -> base(geomTheme)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* Copyright (c) 2026. JetBrains s.r.o.
* Use of this source code is governed by the MIT license that can be found in the LICENSE file.
*/

package org.jetbrains.letsPlot.core.plot.base.geom

import org.jetbrains.letsPlot.commons.geometry.DoubleRectangle
import org.jetbrains.letsPlot.commons.geometry.DoubleVector
import org.jetbrains.letsPlot.core.plot.base.Aesthetics
import org.jetbrains.letsPlot.core.plot.base.CoordinateSystem
import org.jetbrains.letsPlot.core.plot.base.GeomContext
import org.jetbrains.letsPlot.core.plot.base.PositionAdjustment
import org.jetbrains.letsPlot.core.plot.base.render.SvgRoot
import org.jetbrains.letsPlot.datamodel.svg.dom.SvgConstants
import org.jetbrains.letsPlot.datamodel.svg.dom.SvgImageElement
import kotlin.math.max
import kotlin.math.min

class AnnotationRasterGeom(
private val imageUrl: String?,
private val xMin: Double?,
private val xMax: Double?,
private val yMin: Double?,
private val yMax: Double?,
private val interpolate: Boolean,
) : GeomBase() {

override fun buildIntern(
root: SvgRoot,
aesthetics: Aesthetics,
pos: PositionAdjustment,
coord: CoordinateSystem,
ctx: GeomContext
) {
if (imageUrl.isNullOrEmpty()) return
val bbox = dataBounds(coord, ctx) ?: return
val boundsClient = coord.toClient(bbox) ?: return

val svgImageElement = SvgImageElement(
boundsClient.origin.x, boundsClient.origin.y,
boundsClient.dimension.x, boundsClient.dimension.y
)
svgImageElement.href().set(imageUrl)
svgImageElement.setAttribute(
SvgConstants.SVG_STYLE_ATTRIBUTE,
if (interpolate) "image-rendering: auto" else "image-rendering: pixelated;image-rendering: crisp-edges;"
)
root.add(svgImageElement)
}

private fun dataBounds(coord: CoordinateSystem, ctx: GeomContext): DoubleRectangle? {
val contentOrigin = DoubleVector.ZERO
val contentCorner = ctx.getContentBounds().dimension
val dataOrigin = coord.fromClient(contentOrigin)
val dataCorner = coord.fromClient(contentCorner)

val left = resolveBound(xMin, dataOrigin?.x, dataCorner?.x, ::min) ?: return null
val right = resolveBound(xMax, dataOrigin?.x, dataCorner?.x, ::max) ?: return null
val top = resolveBound(yMin, dataOrigin?.y, dataCorner?.y, ::min) ?: return null
val bottom = resolveBound(yMax, dataOrigin?.y, dataCorner?.y, ::max) ?: return null

return DoubleRectangle.LTRB(left, top, right, bottom)
}

companion object {
const val HANDLES_GROUPS = false

private fun resolveBound(bound: Double?, panelStart: Double?, panelEnd: Double?, edge: (Double, Double) -> Double): Double? {
return when {
bound == null && panelStart != null && panelEnd != null -> edge(panelStart, panelEnd)
bound != null && bound.isFinite() -> bound
else -> null
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,14 @@ class GeomProvider internal constructor(
)
}

fun annotationRaster(supplier: (Context) -> Geom): GeomProvider {
return GeomProvider(
GeomKind.ANNOTATION_RASTER,
AnnotationRasterGeom.HANDLES_GROUPS,
supplier
)
}

fun pie(supplier: (Context) -> Geom): GeomProvider {
return GeomProvider(
GeomKind.PIE,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class GeomProto(val geomKind: GeomKind) {
// DENSITY2D,
// DENSITY2DF,
RASTER,
ANNOTATION_RASTER,
IMAGE -> CoordProviders.fixed(1.0)

MAP -> CoordProviders.map(projection = identity().takeIf { layerConfig.has(Layer.USE_CRS) } ?: mercator())
Expand Down Expand Up @@ -118,6 +119,7 @@ class GeomProto(val geomKind: GeomKind) {
LIVE_MAP,
RASTER,
IMAGE,
ANNOTATION_RASTER,
BLANK -> Samplings.NONE
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,17 @@ internal object GeomProviderFactory {
)
}

GeomKind.ANNOTATION_RASTER -> GeomProvider.annotationRaster {
AnnotationRasterGeom(
imageUrl = layerConfig.getString(Option.Geom.Image.HREF),
xMin = annotationRasterBound(layerConfig, Option.Geom.Image.XMIN),
xMax = annotationRasterBound(layerConfig, Option.Geom.Image.XMAX),
yMin = annotationRasterBound(layerConfig, Option.Geom.Image.YMIN),
yMax = annotationRasterBound(layerConfig, Option.Geom.Image.YMAX),
interpolate = layerConfig.getBoolean(Option.Geom.AnnotationRaster.INTERPOLATE),
)
}

GeomKind.PIE -> GeomProvider.pie {
val geom = PieGeom()
layerConfig.getDouble(Pie.HOLE)?.let { geom.holeSize = it }
Expand Down Expand Up @@ -584,4 +595,14 @@ internal object GeomProviderFactory {
)
}
}
}

private fun annotationRasterBound(layerConfig: LayerConfig, option: String): Double? {
return when (val value = layerConfig[option]) {
null -> null
is Number -> value.toDouble()
else -> throw IllegalArgumentException(
"Parameter '$option' expected to be a Number, but was ${value::class.simpleName}"
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,10 @@ object Option {
val YMAX = Aes.YMAX.name
}

object AnnotationRaster {
const val INTERPOLATE = "interpolate"
}

object Text {
const val LABEL_FORMAT = "label_format"
const val NA_TEXT = "na_text"
Expand Down Expand Up @@ -1185,6 +1189,7 @@ object Option {
private const val LABEL_REPEL = "label_repel"
private const val RASTER = "raster"
const val IMAGE = "image"
private const val ANNOTATION_RASTER = "annotation_raster"
const val PIE = "pie"
const val LOLLIPOP = "lollipop"
const val BRACKET = "bracket"
Expand Down Expand Up @@ -1246,6 +1251,7 @@ object Option {
map[LABEL_REPEL] = GeomKind.LABEL_REPEL
map[RASTER] = GeomKind.RASTER
map[IMAGE] = GeomKind.IMAGE
map[ANNOTATION_RASTER] = GeomKind.ANNOTATION_RASTER
map[PIE] = GeomKind.PIE
map[LOLLIPOP] = GeomKind.LOLLIPOP
map[BRACKET] = GeomKind.BRACKET
Expand Down Expand Up @@ -1340,4 +1346,4 @@ object Option {
const val COORD_XLIM_TRANSFORMED = FigureModelOptions.COORD_XLIM_TRANSFORMED // array of two nullable numbers
const val COORD_YLIM_TRANSFORMED = FigureModelOptions.COORD_YLIM_TRANSFORMED
}
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions python-package/lets_plot/plot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# Use of this source code is governed by the MIT license that can be found in the LICENSE file.
#
from .annotation import *
from .annotation_raster_ import *
from .coord import *
from .core import *
from .expand_limits_ import *
Expand Down Expand Up @@ -56,6 +57,7 @@
theme_set.__all__ +
tooltip.__all__ +
annotation.__all__ +
annotation_raster_.__all__ +
marginal_layer.__all__ +
font_features.__all__ +
ggbunch_.__all__ +
Expand Down
Loading