diff --git a/CLAUDE.md b/CLAUDE.md index d77a888..036b0b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,6 +123,21 @@ When `sub_tfl` is set on a `tfl_table`: - when the global `caption` is `NULL`, the suffix becomes the entire caption (no leading prefix). +### Spanning (multi-row) column headers (D-53) + +`tfl_table(col_header_sep = "|||")` (a single `NA` disables) splits each column +`label` on the separator into stacked, **bottom-aligned** header rows; adjacent +columns with equal text in a row merge into one spanning cell. Merging is +**hierarchical** (only within a shared parent span; the row-header/data divide +is a hard boundary; empty cells are transparent singletons). Merge comparison +uses raw text; display right-trims, so a trailing space prevents a merge. A +spanner's width is the **sum of the columns beneath it**, and a spanned block is +**atomic** across column-continuation pages (an over-wide atom routes to +`overflow_action`). `"\n"` still line-breaks *within* a single header cell. When +no label contains the separator the header is a single row and output is +byte-identical to the pre-feature behavior (`R == 1` short-circuit). See D-53 +and `.compute_header_spans()` in `R/table_utils.R`. + --- ## Key behavioral rules (implement exactly as specified) @@ -394,14 +409,22 @@ writetfl/ │ │ .paginate_oversized_group() │ ├── reexports.R ← re-exports unit, gpar from grid │ ├── table_columns.R ← resolve_col_specs(), compute_col_widths(), -│ │ paginate_cols() -│ ├── table_rows.R ← measure_row_heights_tbl(), paginate_rows() +│ │ .apply_header_span_widths(), +│ │ .data_atoms(), paginate_cols() +│ ├── table_rows.R ← .span_deficit() (shared row/col span +│ │ kernel), measure_row_heights_tbl(), +│ │ paginate_rows() │ ├── table_draw.R ← build_table_grob(), -│ │ drawDetails.tfl_table_grob() +│ │ drawDetails.tfl_table_grob(), +│ │ .draw_header_block() (spanning header) │ ├── table_pagelist.R ← tfl_table_to_pagelist(), │ │ compute_table_content_area() -│ └── table_utils.R ← .make_outer_vp(), measurement/formatting -│ helpers shared across table_* files +│ └── table_utils.R ← .make_outer_vp(), .compute_header_spans() +│ (spanning-header structure), +│ .measure_header_block(), +│ .slice_header_spans(), and +│ measurement/formatting helpers shared +│ across table_* files ├── tests/ │ ├── testthat.R │ └── testthat/ diff --git a/R/table_columns.R b/R/table_columns.R index 09ca272..91a337e 100644 --- a/R/table_columns.R +++ b/R/table_columns.R @@ -97,6 +97,7 @@ compute_col_widths <- function(resolved_cols, data, content_width_in, overflow_action = c("error", "warn"), validate_overflow = TRUE, floor_overrides = NULL, + spans = NULL, cache = NULL) { overflow_action <- match.arg(overflow_action) strategy <- tbl$col_split_strategy %||% "balanced" @@ -108,6 +109,14 @@ compute_col_widths <- function(resolved_cols, data, content_width_in, cache = cache ) + # Spanning-header natural-width constraint: each above-leaf spanner cell + # must be at least as wide as the sum of the columns beneath it. No-op + # when there is no spanning header (setup$widths_natural unchanged). + setup$widths_natural <- .apply_header_span_widths( + setup$widths_natural, setup$resolved_cols, spans, tbl, + setup$h_pad_in, mode = "natural", margins = margins + ) + # Dispatch. Each strategy returns list(resolved_cols, col_groups). if (identical(strategy, "wrap_first")) { res <- .compute_col_widths_wrap_first( @@ -124,7 +133,8 @@ compute_col_widths <- function(resolved_cols, data, content_width_in, h_pad_in = setup$h_pad_in, min_in = setup$min_in, n_grp = setup$n_grp, - breaks = setup$breaks + breaks = setup$breaks, + spans = spans ) } else { res <- .compute_col_widths_balanced( @@ -142,7 +152,8 @@ compute_col_widths <- function(resolved_cols, data, content_width_in, min_in = setup$min_in, n_grp = setup$n_grp, breaks = setup$breaks, - floor_overrides = floor_overrides + floor_overrides = floor_overrides, + spans = spans ) } @@ -200,7 +211,11 @@ compute_col_widths <- function(resolved_cols, data, content_width_in, hdr_gp <- .gp_with_lineheight( .resolve_table_gp(tbl$gp, "header_row"), tbl$line_height ) - parts <- .split_col_strings(data[[cs$col]], cs$label, na_str, max_rows) + # Per-column header width uses only the column's leaf (bottom) segment; + # spanning super-header rows are accounted for separately by + # .apply_header_span_widths(). For non-spanning tables leaf == label. + parts <- .split_col_strings(data[[cs$col]], cs$leaf_label %||% cs$label, + na_str, max_rows) cell_key <- paste0(if (cs$is_group_col) "group_col" else "data_row", "_lh", tbl$line_height) w_data <- .measure_max_string_width(parts$data, cell_gp, @@ -249,7 +264,10 @@ compute_col_widths <- function(resolved_cols, data, content_width_in, for (j in seq_len(n_cols)) { cs_j <- resolved_cols[[j]] if (is.logical(cs_j$wrap) && length(cs_j$wrap) == 1L && is.na(cs_j$wrap)) { - strings <- .collect_col_strings(data[[cs_j$col]], cs_j$label, + # Use the leaf (bottom) header segment, not the full label: spaces in a + # spanning super-header must not make the leaf column wrap-eligible. + strings <- .collect_col_strings(data[[cs_j$col]], + cs_j$leaf_label %||% cs_j$label, na_str, max_rows) resolved_cols[[j]]$wrap <- .column_has_breakable_text(strings, breaks) } @@ -277,7 +295,8 @@ compute_col_widths <- function(resolved_cols, data, content_width_in, content_width_in, tbl, pg_width, pg_height, margins, overflow_action, validate_overflow, - h_pad_in, min_in, n_grp, breaks) { + h_pad_in, min_in, n_grp, breaks, + spans = NULL) { n_cols <- length(resolved_cols) na_str <- tbl$na_string max_rows <- tbl$max_measure_rows @@ -313,13 +332,17 @@ compute_col_widths <- function(resolved_cols, data, content_width_in, cs }) col_groups <- paginate_cols(widths_in, content_width_in, n_grp, - tbl$allow_col_split, tbl$balance_col_pages) + tbl$allow_col_split, tbl$balance_col_pages, + spanned_gap = spans$spanned_gap) return(list(resolved_cols = resolved_cols, col_groups = col_groups)) } errors <- .check_col_overflow_per_col(widths_in, resolved_cols, n_grp, content_width_in, overflow_action, errors) + errors <- .check_span_atom_overflow(widths_in, spans, n_grp, + content_width_in, overflow_action, + errors) if (total_w > content_width_in + 1e-6 && !tbl$allow_col_split) { errors <- .check_total_width_overflow(widths_in, resolved_cols, content_width_in, overflow_action, @@ -335,7 +358,8 @@ compute_col_widths <- function(resolved_cols, data, content_width_in, cs }) col_groups <- paginate_cols(widths_in, content_width_in, n_grp, - tbl$allow_col_split, tbl$balance_col_pages) + tbl$allow_col_split, tbl$balance_col_pages, + spanned_gap = spans$spanned_gap) list(resolved_cols = resolved_cols, col_groups = col_groups) } @@ -366,7 +390,7 @@ compute_col_widths <- function(resolved_cols, data, content_width_in, pg_width, pg_height, margins, overflow_action, validate_overflow, h_pad_in, min_in, n_grp, breaks, - floor_overrides) { + floor_overrides, spans = NULL) { n_cols <- length(resolved_cols) na_str <- tbl$na_string max_rows <- tbl$max_measure_rows @@ -408,6 +432,18 @@ compute_col_widths <- function(resolved_cols, data, content_width_in, } } + # Spanning-header minimum-width constraint: each above-leaf spanner cell's + # longest unbreakable token must fit the sum of the columns beneath it + # (the super-header wraps to the span width at draw time, so only the token + # floor -- not the full header width -- constrains the minimum). Then + # re-establish the min <= natural invariant that .water_fill_to_budget() + # relies on. No-op without a spanning header. + widths_min <- .apply_header_span_widths( + widths_min, resolved_cols, spans, tbl, h_pad_in, mode = "min", + margins = margins + ) + widths_natural <- pmax(widths_natural, widths_min) + total_natural <- sum(widths_natural) total_min <- sum(widths_min) @@ -430,7 +466,8 @@ compute_col_widths <- function(resolved_cols, data, content_width_in, # on each page water-fill from natural down to that page's slack. col_groups <- paginate_cols( widths_min, content_width_in, n_grp, - tbl$allow_col_split, tbl$balance_col_pages + tbl$allow_col_split, tbl$balance_col_pages, + spanned_gap = spans$spanned_gap ) per_page_widths <- vector("list", length(col_groups)) @@ -499,6 +536,9 @@ compute_col_widths <- function(resolved_cols, data, content_width_in, errors <- .check_col_overflow_per_col(widths_in, resolved_cols, n_grp, content_width_in, overflow_action, errors) + errors <- .check_span_atom_overflow(widths_in, spans, n_grp, + content_width_in, overflow_action, + errors) if (sum(widths_in) > content_width_in + eps && !tbl$allow_col_split && length(col_groups) > 1L) { errors <- .check_total_width_overflow(widths_in, resolved_cols, @@ -564,6 +604,61 @@ compute_col_widths <- function(resolved_cols, data, content_width_in, widths_out } +# --------------------------------------------------------------------------- +# .apply_header_span_widths() - spanning-header width constraint +# --------------------------------------------------------------------------- + +# Raise per-column widths so every ABOVE-leaf spanning-header cell is at least +# as wide as the sum of the columns beneath it. `mode` selects the required +# width basis: +# "natural" -> full rendered header width (max over "\n" lines) + h_pad +# "min" -> longest unbreakable token + h_pad (the super-header wraps to +# the span width at draw, so its full width must not inflate the +# minimum) +# A cell's deficit (required minus the summed member widths) is distributed +# across its member columns proportional to their current width. Idempotent +# (a second call finds zero deficit) and a total no-op when there is no +# spanning header (spans$R <= 1), which keeps single-row-header tables +# byte-identical. Must run under an active graphics device (D-48); pushes its +# own outer viewport so width conversions resolve against the content area. +.apply_header_span_widths <- function(widths, resolved_cols, spans, tbl, + h_pad_in, mode, margins) { + if (is.null(spans) || spans$R <= 1L) return(widths) + + breaks <- tbl$wrap_breaks %||% wrap_breaks_default() + hdr_gp <- .gp_with_lineheight( + .resolve_table_gp(tbl$gp, "header_row"), tbl$line_height + ) + hdr_gp_key <- paste0("header_row_lh", tbl$line_height) + + outer_vp <- .make_outer_vp(margins) + grid::pushViewport(outer_vp) + on.exit(grid::popViewport(), add = TRUE) + + # Rows 1..R-1 sit ABOVE the leaf row (R); the leaf is already folded into + # each column's per-column natural / min width. + for (r in seq_len(spans$R - 1L)) { + for (cell in spans$cells_by_row[[r]]) { + txt <- cell$text + if (!nzchar(txt)) next + required <- if (identical(mode, "min")) { + .column_min_token_width_in(txt, hdr_gp, breaks) + h_pad_in + } else { + .measure_max_string_width(txt, hdr_gp, gp_key = hdr_gp_key) + h_pad_in + } + idx <- cell$start:cell$end + deficit <- .span_deficit(widths[idx], required) + if (deficit > 0) { + w <- widths[idx] + tot <- sum(w) + add <- if (tot > 0) deficit * w / tot else rep(deficit / length(w), length(w)) + widths[idx] <- w + add + } + } + } + widths +} + # --------------------------------------------------------------------------- # Shared overflow-check helpers # --------------------------------------------------------------------------- @@ -631,6 +726,50 @@ compute_col_widths <- function(resolved_cols, data, content_width_in, .overflow_signal(msg, overflow_action, errors) } +# Overflow check for spanning-header atoms. A multi-column span keeps its +# columns together on a page, so its combined width (plus the repeated group +# columns) must fit the content width; unlike a per-column overflow this +# cannot be resolved by column pagination, so it is checked regardless of +# `allow_col_split`. No-op without a spanning header. +.check_span_atom_overflow <- function(widths_in, spans, n_grp, + content_width_in, overflow_action, + errors) { + if (is.null(spans) || spans$R <= 1L) return(errors) + n_cols <- length(widths_in) + atoms <- .data_atoms(spans$spanned_gap, n_grp, n_cols) + grp_w <- if (n_grp > 0L) sum(widths_in[seq_len(n_grp)]) else 0 + for (a in atoms) { + if (length(a) < 2L) next + aw <- sum(widths_in[a]) + if (grp_w + aw > content_width_in + 1e-6) { + lbl <- .span_atom_label(spans, a) + msg <- sprintf( + paste0("Spanning header '%s' over %d columns (%.3g in) plus group ", + "columns (%.3g in) = %.3g in exceeds available content width ", + "(%.3g in); a spanned block cannot be split across pages"), + lbl, length(a), aw, grp_w, grp_w + aw, content_width_in + ) + errors <- .overflow_signal(msg, overflow_action, errors) + } + } + errors +} + +# Representative header label for an atom's columns (the outermost non-empty +# spanner cell covering the whole atom), for use in overflow messages. +.span_atom_label <- function(spans, atom_cols) { + lo <- min(atom_cols) + hi <- max(atom_cols) + for (r in seq_len(spans$R - 1L)) { + for (cell in spans$cells_by_row[[r]]) { + if (cell$start <= lo && cell$end >= hi && nzchar(cell$text)) { + return(cell$text) + } + } + } + "(spanned)" +} + # --------------------------------------------------------------------------- # paginate_cols() — split data column indices into groups # --------------------------------------------------------------------------- @@ -646,53 +785,102 @@ compute_col_widths <- function(resolved_cols, data, content_width_in, #' #' @return List of integer vectors (column indices into resolved_cols). #' @keywords internal +# Group data columns into atoms: maximal runs of consecutive data columns +# joined by a spanned gap (a gap covered by a multi-column header span). An +# atom is indivisible for pagination so a spanning header is never split +# across column-continuation pages. Returns a list of integer vectors of +# column indices (into widths_in / resolved_cols), in left-to-right order. +# `spanned_gap` is NULL or a logical vector of length n_cols-1; NULL (or all +# FALSE) yields one atom per data column, reproducing the pre-feature greedy +# behaviour exactly. +.data_atoms <- function(spanned_gap, n_group_cols, n_cols) { + n_data <- n_cols - n_group_cols + if (n_data <= 0L) return(list()) + data_idx <- seq_len(n_data) + n_group_cols + + atoms <- list() + cur <- data_idx[[1L]] + if (n_data >= 2L) { + for (t in 2L:n_data) { + j_prev <- data_idx[[t - 1L]] + spanned <- !is.null(spanned_gap) && length(spanned_gap) >= j_prev && + isTRUE(spanned_gap[[j_prev]]) + if (spanned) { + cur <- c(cur, data_idx[[t]]) + } else { + atoms[[length(atoms) + 1L]] <- cur + cur <- data_idx[[t]] + } + } + } + atoms[[length(atoms) + 1L]] <- cur + atoms +} + +#' Split data columns into groups that fit within content_width_in +#' +#' Group columns (first n_group_cols) are always included in every group. +#' Data columns are greedily packed left-to-right in units of *atoms* (a +#' multi-column header span keeps its columns together; see [`.data_atoms()`]). +#' When `balance_col_pages` is `TRUE` and the greedy pass produces more than +#' one page, atoms are redistributed so that each page receives approximately +#' the same number of atoms (while still verifying that each balanced group +#' fits within the available width). +#' +#' @param spanned_gap NULL or a logical vector (length `n_cols-1`) marking +#' gaps covered by a multi-column header span; those gaps are never split. +#' @return List of integer vectors (column indices into resolved_cols). +#' @keywords internal paginate_cols <- function(widths_in, content_width_in, n_group_cols, - allow_col_split, balance_col_pages = FALSE) { + allow_col_split, balance_col_pages = FALSE, + spanned_gap = NULL) { n_cols <- length(widths_in) n_data <- n_cols - n_group_cols grp_w <- if (n_group_cols > 0L) sum(widths_in[seq_len(n_group_cols)]) else 0 avail_w <- content_width_in - grp_w - data_idx <- seq_len(n_data) + n_group_cols # 1-based into widths_in if (n_data == 0L) return(list(seq_len(n_group_cols))) - # --- Greedy left-to-right pagination --- + atoms <- .data_atoms(spanned_gap, n_group_cols, n_cols) + atom_w <- vapply(atoms, function(a) sum(widths_in[a]), numeric(1L)) + + # --- Greedy left-to-right pagination over atoms --- groups <- list() current_idxs <- integer(0L) current_w <- 0 - for (j in data_idx) { - col_w <- widths_in[[j]] - if (current_w + col_w > avail_w + 1e-6 && length(current_idxs) > 0L) { + for (a in seq_along(atoms)) { + aw <- atom_w[[a]] + if (current_w + aw > avail_w + 1e-6 && length(current_idxs) > 0L) { groups <- c(groups, list(c(seq_len(n_group_cols), current_idxs))) - current_idxs <- j - current_w <- col_w + current_idxs <- atoms[[a]] + current_w <- aw } else { - current_idxs <- c(current_idxs, j) - current_w <- current_w + col_w + current_idxs <- c(current_idxs, atoms[[a]]) + current_w <- current_w + aw } } if (length(current_idxs) > 0L) { groups <- c(groups, list(c(seq_len(n_group_cols), current_idxs))) } - # --- Optional: balance columns evenly across pages --- + # --- Optional: balance atoms evenly across pages --- if (balance_col_pages && length(groups) > 1L) { p <- length(groups) - base <- n_data %/% p - extra <- n_data %% p - # Sizes: first 'extra' pages get (base+1), the rest get base + n_atom <- length(atoms) + base <- n_atom %/% p + extra <- n_atom %% p + # Sizes: first 'extra' pages get (base+1) atoms, the rest get base sizes <- c(rep(base + 1L, extra), rep(base, p - extra)) - # Build candidate balanced groups from those sizes balanced <- vector("list", p) offset <- 0L ok <- TRUE for (k in seq_len(p)) { - idxs <- data_idx[offset + seq_len(sizes[[k]])] - offset <- offset + sizes[[k]] - page_w <- sum(widths_in[idxs]) - if (page_w > avail_w + 1e-6) { ok <- FALSE; break } + atom_slice <- atoms[offset + seq_len(sizes[[k]])] + offset <- offset + sizes[[k]] + idxs <- unlist(atom_slice, use.names = FALSE) + if (sum(widths_in[idxs]) > avail_w + 1e-6) { ok <- FALSE; break } balanced[[k]] <- c(seq_len(n_group_cols), idxs) } if (ok) groups <- balanced diff --git a/R/table_draw.R b/R/table_draw.R index 4498816..c0ecf7a 100644 --- a/R/table_draw.R +++ b/R/table_draw.R @@ -67,7 +67,9 @@ build_table_grob <- function(row_page, col_group_idx, n_group_cols, cont_row_h_in = NULL, is_first_col_page = TRUE, is_last_col_page = TRUE, - clip_width_caches = NULL) { + clip_width_caches = NULL, + header_spans = NULL, + header_block = NULL) { # Subset to display columns for this page page_cols <- resolved_cols[col_group_idx] @@ -87,6 +89,10 @@ build_table_grob <- function(row_page, col_group_idx, n_group_cols, # drawDetails will create # per-page envs (e.g. grobs # assembled outside pipeline) + header_spans = header_spans, # full-table span structure + # (NULL -> single-row header) + header_block = header_block, # cached per-row header heights + # + total; NULL -> recompute cl = "tfl_table_grob" ) } @@ -150,12 +156,25 @@ drawDetails.tfl_table_grob <- function(x, recording) { lh <- tbl$line_height %||% 1.05 # defensive fallback for old grob objects - # Header row height (delegates to the same helper used during pagination so - # any auto-wrapping of column labels is accounted for here too). - header_row_h <- if (tbl$show_col_names) { - .measure_header_row_height(page_cols, gp_tbl, cp, lh, breaks = breaks, - wrap_extra_pad_in = wrap_extra_pad_in) - } else 0 + # Spanning-header structure sliced to this page's columns (group columns + # first, then a contiguous data range). NULL / R == 1 -> single-row header. + spans_full <- x$header_spans + spans_page <- if (!is.null(spans_full)) { + .slice_header_spans(spans_full, x$col_group_idx) + } else NULL + + # Header block height. Prefer the per-row heights cached during pagination + # (full-table, uniform across pages); recompute for grobs built outside the + # normal pipeline. Delegates to the single-row helper when R == 1. + header_block <- if (!is.null(x$header_block)) { + x$header_block + } else if (tbl$show_col_names) { + .measure_header_block(page_cols, spans_page, gp_tbl, cp, lh, + breaks = breaks, wrap_extra_pad_in = wrap_extra_pad_in) + } else { + list(row_heights = numeric(0L), total = 0) + } + header_row_h <- if (tbl$show_col_names) header_block$total else 0 # Continuation row height — prefer cached value cont_row_h <- if (!is.null(x$cont_row_h_in)) { @@ -275,11 +294,20 @@ drawDetails.tfl_table_grob <- function(x, recording) { gp = grid::gpar(fill = hdr_gp_full$fill, col = NA) ) } - .draw_header_row(page_cols, col_x_left, col_x_right, col_widths_in, - y_cursor, header_row_h, vp_w, vp_h, - h_lft_in, h_rgt_in, v_top_in, gp_tbl, lh, - breaks = breaks, - text_dim_cache = text_dim_cache) + if (!is.null(spans_page) && spans_page$R > 1L) { + .draw_header_block(page_cols, col_x_left, col_x_right, + y_cursor, header_block$row_heights, spans_page, + vp_w, vp_h, h_lft_in, h_rgt_in, v_top_in, gp_tbl, lh, + breaks = breaks, + span_rule = isTRUE(tbl$col_header_span_rule %||% TRUE), + text_dim_cache = text_dim_cache) + } else { + .draw_header_row(page_cols, col_x_left, col_x_right, col_widths_in, + y_cursor, header_row_h, vp_w, vp_h, + h_lft_in, h_rgt_in, v_top_in, gp_tbl, lh, + breaks = breaks, + text_dim_cache = text_dim_cache) + } y_cursor <- y_cursor + header_row_h # Column header rule — spans table width only @@ -571,6 +599,85 @@ drawDetails.tfl_table_grob <- function(x, recording) { } } +# Draw the multi-row / spanning column-header block. +# +# `spans_page` is the page-local span structure from `.slice_header_spans()`; +# `row_heights` are the per-row band heights (from the cached header block). +# Each non-empty span cell is drawn once, centered across the columns beneath +# it (`col_x_left[a] .. col_x_right[b]`), wrapped to that span width, reusing +# `.draw_cell_text()`. The leaf (bottom) row is drawn here too. +# +# When `span_rule` is TRUE, an underline is drawn beneath each multi-column +# spanner cell in the above-leaf rows. Where two spanner underlines are +# horizontally adjacent, each is inset by half the cell's side padding so the +# two rules are separated by a gap equal to one side margin (the underlines +# read as distinct group underlines). +.draw_header_block <- function(page_cols, col_x_left, col_x_right, + y_top_in, row_heights, spans_page, + vp_w, vp_h, h_lft_in, h_rgt_in, v_top_in, + gp_tbl, lh, breaks = NULL, + span_rule = TRUE, text_dim_cache = NULL) { + hdr_gp <- .gp_with_lineheight(.resolve_table_gp(gp_tbl, "header_row"), lh) + hdr_gp_key <- paste0("header_row_lh", lh) + h_pad_in <- h_lft_in + h_rgt_in + + # Span-rule style: an explicit gp$col_header_span_rule wins, otherwise the + # underline inherits the gp$col_header_rule style. + span_rule_gp <- if (span_rule) { + if (is.list(gp_tbl) && !is.null(gp_tbl[["col_header_span_rule"]])) { + .resolve_table_gp(gp_tbl, "col_header_span_rule") + } else { + .resolve_table_gp(gp_tbl, "col_header_rule") + } + } else NULL + gap_half <- (h_lft_in + h_rgt_in) / 4 # half the side-margin gap + + y <- y_top_in + for (r in seq_len(spans_page$R)) { + row_h <- row_heights[[r]] + cells <- spans_page$cells_by_row[[r]] + for (cell in cells) { + if (!nzchar(cell$text)) next + x_left <- col_x_left[[cell$start]] + x_right <- col_x_right[[cell$end]] + span_w <- x_right - x_left + label <- .wrap_header_cell(cell$text, span_w, h_pad_in, hdr_gp, breaks) + .draw_cell_text(label, "centre", + x_left, x_right, y, row_h, vp_w, vp_h, + h_lft_in, h_rgt_in, v_top_in, + hdr_gp, span_w, + text_dim_cache = text_dim_cache, + gp_key = hdr_gp_key) + } + + # Underlines beneath multi-column spanners (not the leaf row: the leaf is + # followed by the full-width col_header_rule). + if (span_rule && r < spans_page$R) { + y_rule_npc <- 1 - (y + row_h) / vp_h + n_cells <- length(cells) + for (ci in seq_len(n_cells)) { + cell <- cells[[ci]] + if (cell$end <= cell$start) next # only true (multi-col) spans + x_l <- col_x_left[[cell$start]] + x_r <- col_x_right[[cell$end]] + # Inset only where the neighbour is itself a spanner, so a gap opens + # between two adjacent group underlines (but not at the block edges + # or beside a single column). + left_nb <- if (ci > 1L) cells[[ci - 1L]] else NULL + right_nb <- if (ci < n_cells) cells[[ci + 1L]] else NULL + if (!is.null(left_nb) && left_nb$end > left_nb$start) x_l <- x_l + gap_half + if (!is.null(right_nb) && right_nb$end > right_nb$start) x_r <- x_r - gap_half + grid::grid.lines( + x = grid::unit(c(x_l / vp_w, x_r / vp_w), "npc"), + y = grid::unit(c(y_rule_npc, y_rule_npc), "npc"), + gp = span_rule_gp + ) + } + } + y <- y + row_h + } +} + # Draw a continuation-marker row .draw_cont_row <- function(msg, n_group_cols, n_disp_cols, col_x_left, col_x_right, diff --git a/R/table_pagelist.R b/R/table_pagelist.R index 0da1437..1266257 100644 --- a/R/table_pagelist.R +++ b/R/table_pagelist.R @@ -153,6 +153,20 @@ tfl_table_to_pagelist <- function(tbl, pg_width, pg_height, dots, resolved_cols <- resolve_col_specs(tbl) n_group_cols <- length(tbl$group_vars) + # Spanning (multi-row) column-header structure, computed once on the full + # column set. Attaches each column's leaf (bottom-row) segment so width + # measurement uses only the leaf; the full span structure drives width + # adjustment, atomic pagination, and drawing. Trivial (R == 1) when no + # label contains `col_header_sep`, in which case every downstream step is + # byte-identical to the single-row-header behaviour. + header_spans <- .compute_header_spans( + vapply(resolved_cols, function(cs) cs$label, character(1L)), + tbl$col_header_sep, n_group_cols + ) + for (j in seq_along(resolved_cols)) { + resolved_cols[[j]]$leaf_label <- header_spans$leaf_labels[[j]] + } + # --- Step 4-6: Compute column widths, measure row heights, paginate --- # Under col_split_strategy = "balanced", a row whose wrapped height exceeds # the available page content height triggers a retry: the bottleneck @@ -202,12 +216,17 @@ tfl_table_to_pagelist <- function(tbl, pg_width, pg_height, dots, grid::pushViewport(rh_outer_vp) on.exit(grid::popViewport(), add = TRUE) - header_row_h <- if (tbl$show_col_names) { - .measure_header_row_height(resolved_cols, tbl$gp, tbl$cell_padding, - tbl$line_height, breaks = breaks, - wrap_extra_pad_in = wrap_extra_pad_in, - cache = text_dim_cache) - } else 0 + # Multi-row / spanning header block. $total drives pagination; the + # per-row heights are cached for drawing so measure and draw agree. + header_block <- if (tbl$show_col_names) { + .measure_header_block(resolved_cols, header_spans, tbl$gp, + tbl$cell_padding, tbl$line_height, breaks = breaks, + wrap_extra_pad_in = wrap_extra_pad_in, + cache = text_dim_cache) + } else { + list(row_heights = numeric(0L), total = 0) + } + header_row_h <- header_block$total cell_h_mat <- measure_row_heights_tbl( tbl$data, resolved_cols, tbl$gp, tbl$cell_padding, @@ -236,9 +255,10 @@ tfl_table_to_pagelist <- function(tbl, pg_width, pg_height, dots, } pr_res <- do.call(paginate_rows, pr_args) list( - pr_res = pr_res, - cell_h_mat = cell_h_mat, - cont_row_h = cont_row_h + pr_res = pr_res, + cell_h_mat = cell_h_mat, + cont_row_h = cont_row_h, + header_block = header_block ) } @@ -247,6 +267,7 @@ tfl_table_to_pagelist <- function(tbl, pg_width, pg_height, dots, resolved_cols_0, tbl$data, cw, tbl, pg_width, pg_height, margins, overflow_action = overflow_action, floor_overrides = floor_overrides, + spans = header_spans, cache = text_dim_cache ) resolved_cols <- col_result$resolved_cols @@ -270,6 +291,7 @@ tfl_table_to_pagelist <- function(tbl, pg_width, pg_height, dots, overflow_action = overflow_action, validate_overflow = FALSE, floor_overrides = floor_overrides, + spans = header_spans, cache = text_dim_cache ) resolved_cols <- col_result$resolved_cols @@ -280,9 +302,10 @@ tfl_table_to_pagelist <- function(tbl, pg_width, pg_height, dots, if (use_retry_loop && retries < max_retries) { iter_res <- .run_pagination_iter(resolved_cols, collect_overflows = TRUE) if (length(iter_res$pr_res$overflows) == 0L) { - row_pages <- iter_res$pr_res$pages - cell_h_mat <- iter_res$cell_h_mat - cont_row_h <- iter_res$cont_row_h + row_pages <- iter_res$pr_res$pages + cell_h_mat <- iter_res$cell_h_mat + cont_row_h <- iter_res$cont_row_h + header_block <- iter_res$header_block break } # Raise the bottleneck column's floor for the next retry. @@ -305,9 +328,10 @@ tfl_table_to_pagelist <- function(tbl, pg_width, pg_height, dots, # No retries left (or wrap_first mode): make the final call with the # user's overflow_action so error/warn fires through the normal path. iter_res <- .run_pagination_iter(resolved_cols, collect_overflows = FALSE) - row_pages <- iter_res$pr_res - cell_h_mat <- iter_res$cell_h_mat - cont_row_h <- iter_res$cont_row_h + row_pages <- iter_res$pr_res + cell_h_mat <- iter_res$cell_h_mat + cont_row_h <- iter_res$cont_row_h + header_block <- iter_res$header_block break } } @@ -339,7 +363,9 @@ tfl_table_to_pagelist <- function(tbl, pg_width, pg_height, dots, cont_row_h_in = cont_row_h, is_first_col_page = (cg == 1L), is_last_col_page = (cg == n_cg), - clip_width_caches = clip_width_caches + clip_width_caches = clip_width_caches, + header_spans = header_spans, + header_block = header_block ) page_spec <- list(content = grob) pages[[idx]] <- page_spec diff --git a/R/table_rows.R b/R/table_rows.R index 7c71d23..0471659 100644 --- a/R/table_rows.R +++ b/R/table_rows.R @@ -5,6 +5,20 @@ # .compute_page_row_heights() — resolve per-row heights with group spanning # paginate_rows() — split rows into pages (span-aware) +# --------------------------------------------------------------------------- +# .span_deficit() — shared span-extent kernel +# --------------------------------------------------------------------------- + +# The amount by which a span's `required` extent exceeds the summed extent of +# its members, or 0 when the members already cover it (within `eps`). Shared +# by the row-span height logic (below, deficit applied to the span-start row) +# and the column-span width logic in R/table_columns.R (deficit distributed +# across members). +.span_deficit <- function(members, required, eps = 1e-6) { + d <- required - sum(members) + if (d > eps) d else 0 +} + # --------------------------------------------------------------------------- # measure_row_heights_tbl() — per-cell height matrix # --------------------------------------------------------------------------- @@ -185,10 +199,11 @@ measure_row_heights_tbl <- function(data, resolved_cols, gp_tbl, cell_padding, 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) - } + # Deficit (label taller than the summed span rows) grows only the + # span-start row, so a multi-line label flows downward into the + # suppressed cells. eps kept at 1e-9 to preserve prior heights. + deficit <- .span_deficit(row_h[ri_start:ri_end], label_h, eps = 1e-9) + row_h[[ri_start]] <- row_h[[ri_start]] + deficit } } row_h diff --git a/R/table_utils.R b/R/table_utils.R index 6e3db4c..9161ed4 100644 --- a/R/table_utils.R +++ b/R/table_utils.R @@ -76,6 +76,86 @@ }, numeric(1L))) + v_pad_in } +# Wrap a spanning-header cell to the width of the columns beneath it (span +# width minus horizontal padding). Shared by the header-block measurement +# and drawing paths so their line counts and heights agree. +.wrap_header_cell <- function(text, span_width_in, h_pad_in, gp, breaks) { + .wrap_label_for_width(text, span_width_in, h_pad_in, gp, breaks) +} + +# Measure the multi-row column-header block. +# +# Returns list(row_heights = numeric(R), total = sum(row_heights)). Row `r`'s +# height is the max over that row's non-empty span cells of the wrapped cell +# height (each cell wrapped to the width of the columns beneath it) plus +# vertical padding, with `wrap_extra_pad_in` added for multi-line cells. +# +# When `spans` is trivial (R == 1) this delegates to the single-row +# `.measure_header_row_height()` so non-spanning tables measure byte-identically. +# Must be called while a viewport with the header font context is active; uses +# the resolved column widths (`cs$width_in`) to size span cells. +.measure_header_block <- function(resolved_cols, spans, gp_tbl, cell_padding, + line_height, breaks = NULL, + wrap_extra_pad_in = 0, cache = NULL) { + if (is.null(spans) || spans$R <= 1L) { + h <- .measure_header_row_height(resolved_cols, gp_tbl, cell_padding, + line_height, breaks = breaks, + wrap_extra_pad_in = wrap_extra_pad_in, + cache = cache) + return(list(row_heights = h, total = h)) + } + + 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"]]) + h_pad_in <- h_lft_in + h_rgt_in + hdr_gp <- .gp_with_lineheight(.resolve_table_gp(gp_tbl, "header_row"), + line_height) + gp_key <- paste0("header_row_lh", line_height) + col_w <- vapply(resolved_cols, function(cs) cs$width_in %||% 0, numeric(1L)) + + row_heights <- vapply(seq_len(spans$R), function(r) { + cells <- spans$cells_by_row[[r]] + hs <- vapply(cells, function(cell) { + if (!nzchar(cell$text)) return(0) + span_w <- sum(col_w[cell$start:cell$end]) + label <- .wrap_header_cell(cell$text, span_w, h_pad_in, hdr_gp, breaks) + nlines <- max(1L, length(strsplit(label, "\n", fixed = TRUE)[[1L]])) + h_grob <- .measure_text_dims_in(label, hdr_gp, gp_key, cache)$h + h_line <- nlines * .height_in(grid::stringHeight("M")) + extra <- if (nlines > 1L) wrap_extra_pad_in else 0 + max(h_grob, h_line) + extra + }, numeric(1L)) + (if (length(hs) > 0L) max(hs) else 0) + v_pad_in + }, numeric(1L)) + + list(row_heights = row_heights, total = sum(row_heights)) +} + +# Slice a full-table span structure down to the columns shown on one page. +# `col_group_idx` is the vector of full column indices for the page (group +# columns first, then a contiguous data range). Because multi-column spans +# are atomic in pagination and group columns are singletons, every span cell +# is either wholly on the page or wholly off it; on-page cells are re-indexed +# to the page-local column positions. +.slice_header_spans <- function(spans, col_group_idx) { + loc <- function(full_j) match(full_j, col_group_idx) + cells_by_row <- lapply(spans$cells_by_row, function(cells) { + keep <- list() + for (cell in cells) { + cols <- cell$start:cell$end + if (all(cols %in% col_group_idx)) { + keep[[length(keep) + 1L]] <- list(start = loc(cell$start), + end = loc(cell$end), + text = cell$text) + } + } + keep + }) + list(R = spans$R, cells_by_row = cells_by_row) +} + # Measure height of a continuation-marker row .measure_cont_row_height <- function(row_cont_msg, gp_tbl, cell_padding, line_height) { @@ -242,6 +322,128 @@ c(strsplit(label, "\n", fixed = TRUE)[[1L]], data_strs) } +# --------------------------------------------------------------------------- +# Spanning (multi-row) column header helpers +# --------------------------------------------------------------------------- + +# Split a header label into segments on `sep`, preserving trailing empty +# fields (which strsplit() drops). A blank field is produced by two +# separators back-to-back, so "" between them must survive. +.split_header_label <- function(label, sep) { + if (!nzchar(label)) return("") + segs <- strsplit(label, sep, fixed = TRUE)[[1L]] + m <- gregexpr(sep, label, fixed = TRUE)[[1L]] + n_sep <- if (length(m) == 1L && m[[1L]] == -1L) 0L else length(m) + want <- n_sep + 1L + if (length(segs) < want) segs <- c(segs, rep("", want - length(segs))) + if (length(segs) == 0L) segs <- "" + segs +} + +# Compute the multi-row / spanning structure of a set of column header labels. +# +# `labels` : character vector of full labels, in display order (group +# columns first, then data columns). +# `sep` : separator token, or NULL / a single NA to disable the feature. +# `n_grp` : number of leading row-header (group) columns. +# +# Returns a list: +# $R integer number of stacked header rows. +# $cells_by_row list length R; each element a list of cells partitioning +# columns 1..n. A cell is list(start, end, text) where +# `text` is the DISPLAY (right-trimmed) label for that cell. +# Empty cells are single-column with text = "". +# $spanned_gap logical length n-1; TRUE where a multi-column cell covers +# the gap between column j and j+1 (used for atomic +# pagination). +# $leaf_labels character length n; the display (trimmed) bottom-row +# segment per column (fed to per-column width measurement). +# +# Bottom-aligned: a column with fewer segments than R fills the BOTTOM rows. +# Spans are detected by a closed-boundary refinement (hierarchical): merges +# happen only within a shared parent span, empty cells are transparent +# singletons, and the group/data divide is always a hard boundary. +# +# When the feature is off, no label contains `sep`, or R collapses to 1, the +# trivial single-row structure is returned with labels verbatim so that +# downstream measurement and drawing are byte-identical to the pre-feature +# behaviour. +.compute_header_spans <- function(labels, sep, n_grp) { + n <- length(labels) + + trivial <- function() { + list( + R = 1L, + cells_by_row = list(lapply(seq_len(n), function(j) { + list(start = j, end = j, text = labels[[j]]) + })), + spanned_gap = if (n >= 2L) rep(FALSE, n - 1L) else logical(0L), + leaf_labels = labels + ) + } + + if (is.null(sep) || .is_single_na(sep) || n == 0L) return(trivial()) + + seg_list <- lapply(labels, .split_header_label, sep = sep) + R <- max(vapply(seg_list, length, integer(1L))) + if (R <= 1L) return(trivial()) + + # Bottom-aligned segment matrices: raw for merge comparison, display + # (right-trimmed) for rendering and width measurement. + S_raw <- matrix("", nrow = R, ncol = n) + for (j in seq_len(n)) { + s <- seg_list[[j]] + k <- length(s) + if (k > 0L) S_raw[(R - k + 1L):R, j] <- s + } + S_disp <- matrix(sub("[ \t]+$", "", S_raw), nrow = R, ncol = n) + + n_gap <- max(0L, n - 1L) + closed <- rep(FALSE, n_gap) + if (n_grp > 0L && n_grp < n) closed[[n_grp]] <- TRUE # group/data divide + spanned_gap <- rep(FALSE, n_gap) + cells_by_row <- vector("list", R) + + for (r in seq_len(R)) { + cells <- list() + j <- 1L + while (j <= n) { + k <- j + # Extend the run while the next gap is open and both cells share equal + # non-empty raw text (empty cells stop the run -> singletons). + while (k < n && !closed[[k]] && + nzchar(S_raw[r, k]) && + identical(S_raw[r, k], S_raw[r, k + 1L])) { + k <- k + 1L + } + cells[[length(cells) + 1L]] <- list(start = j, end = k, + text = S_disp[r, j]) + if (k > j) spanned_gap[j:(k - 1L)] <- TRUE + j <- k + 1L + } + cells_by_row[[r]] <- cells + + # Close boundaries for lower rows: a gap not inside the same non-empty + # cell becomes closed, UNLESS both sides are empty (empties stay + # transparent so a shared super-header below an empty top row can span). + if (r < R && n_gap > 0L) { + for (gap in seq_len(n_gap)) { + if (closed[[gap]]) next + same_cell <- any(vapply(cells, function(c) { + c$start <= gap && c$end >= gap + 1L + }, logical(1L))) + if (same_cell) next + if (nzchar(S_raw[r, gap]) || nzchar(S_raw[r, gap + 1L])) { + closed[[gap]] <- TRUE + } + } + } + } + + list(R = R, cells_by_row = cells_by_row, spanned_gap = spanned_gap, + leaf_labels = S_disp[R, ]) +} + # --------------------------------------------------------------------------- # Unit conversion helpers # --------------------------------------------------------------------------- @@ -357,6 +559,7 @@ header_row = grid::gpar(fontface = "bold"), continued = grid::gpar(fontface = "italic"), col_header_rule = grid::gpar(lwd = 1), + col_header_span_rule = grid::gpar(lwd = 1), group_rule = grid::gpar(lwd = 0.5, lty = "dotted"), row_rule = grid::gpar(lwd = 0.5), row_header_sep = grid::gpar(lwd = 0.5) diff --git a/R/tfl_table.R b/R/tfl_table.R index 681cf88..e03b11b 100644 --- a/R/tfl_table.R +++ b/R/tfl_table.R @@ -193,10 +193,32 @@ tfl_colspec <- function(col, #' shown at the **top** of a continuation page; the second is shown at the #' **bottom** of the preceding page. A length-1 value is recycled to both #' positions. Default: `c("(continued)", "(continued on next page)")`. +#' @param col_header_sep Character scalar separator used to build **spanning +#' (multi-row) column headers** from `label` strings, or a single `NA` to +#' disable the feature. Default `"|||"`. When a label contains the separator +#' it is split into stacked header rows (bottom-aligned: a shorter label +#' fills the lowest rows). Adjacent columns whose text is equal in a header +#' row merge into one spanning cell, but only within a shared parent span +#' (hierarchical) and never across the row-header/data divide. A spanning +#' header's width is the sum of the columns beneath it, and a spanned block +#' is never split across column-continuation pages. Encode a deliberately +#' blank row with two separators in a row (e.g. `"Top||||||Bottom"`). Merge +#' comparison uses the raw text while trailing whitespace is trimmed for +#' display, so appending a space (`"n "` vs `"n"`) prevents an unwanted +#' merge. `"\n"` still produces a line break *within* a single header cell +#' and is independent of this separator. When no label contains the +#' separator the header is a single row exactly as before. #' @param show_col_names Logical. If `FALSE`, the column header row is omitted #' and `col_header_rule` is also suppressed. #' @param col_header_rule Logical. If `TRUE` (default), a horizontal rule is #' drawn below the column header row. +#' @param col_header_span_rule Logical. If `TRUE` (default), a horizontal rule +#' is drawn beneath each multi-column spanning header cell (see +#' `col_header_sep`). Adjacent spanning groups' rules are separated by a gap +#' equal to the cell's horizontal (side) padding, so the rules read as +#' distinct group underlines. Has no effect when there are no spanning +#' headers. Style is controlled via `gp$col_header_span_rule` (defaults to the +#' `gp$col_header_rule` style). #' @param group_rule Logical. If `TRUE` (default), a horizontal rule is drawn #' between row groups. #' @param group_rule_after_last Logical. If `TRUE`, a rule is also drawn after @@ -226,6 +248,9 @@ tfl_colspec <- function(col, #' \item{`gp$group_col`}{Row-header column cells. Inherits `gp$table`.} #' \item{`gp$continued`}{Continuation-marker row text. Default: italic.} #' \item{`gp$col_header_rule`}{Style of the column-header rule.} +#' \item{`gp$col_header_span_rule`}{Style of the per-spanner underline +#' (see `col_header_span_rule`). Defaults to the `gp$col_header_rule` +#' style.} #' \item{`gp$group_rule`}{Style of between-group rules.} #' \item{`gp$row_rule`}{Style of between-row data rules.} #' \item{`gp$row_header_sep`}{Style of the vertical row-header separator.} @@ -325,8 +350,10 @@ tfl_table <- function(x, col_cont_msg = c("Columns continue from prior page", "Columns continue to next page"), row_cont_msg = c("(continued)", "(continued on next page)"), + col_header_sep = "|||", show_col_names = TRUE, col_header_rule = TRUE, + col_header_span_rule = TRUE, group_rule = TRUE, group_rule_after_last = FALSE, row_rule = FALSE, @@ -481,12 +508,26 @@ tfl_table <- function(x, checkmate::assert_character(sub_tfl_prefix, len = 1L, any.missing = FALSE, .var.name = "sub_tfl_prefix") + # --- Validate col_header_sep --- + # A single NA disables the spanning-header feature (mirrors the NA-as-absent + # convention used elsewhere in the package). Otherwise it must be a + # non-empty single string that does not contain a newline (which is reserved + # for line breaks *within* a header cell). + if (!.is_single_na(col_header_sep)) { + checkmate::assert_string(col_header_sep, min.chars = 1L, + .var.name = "col_header_sep") + if (grepl("\n", col_header_sep, fixed = TRUE)) { + rlang::abort('`col_header_sep` must not contain a newline ("\\n" breaks lines within a header cell).') + } + } + # --- Validate scalar logicals --- checkmate::assert_flag(allow_col_split, .var.name = "allow_col_split") checkmate::assert_flag(balance_col_pages, .var.name = "balance_col_pages") checkmate::assert_flag(suppress_repeated_groups, .var.name = "suppress_repeated_groups") checkmate::assert_flag(show_col_names, .var.name = "show_col_names") checkmate::assert_flag(col_header_rule, .var.name = "col_header_rule") + checkmate::assert_flag(col_header_span_rule, .var.name = "col_header_span_rule") checkmate::assert_flag(group_rule, .var.name = "group_rule") checkmate::assert_flag(group_rule_after_last, .var.name = "group_rule_after_last") checkmate::assert_flag(row_rule, .var.name = "row_rule") @@ -543,8 +584,10 @@ tfl_table <- function(x, sub_tfl_prefix = sub_tfl_prefix, col_cont_msg = col_cont_msg, row_cont_msg = row_cont_msg, + col_header_sep = col_header_sep, show_col_names = show_col_names, col_header_rule = col_header_rule, + col_header_span_rule = col_header_span_rule, group_rule = group_rule, group_rule_after_last = group_rule_after_last, row_rule = row_rule, @@ -628,6 +671,15 @@ print.tfl_table <- function(x, ...) { x$allow_col_split, x$suppress_repeated_groups, x$show_col_names)) cat(sprintf(" col_header_rule=%s group_rule=%s row_rule=%s row_header_sep=%s\n", x$col_header_rule, x$group_rule, x$row_rule, x$row_header_sep)) + sep <- x$col_header_sep + if (!.is_single_na(sep) && !is.null(sep)) { + labels <- vapply(resolved, function(cs) cs$label %||% cs$col, "") + spans <- .compute_header_spans(labels, sep, length(grp)) + if (spans$R > 1L) { + cat(sprintf(" spanning headers: %d rows (col_header_sep=\"%s\", span_rule=%s)\n", + spans$R, sep, x$col_header_span_rule %||% TRUE)) + } + } if (!is.null(x$col_cont_msg)) { cat(sprintf(" col_cont_msg: left=\"%s\" right=\"%s\"\n", x$col_cont_msg[[1L]], x$col_cont_msg[[2L]])) diff --git a/R/wrap.R b/R/wrap.R index 31f37a4..1e35bb2 100644 --- a/R/wrap.R +++ b/R/wrap.R @@ -444,7 +444,8 @@ wrap_breaks_default <- function() { hdr_gp <- .gp_with_lineheight( .resolve_table_gp(tbl$gp, "header_row"), tbl$line_height ) - parts <- .split_col_strings(data[[cs$col]], cs$label, na_str, max_rows) + parts <- .split_col_strings(data[[cs$col]], cs$leaf_label %||% cs$label, + na_str, max_rows) t_data <- .column_min_token_width_in(parts$data, cell_gp, breaks) t_hdr <- .column_min_token_width_in(parts$header, hdr_gp, breaks) max(min_in, max(t_data, t_hdr) + h_pad_in) @@ -635,7 +636,8 @@ wrap_breaks_default <- function() { hdr_gp <- .gp_with_lineheight( .resolve_table_gp(tbl$gp, "header_row"), tbl$line_height ) - parts <- .split_col_strings(data[[cs$col]], cs$label, na_str, max_rows) + parts <- .split_col_strings(data[[cs$col]], cs$leaf_label %||% cs$label, + na_str, max_rows) t_data <- .column_min_token_width_in(parts$data, cell_gp, breaks) t_hdr <- .column_min_token_width_in(parts$header, hdr_gp, breaks) floors[[j]] <- max(min_in, max(t_data, t_hdr) + h_pad_in) diff --git a/design/ARCHITECTURE.md b/design/ARCHITECTURE.md index 52bc86d..43f5e43 100644 --- a/design/ARCHITECTURE.md +++ b/design/ARCHITECTURE.md @@ -377,13 +377,13 @@ export_tfl(x = list_of_table1, ...) [exported] | `R/table1.R` | `export_tfl.table1()`, `table1_to_pagelist()`, `.extract_table1_annotations()`, `.table1_variable_groups()`, `.paginate_table1()`, `.paginate_oversized_group()` | | `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()`, `paginate_cols()` | +| `R/table_columns.R` | `resolve_col_specs()`, `compute_col_widths()`, `.apply_header_span_widths()`, `.data_atoms()`, `.check_span_atom_overflow()`, `.span_atom_label()`, `paginate_cols()` | | `R/wrap.R` | `wrap_breaks()`, `wrap_breaks_default()`, `.is_wrap_breaks()`, `.tokenize_for_wrap()`, `.leading_drop_run()`, `.convert_tabs()`, `.wrap_string()`, `.column_has_breakable_text()`, `.column_min_token_width_in()`, `.wrap_label_for_width()`, `.compute_wrapped_widths()` | -| `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_rows.R` | `.span_deficit()` (shared row/col span kernel), `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_header_block()` (multi-row/spanning header), `.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()`, `.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()` | +| `R/table_utils.R` | `.make_outer_vp()`, `.width_in()`, `.height_in()`, `.split_header_label()`, `.compute_header_spans()` (spanning-header structure), `.wrap_header_cell()`, `.measure_header_block()`, `.slice_header_spans()`, `.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()` | --- @@ -413,6 +413,27 @@ Output: list( A single `NA` (`.is_single_na()`) is treated the same as `NULL`. +### `.compute_header_spans(labels, sep, n_grp)` → `list` (D-53) + +``` +Input: labels character(n) full column labels, group cols first + sep character(1) | NA(1) | NULL separator (NA/NULL disables) + n_grp integer(1) number of leading row-header columns +Output: list( + R = integer(1), # stacked header rows (1 when disabled) + cells_by_row = list length R of # partition of columns 1..n per row; + list(list(start,end,text)), # text = display (r-trimmed) + spanned_gap = logical(n-1), # TRUE where a multi-col cell covers gap j + leaf_labels = character(n) # display bottom-row segment per column +) +``` + +Bottom-aligned; hierarchical closed-boundary merging (see D-53). `R == 1` +(feature off / no separator) returns labels verbatim so downstream is +byte-identical to the single-row-header path. `.slice_header_spans(spans, +col_group_idx)` re-indexes the cells to one page's columns for drawing; +`.measure_header_block()` returns `list(row_heights, total)`. + ### `normalize_rule(x)` → `FALSE | grob` ``` diff --git a/design/DECISIONS.md b/design/DECISIONS.md index 86cbe68..cb32c24 100644 --- a/design/DECISIONS.md +++ b/design/DECISIONS.md @@ -2170,3 +2170,81 @@ dropped. `normalize_text(NA)`, and `normalize_rule(NA)`; `test-export_tfl_page.R` covers the end-to-end NA-as-absent behavior for each argument and the mixed-vector boundary. + +--- + +## D-53: Spanning (multi-row) column headers via a separator token + +**Decision:** `tfl_table()` gains `col_header_sep` (default `"|||"`; a single +`NA` disables the feature via `.is_single_na()`). A column `label` containing +the separator is split into stacked, **bottom-aligned** header rows; adjacent +columns with equal text in a row merge into one spanning cell. A spanning +header's width is the **sum of the columns beneath it**, and a spanned block is +**atomic** across column-continuation pages. + +**Span algorithm — closed-boundary refinement (`.compute_header_spans()`).** +Merges are **hierarchical**: a lower row may only merge within a span already +established above it. Empty cells are transparent singletons (so a shared +super-header below an empty top row still spans), and the row-header/data +divide is always a hard boundary. Merge comparison uses the **raw** segment +text while display uses the **right-trimmed** text, so a trailing space +(`"n "` vs `"n"`) prevents an unwanted merge without changing what is drawn. +The naive "any adjacent equal text merges" rule was rejected: with +bottom-alignment the many empty top cells of a mixed shallow/deep table would +fuse into one giant atom and make wide tables un-paginable, and two identical +leaves under different super-headers (`"Placebo|||n"`, `"Drug|||n"`) would +wrongly merge. + +**Width integration — adjust the existing vectors, don't rewrite.** Per-column +natural / minimum widths measure only the column's **leaf** (bottom) segment +(`cs$leaf_label`), so a super-header never inflates a single column. A +post-adjustment (`.apply_header_span_widths()`) then raises the natural vector +so each above-leaf spanner fits the summed member width (full text width) and +the minimum vector so it fits the longest token (the super-header wraps to the +span width at draw), distributing each deficit across members and re-clamping +`natural >= min`. The existing water-fill / pagination / reconcile machinery +runs unchanged on the adjusted vectors. Because the adjustment touches only +above-leaf rows, a single-row header (`R == 1`, the default when no label +contains the separator) is a **total no-op** and output is byte-identical to +the pre-feature behavior. Wrap auto-detection was also switched to the leaf +segment so a super-header's spaces do not make a leaf column wrap-eligible. + +**Pagination — atomic spans.** `paginate_cols()` packs data columns in units of +*atoms* (`.data_atoms()`), where a multi-column span keeps its columns +together; `balance_col_pages` balances atom counts. An atom wider than the page +routes to `overflow_action` (`.check_span_atom_overflow()`) since it cannot be +split. Atoms of size 1 (every table with no real span) reproduce the prior +greedy behavior exactly. + +**Height + drawing.** `.measure_header_block()` returns the per-row height +vector (span cells wrap to span width) and its sum; it is computed once on the +full column set (uniform header height across pages) and cached on the grob. +`drawDetails.tfl_table_grob()` slices the full span structure to each page's +columns (`.slice_header_spans()`) and draws each spanner once, centered across +`col_x_left[a] .. col_x_right[b]`, reusing `.draw_cell_text()`. `R == 1` keeps +the original single-row `.draw_header_row()` path. + +**Spanner underlines (`col_header_span_rule`, default `TRUE`).** Each +multi-column spanner in an above-leaf row is underlined at the bottom of its +row band (`.draw_header_block()`). Where two spanner underlines are +horizontally adjacent, each is inset by a quarter of the summed horizontal +padding so the two rules are separated by a gap equal to one side margin (the +cell's horizontal padding); outer edges and edges beside a single column are +not inset. Style resolves from `gp$col_header_span_rule`, falling back to +`gp$col_header_rule`. + +**Shared kernel.** `.span_deficit(members, required, eps)` (in `R/table_rows.R`) +is used by both the column-width span adjustment (deficit distributed across +members) and the existing row-span height logic (deficit applied to the +span-start row). Row-span run detection stays on `suppress_mat` (its +outer-group reset semantics differ from header runs). + +**Alternatives rejected:** a spanner tree API (heavier, less ergonomic than +in-string separators for the clinical use case); pure adjacency merging +(un-paginable + wrong cross-parent merges, above); recomputing spans from +`page_cols` at draw time (disagrees with the full-set measurement because group +columns are prepended per page). + +**Tests:** `tests/testthat/test-span_header.R` (algorithm, slicing, validation, +width x-extent, atomic pagination, over-wide-atom error, `R == 1` regression +lock, no-merge-without-separator). diff --git a/design/TESTING.md b/design/TESTING.md index 3089fe2..a980f0e 100644 --- a/design/TESTING.md +++ b/design/TESTING.md @@ -37,6 +37,7 @@ One test file per source file — `tests/testthat/test-.R` covers | `test-wrap.R` | `wrap_breaks()` constructor + validation; `.tokenize_for_wrap()` (drop / keep_before / mixed); `.leading_drop_run()`; `.convert_tabs()` (leading vs. in-line tab expansion, custom counts); `.wrap_string()` (paragraphs, single token, keep_before, leading-space preservation as a hanging indent, tab expansion); `.column_has_breakable_text()`; `.column_min_token_width_in()` (floor calculation, keep_before reduces floor); `.wrap_label_for_width()`; `.compute_wrapped_widths()` (no-eligible no-op, water-from-top widest-first, longest-token floor) | | `test-table_draw.R` | `build_table_grob()`, `drawDetails.tfl_table_grob()` (uncached fallback, wrap branch, rotated col_cont_msg labels, first_data fallback) | | `test-tfl_table.R` | `tfl_colspec()`, `tfl_table()`, column/row pagination, column width calculation, col_cont_msg flags, `tfl_table_to_pagelist()` | +| `test-span_header.R` | Spanning (multi-row) column headers (D-53): `.split_header_label()` (trailing/leading empties), `.compute_header_spans()` (hierarchical no-merge across parents, merge below empty top, mixed depth, group divide, trailing-space escape hatch, feature-off/no-sep), `.slice_header_spans()`, `col_header_sep` validation, and end-to-end width x-extent (= sum of member columns), atomic column pagination, over-wide-atom error, `R == 1` regression lock, no-merge-without-separator | | `test-sub_tfl.R` | `.compute_sub_tfl_groups()`, `.format_sub_tfl_caption()`, `.apply_sub_tfl_caption()`, `.strip_sub_tfl_cols()`, `.resolve_col_label()`, `tfl_table_to_pagelist()` sub_tfl branch (factor ordering, multi-column suffix, NULL caption, group_vars overlap, custom sep/collapse/prefix, label resolution via colspec) | | `test-ggtibble.R` | `ggtibble_to_pagelist()`, `export_tfl.ggtibble()` — conversion, S3 dispatch, end-to-end, `sub_tfl` per-row caption suffix (requires ggtibble, skipped if absent) | | `test-gt.R` | `.extract_gt_annotations()`, `.clean_gt()`, `gt_to_pagelist()`, `.rebuild_gt_subset()` (row groups, formats, styles, substitutions, transforms, locale, stubhead, options, summary), `export_tfl.gt_tbl()`, `export_tfl.list()` with gt_tbl objects, S3 dispatch | diff --git a/man/build_table_grob.Rd b/man/build_table_grob.Rd index 41c814a..ad7b8db 100644 --- a/man/build_table_grob.Rd +++ b/man/build_table_grob.Rd @@ -14,7 +14,9 @@ build_table_grob( cont_row_h_in = NULL, is_first_col_page = TRUE, is_last_col_page = TRUE, - clip_width_caches = NULL + clip_width_caches = NULL, + header_spans = NULL, + header_block = NULL ) } \arguments{ diff --git a/man/compute_col_widths.Rd b/man/compute_col_widths.Rd index 6393f91..61f04fb 100644 --- a/man/compute_col_widths.Rd +++ b/man/compute_col_widths.Rd @@ -15,6 +15,7 @@ compute_col_widths( overflow_action = c("error", "warn"), validate_overflow = TRUE, floor_overrides = NULL, + spans = NULL, cache = NULL ) } diff --git a/man/dot-data_atoms.Rd b/man/dot-data_atoms.Rd new file mode 100644 index 0000000..ec7d864 --- /dev/null +++ b/man/dot-data_atoms.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/table_columns.R +\name{.data_atoms} +\alias{.data_atoms} +\title{Split data columns into groups that fit within content_width_in} +\usage{ +.data_atoms(spanned_gap, n_group_cols, n_cols) +} +\value{ +List of integer vectors (column indices into resolved_cols). +} +\description{ +Group columns (first n_group_cols) are always included in every group. +Data columns are greedily packed left-to-right. When \code{balance_col_pages} +is \code{TRUE} and the greedy pass produces more than one page, the data columns +are redistributed so that each page receives approximately the same number +of columns (while still verifying that each balanced group fits within the +available width). +} +\keyword{internal} diff --git a/man/paginate_cols.Rd b/man/paginate_cols.Rd index 5395d0d..c9041e7 100644 --- a/man/paginate_cols.Rd +++ b/man/paginate_cols.Rd @@ -9,18 +9,24 @@ paginate_cols( content_width_in, n_group_cols, allow_col_split, - balance_col_pages = FALSE + balance_col_pages = FALSE, + spanned_gap = NULL ) } +\arguments{ +\item{spanned_gap}{NULL or a logical vector (length \code{n_cols-1}) marking +gaps covered by a multi-column header span; those gaps are never split.} +} \value{ List of integer vectors (column indices into resolved_cols). } \description{ Group columns (first n_group_cols) are always included in every group. -Data columns are greedily packed left-to-right. When \code{balance_col_pages} -is \code{TRUE} and the greedy pass produces more than one page, the data columns -are redistributed so that each page receives approximately the same number -of columns (while still verifying that each balanced group fits within the -available width). +Data columns are greedily packed left-to-right in units of \emph{atoms} (a +multi-column header span keeps its columns together; see \code{\link[=.data_atoms]{.data_atoms()}}). +When \code{balance_col_pages} is \code{TRUE} and the greedy pass produces more than +one page, atoms are redistributed so that each page receives approximately +the same number of atoms (while still verifying that each balanced group +fits within the available width). } \keyword{internal} diff --git a/man/tfl_table.Rd b/man/tfl_table.Rd index 90ed05a..262c1fa 100644 --- a/man/tfl_table.Rd +++ b/man/tfl_table.Rd @@ -23,8 +23,10 @@ tfl_table( sub_tfl_prefix = "\\n", col_cont_msg = c("Columns continue from prior page", "Columns continue to next page"), row_cont_msg = c("(continued)", "(continued on next page)"), + col_header_sep = "|||", show_col_names = TRUE, col_header_rule = TRUE, + col_header_span_rule = TRUE, group_rule = TRUE, group_rule_after_last = FALSE, row_rule = FALSE, @@ -163,12 +165,36 @@ shown at the \strong{top} of a continuation page; the second is shown at the \strong{bottom} of the preceding page. A length-1 value is recycled to both positions. Default: \code{c("(continued)", "(continued on next page)")}.} +\item{col_header_sep}{Character scalar separator used to build \strong{spanning +(multi-row) column headers} from \code{label} strings, or a single \code{NA} to +disable the feature. Default \code{"|||"}. When a label contains the separator +it is split into stacked header rows (bottom-aligned: a shorter label +fills the lowest rows). Adjacent columns whose text is equal in a header +row merge into one spanning cell, but only within a shared parent span +(hierarchical) and never across the row-header/data divide. A spanning +header's width is the sum of the columns beneath it, and a spanned block +is never split across column-continuation pages. Encode a deliberately +blank row with two separators in a row (e.g. \code{"Top||||||Bottom"}). Merge +comparison uses the raw text while trailing whitespace is trimmed for +display, so appending a space (\code{"n "} vs \code{"n"}) prevents an unwanted +merge. \code{"\\n"} still produces a line break \emph{within} a single header cell +and is independent of this separator. When no label contains the +separator the header is a single row exactly as before.} + \item{show_col_names}{Logical. If \code{FALSE}, the column header row is omitted and \code{col_header_rule} is also suppressed.} \item{col_header_rule}{Logical. If \code{TRUE} (default), a horizontal rule is drawn below the column header row.} +\item{col_header_span_rule}{Logical. If \code{TRUE} (default), a horizontal rule +is drawn beneath each multi-column spanning header cell (see +\code{col_header_sep}). Adjacent spanning groups' rules are separated by a gap +equal to the cell's horizontal (side) padding, so the rules read as +distinct group underlines. Has no effect when there are no spanning +headers. Style is controlled via \code{gp$col_header_span_rule} (defaults to the +\code{gp$col_header_rule} style).} + \item{group_rule}{Logical. If \code{TRUE} (default), a horizontal rule is drawn between row groups.} @@ -204,6 +230,9 @@ for background color; use a vector for alternating rows or groups \item{\code{gp$group_col}}{Row-header column cells. Inherits \code{gp$table}.} \item{\code{gp$continued}}{Continuation-marker row text. Default: italic.} \item{\code{gp$col_header_rule}}{Style of the column-header rule.} +\item{\code{gp$col_header_span_rule}}{Style of the per-spanner underline +(see \code{col_header_span_rule}). Defaults to the \code{gp$col_header_rule} +style.} \item{\code{gp$group_rule}}{Style of between-group rules.} \item{\code{gp$row_rule}}{Style of between-row data rules.} \item{\code{gp$row_header_sep}}{Style of the vertical row-header separator.} diff --git a/tests/testthat/test-span_header.R b/tests/testthat/test-span_header.R new file mode 100644 index 0000000..ce68dcd --- /dev/null +++ b/tests/testthat/test-span_header.R @@ -0,0 +1,281 @@ +# test-span_header.R — Spanning (multi-row) column headers (D-53). +# +# Covers the pure span algorithm (.split_header_label, .compute_header_spans, +# .slice_header_spans), col_header_sep validation, and end-to-end width / +# pagination / drawing behavior. + +# --- helpers (top-level per CLAUDE.md) -------------------------------------- + +# Compact [start,end,text] tuples for one header row. +sp_row <- function(spans, r) { + lapply(spans$cells_by_row[[r]], function(c) list(c$start, c$end, c$text)) +} + +# Column x-left / x-right (inches, x_offset removed) for a page grob's columns. +col_x_bounds <- function(page_cols) { + w <- vapply(page_cols, function(cs) cs$width_in, numeric(1L)) + list(left = c(0, cumsum(w)[-length(w)]), right = cumsum(w), w = w) +} + +# Capture the horizontal rules drawn while rendering `tbl` (preview mode), +# returned as a list of list(y, x1, x2) in inches. Used to check spanner +# underline placement. +capture_header_hlines <- function(tbl) { + .GlobalEnv$.WTFL_HL <- list() + suppressMessages(trace( + grid:::grid.lines, + tracer = quote({ + yy <- tryCatch(grid::convertY(y, "in", valueOnly = TRUE), + error = function(e) NA) + xx <- tryCatch(grid::convertX(x, "in", valueOnly = TRUE), + error = function(e) c(NA, NA)) + if (length(yy) == 2 && abs(yy[[1]] - yy[[2]]) < 1e-6) { + .GlobalEnv$.WTFL_HL[[length(.GlobalEnv$.WTFL_HL) + 1L]] <- + list(y = round(yy[[1]], 4), x1 = xx[[1]], x2 = xx[[2]]) + } + }), + print = FALSE, where = asNamespace("grid") + )) + on.exit(suppressMessages(untrace(grid:::grid.lines, + where = asNamespace("grid"))), add = TRUE) + grDevices::pdf(NULL, width = 11, height = 8.5) + on.exit(grDevices::dev.off(), add = TRUE) + export_tfl(tbl, preview = TRUE) + .GlobalEnv$.WTFL_HL +} + +# Run the pagination pipeline for one tfl_table and return the page grobs. +pagelist_grobs <- function(tbl, pg_width = 11, pg_height = 8.5) { + grDevices::pdf(NULL, width = pg_width, height = pg_height) + on.exit(grDevices::dev.off(), add = TRUE) + pages <- tfl_table_to_pagelist(tbl, pg_width, pg_height, dots = list(), + page_num = NULL) + lapply(pages, function(p) p$content) +} + +# --------------------------------------------------------------------------- +# .split_header_label() +# --------------------------------------------------------------------------- + +test_that(".split_header_label splits and preserves empty fields", { + expect_equal(.split_header_label("A|||B|||C", "|||"), c("A", "B", "C")) + expect_equal(.split_header_label("A", "|||"), "A") + expect_equal(.split_header_label("Top||||||Bottom", "|||"), c("Top", "", "Bottom")) + expect_equal(.split_header_label("A|||", "|||"), c("A", "")) # trailing empty kept + expect_equal(.split_header_label("|||b", "|||"), c("", "b")) # leading empty + expect_equal(.split_header_label("", "|||"), "") +}) + +# --------------------------------------------------------------------------- +# .compute_header_spans() +# --------------------------------------------------------------------------- + +test_that("identical leaves under different parents do NOT merge", { + sp <- .compute_header_spans(c("Placebo|||n", "Drug|||n"), "|||", 0L) + expect_equal(sp$R, 2L) + expect_equal(sp$spanned_gap, FALSE) # no atom + expect_equal(sp_row(sp, 2L), list(list(1L, 1L, "n"), list(2L, 2L, "n"))) +}) + +test_that("shared super-header below an empty top row DOES merge", { + sp <- .compute_header_spans(c("|||Grp|||a", "|||Grp|||b"), "|||", 0L) + expect_equal(sp$R, 3L) + expect_true(sp$spanned_gap[[1L]]) + expect_equal(sp_row(sp, 2L), list(list(1L, 2L, "Grp"))) + expect_equal(sp$leaf_labels, c("a", "b")) +}) + +test_that("mixed shallow/deep labels keep shallow columns free", { + sp <- .compute_header_spans(c("Age", "Trt|||Dose", "Trt|||Resp"), "|||", 0L) + expect_equal(sp$R, 2L) + expect_equal(sp$spanned_gap, c(FALSE, TRUE)) # Age free; Trt spans 2-3 + expect_equal(sp_row(sp, 1L), list(list(1L, 1L, ""), list(2L, 3L, "Trt"))) +}) + +test_that("group/data divide is a hard boundary", { + sp <- .compute_header_spans(c("grp", "Trt|||A", "Trt|||B"), "|||", 1L) + expect_equal(sp$spanned_gap, c(FALSE, TRUE)) # divide never spanned +}) + +test_that("trailing-space escape hatch prevents a leaf merge", { + sp <- .compute_header_spans(c("Trt|||n", "Trt|||n "), "|||", 0L) + expect_equal(sp_row(sp, 1L), list(list(1L, 2L, "Trt"))) # parent still spans + expect_equal(sp_row(sp, 2L), list(list(1L, 1L, "n"), list(2L, 2L, "n"))) + expect_equal(sp$leaf_labels, c("n", "n")) # trimmed for display +}) + +test_that("feature off / no separator returns the trivial R==1 structure", { + off <- .compute_header_spans(c("Placebo|||n", "Drug|||n"), NA, 0L) + expect_equal(off$R, 1L) + expect_equal(off$leaf_labels, c("Placebo|||n", "Drug|||n")) # verbatim + expect_equal(off$spanned_gap, FALSE) + + none <- .compute_header_spans(c("mpg", "hp"), "|||", 0L) + expect_equal(none$R, 1L) + expect_equal(none$leaf_labels, c("mpg", "hp")) +}) + +# --------------------------------------------------------------------------- +# .slice_header_spans() +# --------------------------------------------------------------------------- + +test_that(".slice_header_spans re-indexes on-page cells and drops off-page ones", { + # grp | (Trt: A B) | (Ctl: C D) + sp <- .compute_header_spans( + c("grp", "Trt|||A", "Trt|||B", "Ctl|||C", "Ctl|||D"), "|||", 1L) + # Page shows grp (1) + the Ctl atom (4,5). + sl <- .slice_header_spans(sp, c(1L, 4L, 5L)) + expect_equal(sl$R, sp$R) + # The Ctl super-header cell is present, re-indexed to local cols 2-3. + expect_true(any(vapply(sl$cells_by_row[[1L]], + function(c) c$start == 2L && c$end == 3L && c$text == "Ctl", + logical(1L)))) + # The Trt cell (full cols 2-3) is entirely off this page and dropped. + expect_false(any(vapply(sl$cells_by_row[[1L]], + function(c) identical(c$text, "Trt"), logical(1L)))) +}) + +# --------------------------------------------------------------------------- +# col_header_sep validation +# --------------------------------------------------------------------------- + +test_that("tfl_table validates col_header_sep", { + df <- data.frame(a = 1, b = 2) + expect_error(tfl_table(df, col_header_sep = c("a", "b")), "col_header_sep") + expect_error(tfl_table(df, col_header_sep = ""), "col_header_sep") + expect_error(tfl_table(df, col_header_sep = "a\nb"), "newline") + expect_s3_class(tfl_table(df, col_header_sep = "|||"), "tfl_table") + expect_s3_class(tfl_table(df, col_header_sep = NA), "tfl_table") # disabled +}) + +# --------------------------------------------------------------------------- +# End-to-end: width, drawing geometry, pagination +# --------------------------------------------------------------------------- + +test_that("a spanning-header table renders without error", { + df <- data.frame(id = c("A", "B", "C"), n1 = 1:3, m1 = c(1.1, 2.2, 3.3), + n2 = 4:6, m2 = c(4.4, 5.5, 6.6)) + tbl <- tfl_table(df, col_labels = c( + id = "Subject", n1 = "Placebo|||n", m1 = "Placebo|||Mean", + n2 = "Drug A|||n", m2 = "Drug A|||Mean")) + f <- tempfile(fileext = ".pdf") + on.exit(unlink(f)) + expect_no_error(export_tfl(tbl, file = f)) + expect_true(file.exists(f)) +}) + +test_that("a spanner's x-extent is the sum of the columns beneath it (issue: width bug)", { + df <- data.frame(id = c("A", "B", "C"), n1 = 1:3, m1 = c(1.1, 2.2, 3.3), + n2 = 4:6, m2 = c(4.4, 5.5, 6.6)) + tbl <- tfl_table(df, col_labels = c( + id = "Subject", n1 = "Placebo|||n", m1 = "Placebo|||Mean", + n2 = "Drug A|||n", m2 = "Drug A|||Mean")) + grob <- pagelist_grobs(tbl)[[1L]] + + spans_page <- .slice_header_spans(grob$header_spans, grob$col_group_idx) + b <- col_x_bounds(grob$page_cols) + n_cols <- length(grob$page_cols) + total_w <- sum(b$w) + + # The two super-header cells live in row 1. + supers <- Filter(function(c) c$end > c$start, spans_page$cells_by_row[[1L]]) + expect_length(supers, 2L) + for (cell in supers) { + extent <- b$right[[cell$end]] - b$left[[cell$start]] + # Width equals the summed member widths... + expect_equal(extent, sum(b$w[cell$start:cell$end]), tolerance = 1e-9) + # ...spans more than its first column (not clipped at the first column)... + expect_gt(extent, b$w[[cell$start]]) + # ...and never extends past the last column. + expect_lte(b$right[[cell$end]], total_w + 1e-9) + } +}) + +test_that("a spanned block is never split across column pages (atomic)", { + # Columns aaa(2) + bbb(3) are bound by the "Pair" spanner; a narrow page + # forces a column split, but the pair must stay together. + df <- data.frame( + id = c("r1", "r2"), aaa = 1:2, bbb = 3:4, ccc = 5:6 + ) + tbl <- tfl_table( + df, + col_labels = c(id = "ID", aaa = "Pair|||Left", bbb = "Pair|||Right", + ccc = "Solo"), + # Named LIST of units (a unit *vector* would not resolve by name). + col_widths = stats::setNames( + list(grid::unit(1, "in"), grid::unit(2.2, "in"), + grid::unit(2.2, "in"), grid::unit(2.2, "in")), + c("id", "aaa", "bbb", "ccc")), + allow_col_split = TRUE + ) + grobs <- pagelist_grobs(tbl, pg_width = 7, pg_height = 8.5) + col_pages <- unique(lapply(grobs, function(g) g$col_group_idx)) + expect_gt(length(col_pages), 1L) # a split really happened + for (idx in col_pages) { + expect_equal(2L %in% idx, 3L %in% idx) # aaa present iff bbb present + } +}) + +test_that("a spanner wider than the page errors under overflow_action = 'error'", { + df <- data.frame(a = 1, b = 2) + tbl <- tfl_table( + df, + col_labels = c(a = "Enormous Spanning Header|||a", b = "Enormous Spanning Header|||b"), + col_widths = stats::setNames(list(grid::unit(6, "in"), grid::unit(6, "in")), + c("a", "b")), + allow_col_split = FALSE + ) + f <- tempfile(fileext = ".pdf") + on.exit(unlink(f)) + # 12 in of columns cannot fit a ~10 in content width; the atom cannot split. + expect_error(export_tfl(tbl, file = f, pg_width = 11, pg_height = 8.5)) +}) + +test_that("R==1 regression: NA separator matches default when no label spans", { + df <- data.frame(id = c("A", "B"), x = 1:2, y = 3:4) + labs <- c(id = "ID", x = "X", y = "Y") + g_default <- pagelist_grobs(tfl_table(df, col_labels = labs)) + g_off <- pagelist_grobs(tfl_table(df, col_labels = labs, col_header_sep = NA)) + w_default <- vapply(g_default[[1L]]$page_cols, function(cs) cs$width_in, 0) + w_off <- vapply(g_off[[1L]]$page_cols, function(cs) cs$width_in, 0) + expect_equal(w_default, w_off) + expect_equal(length(g_default), length(g_off)) + expect_equal(g_default[[1L]]$header_spans$R, 1L) +}) + +test_that("spanner underlines are drawn with a side-margin gap between groups", { + resp <- data.frame(subgroup = c("All", "A", "B"), + pn = c(118L, 71L, 47L), pm = c(26.3, 25.4, 27.7), + an = c(120L, 74L, 46L), am = c(56.7, 59.5, 52.2)) + labs <- c(subgroup = "Subgroup", pn = "Placebo|||n", pm = "Placebo|||Mean", + an = "Active|||n", am = "Active|||Mean") + + on <- capture_header_hlines(tfl_table(resp, col_labels = labs)) + # Two arm underlines + the full-width col_header_rule. + expect_equal(length(on), 3L) + + # The two arm underlines share the topmost y; their gap equals the + # horizontal (side) cell padding (default 0.5 lines). + ys <- vapply(on, `[[`, numeric(1L), "y") + top_y <- max(ys) + arms <- on[abs(ys - top_y) < 1e-6] + expect_equal(length(arms), 2L) + arms <- arms[order(vapply(arms, function(l) min(l$x1, l$x2), numeric(1L)))] + gap <- min(arms[[2L]]$x1, arms[[2L]]$x2) - max(arms[[1L]]$x1, arms[[1L]]$x2) + side_pad <- grid::convertWidth(grid::unit(0.5, "lines"), "in", valueOnly = TRUE) + expect_equal(gap, side_pad, tolerance = 1e-3) + + # Toggle off -> only the full-width col_header_rule remains. + off <- capture_header_hlines( + tfl_table(resp, col_labels = labs, col_header_span_rule = FALSE)) + expect_equal(length(off), 1L) +}) + +test_that("two identical adjacent leaves do NOT merge without a separator", { + # No "|||" anywhere -> R==1 -> the two 'n' columns render independently. + df <- data.frame(g = c("x", "y"), n = 1:2, n2 = 3:4) + tbl <- tfl_table(df, col_labels = c(g = "G", n = "n", n2 = "n")) + grob <- pagelist_grobs(tbl)[[1L]] + expect_equal(grob$header_spans$R, 1L) + expect_equal(grob$header_spans$spanned_gap, rep(FALSE, 2L)) +}) diff --git a/vignettes/v03-tfl_table_styling.Rmd b/vignettes/v03-tfl_table_styling.Rmd index 58c471d..ae22146 100644 --- a/vignettes/v03-tfl_table_styling.Rmd +++ b/vignettes/v03-tfl_table_styling.Rmd @@ -954,3 +954,113 @@ The two outputs differ visibly in: - Cell padding (uniform vs. asymmetric v/h form) - Header rule weight - Continuation marker appearance + +--- + +## Spanning (multi-row) column headers + +Clinical and regulatory tables frequently need a **super-header** that spans +several columns — for example one label per treatment arm sitting above that +arm's `n` and `Mean` columns. `tfl_table()` builds these directly from the +column labels: split a label on `col_header_sep` (default `"|||"`) and each +piece becomes one stacked header row. Adjacent columns whose text is equal in a +row merge into a single spanning cell, sized to the **sum of the columns +beneath it**. + +```{r span-basic, fig.width = 11, fig.height = 8.5, out.width = "100%"} +resp <- data.frame( + subgroup = c("All patients", "Age < 65", "Age ≥ 65"), + pbo_n = c(118L, 71L, 47L), + pbo_mean = c(26.3, 25.4, 27.7), + act_n = c(120L, 74L, 46L), + act_mean = c(56.7, 59.5, 52.2) +) + +tbl_span <- tfl_table( + resp, + col_labels = c( + subgroup = "Subgroup", + pbo_n = "Placebo|||n", pbo_mean = "Placebo|||Mean (%)", + act_n = "Active|||n", act_mean = "Active|||Mean (%)" + ) +) + +export_tfl(tbl_span, preview = TRUE, + caption = "Table 2. Response rate by treatment arm.") +``` + +`Placebo` spans its two columns, `Active` spans its two, and `Subgroup` (a +one-piece label) simply sits in the bottom row. Each spanning group is +underlined; adjacent groups' underlines are separated by a gap equal to the +cell's horizontal padding so they read as distinct group rules. Turn the +underlines off with `col_header_span_rule = FALSE`, or restyle them via +`gp$col_header_span_rule` (they default to the `gp$col_header_rule` style). + +### Bottom-alignment of uneven-depth labels + +Labels need not have the same number of pieces. A shorter label fills the +**bottom** rows, leaving the rows above it blank, so single-row labels line up +with the deepest labels' leaf row: + +```{r span-uneven, fig.width = 11, fig.height = 8.5, out.width = "100%"} +tbl_uneven <- tfl_table( + resp, + col_labels = c( + subgroup = "Subgroup", # 1 row -> bottom + pbo_n = "Placebo|||n", pbo_mean = "Placebo|||Mean", + act_n = "Active|||n", act_mean = "Active|||Mean" + ) +) +export_tfl(tbl_uneven, preview = TRUE) +``` + +To leave a deliberately **blank middle row** for one column, use two separators +in a row (e.g. `"Top||||||Bottom"` with the default `"|||"`). + +### Hierarchical merging (only within a shared parent) + +Merging is hierarchical: two adjacent cells merge only when they also share the +span directly above them. This is what keeps two identically named leaf columns +apart when they belong to different super-headers — `"Placebo|||n"` and +`"Active|||n"` never fuse their two `n` cells, because `Placebo` ≠ `Active` +one row up. + +### Preventing an unwanted merge (trailing-space escape hatch) + +Merging compares the **raw** label text but draws it **right-trimmed**. So if +two columns would merge but you want them kept separate, append a space to one +of them: it changes the comparison without changing what is printed. + +```{r span-escape, fig.width = 11, fig.height = 8.5, out.width = "100%"} +# Two "Total" columns kept distinct by a trailing space on the second. +totals <- data.frame(x = 1:2, t1 = 3:4, t2 = 5:6) +tbl_escape <- tfl_table( + totals, + col_labels = c(x = "Item", t1 = "Summary|||Total", t2 = "Summary|||Total ") +) +export_tfl(tbl_escape, preview = TRUE) +``` + +Both `Total` cells render identically but stay in their own columns; `Summary` +still spans both. + +### Disabling the feature or changing the separator + +Set `col_header_sep = NA` to turn spanning headers off entirely (labels are then +taken verbatim, including any literal `"|||"`). If a real label legitimately +contains `"|||"`, pick a different token, e.g. `col_header_sep = "@@"`. + +```{r span-disable, eval = FALSE} +tfl_table(resp, col_labels = c(...), col_header_sep = NA) # feature off +tfl_table(resp, col_labels = c(...), col_header_sep = "@@") # custom separator +``` + +### Gotchas + +- A spanned block is kept **together** across column-continuation pages (it is + never split). If a single spanned block is wider than the page, that is + reported through `overflow_action` (error by default) — widen the page or + narrow the columns. +- `"\n"` still forces a line break **within** a single header cell and is + independent of `col_header_sep`; you can combine them (e.g. + `"Treatment|||Mean\n(SD)"`).