@@ -5,15 +5,12 @@ import org.jetbrains.kotlinx.dataframe.AnyCol
5
5
import org.jetbrains.kotlinx.dataframe.AnyFrame
6
6
import org.jetbrains.kotlinx.dataframe.AnyRow
7
7
import org.jetbrains.kotlinx.dataframe.DataFrame
8
- import org.jetbrains.kotlinx.dataframe.api.FormattingDSL
9
- import org.jetbrains.kotlinx.dataframe.api.RowColFormatter
10
- import org.jetbrains.kotlinx.dataframe.api.asNumbers
11
- import org.jetbrains.kotlinx.dataframe.api.isEmpty
12
- import org.jetbrains.kotlinx.dataframe.api.isNumber
13
- import org.jetbrains.kotlinx.dataframe.api.isSubtypeOf
14
- import org.jetbrains.kotlinx.dataframe.api.rows
8
+ import org.jetbrains.kotlinx.dataframe.api.*
9
+ import org.jetbrains.kotlinx.dataframe.columns.BaseColumn
15
10
import org.jetbrains.kotlinx.dataframe.columns.ColumnGroup
11
+ import org.jetbrains.kotlinx.dataframe.columns.ColumnWithPath
16
12
import org.jetbrains.kotlinx.dataframe.impl.DataFrameSize
13
+ import org.jetbrains.kotlinx.dataframe.impl.columns.addPath
17
14
import org.jetbrains.kotlinx.dataframe.impl.renderType
18
15
import org.jetbrains.kotlinx.dataframe.impl.scale
19
16
import org.jetbrains.kotlinx.dataframe.impl.truncate
@@ -175,13 +172,21 @@ internal fun AnyFrame.toHtmlData(
175
172
return DataFrameHtmlData (" " , body, script)
176
173
}
177
174
178
- internal fun AnyFrame.toStaticHtml (
175
+ /* *
176
+ * Renders [this] [DataFrame] as static HTML (meaning no JS is used).
177
+ * CSS rendering is enabled by default but can be turned off using [includeCss]
178
+ */
179
+ public fun AnyFrame.toStaticHtml (
179
180
configuration : DisplayConfiguration = DisplayConfiguration .DEFAULT ,
180
- cellRenderer : CellRenderer ,
181
+ cellRenderer : CellRenderer = DefaultCellRenderer ,
182
+ includeCss : Boolean = true,
181
183
): DataFrameHtmlData {
182
184
val df = this
183
185
val id = " static_df_${nextTableId()} "
184
- val columnsToRender = columns()
186
+ val flattenedCols = getColumnsWithPaths { cols { ! it.isColumnGroup() }.recursively() }
187
+ val colGrid = getColumnsHeaderGrid()
188
+ val borders = colGrid.last().map { it.borders }
189
+ val nestedRowsLimit = configuration.nestedRowsLimit
185
190
186
191
fun StringBuilder.emitTag (tag : String , attributes : String = "", tagContents : StringBuilder .() -> Unit ) {
187
192
append(" <" )
@@ -200,22 +205,57 @@ internal fun AnyFrame.toStaticHtml(
200
205
}
201
206
202
207
fun StringBuilder.emitHeader () = emitTag(" thead" ) {
203
- emitTag(" tr" ) {
204
- columnsToRender.forEach { col ->
205
- emitTag(" th" ) {
206
- append(col.name())
208
+ for (row in colGrid) {
209
+ emitTag(" tr" ) {
210
+ for ((j, col) in row.withIndex()) {
211
+ var borders = col.borders
212
+ if (row.getOrNull(j + 1 )?.borders?.contains(Border .LEFT ) == true ) {
213
+ // because left does not work unless at first column
214
+ borders + = Border .RIGHT
215
+ }
216
+ emitTag(" th" , borders.toClass()) {
217
+ append(col.columnWithPath?.name ? : " " )
218
+ }
207
219
}
208
220
}
209
221
}
210
222
}
211
223
212
- fun StringBuilder.emitCell (cellValue : Any? ) = emitTag(" td" ) {
213
- append(cellRenderer.content(cellValue, configuration).truncatedContent)
224
+ fun StringBuilder.emitCell (cellValue : Any? , borders : Set <Border >): Unit = emitTag(" td" , borders.toClass()) {
225
+ when (cellValue) {
226
+ is AnyFrame ->
227
+ emitTag(" details" ) {
228
+ emitTag(" summary" ) {
229
+ append(" DataFrame " )
230
+ append(cellRenderer.content(cellValue, configuration).truncatedContent)
231
+ }
232
+ append(
233
+ cellValue.take(nestedRowsLimit ? : Int .MAX_VALUE )
234
+ .toStaticHtml(configuration, cellRenderer, includeCss = false )
235
+ .toString()
236
+ )
237
+ val size = cellValue.rowsCount()
238
+ if (size > nestedRowsLimit ? : Int .MAX_VALUE ) {
239
+ emitTag(" p" ) {
240
+ append(" ... showing only top $nestedRowsLimit of $size rows" )
241
+ }
242
+ }
243
+ }
244
+
245
+ else ->
246
+ append(cellRenderer.content(cellValue, configuration).truncatedContent)
247
+ }
214
248
}
215
249
216
250
fun StringBuilder.emitRow (row : AnyRow ) = emitTag(" tr" ) {
217
- columnsToRender.forEach { col ->
218
- emitCell(row[col.path()])
251
+ for ((i, col) in flattenedCols.withIndex()) {
252
+ var border = borders[i]
253
+ if (borders.getOrNull(i + 1 )?.contains(Border .LEFT ) == true ) {
254
+ // because left does not work unless at first column
255
+ border + = Border .RIGHT
256
+ }
257
+ val cell = row[col.path()]
258
+ emitCell(cell, border)
219
259
}
220
260
}
221
261
@@ -231,12 +271,79 @@ internal fun AnyFrame.toStaticHtml(
231
271
emitBody()
232
272
}
233
273
234
- return DataFrameHtmlData (
235
- body = buildString { emitTable() },
236
- script = """
237
- document.getElementById("$id ").style.display = "none";
238
- """ .trimIndent()
239
- )
274
+ return DataFrameHtmlData (body = buildString { emitTable() }) +
275
+ DataFrameHtmlData .tableDefinitions(includeJs = false , includeCss = includeCss)
276
+ }
277
+
278
+ private enum class Border (val className : String ) {
279
+ // NOTE: these don't render unless at leftmost; add rightborder to the previous cell.
280
+ LEFT (" leftBorder" ),
281
+ RIGHT (" rightBorder" ),
282
+ BOTTOM (" bottomBorder" );
283
+ }
284
+
285
+ private fun Set<Border>.toClass (): String = " class=\" ${joinToString(" " ) { it.className }} \" "
286
+
287
+ private data class ColumnWithPathWithBorder <T >(
288
+ val columnWithPath : ColumnWithPath <T >? = null ,
289
+ val borders : Set <Border > = emptySet(),
290
+ )
291
+
292
+ /* * Returns the depth of the most-nested column in this group, starting at 0 */
293
+ private fun ColumnGroup <* >.maxDepth (): Int = getColumnsWithPaths { all().recursively() }.maxOf { it.depth() }
294
+
295
+ /* * Returns the max number of columns needed to display this column flattened */
296
+ private fun BaseColumn <* >.maxWidth (): Int =
297
+ if (this is ColumnGroup <* >) columns().sumOf { it.maxWidth() }.coerceAtLeast(1 )
298
+ else 1
299
+
300
+ /* *
301
+ * Given a [DataFrame], this function returns a depth-first "matrix" containing all columns
302
+ * layed out in such a way that they can be used to render the header of a table. The
303
+ * [ColumnWithPathWithBorder.columnWithPath] is `null` when nothing should be rendered in that cell.
304
+ * Borders are included too, also for `null` cells.
305
+ *
306
+ * For example:
307
+ * ```
308
+ * `colGroup` ` |` `|colD` `colC`
309
+ * `colA ` `colB|` `| ` ` `
310
+ * ---- ---- ---- ----
311
+ * ```
312
+ */
313
+ private fun AnyFrame.getColumnsHeaderGrid (): List <List <ColumnWithPathWithBorder <* >>> {
314
+ val colGroup = asColumnGroup(" " )
315
+ val maxDepth = colGroup.maxDepth()
316
+ val maxWidth = colGroup.maxWidth()
317
+ val map =
318
+ MutableList (maxDepth + 1 ) { MutableList (maxWidth) { ColumnWithPathWithBorder <Any ?>() } }
319
+
320
+ fun ColumnWithPath <* >.addChildren (depth : Int = 0, breadth : Int = 0) {
321
+ var breadth = breadth
322
+ val children = children()
323
+ val lastIndex = children.lastIndex
324
+ for ((i, child) in children().withIndex()) {
325
+ map[depth][breadth] = map[depth][breadth].copy(columnWithPath = child)
326
+
327
+ // draw colGroup borders unless at start/end of table
328
+ val borders = mutableSetOf<Border >()
329
+ if (i == 0 && breadth != 0 ) borders + = Border .LEFT
330
+ if (i == lastIndex && breadth != maxWidth - 1 ) borders + = Border .RIGHT
331
+ if (depth == maxDepth) borders + = Border .BOTTOM
332
+
333
+ if (borders.isNotEmpty()) {
334
+ for (j in (depth - 1 ).coerceAtLeast(0 ).. maxDepth) {
335
+ map[j][breadth] = map[j][breadth].let { it.copy(borders = it.borders + borders) }
336
+ }
337
+ }
338
+
339
+ if (child is ColumnGroup <* >) {
340
+ child.addChildren(depth + 1 , breadth)
341
+ }
342
+ breadth + = child.maxWidth()
343
+ }
344
+ }
345
+ colGroup.addPath().addChildren()
346
+ return map
240
347
}
241
348
242
349
internal fun DataFrameHtmlData.print () = println (this )
@@ -305,28 +412,45 @@ public data class DataFrameHtmlData(
305
412
@Language(" html" , prefix = " <body>" , suffix = " </body>" ) val body : String = " " ,
306
413
@Language(" js" ) val script : String = " "
307
414
) {
308
- @Language(" html" )
309
- override fun toString (): String = """
310
- <html>
311
- <head>
312
- <style type="text/css">
313
- $style
314
- </style>
315
- </head>
316
- <body>
317
- $body
318
- </body>
319
- <script>
320
- $script
321
- </script>
322
- </html>
323
- """ .trimIndent()
415
+ override fun toString (): String = buildString {
416
+ appendLine(" <html>" )
417
+ if (style.isNotBlank()) {
418
+ appendLine(" <head>" )
419
+ appendLine(" <style type=\" text/css\" >" )
420
+ appendLine(style)
421
+ appendLine(" </style>" )
422
+ appendLine(" </head>" )
423
+ }
424
+ if (body.isNotBlank()) {
425
+ appendLine(" <body>" )
426
+ appendLine(body)
427
+ appendLine(" </body>" )
428
+ }
429
+ if (script.isNotBlank()) {
430
+ appendLine(" <script>" )
431
+ appendLine(script)
432
+ appendLine(" </script>" )
433
+ }
434
+ appendLine(" </html>" )
435
+ }
324
436
325
437
public operator fun plus (other : DataFrameHtmlData ): DataFrameHtmlData =
326
438
DataFrameHtmlData (
327
- style + " \n " + other.style,
328
- body + " \n " + other.body,
329
- script + " \n " + other.script,
439
+ style = when {
440
+ style.isBlank() -> other.style
441
+ other.style.isBlank() -> style
442
+ else -> style + " \n " + other.style
443
+ },
444
+ body = when {
445
+ body.isBlank() -> other.body
446
+ other.body.isBlank() -> body
447
+ else -> body + " \n " + other.body
448
+ },
449
+ script = when {
450
+ script.isBlank() -> other.script
451
+ other.script.isBlank() -> script
452
+ else -> script + " \n " + other.script
453
+ },
330
454
)
331
455
332
456
public fun writeHTML (destination : File ) {
0 commit comments