From 7399276042c22eb8396d615c5f28469e499d9d81 Mon Sep 17 00:00:00 2001 From: Nikita Klimenko Date: Thu, 7 Nov 2024 18:02:36 +0200 Subject: [PATCH] Render FormattedFrame stored inside columns as HTML --- .../jetbrains/kotlinx/dataframe/io/html.kt | 60 +++++++++++++++---- .../dataframe/rendering/RenderingTests.kt | 31 ++++++++++ 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/io/html.kt b/core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/io/html.kt index ed42b595f0..432eab2c02 100644 --- a/core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/io/html.kt +++ b/core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/io/html.kt @@ -5,6 +5,7 @@ import org.jetbrains.kotlinx.dataframe.AnyCol import org.jetbrains.kotlinx.dataframe.AnyFrame import org.jetbrains.kotlinx.dataframe.AnyRow import org.jetbrains.kotlinx.dataframe.DataFrame +import org.jetbrains.kotlinx.dataframe.api.FormattedFrame import org.jetbrains.kotlinx.dataframe.api.FormattingDSL import org.jetbrains.kotlinx.dataframe.api.RowColFormatter import org.jetbrains.kotlinx.dataframe.api.asColumnGroup @@ -138,13 +139,13 @@ internal var sessionId = (Random().nextInt() % 128) shl 24 internal fun nextTableId() = sessionId + (tableInSessionId++) internal fun AnyFrame.toHtmlData( - configuration: DisplayConfiguration = DisplayConfiguration.DEFAULT, + defaultConfiguration: DisplayConfiguration = DisplayConfiguration.DEFAULT, cellRenderer: CellRenderer, ): DataFrameHtmlData { val scripts = mutableListOf() - val queue = LinkedList>() + val queue = LinkedList() - fun AnyFrame.columnToJs(col: AnyCol, rowsLimit: Int?): ColumnDataForJs { + fun AnyFrame.columnToJs(col: AnyCol, rowsLimit: Int?, configuration: DisplayConfiguration): ColumnDataForJs { val values = if (rowsLimit != null) rows().take(rowsLimit) else rows() val scale = if (col.isNumber()) col.asNumbers().scale() else 1 val format = if (scale > 0) { @@ -155,13 +156,15 @@ internal fun AnyFrame.toHtmlData( val renderConfig = configuration.copy(decimalFormat = format) val contents = values.map { val value = it[col] - if (value is AnyFrame) { - if (value.isEmpty()) { + val content = value.toDataFrameLikeOrNull() + if (content != null) { + val df = content.df() + if (df.isEmpty()) { HtmlContent("", null) } else { val id = nextTableId() - queue.add(value to id) - DataFrameReference(id, value.size) + queue.add(RenderingQueueItem(df, id, content.configuration(defaultConfiguration))) + DataFrameReference(id, df.size) } } else { val html = @@ -174,20 +177,25 @@ internal fun AnyFrame.toHtmlData( HtmlContent(html, style) } } + val nested = if (col is ColumnGroup<*>) { + col.columns().map { col.columnToJs(it, rowsLimit, configuration) } + } else { + emptyList() + } return ColumnDataForJs( column = col, - nested = if (col is ColumnGroup<*>) col.columns().map { col.columnToJs(it, rowsLimit) } else emptyList(), + nested = nested, rightAlign = col.isSubtypeOf(), values = contents, ) } val rootId = nextTableId() - queue.add(this to rootId) + queue.add(RenderingQueueItem(this, rootId, defaultConfiguration)) while (!queue.isEmpty()) { - val (nextDf, nextId) = queue.pop() + val (nextDf, nextId, configuration) = queue.pop() val rowsLimit = if (nextId == rootId) configuration.rowsLimit else configuration.nestedRowsLimit - val preparedColumns = nextDf.columns().map { nextDf.columnToJs(it, rowsLimit) } + val preparedColumns = nextDf.columns().map { nextDf.columnToJs(it, rowsLimit, configuration) } val js = tableJs(preparedColumns, nextId, rootId, nextDf.nrow) scripts.add(js) } @@ -196,6 +204,36 @@ internal fun AnyFrame.toHtmlData( return DataFrameHtmlData(style = "", body = body, script = script) } +private interface DataFrameLike { + fun configuration(default: DisplayConfiguration): DisplayConfiguration + + fun df(): AnyFrame +} + +private fun Any?.toDataFrameLikeOrNull(): DataFrameLike? = + when (this) { + is AnyFrame -> { + object : DataFrameLike { + override fun configuration(default: DisplayConfiguration) = default + + override fun df(): AnyFrame = this@toDataFrameLikeOrNull + } + } + + is FormattedFrame<*> -> { + object : DataFrameLike { + override fun configuration(default: DisplayConfiguration): DisplayConfiguration = + getDisplayConfiguration(default) + + override fun df(): AnyFrame = df + } + } + + else -> null + } + +private data class RenderingQueueItem(val df: DataFrame<*>, val id: Int, val configuration: DisplayConfiguration) + private const val DEFAULT_HTML_IMG_SIZE = 100 /** diff --git a/core/src/test/kotlin/org/jetbrains/kotlinx/dataframe/rendering/RenderingTests.kt b/core/src/test/kotlin/org/jetbrains/kotlinx/dataframe/rendering/RenderingTests.kt index 9bb63eb258..786e946649 100644 --- a/core/src/test/kotlin/org/jetbrains/kotlinx/dataframe/rendering/RenderingTests.kt +++ b/core/src/test/kotlin/org/jetbrains/kotlinx/dataframe/rendering/RenderingTests.kt @@ -2,13 +2,16 @@ package org.jetbrains.kotlinx.dataframe.rendering import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldInclude import io.kotest.matchers.string.shouldNotContain import org.jetbrains.kotlinx.dataframe.DataColumn +import org.jetbrains.kotlinx.dataframe.api.CellAttributes import org.jetbrains.kotlinx.dataframe.api.add import org.jetbrains.kotlinx.dataframe.api.asColumnGroup import org.jetbrains.kotlinx.dataframe.api.columnOf import org.jetbrains.kotlinx.dataframe.api.dataFrameOf import org.jetbrains.kotlinx.dataframe.api.emptyDataFrame +import org.jetbrains.kotlinx.dataframe.api.format import org.jetbrains.kotlinx.dataframe.api.group import org.jetbrains.kotlinx.dataframe.api.into import org.jetbrains.kotlinx.dataframe.api.move @@ -16,6 +19,7 @@ import org.jetbrains.kotlinx.dataframe.api.named import org.jetbrains.kotlinx.dataframe.api.parse import org.jetbrains.kotlinx.dataframe.api.schema import org.jetbrains.kotlinx.dataframe.api.toDataFrame +import org.jetbrains.kotlinx.dataframe.api.with import org.jetbrains.kotlinx.dataframe.io.DisplayConfiguration import org.jetbrains.kotlinx.dataframe.io.escapeHTML import org.jetbrains.kotlinx.dataframe.io.formatter @@ -24,7 +28,9 @@ import org.jetbrains.kotlinx.dataframe.io.maxWidth import org.jetbrains.kotlinx.dataframe.io.print import org.jetbrains.kotlinx.dataframe.io.renderToString import org.jetbrains.kotlinx.dataframe.io.renderToStringTable +import org.jetbrains.kotlinx.dataframe.io.tableInSessionId import org.jetbrains.kotlinx.dataframe.io.toHTML +import org.jetbrains.kotlinx.dataframe.io.toStandaloneHTML import org.jetbrains.kotlinx.dataframe.jupyter.DefaultCellRenderer import org.jetbrains.kotlinx.dataframe.jupyter.RenderedContent import org.jetbrains.kotlinx.dataframe.samples.api.TestBase @@ -196,4 +202,29 @@ class RenderingTests : TestBase() { val rendered = schema.toString() rendered shouldBe "a: Int?\nb: IntArray\nc: Array\nd: Array" } + + @Test + fun `render nested FormattedFrame as DataFrame`() { + val empty = object : CellAttributes { + override fun attributes(): List> = emptyList() + } + val df = dataFrameOf("b")(1) + + val formatted = dataFrameOf("a")(df.format { all() }.with { empty }) + val nestedFrame = dataFrameOf("a")(df) + val configuration = DisplayConfiguration(enableFallbackStaticTables = false) + tableInSessionId = 0 + val formattedHtml = formatted.toStandaloneHTML(configuration).toString() + tableInSessionId = 0 + val regularHtml = nestedFrame.toStandaloneHTML(configuration).toString() + + formattedHtml.replace("api.FormattedFrame", "DataFrame") shouldBe regularHtml + } + + @Test + fun `render cell attributes for nested FormattedFrame`() { + val df = dataFrameOf("a")(dataFrameOf("b")(1).format { all() }.with { background(green) }) + val html = df.toStandaloneHTML() + html.toString() shouldInclude "style: \"background-color" + } }