The display shell is the source of truth. Generate the table code from it.

Every study starts from a shell: the mocked-up table with its titles, footnotes, column structure and required statistics. Today a programmer reads that shell and hand-writes the code, and the two drift apart the moment a footnote changes. shell2tlf reads the shell, emits the {rtables} code, builds the table from that same code, and renders RTF whose titles, footnotes and pagination come straight back out of the shell.

It is the sibling of ardflow. ardflow starts from an analysis-results spec and computes results; shell2tlf starts from a display shell and produces the program. Different entry point, same family.

shell.yaml -> shell_code()  -> reviewable rtables program
           -> build_tlf()   -> rtables TableTree
           -> render_tlf()  -> RTF (titles + footnotes + pagination from the shell)
           -> validate_shell(data) -> tibble of problems, before anyone runs it

Install

install.packages(
  "https://clincoder.cloud/shell2tlf/shell2tlf_0.0.0.9000.tar.gz",
  repos = NULL, type = "source"
)

Worked example

library(shell2tlf)

shell <- read_shell(system.file("extdata", "demographics.yaml", package = "shell2tlf"))
adsl  <- utils::read.csv(system.file("extdata", "adsl_demo.csv", package = "shell2tlf"))

validate_shell(shell, adsl)   # 0 rows: nothing to fix
shell_code(shell)             # the program a reviewer reads
tbl <- build_tlf(shell, adsl) # the same program, evaluated
render_tlf(tbl, shell, file.path(tempdir(), "t_14_1_1.rtf"))

shell_code(shell) prints a program, not a black box:

# --------------------------------------------------------------------------
# Table 14.1.1
# Summary of Demographic and Baseline Characteristics
# Safety Population
#
# Population: Safety Population
# Generated by shell2tlf from shell: demographics.yaml
# Edit the shell and regenerate; do not edit this file by hand.
# --------------------------------------------------------------------------

library(rtables)

# Population: Safety Population
adsl <- dplyr::filter(adsl, SAFFL == "Y")

# Fix the level sets so every column shows the same rows in the same order.
adsl <- dplyr::mutate(
  adsl,
  TRTGRP = factor(TRTGRP, levels = c("Placebo", "Xanomeline")),
  TRT01A = factor(TRT01A, levels = c("Placebo", "Xanomeline Low Dose", "Xanomeline High Dose")),
  AGEGR1 = factor(AGEGR1, levels = c("<65", "65-80", ">80")),
  SEX    = factor(SEX, levels = c("F", "M")),
  RACE   = factor(RACE, levels = c("AMERICAN INDIAN OR ALASKA NATIVE", "BLACK OR AFRICAN AMERICAN", "WHITE"))
)

lyt <- basic_table(show_colcounts = TRUE) |>
  split_cols_by("TRTGRP") |>
  split_cols_by("TRT01A", split_fun = drop_split_levels) |>
  add_overall_col("All Subjects") |>
  analyze("AGE", var_labels = "Age (years)", show_labels = "visible",
    afun = function(x, .N_col) {
      in_rows(
        "n"         = rcell(sum(!is.na(x)), format = "xx"),
        "Mean (SD)" = rcell(c(mean(x, na.rm = TRUE), stats::sd(x, na.rm = TRUE)), format = "xx.x (xx.xx)"),
        "Median"    = rcell(stats::median(x, na.rm = TRUE), format = "xx.x"),
        "Q1, Q3"    = rcell(unname(stats::quantile(x, c(0.25, 0.75), na.rm = TRUE)), format = "xx.x, xx.x"),
        "Min, Max"  = rcell(c(min(x, na.rm = TRUE), max(x, na.rm = TRUE)), format = "xx.x, xx.x")
      )
    }) |>
  # ... one analyze() per shell row ...

tbl <- build_table(lyt, adsl)
tbl

build_tlf() evaluates that text. There is no second implementation, so reviewing the code is reviewing the table:

                                       Placebo                     Xanomeline
                                       Placebo     Xanomeline Low Dose   Xanomeline High Dose   All Subjects
                                       (N=59)            (N=60)                 (N=59)            (N=178)
————————————————————————————————————————————————————————————————————————————————————————————————————————————
Age (years)
  n                                      59                60                     59                178
  Mean (SD)                          75.5 (8.14)       74.1 (9.61)           75.0 (7.81)        74.9 (8.53)
  Median                                75.0              74.0                   75.0               75.0
  Q1, Q3                             69.0, 81.0        68.0, 80.0             70.0, 79.5         69.0, 80.0
  Min, Max                           60.0, 92.0        52.0, 92.0             57.0, 92.0         52.0, 92.0
Age group (years)
  <65                                 7 (11.9%)         8 (13.3%)              4 (6.8%)          19 (10.7%)
  65-80                              37 (62.7%)        38 (63.3%)             41 (69.5%)        116 (65.2%)
  >80                                15 (25.4%)        14 (23.3%)             14 (23.7%)         43 (24.2%)
Sex
  F                                  34 (57.6%)        26 (43.3%)             28 (47.5%)         88 (49.4%)
  M                                  25 (42.4%)        34 (56.7%)             31 (52.5%)         90 (50.6%)

The shell

title: "Table 14.1.1"
subtitles: ["Summary of Demographic and Baseline Characteristics", "Safety Population"]
footnotes: ["Percentages are based on the number of subjects in the safety population."]

