From 7b2592ca08575a89111c4f8bc04ee76b281ab791 Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Tue, 28 Apr 2026 21:41:51 -0400 Subject: [PATCH 1/3] Add sub_tfl argument for sub-tables and sub-figures tfl_table() and export_tfl.ggtibble() now accept a sub_tfl character vector that splits the output into one sub-table or sub-figure per unique combination of values, removing those columns from the rendered body and appending them to the caption as "label: value; label: value". Formatting is controlled by sub_tfl_sep, sub_tfl_collapse, and sub_tfl_prefix. Sub-table ordering follows factor levels for factor columns and first-appearance order otherwise; the first column of sub_tfl varies outermost. sub_tfl columns may overlap with dplyr::group_vars(x), in which case they are promoted from row-header rendering to the caption. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 31 ++++ R/ggtibble.R | 54 +++++- R/sub_tfl.R | 137 ++++++++++++++ R/table_pagelist.R | 33 ++++ R/tfl_table.R | 43 +++++ design/ARCHITECTURE.md | 81 +++++++- design/DECISIONS.md | 64 +++++++ design/TESTING.md | 51 +++++- man/ggtibble_to_pagelist.Rd | 17 +- man/tfl_table.Rd | 26 +++ tests/testthat/test-sub_tfl.R | 335 ++++++++++++++++++++++++++++++++++ 11 files changed, 860 insertions(+), 12 deletions(-) create mode 100644 R/sub_tfl.R create mode 100644 tests/testthat/test-sub_tfl.R diff --git a/CLAUDE.md b/CLAUDE.md index d6dcc5b..5256b17 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,6 +98,31 @@ export_tfl_page( `...` in `export_tfl()` is forwarded to `export_tfl_page()`. +`tfl_table()` also accepts (in its signature, not via `...`): + +```r +sub_tfl = NULL, # character vector of column names; splits + # the data into one sub-table per unique + # combination, dropping the columns from the + # body and appending them to the caption. +sub_tfl_sep = ": ", # between label and value +sub_tfl_collapse = "; ", # between successive label:value pairs +sub_tfl_prefix = "\n", # between the existing caption and the suffix +``` + +`export_tfl.ggtibble()` accepts the same four arguments to add a per-row +caption suffix to figure pages (labels are raw column names; no colspec +system). + +When `sub_tfl` is set on a `tfl_table`: +- columns named in `sub_tfl` are removed from the rendered body, including + removal from `group_vars` if they overlap (a common case); +- one sub-table is produced per unique combination of values, ordered by + factor levels for factor columns and first-appearance order otherwise; +- the first column of `sub_tfl` varies outermost; +- when the global `caption` is `NULL`, the suffix becomes the entire caption + (no leading prefix). + --- ## Key behavioral rules (implement exactly as specified) @@ -306,6 +331,11 @@ writetfl/ │ ├── export_tfl_page.R ← exported; single-page layout and draw │ ├── ggtibble.R ← export_tfl.ggtibble(), ggtibble_to_pagelist() │ ├── tfl_table.R ← exported; tfl_table(), tfl_colspec() +│ ├── sub_tfl.R ← .compute_sub_tfl_groups(), +│ │ .resolve_col_label(), +│ │ .format_sub_tfl_caption(), +│ │ .apply_sub_tfl_caption(), +│ │ .strip_sub_tfl_cols() │ ├── normalize.R ← normalize_text(), normalize_rule() │ ├── grob_builders.R ← build_section_grobs(), build_text_grob() │ ├── measure.R ← measure_grob_height(), measure_section_heights(), @@ -358,6 +388,7 @@ writetfl/ │ ├── test-table_utils.R │ ├── test-table_draw.R │ ├── test-tfl_table.R +│ ├── test-sub_tfl.R │ ├── test-ggtibble.R │ ├── test-gt.R │ ├── test-rtables.R diff --git a/R/ggtibble.R b/R/ggtibble.R index a7abda1..1b9908e 100644 --- a/R/ggtibble.R +++ b/R/ggtibble.R @@ -7,16 +7,22 @@ #' @export export_tfl.ggtibble <- function( x, - file = NULL, - pg_width = 11, - pg_height = 8.5, - page_num = "Page {i} of {n}", - preview = FALSE, + file = NULL, + pg_width = 11, + pg_height = 8.5, + page_num = "Page {i} of {n}", + preview = FALSE, + sub_tfl = NULL, + sub_tfl_sep = ": ", + sub_tfl_collapse = "; ", + sub_tfl_prefix = "\n", ... ) { dots <- list(...) .validate_export_args(page_num, preview, file) - x <- ggtibble_to_pagelist(x) + x <- ggtibble_to_pagelist(x, sub_tfl = sub_tfl, sub_tfl_sep = sub_tfl_sep, + sub_tfl_collapse = sub_tfl_collapse, + sub_tfl_prefix = sub_tfl_prefix) .export_tfl_pages(x, file, pg_width, pg_height, page_num, preview, dots) } @@ -24,12 +30,19 @@ export_tfl.ggtibble <- function( #' #' Each row of the ggtibble becomes one page spec. The `figure` column #' provides the content (ggplot). Any columns whose names match -#' [export_tfl_page()] text arguments are used as per-page values. +#' [export_tfl_page()] text arguments are used as per-page values. When +#' `sub_tfl` is supplied, those columns' values are appended to each row's +#' caption. #' #' @param x A `ggtibble` object. +#' @param sub_tfl Character vector of column names in `x`, or `NULL`. +#' @param sub_tfl_sep,sub_tfl_collapse,sub_tfl_prefix Formatting controls for +#' the appended `label: value` suffix. See [tfl_table()]. #' @return A list of page spec lists, each with at least `$content`. #' @keywords internal -ggtibble_to_pagelist <- function(x) { +ggtibble_to_pagelist <- function(x, sub_tfl = NULL, sub_tfl_sep = ": ", + sub_tfl_collapse = "; ", + sub_tfl_prefix = "\n") { # Column names that map to export_tfl_page() text arguments page_arg_names <- c( "caption", "footnote", @@ -38,6 +51,23 @@ ggtibble_to_pagelist <- function(x) { ) present_args <- intersect(page_arg_names, names(x)) + if (!is.null(sub_tfl)) { + if (!is.character(sub_tfl) || length(sub_tfl) == 0L || + anyNA(sub_tfl) || any(!nzchar(sub_tfl))) { + rlang::abort("`sub_tfl` must be NULL or a non-empty character vector.") + } + bad <- setdiff(sub_tfl, names(x)) + if (length(bad) > 0L) { + rlang::abort(paste0( + "`sub_tfl` columns not found in the ggtibble: ", + paste(bad, collapse = ", ") + )) + } + checkmate::assert_string(sub_tfl_sep, .var.name = "sub_tfl_sep") + checkmate::assert_string(sub_tfl_collapse, .var.name = "sub_tfl_collapse") + checkmate::assert_string(sub_tfl_prefix, .var.name = "sub_tfl_prefix") + } + lapply(seq_len(nrow(x)), function(i) { # Extract the ggplot from the figure cell. # gglist[[i]] returns the ggplot directly; for plain list columns, @@ -48,6 +78,14 @@ ggtibble_to_pagelist <- function(x) { for (col in present_args) { spec[[col]] <- x[[col]][[i]] } + if (!is.null(sub_tfl)) { + pairs <- vapply(sub_tfl, function(col) { + paste(col, format(x[[col]][[i]]), sep = sub_tfl_sep) + }, character(1L)) + suffix <- paste(pairs, collapse = sub_tfl_collapse) + spec$caption <- .apply_sub_tfl_caption(spec$caption, suffix, + sub_tfl_prefix) + } spec }) } diff --git a/R/sub_tfl.R b/R/sub_tfl.R new file mode 100644 index 0000000..c1afa2d --- /dev/null +++ b/R/sub_tfl.R @@ -0,0 +1,137 @@ +# sub_tfl.R — Sub-table support for tfl_table and ggtibble. +# +# When `sub_tfl` is set on a tfl_table (or export_tfl.ggtibble), the data is +# split into one sub-table per unique combination of values in the named +# columns. The values are removed from the rendered body and appended to the +# caption as "label: value; label: value". + +# --------------------------------------------------------------------------- +# .compute_sub_tfl_groups() +# --------------------------------------------------------------------------- + +# Returns an ordered list of sub-group specs: +# list(list(values = named-list, row_idx = integer), ...) +# Order: factor columns drive their level order; character/numeric columns use +# first-appearance order. sub_tfl[1] varies outermost (slowest). +#' @keywords internal +.compute_sub_tfl_groups <- function(data, sub_tfl) { + ord_vals <- lapply(sub_tfl, function(col) { + v <- data[[col]] + v_nona <- v[!is.na(v)] + if (is.factor(v)) { + lv <- levels(v) + lv[lv %in% as.character(v_nona)] + } else { + unique(v_nona) + } + }) + names(ord_vals) <- sub_tfl + + # Build combos with sub_tfl[1] outermost. + combos <- lapply(ord_vals[[1L]], function(v) { + stats::setNames(list(v), sub_tfl[[1L]]) + }) + for (k in seq_along(sub_tfl)[-1L]) { + new_combos <- list() + for (rc in combos) { + for (v in ord_vals[[k]]) { + rc_new <- rc + rc_new[[sub_tfl[[k]]]] <- v + new_combos[[length(new_combos) + 1L]] <- rc_new + } + } + combos <- new_combos + } + + # For each combo, find row indices in `data`. Skip combinations that are + # not present in any row (Cartesian product may produce them). + groups <- list() + for (combo in combos) { + matches <- rep(TRUE, nrow(data)) + for (col in sub_tfl) { + v <- data[[col]] + target <- combo[[col]] + m <- v == target + m[is.na(m)] <- FALSE + matches <- matches & m + } + idx <- which(matches) + if (length(idx) > 0L) { + groups[[length(groups) + 1L]] <- list(values = combo, row_idx = idx) + } + } + groups +} + +# --------------------------------------------------------------------------- +# .resolve_col_label() — single source of truth for label fallback +# --------------------------------------------------------------------------- + +# Priority: tfl_colspec$label > tbl$col_labels[col] > col itself. +#' @keywords internal +.resolve_col_label <- function(tbl, col_name) { + if (!is.null(tbl$cols)) { + for (cs in tbl$cols) { + if (identical(cs$col, col_name) && !is.null(cs$label)) { + return(cs$label) + } + } + } + flat <- .nlookup(tbl$col_labels, col_name) + if (!is.null(flat)) return(flat) + col_name +} + +# --------------------------------------------------------------------------- +# .format_sub_tfl_caption() +# --------------------------------------------------------------------------- + +# Build the per-page caption suffix from a named list of values. +#' @keywords internal +.format_sub_tfl_caption <- function(tbl, values) { + pairs <- vapply(names(values), function(col) { + label <- .resolve_col_label(tbl, col) + paste(label, format(values[[col]]), sep = tbl$sub_tfl_sep) + }, character(1L)) + paste(pairs, collapse = tbl$sub_tfl_collapse) +} + +# --------------------------------------------------------------------------- +# .apply_sub_tfl_caption() +# --------------------------------------------------------------------------- + +# Combine a base caption with the sub_tfl suffix using prefix rules. +# Returns the suffix alone when base is NULL. +#' @keywords internal +.apply_sub_tfl_caption <- function(base, suffix, prefix) { + if (is.null(base)) return(suffix) + paste0(base, prefix, suffix) +} + +# --------------------------------------------------------------------------- +# .strip_sub_tfl_cols() +# --------------------------------------------------------------------------- + +# Remove sub_tfl entries from cols / col_widths / col_labels / col_align / +# wrap_cols. The caller is responsible for filtering tbl$data and updating +# tbl$group_vars. +#' @keywords internal +.strip_sub_tfl_cols <- function(tbl) { + drop <- tbl$sub_tfl + if (!is.null(tbl$cols)) { + keep <- vapply(tbl$cols, function(cs) !cs$col %in% drop, logical(1L)) + tbl$cols <- tbl$cols[keep] + if (length(tbl$cols) == 0L) tbl$cols <- NULL + } + for (fld in c("col_widths", "col_labels", "col_align")) { + v <- tbl[[fld]] + if (!is.null(v) && !is.null(names(v))) { + tbl[[fld]] <- v[!names(v) %in% drop] + if (length(tbl[[fld]]) == 0L) tbl[[fld]] <- NULL + } + } + if (is.character(tbl$wrap_cols)) { + tbl$wrap_cols <- setdiff(tbl$wrap_cols, drop) + } + tbl +} diff --git a/R/table_pagelist.R b/R/table_pagelist.R index fa55db8..f1760f2 100644 --- a/R/table_pagelist.R +++ b/R/table_pagelist.R @@ -38,6 +38,39 @@ #' @keywords internal tfl_table_to_pagelist <- function(tbl, pg_width, pg_height, dots, page_num = "Page {i} of {n}") { + # --- Step 0: sub_tfl branch --- + # When sub_tfl is set, split into one sub-table per unique combination of + # values, recurse with sub_tfl = NULL, and concatenate the resulting pages. + # Each sub-group has its own caption (= base caption + sep + suffix), so the + # available content height must be re-measured per group; recursing through + # the existing pipeline handles that naturally. + if (!is.null(tbl$sub_tfl)) { + groups <- .compute_sub_tfl_groups(tbl$data, tbl$sub_tfl) + pages <- list() + for (g in groups) { + sub_tbl <- tbl + keep_cols <- setdiff(names(tbl$data), tbl$sub_tfl) + sub_tbl$data <- tbl$data[g$row_idx, keep_cols, drop = FALSE] + sub_tbl$group_vars <- setdiff(tbl$group_vars, tbl$sub_tfl) + sub_tbl <- .strip_sub_tfl_cols(sub_tbl) + sub_tbl$sub_tfl <- NULL # prevent recursion + + suffix <- .format_sub_tfl_caption(tbl, g$values) + sub_dots <- dots + sub_dots$caption <- .apply_sub_tfl_caption(dots$caption, suffix, + tbl$sub_tfl_prefix) + + sub_pages <- tfl_table_to_pagelist(sub_tbl, pg_width, pg_height, + sub_dots, page_num) + sub_pages <- lapply(sub_pages, function(p) { + p$caption <- sub_dots$caption + p + }) + pages <- c(pages, sub_pages) + } + return(pages) + } + # --- Step 1: Extract layout args from dots --- # Use explicit NULL checks instead of %||% for arguments that can legitimately diff --git a/R/tfl_table.R b/R/tfl_table.R index c4f7be0..df8f357 100644 --- a/R/tfl_table.R +++ b/R/tfl_table.R @@ -114,6 +114,24 @@ tfl_colspec <- function(col, #' 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. +#' @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 +#' form `"label: value; label: value"` so the sub-table is self-identifying; +#' the sub_tfl columns are removed from the rendered body. Sub-table ordering +#' follows factor levels for factor columns and first-appearance order +#' otherwise; the first column of `sub_tfl` varies outermost. Columns may +#' overlap with `dplyr::group_vars(x)`; the overlapping columns are promoted +#' to the caption (removed from row-header rendering). When the global +#' `caption` is `NULL`, the suffix becomes the entire caption. +#' @param sub_tfl_sep Character scalar inserted between each sub_tfl column's +#' label and value. Default `": "`. Passed as `sep` to `paste()`. +#' @param sub_tfl_collapse Character scalar inserted between successive +#' `label: value` pairs when more than one column is named in `sub_tfl`. +#' Default `"; "`. Passed as `collapse` to `paste()`. +#' @param sub_tfl_prefix Character scalar joining the existing caption to the +#' sub_tfl suffix. Default `"\n"` (suffix appears on its own line). Ignored +#' when the global caption is `NULL`. #' @param col_cont_msg Character vector of length 1 or 2, or `NULL`. Rotated #' side labels on column-split pages. The first element is shown #' counter-clockwise 90° at the **left** edge of the viewport when columns @@ -222,6 +240,10 @@ tfl_table <- function(x, allow_col_split = TRUE, balance_col_pages = FALSE, suppress_repeated_groups = TRUE, + sub_tfl = NULL, + sub_tfl_sep = ": ", + sub_tfl_collapse = "; ", + 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)"), @@ -318,6 +340,23 @@ tfl_table <- function(x, # --- Validate cell_padding and normalise to 4-element named vector --- cell_padding <- .normalise_cell_padding(cell_padding) + # --- Validate sub_tfl --- + if (!is.null(sub_tfl)) { + if (!is.character(sub_tfl) || length(sub_tfl) == 0L || + anyNA(sub_tfl) || any(!nzchar(sub_tfl))) { + rlang::abort("`sub_tfl` must be NULL or a non-empty character vector.") + } + bad <- setdiff(sub_tfl, col_names) + if (length(bad) > 0L) { + rlang::abort(paste0( + "`sub_tfl` columns not found in `x`: ", paste(bad, collapse = ", ") + )) + } + } + checkmate::assert_string(sub_tfl_sep, .var.name = "sub_tfl_sep") + checkmate::assert_string(sub_tfl_collapse, .var.name = "sub_tfl_collapse") + checkmate::assert_string(sub_tfl_prefix, .var.name = "sub_tfl_prefix") + # --- 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") @@ -365,6 +404,10 @@ tfl_table <- function(x, allow_col_split = allow_col_split, balance_col_pages = balance_col_pages, suppress_repeated_groups = suppress_repeated_groups, + sub_tfl = sub_tfl, + sub_tfl_sep = sub_tfl_sep, + sub_tfl_collapse = sub_tfl_collapse, + sub_tfl_prefix = sub_tfl_prefix, col_cont_msg = col_cont_msg, row_cont_msg = row_cont_msg, show_col_names = show_col_names, diff --git a/design/ARCHITECTURE.md b/design/ARCHITECTURE.md index 24743ca..e73fd28 100644 --- a/design/ARCHITECTURE.md +++ b/design/ARCHITECTURE.md @@ -37,7 +37,11 @@ export_tfl(x, file, preview, ...) [exported, S3 generic] │ ├── export_tfl.ggtibble() — ggtibble.R │ ├── .validate_export_args(...) - │ ├── ggtibble_to_pagelist(x) — ggtibble.R + │ ├── ggtibble_to_pagelist(x, sub_tfl, sub_tfl_sep, — ggtibble.R + │ │ sub_tfl_collapse, sub_tfl_prefix) + │ │ per row, appends "label: value; ..." suffix to caption via + │ │ .apply_sub_tfl_caption() (sub_tfl.R); raw column names used + │ │ as labels (no colspec system for ggtibble) │ └── .export_tfl_pages(...) │ ├── export_tfl.gt_tbl() — gt.R @@ -115,6 +119,23 @@ export_tfl_page(x, ...) [exported] export_tfl(x = tfl_table_obj, ...) [exported] └── tfl_table_to_pagelist(tbl, pg_width, pg_height, — table_pagelist.R dots, page_num) + ├── [if tbl$sub_tfl is non-NULL — sub-table branch] + │ .compute_sub_tfl_groups(data, sub_tfl) — sub_tfl.R + │ ordered list of sub-groups; factor levels for factors, + │ first-appearance order otherwise + │ for each sub-group g: + │ .strip_sub_tfl_cols(sub_tbl) — sub_tfl.R + │ drops sub_tfl from data, group_vars, cols, col_widths, + │ col_labels, col_align, wrap_cols + │ .format_sub_tfl_caption(tbl, g$values) — sub_tfl.R + │ label: value joined by sep, multi-col joined by collapse + │ labels resolved via .resolve_col_label() (colspec → flat → name) + │ .apply_sub_tfl_caption(base, suffix, prefix) — sub_tfl.R + │ base + prefix + suffix; suffix alone when base is NULL + │ tfl_table_to_pagelist(sub_tbl, ...) [recursion, sub_tfl=NULL] + │ attach $caption to each returned page spec + │ concatenate per-group pages → return + │ ├── compute_table_content_area(...) — table_pagelist.R │ scratch device + outer_vp to measure annotation heights ├── resolve_col_specs(tbl) — table_columns.R @@ -308,6 +329,7 @@ export_tfl(x = list_of_table1, ...) [exported] | `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_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()` | --- @@ -588,6 +610,63 @@ paginate_rows(data, row_heights, cont_row_h, header_row_h, --- +## Sub-tables — `sub_tfl` data contracts + +### `.compute_sub_tfl_groups(data, sub_tfl)` → `list` + +``` +data: data.frame +sub_tfl: character vector of column names in data + +Output: ordered list of group specs, each: + list( + values = named list (one entry per sub_tfl col, scalar value), + row_idx = integer vector (1-based rows of data in this group) + ) +``` + +Ordering: factor columns contribute their levels (in level order); character / +numeric columns contribute first-appearance order. The Cartesian-style +ordering iterates by `sub_tfl` left-to-right (outer to inner). + +### `.resolve_col_label(tbl, col_name)` → `character(1)` + +``` +Priority (highest first): + tbl$cols[[k]]$label where tbl$cols[[k]]$col == col_name + tbl$col_labels[col_name] if named and non-NA + col_name fallback +``` + +Shared between `resolve_col_specs()` and `.format_sub_tfl_caption()`. + +### `.format_sub_tfl_caption(tbl, values)` → `character(1)` + +``` +values: named list (names = sub_tfl columns, values = scalars for one group) + +For each entry: + label <- .resolve_col_label(tbl, name) + pair <- paste(label, format(value), sep = tbl$sub_tfl_sep) +Result <- paste(pairs, collapse = tbl$sub_tfl_collapse) +``` + +### `.apply_sub_tfl_caption(base, suffix, prefix)` → `character(1)` + +``` +base = NULL → return suffix +base ≠ NULL → return paste0(base, prefix, suffix) +``` + +### `.strip_sub_tfl_cols(tbl)` → `tfl_table` + +Removes `tbl$sub_tfl` entries from `tbl$cols` (list of `tfl_colspec`), +`tbl$col_widths`, `tbl$col_labels`, `tbl$col_align`, `tbl$wrap_cols` (when +named). Caller is responsible for filtering `tbl$data` and updating +`tbl$group_vars`. + +--- + ## Error messages (current, for test regexp matching) | Condition | Pattern | diff --git a/design/DECISIONS.md b/design/DECISIONS.md index 73f10d2..3b56633 100644 --- a/design/DECISIONS.md +++ b/design/DECISIONS.md @@ -736,3 +736,67 @@ pattern (`match.arg` + per-page override). Default is `"left"`. - Use `build_text_grob()` — designed for annotation grobs positioned via `editGrob()` in `outer_vp`; not appropriate for top-left-anchored content in its own viewport. + +--- + +## D-38: `sub_tfl` argument for sub-tables and sub-figures + +**Decision:** `tfl_table()` and `export_tfl.ggtibble()` accept a `sub_tfl` +character-vector argument naming columns to split the output by. Each unique +combination of values yields its own sub-table (or sub-figure page). The +sub_tfl values are removed from the rendered body and instead appended to the +caption as `"label: value; label: value"`. Three companion arguments — +`sub_tfl_sep` (default `": "`), `sub_tfl_collapse` (default `"; "`), and +`sub_tfl_prefix` (default `"\n"`) — control formatting. + +**Behaviors:** +- **Body drop:** sub_tfl columns are always removed from the rendered table. + This is unconditional — even if the user also lists a column in `cols` / + `col_widths` / `col_labels` / etc., it is stripped before pagination. +- **Group-var overlap is allowed.** When a sub_tfl column also appears in + `dplyr::group_vars(x)`, it is removed from `group_vars` (promoted to the + caption). This is a common case (e.g. data already grouped by treatment + arm; user wants one sub-table per arm). +- **Ordering:** factor columns drive ordering by their levels; + character/numeric columns by first-appearance order. The combined order + iterates `sub_tfl` left-to-right outer-to-inner. +- **NULL caption:** when the global caption is NULL, the suffix becomes the + whole caption (no leading prefix). +- **Label source (tfl_table):** `tfl_colspec$label` if set, else + `col_labels[col]` if named, else the raw column name. ggtibble uses raw + column names only. +- **Recursion in `tfl_table_to_pagelist()`:** the sub_tfl branch loops over + groups and calls `tfl_table_to_pagelist()` recursively (with `sub_tfl = + NULL` on the inner table). This re-enters the existing measurement / + pagination / drawing pipeline unchanged for each sub-group. +- **Per-page caption attachment:** each returned page spec carries its own + `$caption`. `build_page_args()` (utils.R) already merges per-page values + over the global `dots`, so no change is needed there. + +**Why recursion rather than caption-only injection at the top level:** the +caption suffix has variable line count after word-wrap, which changes the +available content height. Each sub-group must therefore re-run +`compute_table_content_area()` with its own caption. Recursing through the +existing pipeline is the cleanest way to honour that and reuses every +existing helper. + +**Alternatives considered:** +- **Single-pass with one shared content-area calculation** — would mis-size + pages whenever sub_tfl values produce captions of different line counts + (long vs. short labels, or factor levels with very different lengths). +- **Disallow group_vars overlap** — would force users to pre-`ungroup()` data + that is already grouped by the same dimension they want to split on. + Rejected as user-hostile and contrary to the most common use case. +- **List-of-`tfl_colspec` shape for sub_tfl** — overkill; labels and any + per-column formatting can be inherited from the existing colspec system. +- **Magic prefix detection (e.g. columns starting with `sub_`)** — too + implicit; explicit `sub_tfl =` argument is clearer and grep-able. +- **Sub-figures via raw `export_tfl()` (ggplot/grob input)** — out of scope. + Figure users with by-group needs should build a `ggtibble`, which already + has per-row caption support; `sub_tfl` augments that. + +**Implementation:** new file `R/sub_tfl.R` holding `.compute_sub_tfl_groups()`, +`.format_sub_tfl_caption()`, `.apply_sub_tfl_caption()`, +`.strip_sub_tfl_cols()`, and `.resolve_col_label()` (factored out of +`resolve_col_specs()` so sub_tfl and the column-spec resolver share label +fallback logic). diff --git a/design/TESTING.md b/design/TESTING.md index 3394c87..1393ec2 100644 --- a/design/TESTING.md +++ b/design/TESTING.md @@ -26,7 +26,8 @@ One test file per source file — `tests/testthat/test-.R` covers | `test-table_utils.R` | `.compute_group_sizes()`, `.collect_col_strings()`, `.measure_max_string_width()`, `.wrap_text()` | | `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-ggtibble.R` | `ggtibble_to_pagelist()`, `export_tfl.ggtibble()` — conversion, S3 dispatch, end-to-end (requires ggtibble, skipped if absent) | +| `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 | | `test-rtables.R` | `.extract_rtables_annotations()`, `.clean_rtables()`, `.rtables_to_grob()`, `.rtables_lpp_cpp()`, `.rtables_content_height()`, `.rtables_content_width()`, `rtables_to_pagelist()`, `export_tfl.VTableTree()`, `export_tfl.list()` with VTableTree objects, pagination, S3 dispatch | | `test-flextable.R` | `.extract_flextable_annotations()`, `.clean_flextable()`, `.flextable_to_grob()`, `.flextable_grob_height()`, `.flextable_content_height()`, `.flextable_content_width()`, `flextable_to_pagelist()`, `.rebuild_flextable_subset()`, `.paginate_flextable()`, `export_tfl.flextable()`, `export_tfl.list()` with flextable objects, S3 dispatch | @@ -235,6 +236,54 @@ Key areas covered: --- +## `test-sub_tfl.R` — sub-table support + +Key areas covered: + +- `tfl_table()` validation of sub_tfl args: + - non-existent column → error + - `sub_tfl_sep` / `sub_tfl_collapse` / `sub_tfl_prefix` not a single string + → error + - non-character / zero-length `sub_tfl` → error +- `.compute_sub_tfl_groups()`: + - single character column: groups in first-appearance order + - single factor column: groups in factor-level order (even when levels are + not sorted alphabetically and not all are present in data) + - two columns: outer-to-inner iteration order; missing combinations + skipped (no empty groups) + - row indices are correct for each group +- `.resolve_col_label()`: + - colspec label wins over flat `col_labels` + - flat `col_labels[col]` wins over raw column name + - raw column name fallback when neither is set +- `.format_sub_tfl_caption()`: + - default sep/collapse → `"Arm: A; Visit: Week 4"` + - custom sep/collapse propagate + - factor values formatted as character (level label, not integer code) +- `.apply_sub_tfl_caption()`: + - NULL base → suffix returned as-is, no prefix + - non-NULL base → `paste0(base, prefix, suffix)` + - empty-string prefix +- `.strip_sub_tfl_cols()`: removes sub_tfl entries from `cols`, + `col_widths`, `col_labels`, `col_align`, `wrap_cols` (named), leaves + unnamed flat args untouched +- `tfl_table_to_pagelist()` sub_tfl branch: + - one page per group; sub_tfl columns absent from rendered grobs + - per-page `$caption` overrides global caption (verified via + `build_page_args()`) + - global caption NULL: page caption equals suffix only + - global caption set: page caption is `base + prefix + suffix` + - sub_tfl col that was also in `group_vars`: removed from group_vars in + sub-tables; remaining group_vars still render as row headers + - sub_tfl covering ALL group_vars: sub-tables render with no row-header + columns + - sub-table that itself paginates by rows: every page in that sub-group + carries the same caption suffix + - long suffix that wraps to multiple lines: content height re-measured + per group (no overflow on the worst-case group) + +--- + ## `test-gt.R` — gt connector All tests wrapped in `skip_if_not_installed("gt")`. diff --git a/man/ggtibble_to_pagelist.Rd b/man/ggtibble_to_pagelist.Rd index fe58749..8d50c59 100644 --- a/man/ggtibble_to_pagelist.Rd +++ b/man/ggtibble_to_pagelist.Rd @@ -4,10 +4,21 @@ \alias{ggtibble_to_pagelist} \title{Convert a ggtibble object to a list of page specification lists} \usage{ -ggtibble_to_pagelist(x) +ggtibble_to_pagelist( + x, + sub_tfl = NULL, + sub_tfl_sep = ": ", + sub_tfl_collapse = "; ", + sub_tfl_prefix = "\\n" +) } \arguments{ \item{x}{A \code{ggtibble} object.} + +\item{sub_tfl}{Character vector of column names in \code{x}, or \code{NULL}.} + +\item{sub_tfl_sep, sub_tfl_collapse, sub_tfl_prefix}{Formatting controls for +the appended \code{label: value} suffix. See \code{\link[=tfl_table]{tfl_table()}}.} } \value{ A list of page spec lists, each with at least \verb{$content}. @@ -15,6 +26,8 @@ A list of page spec lists, each with at least \verb{$content}. \description{ Each row of the ggtibble becomes one page spec. The \code{figure} column provides the content (ggplot). Any columns whose names match -\code{\link[=export_tfl_page]{export_tfl_page()}} text arguments are used as per-page values. +\code{\link[=export_tfl_page]{export_tfl_page()}} text arguments are used as per-page values. When +\code{sub_tfl} is supplied, those columns' values are appended to each row's +caption. } \keyword{internal} diff --git a/man/tfl_table.Rd b/man/tfl_table.Rd index 5da9dbe..580d884 100644 --- a/man/tfl_table.Rd +++ b/man/tfl_table.Rd @@ -15,6 +15,10 @@ tfl_table( allow_col_split = TRUE, balance_col_pages = FALSE, suppress_repeated_groups = TRUE, + sub_tfl = NULL, + sub_tfl_sep = ": ", + sub_tfl_collapse = "; ", + 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)"), show_col_names = TRUE, @@ -73,6 +77,28 @@ 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.} +\item{sub_tfl}{Character vector of column names in \code{x}, or \code{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 +form \code{"label: value; label: value"} so the sub-table is self-identifying; +the sub_tfl columns are removed from the rendered body. Sub-table ordering +follows factor levels for factor columns and first-appearance order +otherwise; the first column of \code{sub_tfl} varies outermost. Columns may +overlap with \code{dplyr::group_vars(x)}; the overlapping columns are promoted +to the caption (removed from row-header rendering). When the global +\code{caption} is \code{NULL}, the suffix becomes the entire caption.} + +\item{sub_tfl_sep}{Character scalar inserted between each sub_tfl column's +label and value. Default \code{": "}. Passed as \code{sep} to \code{paste()}.} + +\item{sub_tfl_collapse}{Character scalar inserted between successive +\code{label: value} pairs when more than one column is named in \code{sub_tfl}. +Default \code{"; "}. Passed as \code{collapse} to \code{paste()}.} + +\item{sub_tfl_prefix}{Character scalar joining the existing caption to the +sub_tfl suffix. Default \code{"\\n"} (suffix appears on its own line). Ignored +when the global caption is \code{NULL}.} + \item{col_cont_msg}{Character vector of length 1 or 2, or \code{NULL}. Rotated side labels on column-split pages. The first element is shown counter-clockwise 90° at the \strong{left} edge of the viewport when columns diff --git a/tests/testthat/test-sub_tfl.R b/tests/testthat/test-sub_tfl.R new file mode 100644 index 0000000..5774a0a --- /dev/null +++ b/tests/testthat/test-sub_tfl.R @@ -0,0 +1,335 @@ +# test-sub_tfl.R — Tests for sub_tfl support (R/sub_tfl.R + integration) + +library(dplyr, warn.conflicts = FALSE) + +# --------------------------------------------------------------------------- +# .compute_sub_tfl_groups() +# --------------------------------------------------------------------------- + +test_that("compute_sub_tfl_groups orders character columns by first appearance", { + df <- data.frame(arm = c("B", "A", "B", "A", "C"), + v = 1:5, stringsAsFactors = FALSE) + groups <- .compute_sub_tfl_groups(df, "arm") + expect_length(groups, 3L) + expect_equal(vapply(groups, function(g) g$values$arm, character(1)), + c("B", "A", "C")) + expect_equal(groups[[1L]]$row_idx, c(1L, 3L)) + expect_equal(groups[[2L]]$row_idx, c(2L, 4L)) + expect_equal(groups[[3L]]$row_idx, 5L) +}) + +test_that("compute_sub_tfl_groups follows factor levels (not data order)", { + df <- data.frame( + arm = factor(c("B", "A", "B", "A"), levels = c("A", "B", "C")), + v = 1:4 + ) + groups <- .compute_sub_tfl_groups(df, "arm") + # Only A and B are present; C should be dropped. + expect_length(groups, 2L) + expect_equal(vapply(groups, function(g) g$values$arm, character(1)), + c("A", "B")) +}) + +test_that("compute_sub_tfl_groups iterates first column outermost", { + df <- data.frame( + arm = c("A", "A", "B", "B"), + visit = c("V1", "V2", "V1", "V2"), + v = 1:4, + stringsAsFactors = FALSE + ) + groups <- .compute_sub_tfl_groups(df, c("arm", "visit")) + expect_length(groups, 4L) + arms <- vapply(groups, function(g) g$values$arm, character(1)) + visits <- vapply(groups, function(g) g$values$visit, character(1)) + expect_equal(arms, c("A", "A", "B", "B")) + expect_equal(visits, c("V1", "V2", "V1", "V2")) +}) + +test_that("compute_sub_tfl_groups skips empty Cartesian combinations", { + df <- data.frame( + arm = c("A", "B"), + visit = c("V1", "V2"), + stringsAsFactors = FALSE + ) + # Cartesian product is 4 combos but only 2 are present. + groups <- .compute_sub_tfl_groups(df, c("arm", "visit")) + expect_length(groups, 2L) +}) + +test_that("compute_sub_tfl_groups handles NA values by dropping them", { + df <- data.frame(arm = c("A", NA, "A", "B"), v = 1:4, + stringsAsFactors = FALSE) + groups <- .compute_sub_tfl_groups(df, "arm") + expect_length(groups, 2L) + # Row 2 (NA) is in no group. + all_idx <- sort(unlist(lapply(groups, `[[`, "row_idx"))) + expect_equal(all_idx, c(1L, 3L, 4L)) +}) + +# --------------------------------------------------------------------------- +# .resolve_col_label() +# --------------------------------------------------------------------------- + +test_that("resolve_col_label uses tfl_colspec label when present", { + tbl <- list( + cols = list(tfl_colspec("arm", label = "Treatment Arm")), + col_labels = c(arm = "fallback") + ) + expect_equal(.resolve_col_label(tbl, "arm"), "Treatment Arm") +}) + +test_that("resolve_col_label falls back to col_labels", { + tbl <- list(cols = NULL, col_labels = c(arm = "Arm Label")) + expect_equal(.resolve_col_label(tbl, "arm"), "Arm Label") +}) + +test_that("resolve_col_label falls back to column name", { + tbl <- list(cols = NULL, col_labels = NULL) + expect_equal(.resolve_col_label(tbl, "arm"), "arm") +}) + +# --------------------------------------------------------------------------- +# .format_sub_tfl_caption() and .apply_sub_tfl_caption() +# --------------------------------------------------------------------------- + +test_that("format_sub_tfl_caption produces label: value; label: value", { + tbl <- list(cols = NULL, col_labels = NULL, + sub_tfl_sep = ": ", sub_tfl_collapse = "; ") + out <- .format_sub_tfl_caption(tbl, list(arm = "A", visit = "Week 4")) + expect_equal(out, "arm: A; visit: Week 4") +}) + +test_that("format_sub_tfl_caption honours custom sep and collapse", { + tbl <- list(cols = NULL, col_labels = NULL, + sub_tfl_sep = " = ", sub_tfl_collapse = " | ") + out <- .format_sub_tfl_caption(tbl, list(a = 1, b = 2)) + expect_equal(out, "a = 1 | b = 2") +}) + +test_that("format_sub_tfl_caption uses colspec label", { + tbl <- list(cols = list(tfl_colspec("arm", label = "Treatment")), + col_labels = NULL, + sub_tfl_sep = ": ", sub_tfl_collapse = "; ") + expect_equal(.format_sub_tfl_caption(tbl, list(arm = "A")), + "Treatment: A") +}) + +test_that("apply_sub_tfl_caption returns suffix alone when base is NULL", { + expect_equal(.apply_sub_tfl_caption(NULL, "arm: A", "\n"), "arm: A") +}) + +test_that("apply_sub_tfl_caption joins base + prefix + suffix", { + expect_equal(.apply_sub_tfl_caption("Table 1", "arm: A", "\n"), + "Table 1\narm: A") + expect_equal(.apply_sub_tfl_caption("Table 1", "arm: A", " — "), + "Table 1 — arm: A") +}) + +# --------------------------------------------------------------------------- +# .strip_sub_tfl_cols() +# --------------------------------------------------------------------------- + +test_that("strip_sub_tfl_cols removes entries from cols, col_widths, etc.", { + tbl <- list( + sub_tfl = "arm", + cols = list(tfl_colspec("arm", label = "X"), + tfl_colspec("v", label = "Y")), + col_widths = list(arm = 1, v = 2), + col_labels = c(arm = "ax", v = "vx"), + col_align = c(arm = "left", v = "right"), + wrap_cols = c("arm", "v") + ) + out <- .strip_sub_tfl_cols(tbl) + expect_length(out$cols, 1L) + expect_equal(out$cols[[1L]]$col, "v") + expect_equal(names(out$col_widths), "v") + expect_equal(names(out$col_labels), "v") + expect_equal(names(out$col_align), "v") + expect_equal(out$wrap_cols, "v") +}) + +test_that("strip_sub_tfl_cols sets fields to NULL when emptied", { + tbl <- list( + sub_tfl = "arm", + cols = list(tfl_colspec("arm")), + col_widths = list(arm = 1), + col_labels = c(arm = "ax"), + col_align = NULL, + wrap_cols = FALSE + ) + out <- .strip_sub_tfl_cols(tbl) + expect_null(out$cols) + expect_null(out$col_widths) + expect_null(out$col_labels) + expect_false(out$wrap_cols) +}) + +# --------------------------------------------------------------------------- +# tfl_table() — sub_tfl validation +# --------------------------------------------------------------------------- + +test_that("tfl_table rejects non-character sub_tfl", { + df <- data.frame(arm = "A", v = 1) + expect_error(tfl_table(df, sub_tfl = 1), "non-empty character") + expect_error(tfl_table(df, sub_tfl = character(0)), "non-empty character") + expect_error(tfl_table(df, sub_tfl = NA_character_), "non-empty character") +}) + +test_that("tfl_table rejects sub_tfl columns not in x", { + df <- data.frame(arm = "A", v = 1) + expect_error(tfl_table(df, sub_tfl = "missing"), "not found in") +}) + +test_that("tfl_table rejects non-string sub_tfl_sep / collapse / prefix", { + df <- data.frame(arm = "A", v = 1) + expect_error(tfl_table(df, sub_tfl = "arm", sub_tfl_sep = 1)) + expect_error(tfl_table(df, sub_tfl = "arm", sub_tfl_collapse = c("a", "b"))) + expect_error(tfl_table(df, sub_tfl = "arm", sub_tfl_prefix = NULL)) +}) + +test_that("tfl_table stores sub_tfl args on the object", { + df <- data.frame(arm = "A", v = 1, stringsAsFactors = FALSE) + tbl <- tfl_table(df, sub_tfl = "arm", + sub_tfl_sep = " = ", sub_tfl_collapse = " | ", + sub_tfl_prefix = " — ") + expect_equal(tbl$sub_tfl, "arm") + expect_equal(tbl$sub_tfl_sep, " = ") + expect_equal(tbl$sub_tfl_collapse, " | ") + expect_equal(tbl$sub_tfl_prefix, " — ") +}) + +# --------------------------------------------------------------------------- +# tfl_table_to_pagelist() sub_tfl branch — integration +# --------------------------------------------------------------------------- + +test_that("tfl_table_to_pagelist produces one sub-table per group", { + df <- data.frame( + arm = c("A", "A", "B", "B"), + lbl = c("x", "y", "x", "y"), + val = 1:4, + stringsAsFactors = FALSE + ) + tbl <- tfl_table(df, sub_tfl = "arm") + pages <- tfl_table_to_pagelist(tbl, pg_width = 11, pg_height = 8.5, + dots = list(caption = "Table 1")) + expect_length(pages, 2L) + expect_equal(pages[[1L]]$caption, "Table 1\narm: A") + expect_equal(pages[[2L]]$caption, "Table 1\narm: B") +}) + +test_that("tfl_table_to_pagelist suffix becomes caption when global is NULL", { + df <- data.frame(arm = c("A", "B"), val = 1:2, stringsAsFactors = FALSE) + tbl <- tfl_table(df, sub_tfl = "arm") + pages <- tfl_table_to_pagelist(tbl, pg_width = 11, pg_height = 8.5, + dots = list()) + expect_equal(pages[[1L]]$caption, "arm: A") + expect_equal(pages[[2L]]$caption, "arm: B") +}) + +test_that("sub_tfl drops columns from the rendered grob", { + df <- data.frame( + arm = c("A", "A", "B"), + lbl = c("x", "y", "z"), + val = 1:3, + stringsAsFactors = FALSE + ) + tbl <- tfl_table(df, sub_tfl = "arm") + pages <- tfl_table_to_pagelist(tbl, pg_width = 11, pg_height = 8.5, + dots = list()) + rendered_cols <- vapply(pages[[1L]]$content$page_cols, + `[[`, "", "col") + expect_false("arm" %in% rendered_cols) + expect_setequal(rendered_cols, c("lbl", "val")) +}) + +test_that("sub_tfl works when overlapping with group_vars", { + df <- data.frame( + arm = c("A", "A", "B", "B"), + lbl = c("x", "y", "x", "y"), + val = 1:4, + stringsAsFactors = FALSE + ) |> dplyr::group_by(arm) + tbl <- tfl_table(df, sub_tfl = "arm") + pages <- tfl_table_to_pagelist(tbl, pg_width = 11, pg_height = 8.5, + dots = list()) + expect_length(pages, 2L) + # group_vars in rendered sub-tables must no longer include "arm". + expect_equal(pages[[1L]]$content$tbl$group_vars, character(0)) +}) + +test_that("sub_tfl on factor column follows factor levels", { + df <- data.frame( + arm = factor(c("B", "A", "C", "A"), levels = c("A", "B", "C")), + val = 1:4 + ) + tbl <- tfl_table(df, sub_tfl = "arm") + pages <- tfl_table_to_pagelist(tbl, pg_width = 11, pg_height = 8.5, + dots = list()) + caps <- vapply(pages, `[[`, "", "caption") + expect_equal(caps, c("arm: A", "arm: B", "arm: C")) +}) + +test_that("sub_tfl uses tfl_colspec label in caption", { + df <- data.frame(arm = c("A", "B"), val = 1:2, stringsAsFactors = FALSE) + tbl <- tfl_table( + df, + sub_tfl = "arm", + cols = list(tfl_colspec("arm", label = "Treatment")) + ) + pages <- tfl_table_to_pagelist(tbl, pg_width = 11, pg_height = 8.5, + dots = list(caption = "T1")) + expect_equal(pages[[1L]]$caption, "T1\nTreatment: A") +}) + +test_that("sub_tfl with multiple columns iterates outer-first", { + df <- data.frame( + arm = c("A", "A", "B", "B"), + visit = c("V1", "V2", "V1", "V2"), + val = 1:4, + stringsAsFactors = FALSE + ) + tbl <- tfl_table(df, sub_tfl = c("arm", "visit")) + pages <- tfl_table_to_pagelist(tbl, pg_width = 11, pg_height = 8.5, + dots = list()) + caps <- vapply(pages, `[[`, "", "caption") + expect_equal(caps, c("arm: A; visit: V1", + "arm: A; visit: V2", + "arm: B; visit: V1", + "arm: B; visit: V2")) +}) + +# --------------------------------------------------------------------------- +# ggtibble integration +# --------------------------------------------------------------------------- + +test_that("ggtibble_to_pagelist appends sub_tfl suffix to caption", { + skip_if_not_installed("ggplot2") + p <- ggplot2::ggplot(mtcars, ggplot2::aes(mpg, hp)) + ggplot2::geom_point() + x <- tibble::tibble( + figure = list(p, p), + caption = c("Cars 1", "Cars 2"), + arm = c("A", "B") + ) + class(x) <- c("ggtibble", class(x)) + pages <- ggtibble_to_pagelist(x, sub_tfl = "arm") + expect_equal(pages[[1L]]$caption, "Cars 1\narm: A") + expect_equal(pages[[2L]]$caption, "Cars 2\narm: B") +}) + +test_that("ggtibble_to_pagelist suffix becomes caption when row caption absent", { + skip_if_not_installed("ggplot2") + p <- ggplot2::ggplot(mtcars, ggplot2::aes(mpg, hp)) + ggplot2::geom_point() + x <- tibble::tibble(figure = list(p, p), arm = c("A", "B")) + class(x) <- c("ggtibble", class(x)) + pages <- ggtibble_to_pagelist(x, sub_tfl = "arm") + expect_equal(pages[[1L]]$caption, "arm: A") + expect_equal(pages[[2L]]$caption, "arm: B") +}) + +test_that("ggtibble_to_pagelist rejects unknown sub_tfl columns", { + skip_if_not_installed("ggplot2") + p <- ggplot2::ggplot(mtcars, ggplot2::aes(mpg, hp)) + ggplot2::geom_point() + x <- tibble::tibble(figure = list(p), arm = "A") + class(x) <- c("ggtibble", class(x)) + expect_error(ggtibble_to_pagelist(x, sub_tfl = "missing"), "not found") +}) From 4c42754f4e2f9d2d5ce0d2157335498882ce33af Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Tue, 28 Apr 2026 21:43:41 -0400 Subject: [PATCH 2/3] Document sub_tfl in tfl_table styling vignette Adds section 13 with a worked example, the formatting argument table, ordering rules, group_vars overlap behaviour, and a pointer to the ggtibble equivalent. Renumbers the complete-example section to 14. Co-Authored-By: Claude Opus 4.7 (1M context) --- vignettes/v03-tfl_table_styling.Rmd | 104 +++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 1 deletion(-) diff --git a/vignettes/v03-tfl_table_styling.Rmd b/vignettes/v03-tfl_table_styling.Rmd index 499df7e..97ee1e6 100644 --- a/vignettes/v03-tfl_table_styling.Rmd +++ b/vignettes/v03-tfl_table_styling.Rmd @@ -482,7 +482,109 @@ tbl_no_msg <- tfl_table( --- -## 13. Complete example: clinical default vs. publication style +## 13. Sub-tables — `sub_tfl` + +`sub_tfl` splits a single `tfl_table` into one self-identifying sub-table per +unique combination of values in the named columns. The columns named in +`sub_tfl` are **removed from the rendered table body** and instead appear in +the caption as `"label: value; label: value"`. + +This is the idiomatic way to produce by-group tables (e.g. one table per +treatment arm, per visit) without manually splitting the data and stitching +the page lists together. + +```{r sub-tfl-basic, fig.width = 11, fig.height = 8.5, out.width = "100%"} +tbl_by_arm <- tfl_table( + clinical, + sub_tfl = "treatment", + cols = col_spec +) + +export_tfl( + tbl_by_arm, + caption = "Table 1. Response by Subgroup", + preview = 1L +) +``` + +The first preview page shows `Table 1. Response by Subgroup` followed on a +new line by `Treatment Arm: Active (n=120)` (the colspec label is reused, not +the raw column name). The `treatment` column itself is no longer in the +table body. A second page contains the placebo arm. + +### Multiple sub_tfl columns + +Naming more than one column produces the Cartesian product, with the first +column varying outermost: + +```{r sub-tfl-multi, eval = FALSE} +tbl <- tfl_table( + clinical, + sub_tfl = c("treatment", "subgroup") +) +# Page captions, in order: +# "Treatment Arm: Active (n=120); Subgroup: All patients" +# "Treatment Arm: Active (n=120); Subgroup: Age < 65" +# ... +``` + +### Formatting controls + +Three formatting arguments mirror `paste()`: + +| Argument | Default | Role | +|----------|---------|------| +| `sub_tfl_sep` | `": "` | between each column's label and value | +| `sub_tfl_collapse` | `"; "` | between successive `label: value` pairs | +| `sub_tfl_prefix` | `"\n"` | between the existing caption and the suffix | + +```{r sub-tfl-format, eval = FALSE} +tfl_table( + clinical, + sub_tfl = c("treatment", "subgroup"), + sub_tfl_sep = " = ", + sub_tfl_collapse = " | ", + sub_tfl_prefix = " — " +) +# Caption per page: "Table 1 — Treatment Arm = Active (n=120) | Subgroup = ..." +``` + +When the global `caption` is `NULL`, the suffix becomes the entire caption +(no leading prefix). + +### Ordering + +Sub-tables are produced in this order: + +- **Factor columns** drive their own ordering by `levels()` (only levels that + appear in the data are emitted). Use a factor when you need a clinically + meaningful order such as `Active` before `Placebo`. +- **Character / numeric columns** use first-appearance order in the data. + +### Overlap with `group_vars()` + +When a column listed in `sub_tfl` is *also* a `dplyr::group_by()` variable +(a row-header column), it is promoted to the caption — i.e. removed from +both the rendered body and from `group_vars`. This is a common case: + +```{r sub-tfl-overlap, eval = FALSE} +clinical |> + group_by(treatment) |> + tfl_table(sub_tfl = "treatment") +# treatment is no longer a row-header column; instead each treatment arm +# is its own sub-table, with the arm name in the caption. +``` + +### Sub-figures via `ggtibble` + +`export_tfl.ggtibble()` accepts the same four arguments. The named columns +are appended to each row's caption (using the raw column names as labels, as +ggtibbles have no colspec system). This is the recommended way to build +by-group sub-figure decks. + +--- + +## 14. Complete example: clinical default vs. publication style The following pair of examples contrasts the out-of-the-box clinical appearance with a more compact publication-style variant. Both render using `preview = TRUE`. From 6652878ea30ec7b9b07a6f544ee51b00759eeab7 Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Tue, 28 Apr 2026 21:57:28 -0400 Subject: [PATCH 3/3] Address review feedback on sub_tfl PR - Replace nested anonymous functions in lapply/vapply with named top-level helpers (.ordered_unique_values, .named_one_value, .colspec_not_in, .format_sub_tfl_pair, .format_ggtibble_sub_tfl_pair, .ggtibble_row_pagespec, .attach_page_caption). - Tighten string assertions to assert_character(..., len = 1L, any.missing = FALSE) so the length-1 contract is explicit. - Restructure tfl_table_to_pagelist() as an if/else dispatch over two helpers (.tfl_table_to_pagelist_default and ..._sub_tfl) instead of an early return. Co-Authored-By: Claude Opus 4.7 (1M context) --- R/ggtibble.R | 74 +++++++++++++++++++++++++------------------ R/sub_tfl.R | 66 +++++++++++++++++++++++++++----------- R/table_pagelist.R | 79 ++++++++++++++++++++++++++++------------------ R/tfl_table.R | 9 ++++-- 4 files changed, 146 insertions(+), 82 deletions(-) diff --git a/R/ggtibble.R b/R/ggtibble.R index 1b9908e..b34180f 100644 --- a/R/ggtibble.R +++ b/R/ggtibble.R @@ -26,6 +26,36 @@ export_tfl.ggtibble <- function( .export_tfl_pages(x, file, pg_width, pg_height, page_num, preview, dots) } +# Page-spec arg names recognised on a ggtibble row. +.ggtibble_page_arg_names <- c( + "caption", "footnote", + "header_left", "header_center", "header_right", + "footer_left", "footer_center", "footer_right" +) + +# Build one page spec from a single ggtibble row. +#' @keywords internal +.ggtibble_row_pagespec <- function(i, x, present_args, sub_tfl, + sub_tfl_sep, sub_tfl_collapse, + sub_tfl_prefix) { + # Extract the ggplot from the figure cell. gglist[[i]] returns the ggplot + # directly; for plain list columns, unwrap one level if needed. + fig <- x$figure[[i]] + if (!inherits(fig, "gg") && is.list(fig)) fig <- fig[[1L]] + spec <- list(content = fig) + for (col in present_args) { + spec[[col]] <- x[[col]][[i]] + } + if (!is.null(sub_tfl)) { + pairs <- vapply(sub_tfl, .format_ggtibble_sub_tfl_pair, + character(1L), x = x, i = i, sep = sub_tfl_sep) + suffix <- paste(pairs, collapse = sub_tfl_collapse) + spec$caption <- .apply_sub_tfl_caption(spec$caption, suffix, + sub_tfl_prefix) + } + spec +} + #' Convert a ggtibble object to a list of page specification lists #' #' Each row of the ggtibble becomes one page spec. The `figure` column @@ -43,13 +73,7 @@ export_tfl.ggtibble <- function( ggtibble_to_pagelist <- function(x, sub_tfl = NULL, sub_tfl_sep = ": ", sub_tfl_collapse = "; ", sub_tfl_prefix = "\n") { - # Column names that map to export_tfl_page() text arguments - page_arg_names <- c( - "caption", "footnote", - "header_left", "header_center", "header_right", - "footer_left", "footer_center", "footer_right" - ) - present_args <- intersect(page_arg_names, names(x)) + present_args <- intersect(.ggtibble_page_arg_names, names(x)) if (!is.null(sub_tfl)) { if (!is.character(sub_tfl) || length(sub_tfl) == 0L || @@ -63,29 +87,19 @@ ggtibble_to_pagelist <- function(x, sub_tfl = NULL, sub_tfl_sep = ": ", paste(bad, collapse = ", ") )) } - checkmate::assert_string(sub_tfl_sep, .var.name = "sub_tfl_sep") - checkmate::assert_string(sub_tfl_collapse, .var.name = "sub_tfl_collapse") - checkmate::assert_string(sub_tfl_prefix, .var.name = "sub_tfl_prefix") + checkmate::assert_character(sub_tfl_sep, len = 1L, + any.missing = FALSE, + .var.name = "sub_tfl_sep") + checkmate::assert_character(sub_tfl_collapse, len = 1L, + any.missing = FALSE, + .var.name = "sub_tfl_collapse") + checkmate::assert_character(sub_tfl_prefix, len = 1L, + any.missing = FALSE, + .var.name = "sub_tfl_prefix") } - lapply(seq_len(nrow(x)), function(i) { - # Extract the ggplot from the figure cell. - # gglist[[i]] returns the ggplot directly; for plain list columns, - # unwrap one level if needed. - fig <- x$figure[[i]] - if (!inherits(fig, "gg") && is.list(fig)) fig <- fig[[1L]] - spec <- list(content = fig) - for (col in present_args) { - spec[[col]] <- x[[col]][[i]] - } - if (!is.null(sub_tfl)) { - pairs <- vapply(sub_tfl, function(col) { - paste(col, format(x[[col]][[i]]), sep = sub_tfl_sep) - }, character(1L)) - suffix <- paste(pairs, collapse = sub_tfl_collapse) - spec$caption <- .apply_sub_tfl_caption(spec$caption, suffix, - sub_tfl_prefix) - } - spec - }) + lapply(seq_len(nrow(x)), .ggtibble_row_pagespec, + x = x, present_args = present_args, sub_tfl = sub_tfl, + sub_tfl_sep = sub_tfl_sep, sub_tfl_collapse = sub_tfl_collapse, + sub_tfl_prefix = sub_tfl_prefix) } diff --git a/R/sub_tfl.R b/R/sub_tfl.R index c1afa2d..84999da 100644 --- a/R/sub_tfl.R +++ b/R/sub_tfl.R @@ -5,6 +5,49 @@ # columns. The values are removed from the rendered body and appended to the # caption as "label: value; label: value". +# --------------------------------------------------------------------------- +# Top-level helpers (no nested function definitions) +# --------------------------------------------------------------------------- + +# Ordered unique values of a single column. Factor columns drive their level +# order (filtered to present values); other columns use first-appearance. +#' @keywords internal +.ordered_unique_values <- function(col_data) { + v_nona <- col_data[!is.na(col_data)] + if (is.factor(col_data)) { + lv <- levels(col_data) + lv[lv %in% as.character(v_nona)] + } else { + unique(v_nona) + } +} + +# Wrap a single column's value as a one-element named list — the seed for the +# Cartesian-product accumulator in .compute_sub_tfl_groups(). +#' @keywords internal +.named_one_value <- function(value, name) { + stats::setNames(list(value), name) +} + +# Logical predicate used by .strip_sub_tfl_cols() to filter colspec entries. +#' @keywords internal +.colspec_not_in <- function(cs, drop) { + !cs$col %in% drop +} + +# Build a single "label: value" pair for one sub_tfl column. +#' @keywords internal +.format_sub_tfl_pair <- function(col, tbl, values) { + label <- .resolve_col_label(tbl, col) + paste(label, format(values[[col]]), sep = tbl$sub_tfl_sep) +} + +# Build a single "col: value" pair for ggtibble (raw column names, no colspec). +#' @keywords internal +.format_ggtibble_sub_tfl_pair <- function(col, x, i, sep) { + paste(col, format(x[[col]][[i]]), sep = sep) +} + # --------------------------------------------------------------------------- # .compute_sub_tfl_groups() # --------------------------------------------------------------------------- @@ -15,22 +58,11 @@ # first-appearance order. sub_tfl[1] varies outermost (slowest). #' @keywords internal .compute_sub_tfl_groups <- function(data, sub_tfl) { - ord_vals <- lapply(sub_tfl, function(col) { - v <- data[[col]] - v_nona <- v[!is.na(v)] - if (is.factor(v)) { - lv <- levels(v) - lv[lv %in% as.character(v_nona)] - } else { - unique(v_nona) - } - }) + ord_vals <- lapply(data[sub_tfl], .ordered_unique_values) names(ord_vals) <- sub_tfl # Build combos with sub_tfl[1] outermost. - combos <- lapply(ord_vals[[1L]], function(v) { - stats::setNames(list(v), sub_tfl[[1L]]) - }) + combos <- lapply(ord_vals[[1L]], .named_one_value, name = sub_tfl[[1L]]) for (k in seq_along(sub_tfl)[-1L]) { new_combos <- list() for (rc in combos) { @@ -89,10 +121,8 @@ # Build the per-page caption suffix from a named list of values. #' @keywords internal .format_sub_tfl_caption <- function(tbl, values) { - pairs <- vapply(names(values), function(col) { - label <- .resolve_col_label(tbl, col) - paste(label, format(values[[col]]), sep = tbl$sub_tfl_sep) - }, character(1L)) + pairs <- vapply(names(values), .format_sub_tfl_pair, + character(1L), tbl = tbl, values = values) paste(pairs, collapse = tbl$sub_tfl_collapse) } @@ -119,7 +149,7 @@ .strip_sub_tfl_cols <- function(tbl) { drop <- tbl$sub_tfl if (!is.null(tbl$cols)) { - keep <- vapply(tbl$cols, function(cs) !cs$col %in% drop, logical(1L)) + keep <- vapply(tbl$cols, .colspec_not_in, logical(1L), drop = drop) tbl$cols <- tbl$cols[keep] if (length(tbl$cols) == 0L) tbl$cols <- NULL } diff --git a/R/table_pagelist.R b/R/table_pagelist.R index f1760f2..8b39924 100644 --- a/R/table_pagelist.R +++ b/R/table_pagelist.R @@ -38,39 +38,56 @@ #' @keywords internal tfl_table_to_pagelist <- function(tbl, pg_width, pg_height, dots, page_num = "Page {i} of {n}") { - # --- Step 0: sub_tfl branch --- - # When sub_tfl is set, split into one sub-table per unique combination of - # values, recurse with sub_tfl = NULL, and concatenate the resulting pages. - # Each sub-group has its own caption (= base caption + sep + suffix), so the - # available content height must be re-measured per group; recursing through - # the existing pipeline handles that naturally. - if (!is.null(tbl$sub_tfl)) { - groups <- .compute_sub_tfl_groups(tbl$data, tbl$sub_tfl) - pages <- list() - for (g in groups) { - sub_tbl <- tbl - keep_cols <- setdiff(names(tbl$data), tbl$sub_tfl) - sub_tbl$data <- tbl$data[g$row_idx, keep_cols, drop = FALSE] - sub_tbl$group_vars <- setdiff(tbl$group_vars, tbl$sub_tfl) - sub_tbl <- .strip_sub_tfl_cols(sub_tbl) - sub_tbl$sub_tfl <- NULL # prevent recursion - - suffix <- .format_sub_tfl_caption(tbl, g$values) - sub_dots <- dots - sub_dots$caption <- .apply_sub_tfl_caption(dots$caption, suffix, - tbl$sub_tfl_prefix) - - sub_pages <- tfl_table_to_pagelist(sub_tbl, pg_width, pg_height, - sub_dots, page_num) - sub_pages <- lapply(sub_pages, function(p) { - p$caption <- sub_dots$caption - p - }) - pages <- c(pages, sub_pages) - } - return(pages) + if (is.null(tbl$sub_tfl)) { + .tfl_table_to_pagelist_default(tbl, pg_width, pg_height, dots, page_num) + } else { + .tfl_table_to_pagelist_sub_tfl(tbl, pg_width, pg_height, dots, page_num) } +} +# Sub-table dispatch: split data by sub_tfl, run the default pipeline once per +# group with that group's caption (base + prefix + suffix), and concatenate +# the resulting pages. Available content height varies per group because the +# caption suffix has variable line count, so each group must re-run the full +# measurement pipeline; recursion handles that naturally. +#' @keywords internal +.tfl_table_to_pagelist_sub_tfl <- function(tbl, pg_width, pg_height, dots, + page_num) { + groups <- .compute_sub_tfl_groups(tbl$data, tbl$sub_tfl) + pages <- list() + for (g in groups) { + sub_tbl <- tbl + keep_cols <- setdiff(names(tbl$data), tbl$sub_tfl) + sub_tbl$data <- tbl$data[g$row_idx, keep_cols, drop = FALSE] + sub_tbl$group_vars <- setdiff(tbl$group_vars, tbl$sub_tfl) + sub_tbl <- .strip_sub_tfl_cols(sub_tbl) + sub_tbl$sub_tfl <- NULL # prevent recursion + + suffix <- .format_sub_tfl_caption(tbl, g$values) + sub_dots <- dots + sub_dots$caption <- .apply_sub_tfl_caption(dots$caption, suffix, + tbl$sub_tfl_prefix) + + sub_pages <- tfl_table_to_pagelist(sub_tbl, pg_width, pg_height, + sub_dots, page_num) + sub_pages <- lapply(sub_pages, .attach_page_caption, + caption = sub_dots$caption) + pages <- c(pages, sub_pages) + } + pages +} + +# Attach a caption to a single page spec. Used by .tfl_table_to_pagelist_sub_tfl +# to ensure each sub-page carries its caption when build_page_args() merges. +#' @keywords internal +.attach_page_caption <- function(page, caption) { + page$caption <- caption + page +} + +#' @keywords internal +.tfl_table_to_pagelist_default <- function(tbl, pg_width, pg_height, dots, + page_num) { # --- Step 1: Extract layout args from dots --- # Use explicit NULL checks instead of %||% for arguments that can legitimately diff --git a/R/tfl_table.R b/R/tfl_table.R index df8f357..b14ed51 100644 --- a/R/tfl_table.R +++ b/R/tfl_table.R @@ -353,9 +353,12 @@ tfl_table <- function(x, )) } } - checkmate::assert_string(sub_tfl_sep, .var.name = "sub_tfl_sep") - checkmate::assert_string(sub_tfl_collapse, .var.name = "sub_tfl_collapse") - checkmate::assert_string(sub_tfl_prefix, .var.name = "sub_tfl_prefix") + checkmate::assert_character(sub_tfl_sep, len = 1L, any.missing = FALSE, + .var.name = "sub_tfl_sep") + checkmate::assert_character(sub_tfl_collapse, len = 1L, any.missing = FALSE, + .var.name = "sub_tfl_collapse") + checkmate::assert_character(sub_tfl_prefix, len = 1L, any.missing = FALSE, + .var.name = "sub_tfl_prefix") # --- Validate scalar logicals --- checkmate::assert_flag(allow_col_split, .var.name = "allow_col_split")