Skip to content

Format colgroups #1374

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Aug 13, 2025
Merged
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
31 changes: 24 additions & 7 deletions core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/api/format.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package org.jetbrains.kotlinx.dataframe.api

import org.jetbrains.kotlinx.dataframe.ColumnsSelector
import org.jetbrains.kotlinx.dataframe.DataColumn
import org.jetbrains.kotlinx.dataframe.DataFrame
import org.jetbrains.kotlinx.dataframe.DataRow
import org.jetbrains.kotlinx.dataframe.RowColumnExpression
Expand All @@ -15,7 +14,10 @@ import org.jetbrains.kotlinx.dataframe.api.FormattingDsl.linear
import org.jetbrains.kotlinx.dataframe.api.FormattingDsl.linearBg
import org.jetbrains.kotlinx.dataframe.api.FormattingDsl.rgb
import org.jetbrains.kotlinx.dataframe.api.FormattingDsl.textColor
import org.jetbrains.kotlinx.dataframe.columns.ColumnGroup
import org.jetbrains.kotlinx.dataframe.columns.ColumnReference
import org.jetbrains.kotlinx.dataframe.columns.ColumnWithPath
import org.jetbrains.kotlinx.dataframe.columns.FrameColumn
import org.jetbrains.kotlinx.dataframe.columns.toColumnSet
import org.jetbrains.kotlinx.dataframe.dataTypes.IFRAME
import org.jetbrains.kotlinx.dataframe.dataTypes.IMG
Expand Down Expand Up @@ -66,6 +68,21 @@ import kotlin.reflect.KProperty
*
* You can continue formatting the [FormattedFrame] by calling [format][FormattedFrame.format] on it again.
*
* Specifying a [column group][ColumnGroup] makes all of its inner columns be formatted in the same way unless
* overridden.
*
* Formatting is done additively, meaning you can add more formatting to a cell that's already formatted or
* override certain attributes inherited from its outer group.
*
* Specifying a [frame column][FrameColumn] at the moment does nothing
* ([Issue #1375](https://github.com/Kotlin/dataframe/issues/1375)),
* convert each nested [DataFrame] to a [FormattedFrame] instead:
* ```kt
* df.convert { myFrameCol }.with {
* it.format { someCol }.with { background(green) }
* }.toStandaloneHtml()
* ```
*
* Check out the [Grammar].
*
* For more information: {@include [DocumentationUrls.Format]}
Expand Down Expand Up @@ -169,7 +186,7 @@ internal interface FormatDocs {
interface CellFormatterDef

/**
* `rowColFormatter: `{@include [FormattingDslGrammarRef]}`.(row: `[DataRow][DataRow]`<T>, col: `[DataColumn][DataColumn]`<C>) -> `[CellAttributes][CellAttributes]`?`
* `rowColFormatter: `{@include [FormattingDslGrammarRef]}`.(row: `[DataRow][DataRow]`<T>, col: `[ColumnWithPath][ColumnWithPath]`<C>) -> `[CellAttributes][CellAttributes]`?`
*/
interface RowColFormatterDef

Expand Down Expand Up @@ -348,7 +365,7 @@ public fun <T> FormattedFrame<T>.format(vararg columns: String): FormatClause<T,
* .toStandaloneHtml().openInBrowser()
* ```
*/
public fun <T> FormattedFrame<T>.format(): FormatClause<T, Any?> = FormatClause(df, null, formatter)
public fun <T> FormattedFrame<T>.format(): FormatClause<T, Any?> = FormatClause(df = df, oldFormatter = formatter)

// endregion

Expand Down Expand Up @@ -470,7 +487,7 @@ public fun <T, C> FormatClause<T, C>.perRowCol(formatter: RowColFormatter<T, C>)
*/
@Suppress("UNCHECKED_CAST")
public fun <T, C> FormatClause<T, C>.with(formatter: CellFormatter<C>): FormattedFrame<T> =
formatImpl { row, col -> formatter(row[col.name] as C) }
formatImpl { row, col -> formatter(col[row] as C) }

/**
* Creates a new [FormattedFrame] that uses the specified [CellFormatter] to format selected non-null cells of the dataframe.
Expand Down Expand Up @@ -734,14 +751,14 @@ public object FormattingDsl {

/**
* A lambda function expecting a [CellAttributes] or `null` given an instance of
* [DataRow][DataRow]`<`[T][T]`>` and [DataColumn][DataColumn]`<`[C][C]`>`.
* [DataRow][DataRow]`<`[T][T]`>` and [ColumnWithPath][ColumnWithPath]`<`[C][C]`>`.
*
* This is similar to a [RowColumnExpression], except that you also have access
* to the [FormattingDsl] in the context.
*
* @include [FormattingDsl]
*/
public typealias RowColFormatter<T, C> = FormattingDsl.(row: DataRow<T>, col: DataColumn<C>) -> CellAttributes?
public typealias RowColFormatter<T, C> = FormattingDsl.(row: DataRow<T>, col: ColumnWithPath<C>) -> CellAttributes?

/**
* A lambda function expecting a [CellAttributes] or `null` given an instance of a cell: [C] of the dataframe.
Expand Down Expand Up @@ -838,7 +855,7 @@ public class FormattedFrame<T>(internal val df: DataFrame<T>, internal val forma
*/
public class FormatClause<T, C>(
internal val df: DataFrame<T>,
internal val columns: ColumnsSelector<T, C>? = null,
internal val columns: ColumnsSelector<T, C> = { all().cast() },
internal val oldFormatter: RowColFormatter<T, C>? = null,
internal val filter: RowValueFilter<T, C> = { true },
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import org.jetbrains.kotlinx.dataframe.api.RgbColor
import org.jetbrains.kotlinx.dataframe.api.RowColFormatter
import org.jetbrains.kotlinx.dataframe.api.and
import org.jetbrains.kotlinx.dataframe.api.cast
import org.jetbrains.kotlinx.dataframe.api.getColumnsWithPaths
import org.jetbrains.kotlinx.dataframe.api.name
import org.jetbrains.kotlinx.dataframe.columns.depth
import org.jetbrains.kotlinx.dataframe.api.getColumnPaths
import org.jetbrains.kotlinx.dataframe.columns.UnresolvedColumnsPolicy
import org.jetbrains.kotlinx.dataframe.impl.getColumnPaths
import org.jetbrains.kotlinx.dataframe.path

internal class SingleAttribute(val key: String, val value: String) : CellAttributes {
override fun attributes() = listOf(key to value)
Expand Down Expand Up @@ -53,25 +54,17 @@ internal inline fun <T, C> FormatClause<T, C>.formatImpl(
crossinline formatter: RowColFormatter<T, C>,
): FormattedFrame<T> {
val clause = this
val columns =
if (clause.columns != null) {
clause.df.getColumnsWithPaths(clause.columns)
.mapNotNull { if (it.depth == 0) it.name else null } // TODO Causes #1356
.toSet()
} else {
null
}
val columns = clause.df.getColumnPaths(UnresolvedColumnsPolicy.Skip, clause.columns).toSet()

return FormattedFrame(clause.df) { row, col ->
val oldAttributes = clause.oldFormatter?.invoke(FormattingDsl, row, col.cast())
if (columns == null || columns.contains(col.name())) {
val value = row[col.name] as C
if (col.path in columns) {
val value = col[row] as C
if (clause.filter(row, value)) {
oldAttributes and formatter(FormattingDsl, row.cast(), col.cast())
} else {
oldAttributes
return@FormattedFrame oldAttributes and formatter(FormattingDsl, row.cast(), col.cast())
}
} else {
oldAttributes
}

oldAttributes
}
}
58 changes: 44 additions & 14 deletions core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/io/html.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ 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.CellAttributes
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.and
import org.jetbrains.kotlinx.dataframe.api.asColumnGroup
import org.jetbrains.kotlinx.dataframe.api.asNumbers
import org.jetbrains.kotlinx.dataframe.api.format
Expand All @@ -24,6 +26,7 @@ import org.jetbrains.kotlinx.dataframe.columns.FrameColumn
import org.jetbrains.kotlinx.dataframe.dataTypes.IFRAME
import org.jetbrains.kotlinx.dataframe.dataTypes.IMG
import org.jetbrains.kotlinx.dataframe.impl.DataFrameSize
import org.jetbrains.kotlinx.dataframe.impl.columns.addParentPath
import org.jetbrains.kotlinx.dataframe.impl.columns.addPath
import org.jetbrains.kotlinx.dataframe.impl.io.resizeKeepingAspectRatio
import org.jetbrains.kotlinx.dataframe.impl.renderType
Expand Down Expand Up @@ -161,7 +164,11 @@ internal fun AnyFrame.toHtmlData(
val scripts = mutableListOf<String>()
val queue = LinkedList<RenderingQueueItem>()

fun AnyFrame.columnToJs(col: AnyCol, rowsLimit: Int?, configuration: DisplayConfiguration): ColumnDataForJs {
fun AnyFrame.columnToJs(
col: ColumnWithPath<*>,
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) {
Expand All @@ -170,23 +177,40 @@ internal fun AnyFrame.toHtmlData(
RendererDecimalFormat.of("%e")
}
val renderConfig = configuration.copy(decimalFormat = format)
val contents = values.map {
val value = col[it]
val content = value.toDataFrameLikeOrNull()
if (content != null) {
val df = content.df()
val contents = values.map { row ->
val value = col[row]
val dfLikeContent = value.toDataFrameLikeOrNull()
if (dfLikeContent != null) {
val df = dfLikeContent.df()
if (df.isEmpty()) {
HtmlContent("", null)
} else {
val id = nextTableId()
queue += RenderingQueueItem(df, id, content.configuration(defaultConfiguration))
queue += RenderingQueueItem(df, id, dfLikeContent.configuration(defaultConfiguration))
DataFrameReference(id, df.size)
}
} else {
val html =
formatter.format(downsizeBufferedImageIfNeeded(value, renderConfig), cellRenderer, renderConfig)
val style = renderConfig.cellFormatter
?.invoke(FormattingDsl, it, col)
val html = formatter.format(
value = downsizeBufferedImageIfNeeded(value, renderConfig),
renderer = cellRenderer,
configuration = renderConfig,
)

val formatter = renderConfig.cellFormatter
?: return@map HtmlContent(html, null)

// ask formatter for all attributes defined for this cell or any of its parents (outer column groups)
val parentCols = col.path.indices
.map { i -> col.path.take(i + 1) }
.dropLast(1)
.map { ColumnWithPath(this@toHtmlData[it], it) }
val parentAttributes = parentCols
.map { formatter(FormattingDsl, row, it) }
.reduceOrNull(CellAttributes?::and)

val cellAttributes = formatter(FormattingDsl, row, col)

val style = (parentAttributes and cellAttributes)
?.attributes()
?.ifEmpty { null }
?.flatMap {
Expand All @@ -204,12 +228,16 @@ internal fun AnyFrame.toHtmlData(
listOf(it)
}
}
?.joinToString(";") { "${it.first}:${it.second}" }
?.toMap() // removing duplicate keys, allowing only the final one to be applied
?.entries
?.joinToString(";") { "${it.key}:${it.value}" }
HtmlContent(html, style)
}
}
val nested = if (col is ColumnGroup<*>) {
col.columns().map { col.columnToJs(it, rowsLimit, configuration) }
col.columns().map {
col.columnToJs(it.addParentPath(col.path), rowsLimit, configuration)
}
} else {
emptyList()
}
Expand All @@ -226,7 +254,9 @@ internal fun AnyFrame.toHtmlData(
while (!queue.isEmpty()) {
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, configuration) }
val preparedColumns = nextDf.columns().map {
nextDf.columnToJs(it.addPath(), rowsLimit, configuration)
}
val js = tableJs(preparedColumns, nextId, rootId, nextDf.nrow)
scripts.add(js)
}
Expand Down
Loading