population:
  label: Safety Population
  dataset: adsl
  filter: SAFFL == "Y"

columns:
  var: TRT01A
  levels: ["Placebo", "Xanomeline Low Dose", "Xanomeline High Dose"]
  spanning: { var: TRTGRP, levels: ["Placebo", "Xanomeline"] }
  total: "All Subjects"

rows:
  - { var: AGE,  label: "Age (years)", type: summary, stats: [n, mean_sd, median, q1_q3, min_max] }
  - { var: SEX,  label: "Sex",         type: count,   stats: [n_pct], levels: ["F", "M"] }

pagination: { rows_per_page: 26, orientation: portrait }

An adverse-event shell nests preferred term inside system organ class, counts distinct subjects, and sorts by frequency:

rows:
  - var: AEBODSYS
    label: "System Organ Class"
    type: count
    unique_by: USUBJID
    stats: [n_pct]
    sort: descending
    sub:
      - { var: AEDECOD, label: "Preferred Term", type: count,
          unique_by: USUBJID, stats: [n_pct], sort: descending }

What it replaces

SAS idiom shell2tlf
Shell document retyped into a PROC REPORT program read_shell() + shell_code()
TITLE/FOOTNOTE statements maintained apart from the table render_tlf() reads them from the shell
Checking the shell against PROC CONTENTS by eye validate_shell(shell, data)
ODS RTF with hand-set page breaks pagination: in the shell
PROC FREQ with TABLES base*post and a hand-worked denominator type: shift with denominator:
PROC REPORT listing whose ORDER variables collapse repeated records display: listing

Shift tables

A shift table is a cross-tabulation, and the thing that goes wrong with one is the denominator. The shell states it; the package never infers it.

rows:
  - var: ANRIND              # post-baseline category -- the rows
    baseline_var: BNRIND     # baseline category      -- the row groups
    label: "Baseline"
    type: shift
    stats: [n_pct]
    levels: ["LOW", "NORMAL", "HIGH"]
    denominator: baseline_row   # or: column
    unique_by: USUBJID
            Placebo     Xanomeline Low Dose   Xanomeline High Dose   All Subjects
Baseline     (N=83)           (N=75)                 (N=72)            (N=230)
—————————————————————————————————————————————————————————————————————————————————
LOW             4                 1                      1                  6
  LOW       0 (0.0%)         1 (100.0%)              0 (0.0%)           1 (16.7%)
  NORMAL   4 (100.0%)         0 (0.0%)              1 (100.0%)          5 (83.3%)
  HIGH      0 (0.0%)          0 (0.0%)               0 (0.0%)           0 (0.0%)
NORMAL          74               70                     70                 214
  LOW       0 (0.0%)          0 (0.0%)               0 (0.0%)           0 (0.0%)
  NORMAL   70 (94.6%)        66 (94.3%)             66 (94.3%)        202 (94.4%)
  HIGH      4 (5.4%)          4 (5.7%)               4 (5.7%)           12 (5.6%)
  • baseline_row (default): the denominator is the subjects in the same baseline category and the same column. Each block sums to 100%, and the number beside each baseline category is that denominator, printed so a reviewer can check it against the page.
  • column: the denominator is the column N and the whole table sums to 100%. With unique_by, also set population.denominator, or the column N counts records rather than subjects.

A record whose baseline or post-baseline value is not one of the declared levels leaves both the numerator and the denominator. validate_shell() counts them, because an unnoticed handful is how a shift table stops reconciling with its own column header.

Listings

display: listing
population: { dataset: adae, filter: SAFFL == "Y" & TRTEMFL == "Y" }
listing:
  vars:   [USUBJID, TRT01A, AEBODSYS, AEDECOD, AESEV]
  labels: ["Subject", "Treatment", "System Organ Class", "Preferred Term", "Severity"]
  sort_by: [USUBJID, AEBODSYS, AEDECOD]

A listing is deliberately not de-duplicated: 335 treatment-emergent records from 144 subjects produce 335 rows. The generated program has no aggregation step at all – filter, arrange(), data.frame(), df_to_tt() – so there is nothing that could collapse a repeated key. render_tlf() takes a listing without a listing branch, because it comes back as an ordinary rtables table.

Scope

Three table display types plus listings, done properly:

  • summaryn, mean, sd, mean_sd, median, q1_q3, min_max, missing for a numeric variable.
  • countn_pct or n per level of a categorical variable, optionally counting distinct subjects (unique_by) and nested into a hierarchy.
  • shift – baseline category x post-baseline category, n_pct or n, with the denominator stated in the shell.
  • listing – one row per record, no de-duplication.

Limits

  • Figures are out of scope. Nothing here produces or renders a plot.
  • Inferential columns are out of scope: no p-values, treatment differences, confidence intervals for a difference, or model-based statistics. The statistic set is closed – a shell asking for one fails validation rather than producing a display that looks right and is not.
  • One column split plus one optional spanning header.
  • A shift row is one cross-tabulation and must be the last row at its level; rtables nests everything that follows a row split. Several parameters means several shells, filtered by PARAMCD in population.filter.
  • Listing values are reproduced with as.character(). Rounding and formatting of derived variables belong in the ADaM step, not the display.

A wide set done loosely is worse than a narrow set you can trust.

License

MIT (c) Bhanoji Duppada