diff --git a/R/table_draw.R b/R/table_draw.R index 9ae50b9..8e4faf4 100644 --- a/R/table_draw.R +++ b/R/table_draw.R @@ -63,24 +63,25 @@ #' @keywords internal build_table_grob <- function(row_page, col_group_idx, n_group_cols, resolved_cols, tbl, - row_heights_in = NULL, - cont_row_h_in = NULL, - is_first_col_page = TRUE, - is_last_col_page = TRUE) { + cell_heights_in_mat = NULL, + cont_row_h_in = NULL, + is_first_col_page = TRUE, + is_last_col_page = TRUE) { # Subset to display columns for this page page_cols <- resolved_cols[col_group_idx] grid::gTree( - row_page = row_page, - col_group_idx = col_group_idx, - n_group_cols = n_group_cols, - page_cols = page_cols, - tbl = tbl, - row_heights_in = row_heights_in, # cached from paginate phase - cont_row_h_in = cont_row_h_in, # cached from paginate phase - is_first_col_page = is_first_col_page, # FALSE when prior col pages exist - is_last_col_page = is_last_col_page, # FALSE when more col pages follow - cl = "tfl_table_grob" + row_page = row_page, + col_group_idx = col_group_idx, + n_group_cols = n_group_cols, + page_cols = page_cols, + resolved_cols = resolved_cols, # full list, for span recompute + tbl = tbl, + cell_heights_in_mat = cell_heights_in_mat, # cached full matrix + cont_row_h_in = cont_row_h_in, # cached from paginate phase + is_first_col_page = is_first_col_page, # FALSE when prior col pages exist + is_last_col_page = is_last_col_page, # FALSE when more col pages follow + cl = "tfl_table_grob" ) } @@ -165,15 +166,38 @@ drawDetails.tfl_table_grob <- function(x, recording) { max(vapply(tbl$row_cont_msg, .cont_h, numeric(1L))) } - # Data row heights — prefer cached values - row_h_vec <- if (!is.null(x$row_heights_in) && - length(x$row_heights_in) >= (if (n_rows > 0L) max(rows) else 0L)) { - x$row_heights_in[rows] + group_vars <- tbl$group_vars + + # Precompute the per-page suppression matrix. Drives cell-content + # blanking, span-aware row heights, span clipping for non-suppressed + # group cells, and within-span row-rule suppression. + suppress_mat <- if (isTRUE(tbl$suppress_repeated_groups) && + length(group_vars) > 0L) { + .compute_cell_suppression(data, group_vars, rows) + } else NULL + + # Per-page row heights — prefer the heights that pagination committed; if + # absent, recompute from the cached cell-height matrix using the same + # algorithm pagination uses. As a final fallback, build a per-page + # matrix on the fly (covers grobs assembled outside the normal pipeline). + row_h_vec <- if (!is.null(row_page$row_heights_in) && + length(row_page$row_heights_in) == n_rows) { + row_page$row_heights_in + } else if (!is.null(x$cell_heights_in_mat) && !is.null(x$resolved_cols)) { + .compute_page_row_heights( + x$cell_heights_in_mat, rows, x$resolved_cols, group_vars, suppress_mat + ) } else { - vapply(rows, function(i) { - max(vapply(page_cols, function(cs) { - s <- .fmt_cell(data[[cs$col]][i], na_str) - gp_c <- .gp_with_lineheight(.resolve_table_cell_gp(gp_tbl, cs$is_group_col), lh) + # Per-page fallback: build a small matrix for just the rows on this page + # using page_cols, then apply the algorithm. + fallback_mat <- matrix(0, nrow = n_rows, ncol = length(page_cols)) + for (j in seq_along(page_cols)) { + cs <- page_cols[[j]] + gp_c <- .gp_with_lineheight( + .resolve_table_cell_gp(gp_tbl, cs$is_group_col), lh + ) + for (ri in seq_len(n_rows)) { + s <- .fmt_cell(data[[cs$col]][rows[[ri]]], na_str) disp_s <- if (cs$wrap && !is.null(cs$width_in)) { .wrap_text(s, cs$width_in - h_lft_in - h_rgt_in, gp_c) } else s @@ -181,15 +205,35 @@ drawDetails.tfl_table_grob <- function(x, recording) { grob <- grid::textGrob(disp_s, gp = gp_c) h1 <- .height_in(grid::grobHeight(grob)) h2 <- nlines * .height_in(grid::stringHeight("M")) - max(h1, h2) - }, numeric(1L))) + v_pad_in - }, numeric(1L)) + fallback_mat[ri, j] <- max(h1, h2) + v_pad_in + } + } + .compute_page_row_heights( + fallback_mat, seq_len(n_rows), page_cols, group_vars, suppress_mat + ) } - # Precompute group sizes — group rules are suppressed for single-row groups - group_vars <- tbl$group_vars - group_sizes <- if (tbl$group_rule && length(group_vars) > 0L) { - .compute_group_sizes(data, group_vars) + # Group-rule metadata: outermost-changing-level size + level for each + # group_start. Drawing reads $levels to set the rule's left-edge column + # (so unchanged outer columns aren't sliced through by the rule line). + group_rule_info <- if (tbl$group_rule && length(group_vars) > 0L) { + .compute_group_rule_info(data, group_vars) + } else NULL + + # Precompute span ends per group column on this page so non-suppressed + # group cells can be drawn with a clip viewport that covers the whole + # span. span_end_mat[ri, g] is the last row index in the same span as + # ri for group column g. + span_end_mat <- if (!is.null(suppress_mat)) { + se <- matrix(NA_integer_, nrow = n_rows, ncol = length(group_vars)) + for (g in seq_along(group_vars)) { + starts <- which(!suppress_mat[, g]) + if (length(starts) > 0L) { + ends <- c(starts[-1L] - 1L, n_rows) + se[starts, g] <- ends + } + } + se } else NULL # --- Build row y-positions (top-to-bottom, in inches from top of vp) --- @@ -240,11 +284,7 @@ drawDetails.tfl_table_grob <- function(x, recording) { } # Group boundaries (track previous group key to detect changes) - grp_starts <- row_page$group_starts - # Precompute which group cells to suppress (hierarchical: outer change resets inner) - suppress_mat <- if (tbl$suppress_repeated_groups && length(group_vars) > 0L) { - .compute_cell_suppression(data, group_vars, rows) - } else NULL + grp_starts <- row_page$group_starts # Data row background fill setup data_row_gp <- .resolve_table_gp(gp_tbl, "data_row") @@ -256,16 +296,21 @@ drawDetails.tfl_table_grob <- function(x, recording) { i <- rows[[ri]] row_h <- row_h_vec[[ri]] - # Group rule before this row (if it starts a group, not the first visible row, - # and the group has more than one row in the full data) + # Group rule before this row (if it starts a group and is not the first + # visible row). The rule starts at the column corresponding to the + # outermost group_var level that actually changed at this transition, + # so unchanged outer columns through which the label is flowing + # aren't sliced. Drawn at every transition. if (i %in% grp_starts && ri > 1L) group_fill_idx <- group_fill_idx + 1L - if (tbl$group_rule && i %in% grp_starts && y_cursor > header_row_h + 1e-6) { - gs <- if (!is.null(group_sizes)) group_sizes[as.character(i)] else NA_integer_ - if (is.na(gs) || gs > 1L) { - rule_gp <- .resolve_table_gp(gp_tbl, "group_rule") - y_rule_npc <- 1 - y_cursor / vp_h - x_left_npc <- col_x_left[[1L]] / vp_w - x_right_npc <- col_x_right[[n_disp_cols]] / vp_w + if (tbl$group_rule && i %in% grp_starts && y_cursor > header_row_h + 1e-6 && + !is.null(group_rule_info)) { + gk <- group_rule_info$levels[as.character(i)] + if (!is.na(gk)) { + rule_start_col <- min(as.integer(gk), n_disp_cols) + rule_gp <- .resolve_table_gp(gp_tbl, "group_rule") + y_rule_npc <- 1 - y_cursor / vp_h + x_left_npc <- col_x_left[[rule_start_col]] / vp_w + x_right_npc <- col_x_right[[n_disp_cols]] / vp_w grid::grid.lines(x = grid::unit(c(x_left_npc, x_right_npc), "npc"), y = grid::unit(c(y_rule_npc, y_rule_npc), "npc"), gp = rule_gp) @@ -294,10 +339,23 @@ drawDetails.tfl_table_grob <- function(x, recording) { raw_val <- data[[cs$col]][i] cell_str <- .fmt_cell(raw_val, na_str) - # Group repeat suppression + # Group repeat suppression and span detection + clip_h <- row_h if (!is.null(suppress_mat) && cs$is_group_col) { col_pos <- match(cs$col, group_vars, nomatch = 0L) - if (col_pos > 0L && suppress_mat[[ri, col_pos]]) cell_str <- "" + if (col_pos > 0L) { + if (suppress_mat[[ri, col_pos]]) { + cell_str <- "" + } else if (!is.null(span_end_mat)) { + # Non-suppressed group cell: clip to the full span height so the + # (possibly multi-line) label can flow into the suppressed rows + # below it (HTML rowspan-style). + ri_end <- span_end_mat[[ri, col_pos]] + if (!is.na(ri_end) && ri_end > ri) { + clip_h <- sum(row_h_vec[ri:ri_end]) + } + } + } } # Resolve cell gpar (with lineheight applied) @@ -312,15 +370,20 @@ drawDetails.tfl_table_grob <- function(x, recording) { .draw_cell_text(display_str, cs$align, col_x_left[[j]], col_x_right[[j]], - y_cursor, row_h, vp_w, vp_h, + y_cursor, clip_h, vp_w, vp_h, h_lft_in, h_rgt_in, v_top_in, cell_gp, cs$width_in) } y_cursor <- y_cursor + row_h - # Row rule between data rows (not after last) - if (tbl$row_rule && ri < n_rows) { + # Row rule between data rows (not after last). Suppress the rule if + # the next row is part of a multi-row group span starting at or + # before this row — drawing a horizontal line through a label that + # flows downward would visually slice it. + rule_inside_span <- !is.null(suppress_mat) && ri < n_rows && + any(suppress_mat[ri + 1L, ]) + if (tbl$row_rule && ri < n_rows && !rule_inside_span) { rule_gp <- .resolve_table_gp(gp_tbl, "row_rule") y_rule_npc <- 1 - y_cursor / vp_h x_left_npc <- col_x_left[[1L]] / vp_w diff --git a/R/table_pagelist.R b/R/table_pagelist.R index f080273..d32065e 100644 --- a/R/table_pagelist.R +++ b/R/table_pagelist.R @@ -187,7 +187,7 @@ tfl_table_to_pagelist <- function(tbl, pg_width, pg_height, dots, tbl$line_height) } else 0 - row_heights <- measure_row_heights_tbl( + cell_h_mat <- measure_row_heights_tbl( tbl$data, resolved_cols, tbl$gp, tbl$cell_padding, tbl$na_string, tbl$line_height, tbl$max_measure_rows ) @@ -206,8 +206,10 @@ tfl_table_to_pagelist <- function(tbl, pg_width, pg_height, dots, # --- Step 6: Paginate rows --- row_pages <- paginate_rows( - tbl$data, row_heights, cont_row_h, header_row_h, ch, - tbl$group_vars, tbl$row_cont_msg, tbl$group_rule + tbl$data, cell_h_mat, resolved_cols, tbl$group_vars, + cont_row_h, header_row_h, ch, + tbl$row_cont_msg, tbl$group_rule, + suppress_repeated_groups = isTRUE(tbl$suppress_repeated_groups) ) # --- Step 7: Assemble page specs --- @@ -219,15 +221,15 @@ tfl_table_to_pagelist <- function(tbl, pg_width, pg_height, dots, for (rp in seq_len(n_rp)) { for (cg in seq_len(n_cg)) { grob <- build_table_grob( - row_page = row_pages[[rp]], - col_group_idx = col_groups[[cg]], - n_group_cols = n_group_cols, - resolved_cols = resolved_cols, - tbl = tbl, - row_heights_in = row_heights, - cont_row_h_in = cont_row_h, - is_first_col_page = (cg == 1L), - is_last_col_page = (cg == n_cg) + row_page = row_pages[[rp]], + col_group_idx = col_groups[[cg]], + n_group_cols = n_group_cols, + resolved_cols = resolved_cols, + tbl = tbl, + cell_heights_in_mat = cell_h_mat, + cont_row_h_in = cont_row_h, + is_first_col_page = (cg == 1L), + is_last_col_page = (cg == n_cg) ) page_spec <- list(content = grob) pages[[idx]] <- page_spec diff --git a/R/table_rows.R b/R/table_rows.R index a88fd38..66e2341 100644 --- a/R/table_rows.R +++ b/R/table_rows.R @@ -1,29 +1,39 @@ # table_rows.R — Row height measurement and group-aware row pagination # # Functions: -# measure_row_heights_tbl() — memoised per-row height measurement -# paginate_rows() — split rows into pages respecting group boundaries +# measure_row_heights_tbl() — per-cell height matrix +# .compute_page_row_heights() — resolve per-row heights with group spanning +# paginate_rows() — split rows into pages (span-aware) # --------------------------------------------------------------------------- -# measure_row_heights_tbl() — memoised row height measurement +# measure_row_heights_tbl() — per-cell height matrix # --------------------------------------------------------------------------- -#' Measure the rendered height of each data row in inches +#' Measure the rendered height of each table cell in inches #' #' Must be called while a viewport is active. #' Uses a memoised string-height function to avoid re-measuring repeated values. #' -#' @return Numeric vector of row heights in inches (length = nrow(data)). +#' Returns a matrix of cell heights (rows = data rows, cols = resolved_cols). +#' Each entry is the rendered height of that cell in inches, **including the +#' top + bottom cell padding (`v_pad_in`)** so that the per-row height is +#' simply `max(cell_h_mat[i, ])` without further adjustment. +#' +#' @param max_measure_rows Maximum number of rows to measure individually. +#' Non-sampled rows take the per-column max of the sampled rows (a +#' conservative estimate that mirrors prior behaviour). +#' @return Numeric matrix `[nrow(data) × length(resolved_cols)]` of inches. #' @keywords internal measure_row_heights_tbl <- function(data, resolved_cols, gp_tbl, cell_padding, na_string, line_height, max_measure_rows) { n_rows <- nrow(data) + n_cols <- length(resolved_cols) v_pad_in <- .height_in(cell_padding[["top"]]) + .height_in(cell_padding[["bottom"]]) h_lft_in <- .width_in(cell_padding[["left"]]) h_rgt_in <- .width_in(cell_padding[["right"]]) - # Memoised height function: (string, gp_key) -> height_in + # Memoised per-cell-text-height function: (string, gp_key) -> height_in memo <- new.env(hash = TRUE, parent = emptyenv()) .memo_str_height <- function(s, gp_key, gp) { key <- paste0(gp_key, "\x01", s) @@ -48,35 +58,138 @@ measure_row_heights_tbl <- function(data, resolved_cols, gp_tbl, cell_padding, seq_len(n_rows) } - # Measure sampled rows - sampled_heights <- vapply(sample_rows, function(i) { - max(vapply(resolved_cols, function(cs) { + # Build the matrix. Iterate column-major so we can resolve gpar once per + # column rather than once per cell. + cell_h_mat <- matrix(0, nrow = n_rows, ncol = n_cols) + for (j in seq_len(n_cols)) { + cs <- resolved_cols[[j]] + base_gp <- .resolve_table_cell_gp(gp_tbl, cs$is_group_col) + cell_gp <- .gp_with_lineheight(base_gp, line_height) + gp_key <- paste0(if (cs$is_group_col) "group_col" else "data_row", + "_lh", line_height) + avail_w <- if (!is.null(cs$width_in)) cs$width_in - h_lft_in - h_rgt_in + else NA_real_ + + for (i in sample_rows) { cell_str <- .fmt_cell(data[[cs$col]][i], na_string) - base_gp <- .resolve_table_cell_gp(gp_tbl, cs$is_group_col) - cell_gp <- .gp_with_lineheight(base_gp, line_height) - gp_key <- paste0(if (cs$is_group_col) "group_col" else "data_row", - "_lh", line_height) - # For wrap-eligible columns, wrap the text to the column width first display_str <- if (cs$wrap && !is.null(cs$width_in)) { - avail_w <- cs$width_in - h_lft_in - h_rgt_in .wrap_text(cell_str, avail_w, cell_gp) } else { cell_str } - # Count lines for conservative estimate - nlines <- max(1L, length(strsplit(display_str, "\n", fixed = TRUE)[[1L]])) - h_grob <- .memo_str_height(display_str, gp_key, cell_gp) - h_line <- nlines * .height_in(grid::stringHeight("M")) - max(h_grob, h_line) - }, numeric(1L))) + v_pad_in - }, numeric(1L)) - - max_sampled <- max(sampled_heights) - - # Build full height vector - heights <- rep(max_sampled, n_rows) - heights[sample_rows] <- sampled_heights - heights + nlines <- max(1L, length(strsplit(display_str, "\n", fixed = TRUE)[[1L]])) + h_grob <- .memo_str_height(display_str, gp_key, cell_gp) + h_line <- nlines * .height_in(grid::stringHeight("M")) + cell_h_mat[i, j] <- max(h_grob, h_line) + v_pad_in + } + } + + # For non-sampled rows, fill each column with the per-column max-of-sampled + # so that conservative heights are preserved. + if (length(sample_rows) < n_rows) { + not_sampled <- setdiff(seq_len(n_rows), sample_rows) + if (length(sample_rows) > 0L) { + col_max <- apply(cell_h_mat[sample_rows, , drop = FALSE], 2L, max) + } else { + col_max <- rep(0, n_cols) # nocov + } + for (j in seq_len(n_cols)) { + cell_h_mat[not_sampled, j] <- col_max[[j]] + } + } + + cell_h_mat +} + +# --------------------------------------------------------------------------- +# .compute_page_row_heights() — resolve per-row heights for one page +# --------------------------------------------------------------------------- + +# Compute the per-row heights for the rows on a single page. +# +# A single rule, dispatched on whether suppression is active: +# +# * **`suppress_mat` is `NULL`** — suppression is off, so every group +# cell renders on every row. Row height is the per-row max over +# every cell (group and non-group alike). +# +# * **`suppress_mat` is non-NULL** — suppression is on. Group columns +# never inflate row heights. Initialise row_h from non-group cells +# only, then walk group_vars innermost-first and, for each span, +# grow row_h[span_start] only if the label exceeds the cumulative +# span height — which lets multi-line labels flow downward through +# the blanked cells (HTML-`rowspan` behaviour) instead of inflating +# just the labelled row. Innermost-first so outer spans can borrow +# space inner spans already pushed for. First-row growth matches +# the label's top-aligned drawing. +# +# @param cell_h_mat Full matrix of cell heights including v_pad (inches). +# @param page_rows Integer vector of data-row indices visible on this page. +# @param resolved_cols List of resolved column specs (full list, not just page). +# @param group_vars Character vector of group column names. +# @param suppress_mat Logical matrix [length(page_rows) × length(group_vars)] +# from .compute_cell_suppression(), or NULL when suppression is disabled. +# @return Numeric vector of length(page_rows), heights in inches. +#' @keywords internal +.compute_page_row_heights <- function(cell_h_mat, page_rows, resolved_cols, + group_vars, suppress_mat) { + n_pr <- length(page_rows) + if (n_pr == 0L) return(numeric(0L)) + + n_grp <- length(group_vars) + + col_names <- vapply(resolved_cols, function(cs) cs$col, character(1L)) + is_group <- vapply(resolved_cols, function(cs) isTRUE(cs$is_group_col), + logical(1L)) + + # Per-row max over non-group cells. Falls back to the full-matrix max + # if every column is somehow a group column (degenerate input). + non_group_idx <- which(!is_group) + ng_max <- if (length(non_group_idx) > 0L) { + apply(cell_h_mat[page_rows, non_group_idx, drop = FALSE], 1L, max) + } else { + apply(cell_h_mat[page_rows, , drop = FALSE], 1L, max) # nocov + } + row_h <- as.numeric(ng_max) + + if (n_grp == 0L) return(row_h) + group_col_idx <- match(group_vars, col_names) + + if (is.null(suppress_mat)) { + # No suppression: every group cell is rendered fully on its row, so + # group cells contribute to the row max alongside non-group cells. + for (g in seq_len(n_grp)) { + j_mat <- group_col_idx[[g]] + if (is.na(j_mat)) next # safety; shouldn't happen + row_h <- pmax(row_h, cell_h_mat[page_rows, j_mat]) + } + return(row_h) + } + + # Suppression on: group columns never inflate rows. For each span the + # label is amortised across the span and only grows the start row when + # the deficit is positive (so a multi-line label flows downward into + # the suppressed cells). Innermost-first lets outer spans borrow + # whatever growth inner spans already produced. + for (g in rev(seq_len(n_grp))) { + j_mat <- group_col_idx[[g]] + if (is.na(j_mat)) next # safety; shouldn't happen + + starts <- which(!suppress_mat[, g]) + if (length(starts) == 0L) next # entire column suppressed (shouldn't happen) + ends <- c(starts[-1L] - 1L, n_pr) + + for (s_idx in seq_along(starts)) { + ri_start <- starts[[s_idx]] + ri_end <- ends[[s_idx]] + label_h <- cell_h_mat[page_rows[[ri_start]], j_mat] + avail <- sum(row_h[ri_start:ri_end]) + if (label_h > avail + 1e-9) { + row_h[[ri_start]] <- row_h[[ri_start]] + (label_h - avail) + } + } + } + row_h } # --------------------------------------------------------------------------- @@ -85,60 +198,80 @@ measure_row_heights_tbl <- function(data, resolved_cols, gp_tbl, cell_padding, #' Split rows into pages, respecting group boundaries #' +#' Uses a per-page tentative recompute of `.compute_page_row_heights()` so that +#' span-aware row heights drive the page-fit decision. Two non-obvious +#' properties of this scheme are preserved by the algorithm: +#' +#' * Adding a row to an existing group-span on the current page may leave the +#' total page height unchanged (the span absorbs deficit that previously +#' inflated earlier rows), so more rows can fit than a per-row scalar sum +#' would predict. +#' * When only the first row of a multi-row group lands on the current page +#' (group orphan), that row's span on the page is length 1 and the row is +#' grown to fit the full label height. `committed_rh` snapshots heights +#' after each successful append, so the orphan-correct heights are what gets +#' flushed to the page spec. +#' #' @param data Data frame. -#' @param row_heights_in Numeric vector of row heights in inches. +#' @param cell_h_mat Per-cell height matrix from `measure_row_heights_tbl()`. +#' @param resolved_cols Full list of resolved column specs (used to identify +#' non-group columns). +#' @param group_vars Character vector of group column names. #' @param cont_row_h Height of a continuation-marker row in inches. #' @param header_row_h Height of the column header row (0 if suppressed). #' @param content_height_in Available content height per page. -#' @param group_vars Character vector of group column names. #' @param row_cont_msg Text for continuation-marker rows. -#' @param group_rule Logical — are group rules drawn? -#' @return A list of row-page specs (see internal structure below). +#' @param group_rule Logical — are group rules drawn? (Reserved for future +#' use; currently does not affect pagination because rules are 0-height.) +#' @param suppress_repeated_groups Logical, from `tbl$suppress_repeated_groups`. +#' @return A list of row-page specs, each with `$rows`, `$is_cont_top`, +#' `$is_cont_bottom`, `$group_starts`, and `$row_heights_in` (the committed +#' per-row heights for that page in inches). #' @keywords internal -paginate_rows <- function(data, row_heights_in, cont_row_h, header_row_h, - content_height_in, group_vars, row_cont_msg, - group_rule) { - n_rows <- nrow(data) - n_grp <- length(group_vars) +paginate_rows <- function(data, cell_h_mat, resolved_cols, group_vars, + cont_row_h, header_row_h, content_height_in, + row_cont_msg, group_rule, + suppress_repeated_groups = TRUE) { + n_rows <- nrow(data) - # Identify group boundaries: rows that start a new group + # Group boundaries in the *full* data — used for the page-spec $group_starts + # field that the drawing code consults for group-rule placement. group_starts <- .compute_group_starts(data, group_vars) - pages <- list() - cur_rows <- integer(0L) - cur_h <- header_row_h - # Rule heights: rules are drawn within existing row boundaries, 0 extra height - # (they render at the row boundary, not consuming additional space) + pages <- list() + cur_rows <- integer(0L) + committed_rh <- numeric(0L) # heights for cur_rows after last successful add + is_cont_top <- FALSE - flush_page <- function(rows, is_cont_top, is_cont_bottom) { + flush_page <- function(rows, row_heights_in, is_cont_top, is_cont_bottom) { pages[[length(pages) + 1L]] <<- list( rows = rows, is_cont_top = is_cont_top, is_cont_bottom = is_cont_bottom, - group_starts = intersect(group_starts, rows) + group_starts = intersect(group_starts, rows), + row_heights_in = row_heights_in ) } i <- 1L - is_cont_top <- FALSE # does this page start with a (continued) row? - while (i <= n_rows) { - rh <- row_heights_in[[i]] - - # Would this row fit? - extra_h <- rh - if (is_cont_top) extra_h <- extra_h # cont row already counted below - - needs_group_rule <- group_rule && i %in% group_starts && length(cur_rows) > 0L - # Group rule uses no additional height (drawn at boundary) - - # Does adding this row overflow? - if (cur_h + extra_h + cont_row_h > content_height_in + 1e-6 && length(cur_rows) > 0L) { + candidate <- c(cur_rows, i) + sup <- if (suppress_repeated_groups && length(group_vars) > 0L) { + .compute_cell_suppression(data, group_vars, candidate) + } else NULL + rh <- .compute_page_row_heights( + cell_h_mat, candidate, resolved_cols, group_vars, sup + ) + total <- header_row_h + + (if (is_cont_top) cont_row_h else 0) + + sum(rh) + + cont_row_h # reserve bottom continuation marker + if (total > content_height_in + 1e-6 && length(cur_rows) > 0L) { # Warn whenever a group is split across pages (row i and the last row - # on the current page belong to the same group) - if (length(group_vars) > 0L && length(cur_rows) > 0L) { - last_in_page <- cur_rows[length(cur_rows)] + # on the current page belong to the same group). + if (length(group_vars) > 0L) { + last_in_page <- cur_rows[[length(cur_rows)]] same_group <- all(vapply(group_vars, function(gv) { identical(data[[gv]][last_in_page], data[[gv]][i]) }, logical(1L))) @@ -150,23 +283,21 @@ paginate_rows <- function(data, row_heights_in, cont_row_h, header_row_h, } } - flush_page(cur_rows, is_cont_top, is_cont_bottom = TRUE) + flush_page(cur_rows, committed_rh, is_cont_top, is_cont_bottom = TRUE) - # Re-init next page with cont_top marker cur_rows <- integer(0L) - cur_h <- header_row_h + cont_row_h # top cont row + committed_rh <- numeric(0L) is_cont_top <- TRUE - - next # restart loop iteration to re-add row i + next # re-process row i on a fresh page } - cur_rows <- c(cur_rows, i) - cur_h <- cur_h + rh - i <- i + 1L + cur_rows <- candidate + committed_rh <- rh + i <- i + 1L } if (length(cur_rows) > 0L) { - flush_page(cur_rows, is_cont_top, is_cont_bottom = FALSE) + flush_page(cur_rows, committed_rh, is_cont_top, is_cont_bottom = FALSE) } pages diff --git a/R/table_utils.R b/R/table_utils.R index 85359a7..1884093 100644 --- a/R/table_utils.R +++ b/R/table_utils.R @@ -106,7 +106,8 @@ # Compute group sizes (number of rows per group) from data and group vars. # Returns a named integer vector: name = row index of group start (as string), -# value = number of rows in that group. +# value = number of rows in that group. Group is defined by the *full* +# group_vars vector — i.e. the most-specific (innermost) grouping. .compute_group_sizes <- function(data, group_vars) { if (length(group_vars) == 0L || nrow(data) == 0L) return(integer(0L)) all_starts <- .compute_group_starts(data, group_vars) @@ -115,6 +116,68 @@ stats::setNames(sizes, as.character(all_starts)) } +# Per-group-start metadata for group-rule visibility and width. +# +# Returns a list with two named integer vectors keyed by group_start row +# index (as string): +# +# $sizes — for each group_start row i, the size of the group at the +# *outermost* level that changed between rows i-1 and i (the +# count of rows in `data` whose group_vars[1..k] all match +# row i's values, where k is the outermost changing level). +# The first group_start is NA (no transition before it). +# $levels — the column index k (1-based, into group_vars) of the +# outermost changing level. NA for the first group_start. +# +# This differs from .compute_group_sizes(), which always uses the full +# group_vars vector for sizing. When a transition crosses an outer-group +# boundary but the new innermost group has only a single row (e.g. Cohort +# changes from 1 to 2 and Cohort 2 happens to start with a one-row Visit), +# .compute_group_sizes() returns 1 and the rule gets suppressed; here the +# outer Cohort group size is returned so a meaningful boundary still gets +# a rule. $levels lets callers draw a partial-width rule that starts at +# the changing column instead of always spanning the full table width. +# +# Used only when tbl$simplify_rowspan is TRUE. +.compute_group_rule_info <- function(data, group_vars) { + if (length(group_vars) == 0L || nrow(data) == 0L) { + return(list(sizes = integer(0L), levels = integer(0L))) + } + starts <- .compute_group_starts(data, group_vars) + sizes <- rep(NA_integer_, length(starts)) + levels <- rep(NA_integer_, length(starts)) + names(sizes) <- as.character(starts) + names(levels) <- as.character(starts) + if (length(starts) <= 1L) return(list(sizes = sizes, levels = levels)) + + for (idx in seq_along(starts)[-1L]) { + i <- starts[[idx]] + i_prev <- i - 1L + for (k in seq_along(group_vars)) { + if (!identical(data[[group_vars[[k]]]][[i_prev]], + data[[group_vars[[k]]]][[i]])) { + # Outermost changing level is k; count rows whose group_vars[1..k] + # all equal row i's values. + cols <- group_vars[seq_len(k)] + mask <- rep(TRUE, nrow(data)) + for (gv in cols) { + v <- data[[gv]] + target <- data[[gv]][[i]] + if (is.na(target)) { + mask <- mask & is.na(v) + } else { + mask <- mask & !is.na(v) & v == target + } + } + sizes[[idx]] <- sum(mask) + levels[[idx]] <- k + break + } + } + } + list(sizes = sizes, levels = levels) +} + # --------------------------------------------------------------------------- # String / cell formatting helpers # --------------------------------------------------------------------------- diff --git a/R/tfl_table.R b/R/tfl_table.R index b14ed51..b67eac0 100644 --- a/R/tfl_table.R +++ b/R/tfl_table.R @@ -113,7 +113,17 @@ tfl_colspec <- function(col, #' @param suppress_repeated_groups Logical. When `TRUE` (default), group column #' cells whose value equals the immediately preceding rendered row on the #' same page are left blank. The first data row on each page always shows -#' the group value. +#' the group value. When suppression is active, multi-line group labels +#' render HTML-`rowspan`-style: the label's full vertical extent flows +#' downward through the blanked cells below it instead of inflating the +#' labelled row alone. Row heights are computed per page (a row may +#' render at different heights on different pages when a group is split, +#' because the first row on a page re-shows the label and may need to +#' grow to fit it alone). `row_rule` lines that would slice through a +#' flowing label are suppressed, and `group_rule` lines start at the +#' first column whose group value is actually changing at that +#' boundary. Set `suppress_repeated_groups = FALSE` to render every +#' group cell on every row instead. #' @param sub_tfl Character vector of column names in `x`, or `NULL` (default). #' When non-NULL, the table is split into one sub-table per unique combination #' of values in these columns. Each sub-table's caption gains a suffix of the diff --git a/design/ARCHITECTURE.md b/design/ARCHITECTURE.md index 19a5984..d4f4574 100644 --- a/design/ARCHITECTURE.md +++ b/design/ARCHITECTURE.md @@ -148,27 +148,42 @@ export_tfl(x = tfl_table_obj, ...) [exported] │ paginate_cols(...) ├── [scratch device + outer_vp] measure heights: │ .measure_header_row_height() — table_utils.R - │ measure_row_heights_tbl() — table_rows.R + │ measure_row_heights_tbl() → cell_h_mat — table_rows.R + │ Per-cell height matrix [nrow × ncol]; each entry includes + │ the v_pad_in (top + bottom padding). This is the input to + │ the per-page row-height resolver below. │ .measure_cont_row_height() — table_utils.R ├── paginate_rows(...) — table_rows.R + │ Span-aware pagination: per-page tentative recompute via + │ .compute_page_row_heights() to drive page-fit decisions. Each + │ committed page spec carries $row_heights_in (orphan-correct). └── for rp × cg: build_table_grob(row_page, col_group_idx, — table_draw.R n_group_cols, resolved_cols, tbl, - row_heights_in, cont_row_h_in, + cell_heights_in_mat, cont_row_h_in, is_first_col_page, is_last_col_page) → gTree of class "tfl_table_grob" [grob passed as x$content to export_tfl_page()] drawDetails.tfl_table_grob(x, recording) — table_draw.R - ├── Reuse or recompute: header_row_h, cont_row_h, row_h_vec + ├── Reuse or recompute: header_row_h, cont_row_h, suppress_mat + ├── row_h_vec ← row_page$row_heights_in (committed by pagination), or + │ recompute via .compute_page_row_heights(cell_heights_in_mat, …). + ├── span_end_mat (per group column, per row) — last row index in the + │ span starting at each non-suppressed row; lets non-suppressed group + │ cells be drawn with a clip viewport spanning the full span height + │ so multi-line labels flow into suppressed rows below (rowspan-style). ├── Draw column header row (.draw_header_row) ├── Draw col_header_rule (grid.lines) ├── Draw top continuation row (.draw_cont_row) ├── for each data row: │ group rule before row (grid.lines) - │ draw each cell (.draw_cell_text) - │ row rule after row (grid.lines, if row_rule && not last) + │ draw each cell (.draw_cell_text); for non-suppressed + │ group cells whose span > 1 row, pass span_h instead of row_h + │ so the clip viewport extends over the whole span. + │ row rule after row (grid.lines), suppressed when row ri+1 + │ has any suppressed group column (rule would slice a label). ├── group_rule_after_last (grid.lines) ├── Draw bottom continuation row (.draw_cont_row) ├── Draw row_header_sep (grid.lines) @@ -330,11 +345,11 @@ export_tfl(x = list_of_table1, ...) [exported] | `R/reexports.R` | `%||%` from rlang | | `R/tfl_table.R` | `tfl_colspec()`, `tfl_table()`, `print.tfl_table()`, `.check_named_subset()` | | `R/table_columns.R` | `resolve_col_specs()`, `compute_col_widths()`, `.apply_col_wrapping()`, `paginate_cols()` | -| `R/table_rows.R` | `measure_row_heights_tbl()`, `paginate_rows()` | -| `R/table_draw.R` | `build_table_grob()`, `drawDetails.tfl_table_grob()`, `.draw_header_row()`, `.draw_cont_row()`, `.draw_cell_text()` | +| `R/table_rows.R` | `measure_row_heights_tbl()` (returns per-cell matrix), `.compute_page_row_heights()`, `paginate_rows()` | +| `R/table_draw.R` | `build_table_grob()`, `drawDetails.tfl_table_grob()`, `.compute_cell_suppression()`, `.draw_header_row()`, `.draw_cont_row()`, `.draw_cell_text()` | | `R/table_pagelist.R` | `tfl_table_to_pagelist()`, `compute_table_content_area()` | | `R/sub_tfl.R` | `.compute_sub_tfl_groups()`, `.format_sub_tfl_caption()`, `.apply_sub_tfl_caption()`, `.strip_sub_tfl_cols()`, `.resolve_col_label()` | -| `R/table_utils.R` | `.make_outer_vp()`, `.width_in()`, `.height_in()`, `.measure_header_row_height()`, `.measure_cont_row_height()`, `.gp_with_lineheight()`, `.compute_group_starts()`, `.compute_group_sizes()`, `.collect_col_strings()`, `.fmt_cell()`, `.fmt_cell_vec()`, `.measure_max_string_width()`, `.resolve_table_gp()`, `.resolve_table_cell_gp()`, `.default_align()`, `.wrap_text()` | +| `R/table_utils.R` | `.make_outer_vp()`, `.width_in()`, `.height_in()`, `.measure_header_row_height()`, `.measure_cont_row_height()`, `.gp_with_lineheight()`, `.compute_group_starts()`, `.compute_group_sizes()`, `.compute_group_rule_info()` (used when `simplify_rowspan = TRUE` for outer-level rule visibility and partial-width start column), `.collect_col_strings()`, `.fmt_cell()`, `.fmt_cell_vec()`, `.measure_max_string_width()`, `.resolve_table_gp()`, `.resolve_table_cell_gp()`, `.default_align()`, `.wrap_text()` | --- @@ -607,16 +622,37 @@ paginate_cols(col_indices, col_widths_in, group_col_indices, ``` measure_row_heights_tbl(data, resolved_cols, gp_tbl, cell_padding, na_string, line_height, max_measure_rows) - → numeric vector of length nrow(data), heights in inches + → numeric MATRIX [nrow(data) × length(resolved_cols)] of heights in inches + Each entry includes the v_pad_in (top + bottom padding) so that + per-row max(matrix[i, ]) is the row height when no spanning happens. Uses memoised string-height to avoid re-measuring repeated values. max_measure_rows: sample the longest rows to cap measurement cost. - -paginate_rows(data, row_heights, cont_row_h, header_row_h, - avail_h, group_vars, row_cont_msg, group_rule) + Non-sampled rows take the per-column max-of-sampled value. + +.compute_page_row_heights(cell_h_mat, page_rows, resolved_cols, + group_vars, suppress_mat) + → numeric vector of length(page_rows) + Span-aware per-page resolver. Initialises row_h[ri] = max over + non-group columns of cell_h_mat[page_rows[ri], col]. Then for each + group column from innermost (last) to outermost (first), finds spans + in suppress_mat and grows row_h[ri_start] by any deficit between the + label height (cell_h_mat[page_rows[ri_start], col_g]) and the sum of + the span's row heights. Innermost-first ensures outer groups can + borrow the space inner groups already pushed for. Early-exit when + no group columns or suppress_mat = NULL — returns per-row max over + *all* columns (no flow when suppression is disabled). + +paginate_rows(data, cell_h_mat, resolved_cols, group_vars, + cont_row_h, header_row_h, content_height_in, + row_cont_msg, group_rule, suppress_repeated_groups) → list of row_page structs: - { rows, is_cont_top, is_cont_bottom, group_starts } - Group-aware: tries to keep group blocks together; places - continuation-marker rows at page boundaries. + { rows, is_cont_top, is_cont_bottom, group_starts, row_heights_in } + Span-aware pagination via per-page tentative recompute: each candidate + row addition recomputes suppress_mat and .compute_page_row_heights for + c(cur_rows, i) and checks the total against content_height_in. When + overflow is detected, the previously committed (cur_rows, committed_rh) + pair is flushed — committed_rh captures the orphan-correct heights for + the row that landed alone at the page boundary. ``` --- diff --git a/design/DECISIONS.md b/design/DECISIONS.md index 19d9617..bf2d2e2 100644 --- a/design/DECISIONS.md +++ b/design/DECISIONS.md @@ -900,3 +900,137 @@ bug being fixed. Users who want the old behavior can pass grob check), `R/table_columns.R` (`compute_col_widths()` per-column + total-width checks), `R/table_pagelist.R` (thread the arg through), `R/export_tfl.R` (`@inheritDotParams`). + +--- + +## D-40: Group-label rowspan-style flow (issue #29) + +**Decision:** When a group column's value is multi-line, do not force its +row to be tall enough to fit the whole label. Instead let the label flow +through the suppressed (blanked) cells in the rows below it the same way +HTML `