diff --git a/NAMESPACE b/NAMESPACE
index 9edefe8d..e7fa3144 100644
--- a/NAMESPACE
+++ b/NAMESPACE
@@ -46,6 +46,7 @@ export(label_roche_percent)
export(label_roche_pvalue)
export(label_roche_ratio)
export(modify_header_rm_md)
+export(modify_split_caption)
export(modify_zero_recode)
export(preprocess_lineplot_data)
export(process_survfit)
diff --git a/NEWS.md b/NEWS.md
index 9d1f4f65..3952ad62 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,5 +1,7 @@
# crane 0.3.3.9009
+* Added `modify_split_caption()` to subtitle each page of a split `{gtsummary}` table (e.g. from `tbl_listing()`, `tbl_baseline_chg()`, or `tbl_shift()`) from its split level via a glue `pattern` (default `"Parameter: {spl_level}"`) and hide the now-redundant split column. (#282)
+
* `annotate_riskdf()` now builds the "Numbers at Risk" table at the plot's x-axis breaks, so custom ticks set with `ggplot2::scale_x_continuous(breaks = ...)` are reflected in the table. (#278)
* `theme_gtsummary_roche()` now frames the flextable column labels with an outer border only, removing the internal borders between header rows and the inconsistent missing right border. (#272)
diff --git a/R/modify_split_caption.R b/R/modify_split_caption.R
new file mode 100644
index 00000000..3a1b56fc
--- /dev/null
+++ b/R/modify_split_caption.R
@@ -0,0 +1,108 @@
+#' Label and Trim Split Tables
+#'
+#' @description
+#' Post-process a table that was paginated with
+#' [gtsummary::tbl_split_by_rows()] (via its `variable_level` argument). For each
+#' page, a subtitle is built from the split level using a [glue()]
+#' `pattern`, and the now-redundant split column is hidden.
+#'
+#' This is useful for any split `{gtsummary}` table, such as listings
+#' ([tbl_listing()]) split by treatment, or baseline/change tables
+#' ([tbl_baseline_chg()]) and shift tables ([tbl_shift()]) split by `PARAM`.
+#'
+#' The label is set both as a `{gtsummary}` caption (via
+#' [gtsummary::modify_caption()]) and on the native `variable_level` attribute.
+#' The caption makes the subtitle visible when the table is printed on its own
+#' (it renders inside the table's `
` element), while `variable_level`
+#' lets downstream reporting engines promote the same text to a dedicated
+#' subtitle row.
+#'
+#' @param x (`tbl_split`, `list`, or `gtsummary`)\cr
+#' a table split with [gtsummary::tbl_split_by_rows()], the list of split
+#' tables, or a single split page.
+#' @param spl_col (`string`)\cr
+#' name of the column the table was split by (the `variable_level` passed to
+#' [gtsummary::tbl_split_by_rows()]). Used to hide that column on each page
+#' when `hide_spl_col = TRUE`.
+#' @param pattern (`string`)\cr
+#' a [glue()] pattern used to build each page's subtitle from the split level.
+#' The `{spl_level}` token is replaced by the split value. Defaults to
+#' `"Parameter: {spl_level}"`, matching parameter-split tables such as
+#' laboratory tables split by `PARAM`.
+#' @param hide_spl_col (`flag`)\cr
+#' whether to hide `spl_col` on each split page, since it is redundant after a
+#' single-level split. Defaults to `TRUE`. Silently does nothing when the
+#' column is absent or already hidden.
+#'
+#' @returns an object of the same class as `x`, with the split subtitle set as a
+#' caption and on the `variable_level` attribute (and the split column hidden)
+#' on each page.
+#'
+#' @seealso [tbl_listing()], [tbl_baseline_chg()], [tbl_shift()],
+#' [gtsummary::tbl_split_by_rows()]
+#'
+#' @examples
+#' lst <- gtsummary::trial |>
+#' dplyr::select(trt, age, grade) |>
+#' dplyr::arrange(trt) |>
+#' tbl_listing(split_by_rows = list(variable_level = "trt"))
+#'
+#' # Split a listing by treatment, then subtitle each page and drop the column
+#' modify_split_caption(lst, spl_col = "trt", pattern = "Treatment: {spl_level}")[[1]]
+#'
+#' # Keep the split column and use the default "Parameter:" subtitle
+#' modify_split_caption(lst, spl_col = "trt", hide_spl_col = FALSE)[[1]]
+#'
+#' @export
+modify_split_caption <- function(x,
+ spl_col,
+ pattern = "Parameter: {spl_level}",
+ hide_spl_col = TRUE) {
+ set_cli_abort_call()
+
+ # checks ---------------------------------------------------------------------
+ # argument checks run before the map so a list of split tables is validated
+ # once, up front, rather than on each recursive call.
+ check_not_missing(x)
+ check_not_missing(spl_col)
+ check_string(spl_col)
+ check_string(pattern)
+ check_scalar_logical(hide_spl_col)
+
+ # map over a list of split tables --------------------------------------------
+ if (is.list(x) && inherits(x[[1]], "gtsummary")) {
+ return(
+ map(
+ x,
+ modify_split_caption,
+ spl_col = spl_col,
+ pattern = pattern,
+ hide_spl_col = hide_spl_col
+ ) |>
+ structure(class = class(x))
+ )
+ }
+
+ check_class(x, "gtsummary")
+
+ # build the split subtitle from the split level ------------------------------
+ # `variable_level` is the gtsummary-native attribute set by
+ # tbl_split_by_rows(variable_level = ); row-number splits do not set it, so
+ # those pages are skipped silently.
+ spl_level <- attr(x, "variable_level")
+ if (!is_empty(spl_level)) {
+ subtitle <- as.character(glue::glue(pattern))
+ # set the label as a caption so it is visible when the page is printed on
+ # its own, and mirror it onto `variable_level` so reporting engines can
+ # promote the same text to a dedicated subtitle row.
+ x <- gtsummary::modify_caption(x, caption = subtitle)
+ attr(x, "variable_level") <- subtitle
+ }
+
+ # hide the split column, if present and not already hidden --------------------
+ if (isTRUE(hide_spl_col) && all(spl_col %in% x$table_styling$header$column)) {
+ x <- gtsummary::modify_column_hide(x, columns = all_of(spl_col))
+ }
+
+ x
+}
diff --git a/R/tbl_baseline_chg.R b/R/tbl_baseline_chg.R
index 6cabd029..7747adab 100644
--- a/R/tbl_baseline_chg.R
+++ b/R/tbl_baseline_chg.R
@@ -67,8 +67,12 @@
#' .combine_with = "tbl_stack",
#' .combine_args = list(group_header = NULL, quiet = TRUE)
#' ) |>
-#' tbl_split_by_rows(variable_level = ends_with("lbl"))
+#' tbl_split_by_rows(variable_level = ends_with("lbl")) |>
+#' # caption each page with its parameter and hide the split column
+#' modify_split_caption(spl_col = "tbl_id1_lbl")
#'
+#' @seealso [modify_split_caption()] to caption each page of a split table and
+#' hide the redundant split column.
#' @rdname tbl_baseline_chg
#' @export
tbl_baseline_chg <- function(data,
diff --git a/R/tbl_listing.R b/R/tbl_listing.R
index 2bbad49b..dbeb29ff 100644
--- a/R/tbl_listing.R
+++ b/R/tbl_listing.R
@@ -22,6 +22,8 @@
#' string to use for blank values. Defaults to `NA`. It should not be changed.
#'
#' @name tbl_listing
+#' @seealso [modify_split_caption()] to label each page of a split table and
+#' hide the redundant split column.
#' @note
#' Common pre-processing steps for the data frame that may be common:
#' * Unique values - this should be enforced in pre-processing by users.
@@ -88,6 +90,12 @@
#' out <- list_lst |>
#' remove_duplicate_keys(keys = c("trt", "stage"))
#' out[[2]]
+#'
+#' # Example 8 --------------------------------
+#' # Label each split page and hide the redundant split column
+#' by_stage <- tbl_listing(trial_data, split_by_rows = list(variable_level = "stage")) |>
+#' modify_split_caption(spl_col = "stage", pattern = "Stage: {spl_level}")
+#' by_stage[[1]]
NULL
#' @export
diff --git a/R/tbl_shift.R b/R/tbl_shift.R
index d2694f6c..6b3a917c 100644
--- a/R/tbl_shift.R
+++ b/R/tbl_shift.R
@@ -140,6 +140,8 @@
#' modify_spanning_header(all_stat_cols() ~ "Worst Post-baseline NCI-CTCAE Grade")
NULL
+#' @seealso [modify_split_caption()] to caption each page of a split table and
+#' hide the redundant split column.
#' @rdname tbl_shift
#' @export
tbl_shift <- function(data,
diff --git a/_pkgdown.yml b/_pkgdown.yml
index cd0deb8e..78ec8d56 100644
--- a/_pkgdown.yml
+++ b/_pkgdown.yml
@@ -90,6 +90,7 @@ reference:
- theme_gtsummary_roche
- modify_header_rm_md
- modify_zero_recode
+ - modify_split_caption
- add_blank_rows
- label_roche
- reverse_ci
diff --git a/man/modify_split_caption.Rd b/man/modify_split_caption.Rd
new file mode 100644
index 00000000..026bbcea
--- /dev/null
+++ b/man/modify_split_caption.Rd
@@ -0,0 +1,73 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/modify_split_caption.R
+\name{modify_split_caption}
+\alias{modify_split_caption}
+\title{Label and Trim Split Tables}
+\usage{
+modify_split_caption(
+ x,
+ spl_col,
+ pattern = "Parameter: {spl_level}",
+ hide_spl_col = TRUE
+)
+}
+\arguments{
+\item{x}{(\code{tbl_split}, \code{list}, or \code{gtsummary})\cr
+a table split with \code{\link[gtsummary:tbl_split_by_rows]{gtsummary::tbl_split_by_rows()}}, the list of split
+tables, or a single split page.}
+
+\item{spl_col}{(\code{string})\cr
+name of the column the table was split by (the \code{variable_level} passed to
+\code{\link[gtsummary:tbl_split_by_rows]{gtsummary::tbl_split_by_rows()}}). Used to hide that column on each page
+when \code{hide_spl_col = TRUE}.}
+
+\item{pattern}{(\code{string})\cr
+a \code{\link[glue:glue]{glue()}} pattern used to build each page's subtitle from the split level.
+The \code{{spl_level}} token is replaced by the split value. Defaults to
+\code{"Parameter: {spl_level}"}, matching parameter-split tables such as
+laboratory tables split by \code{PARAM}.}
+
+\item{hide_spl_col}{(\code{flag})\cr
+whether to hide \code{spl_col} on each split page, since it is redundant after a
+single-level split. Defaults to \code{TRUE}. Silently does nothing when the
+column is absent or already hidden.}
+}
+\value{
+an object of the same class as \code{x}, with the split subtitle set as a
+caption and on the \code{variable_level} attribute (and the split column hidden)
+on each page.
+}
+\description{
+Post-process a table that was paginated with
+\code{\link[gtsummary:tbl_split_by_rows]{gtsummary::tbl_split_by_rows()}} (via its \code{variable_level} argument). For each
+page, a subtitle is built from the split level using a \code{\link[glue:glue]{glue()}}
+\code{pattern}, and the now-redundant split column is hidden.
+
+This is useful for any split \code{{gtsummary}} table, such as listings
+(\code{\link[=tbl_listing]{tbl_listing()}}) split by treatment, or baseline/change tables
+(\code{\link[=tbl_baseline_chg]{tbl_baseline_chg()}}) and shift tables (\code{\link[=tbl_shift]{tbl_shift()}}) split by \code{PARAM}.
+
+The label is set both as a \code{{gtsummary}} caption (via
+\code{\link[gtsummary:modify_caption]{gtsummary::modify_caption()}}) and on the native \code{variable_level} attribute.
+The caption makes the subtitle visible when the table is printed on its own
+(it renders inside the table's \verb{} element), while \code{variable_level}
+lets downstream reporting engines promote the same text to a dedicated
+subtitle row.
+}
+\examples{
+lst <- gtsummary::trial |>
+ dplyr::select(trt, age, grade) |>
+ dplyr::arrange(trt) |>
+ tbl_listing(split_by_rows = list(variable_level = "trt"))
+
+# Split a listing by treatment, then subtitle each page and drop the column
+modify_split_caption(lst, spl_col = "trt", pattern = "Treatment: {spl_level}")[[1]]
+
+# Keep the split column and use the default "Parameter:" subtitle
+modify_split_caption(lst, spl_col = "trt", hide_spl_col = FALSE)[[1]]
+
+}
+\seealso{
+\code{\link[=tbl_listing]{tbl_listing()}}, \code{\link[=tbl_baseline_chg]{tbl_baseline_chg()}}, \code{\link[=tbl_shift]{tbl_shift()}},
+\code{\link[gtsummary:tbl_split_by_rows]{gtsummary::tbl_split_by_rows()}}
+}
diff --git a/man/tbl_baseline_chg.Rd b/man/tbl_baseline_chg.Rd
index 5b14bd4c..2a21cf4d 100644
--- a/man/tbl_baseline_chg.Rd
+++ b/man/tbl_baseline_chg.Rd
@@ -126,6 +126,12 @@ tbl_strata(
.combine_with = "tbl_stack",
.combine_args = list(group_header = NULL, quiet = TRUE)
) |>
- tbl_split_by_rows(variable_level = ends_with("lbl"))
+ tbl_split_by_rows(variable_level = ends_with("lbl")) |>
+ # caption each page with its parameter and hide the split column
+ modify_split_caption(spl_col = "tbl_id1_lbl")
\dontshow{\}) # examplesIf}
}
+\seealso{
+\code{\link[=modify_split_caption]{modify_split_caption()}} to caption each page of a split table and
+hide the redundant split column.
+}
diff --git a/man/tbl_listing.Rd b/man/tbl_listing.Rd
index 8ab2b6d3..3fbde611 100644
--- a/man/tbl_listing.Rd
+++ b/man/tbl_listing.Rd
@@ -116,5 +116,15 @@ list_lst[[2]]
out <- list_lst |>
remove_duplicate_keys(keys = c("trt", "stage"))
out[[2]]
+
+# Example 8 --------------------------------
+# Label each split page and hide the redundant split column
+by_stage <- tbl_listing(trial_data, split_by_rows = list(variable_level = "stage")) |>
+ modify_split_caption(spl_col = "stage", pattern = "Stage: {spl_level}")
+by_stage[[1]]
\dontshow{\}) # examplesIf}
}
+\seealso{
+\code{\link[=modify_split_caption]{modify_split_caption()}} to label each page of a split table and
+hide the redundant split column.
+}
diff --git a/man/tbl_shift.Rd b/man/tbl_shift.Rd
index 3ae82e6a..23a0458f 100644
--- a/man/tbl_shift.Rd
+++ b/man/tbl_shift.Rd
@@ -183,3 +183,7 @@ filter(adlb, PARAMCD \%in\% "CHOLES") |>
modify_spanning_header(all_stat_cols() ~ "Worst Post-baseline NCI-CTCAE Grade")
\dontshow{\}) # examplesIf}
}
+\seealso{
+\code{\link[=modify_split_caption]{modify_split_caption()}} to caption each page of a split table and
+hide the redundant split column.
+}
diff --git a/tests/testthat/test-modify_split_caption.R b/tests/testthat/test-modify_split_caption.R
new file mode 100644
index 00000000..be5953ad
--- /dev/null
+++ b/tests/testthat/test-modify_split_caption.R
@@ -0,0 +1,100 @@
+tld <- trial |> # table_listing_data
+ dplyr::select(trt, age, marker, stage) |>
+ dplyr::filter(stage %in% c("T2", "T3")) |> # down sampling
+ dplyr::slice_head(n = 2, by = c(trt, stage))
+
+# get the hide flag of a single column from a gtsummary table
+hide_flag <- function(x, col) {
+ x$table_styling$header$hide[x$table_styling$header$column == col]
+}
+
+test_that("modify_split_caption() labels each page and hides the split column", {
+ expect_no_error(
+ out <- tbl_listing(tld, split_by_rows = list(variable_level = "trt")) |>
+ modify_split_caption(spl_col = "trt")
+ )
+
+ # one page per treatment level, class preserved
+ expect_s3_class(out, "tbl_split")
+ expect_s3_class(out[[1]], "tbl_listing")
+
+ # default pattern is applied on every page as both a caption (visible when
+ # the page prints on its own) and the variable_level attribute (promoted to a
+ # subtitle row by reporting engines)
+ expect_equal(attr(out[[1]], "variable_level"), "Parameter: Drug B")
+ expect_equal(attr(out[[2]], "variable_level"), "Parameter: Drug A")
+ expect_equal(as.character(out[[1]]$table_styling$caption), "Parameter: Drug B")
+ expect_equal(as.character(out[[2]]$table_styling$caption), "Parameter: Drug A")
+
+ # the split column is hidden by default on every page
+ expect_true(hide_flag(out[[1]], "trt"))
+ expect_true(hide_flag(out[[2]], "trt"))
+})
+
+test_that("modify_split_caption() honors a custom glue pattern", {
+ out <- tbl_listing(tld, split_by_rows = list(variable_level = "trt")) |>
+ modify_split_caption(spl_col = "trt", pattern = "Treatment: {spl_level}")
+
+ expect_equal(attr(out[[1]], "variable_level"), "Treatment: Drug B")
+})
+
+test_that("modify_split_caption(hide_spl_col = FALSE) keeps the split column", {
+ out <- tbl_listing(tld, split_by_rows = list(variable_level = "trt")) |>
+ modify_split_caption(spl_col = "trt", hide_spl_col = FALSE)
+
+ expect_false(hide_flag(out[[1]], "trt"))
+ # subtitle is still applied
+ expect_equal(attr(out[[1]], "variable_level"), "Parameter: Drug B")
+})
+
+test_that("modify_split_caption() is silent when the column is absent or already hidden", {
+ base <- tbl_listing(tld, split_by_rows = list(variable_level = "trt"))
+
+ # absent column -> no error, subtitle still applied from the split level
+ expect_no_error(
+ out_absent <- modify_split_caption(base, spl_col = "not_a_column")
+ )
+ expect_equal(attr(out_absent[[1]], "variable_level"), "Parameter: Drug B")
+
+ # applying twice is idempotent (column already hidden the second time)
+ expect_no_error(
+ out_twice <- base |>
+ modify_split_caption(spl_col = "trt") |>
+ modify_split_caption(spl_col = "trt")
+ )
+ expect_true(hide_flag(out_twice[[1]], "trt"))
+})
+
+test_that("modify_split_caption() skips row-number splits that lack a level", {
+ # row_numbers splits do not set the variable_level attribute
+ out <- tbl_listing(tld, split_by_rows = list(row_numbers = c(2, 3))) |>
+ modify_split_caption(spl_col = "trt", hide_spl_col = FALSE)
+
+ expect_null(attr(out[[1]], "variable_level"))
+})
+
+test_that("modify_split_caption() works on a single (non-list) split element", {
+ one <- tbl_listing(tld, split_by_rows = list(variable_level = "trt"))[[1]]
+ out <- modify_split_caption(one, spl_col = "trt")
+
+ expect_s3_class(out, "tbl_listing")
+ expect_equal(attr(out, "variable_level"), "Parameter: Drug B")
+ expect_true(hide_flag(out, "trt"))
+})
+
+test_that("modify_split_caption() checks its inputs", {
+ one <- tbl_listing(tld, split_by_rows = list(variable_level = "trt"))[[1]]
+
+ expect_error(
+ modify_split_caption(data.frame(a = 1), spl_col = "trt"),
+ "must be class"
+ )
+ expect_error(
+ modify_split_caption(one),
+ "cannot be missing"
+ )
+ expect_error(
+ modify_split_caption(one, spl_col = "trt", pattern = 5),
+ "must be a string"
+ )
+})