Skip to contents

The last mile for Pharmaverse Example tables

The pharmaverse already produces beautiful clinical tables – tern / rtables, gtsummary and tfrmt compute the statistics and format the numbers, and the official Pharmaverse Examples show exactly how. Where they stop is the last mile: turning that finished table object into the paginated, header-bearing RTF page a regulatory deliverable actually ships. That last little step is the gap rtfreporter fills – with full respect for everything the pharmaverse already does, and without reinventing any of it.

So this article picks up right where a Pharmaverse Example leaves off. It takes the table objects built – verbatim – by the demographic and adverse events examples and adds only the final RTF step, with as_rtftables() + rtf_tables() + generate_rtfreport(). The point is that whichever pharmaverse package built your table, rtfreporter can read its object and carry it that last mile.

All credit for the tables below belongs to the Pharmaverse Examples and their authors; rtfreporter only adds the last-mile RTF rendering.

The rendered .rtf files produced by this article are committed to the repository so you can open them yourself: inst/rtf-examples/. The file names mark which pharmaverse example table each one came from (e.g. pharmaverse-adverse-events-tfrmt.rtf).

Rendering the Pharmaverse Example tables

Each table below is shown in four steps: the Pharmaverse Example code that builds the table object (folded – expand it to copy), the table object as the example prints it, the rtfreporter code that carries it the last mile to RTF, and a snapshot of the resulting .rtf.

Two small helpers build the clinical running header and footer used by every table below (the defaults – landscape Letter, one blank line under the header – need no setup; see Using rtfreporter for the details):

Show code
make_header <- function(table_no, title, subtitle = "Safety Analysis Set") {
  rtf_header(rows = list(
    c(l = "HOGESTER Co. Limited", r = "CONFIDENTIAL"),
    c(l = "Protocol: RTF-101",     r = "Page {PAGE} of {TOTAL_PAGES}"),
    c(c = paste0("Table ", table_no)), c(c = title),
    c(c = paste0("<", subtitle, ">"))))
}
make_footer <- function(built_with) rtf_footer(c(l = paste0(
  "Table object built with ", built_with,
  " (Pharmaverse Example); rendered to RTF by rtfreporter.")))

Demographics – three pharmaverse packages

The Pharmaverse demographic example builds the same table three different ways – a tern TableTree, a gtsummary gt_tbl and a tfrmt gt_tbl. as_rtftables() reads all three and the RTF comes out the same clinical shape.

The data preparation is shared – verbatim from the pharmaverse example: screen failures are dropped and SEX / AGEGR1 are recoded and labelled.

Show code
library(dplyr)
adsl <- pharmaverseadam::adsl |>
  filter(!ACTARM %in% "Screen Failure") |>
  mutate(
    SEX = case_match(SEX, "M" ~ "MALE", "F" ~ "FEMALE"),
    AGEGR1 = case_when(
      between(AGE, 18, 40) ~ "18-40",
      between(AGE, 41, 64) ~ "41-64",
      AGE > 64            ~ ">=65") |>
      factor(levels = c("18-40", "41-64", ">=65"))) |>
  labelled::set_variable_labels(
    AGE = "Age (yr)", AGEGR1 = "Age group", SEX = "Sex", RACE = "Race")

tern + rtables (a TableTree)

Pharmaverse Example code (expand to copy)
Show code
library(tern)
adsl2 <- adsl |> df_explicit_na()
vars <- c("AGE", "AGEGR1", "SEX", "RACE")
var_labels <- c("Age (yr)", "Age group", "Sex", "Race")
lyt <- basic_table(show_colcounts = TRUE) |>
  split_cols_by(var = "ACTARM") |>
  add_overall_col("All Patients") |>
  analyze_vars(vars = vars, var_labels = var_labels)
dm_tern <- build_table(lyt, adsl2)
#>                                        Placebo     Xanomeline High Dose   Xanomeline Low Dose   All Patients
#>                                        (N=86)             (N=72)                (N=96)            (N=254)   
#> ————————————————————————————————————————————————————————————————————————————————————————————————————————————
#> Age (yr)                                                                                                    
#>   n                                      86                 72                    96                254     
#>   Mean (SD)                          75.2 (8.6)         73.8 (7.9)            76.0 (8.1)         75.1 (8.2) 
#>   Median                                76.0               75.5                  78.0               77.0    
#>   Min - Max                          52.0 - 89.0       56.0 - 88.0            51.0 - 88.0       51.0 - 89.0 
#> Age group                                                                                                   
#>   n                                      86                 72                    96                254     
#>   18-40                                   0                 0                      0                 0      
#>   41-64                              14 (16.3%)         11 (15.3%)             8 (8.3%)           33 (13%)  
#>   >=65                               72 (83.7%)         61 (84.7%)            88 (91.7%)         221 (87%)  
#> Sex                                                                                                         
#>   n                                      86                 72                    96                254     
#>   FEMALE                             53 (61.6%)         35 (48.6%)            55 (57.3%)        143 (56.3%) 
#>   MALE                               33 (38.4%)         37 (51.4%)            41 (42.7%)        111 (43.7%) 
#> Race                                                                                                        
#>   n                                      86                 72                    96                254     
#>   AMERICAN INDIAN OR ALASKA NATIVE        0              1 (1.4%)                  0              1 (0.4%)  
#>   BLACK OR AFRICAN AMERICAN           8 (9.3%)          9 (12.5%)              6 (6.2%)          23 (9.1%)  
#>   WHITE                              78 (90.7%)         62 (86.1%)            90 (93.8%)        230 (90.6%)

rtfreporter reads the TableTree with as_rtftables() (sizing the columns to their content with auto_width), wraps it in the clinical header/footer, and writes the .rtf:

Show code
out_dm_tern <- tempfile(fileext = ".rtf")
rtf_document() |>
  rtf_section(page = 1,
    header = make_header("14.1.1a", "Demographic and Baseline Characteristics (tern)"),
    footer = make_footer("tern + rtables")) |>
  rtf_tables(as_rtftables(dm_tern, blank_rows = "between_groups",
                          auto_width = TRUE, cell_format = fmt_count_paren)) |>
  generate_rtfreport(out_dm_tern, overwrite = TRUE)

gtsummary + cards (a gt_tbl)

Pharmaverse Example code (expand to copy)
Show code
library(gtsummary); library(cards)
theme_gtsummary_compact()
ard <- ard_stack(
  adsl,
  ard_summary(variables = AGE),
  ard_tabulate(variables = c(AGEGR1, SEX, RACE)),
  .by = ACTARM, .attributes = TRUE)
dm_gts <- tbl_ard_summary(
  cards = ard, by = ACTARM,
  include = c(AGE, AGEGR1, SEX, RACE),
  type = AGE ~ "continuous2",
  statistic = AGE ~ c("{N}", "{mean} ({sd})",
                      "{median} ({p25}, {p75})", "{min}, {max}")) |>
  bold_labels() |>
  modify_header(all_stat_cols() ~ "**{level}**  \nN = {n}") |>
  modify_footnote(everything() ~ NA)
Characteristic Placebo
N = 86
Xanomeline High Dose
N = 72
Xanomeline Low Dose
N = 96
Age (yr)


    N 86 72 96
    Mean (SD) 75.2 (8.6) 73.8 (7.9) 76.0 (8.1)
    Median (Q1, Q3) 76.0 (69.0, 82.0) 75.5 (70.0, 79.0) 78.0 (71.0, 82.0)
    Min, Max 52.0, 89.0 56.0, 88.0 51.0, 88.0
Age group


    18-40 0 (0.0%) 0 (0.0%) 0 (0.0%)
    41-64 14 (16.3%) 11 (15.3%) 8 (8.3%)
    >=65 72 (83.7%) 61 (84.7%) 88 (91.7%)
Sex


    FEMALE 53 (61.6%) 35 (48.6%) 55 (57.3%)
    MALE 33 (38.4%) 37 (51.4%) 41 (42.7%)
Race


    AMERICAN INDIAN OR ALASKA NATIVE 0 (0.0%) 1 (1.4%) 0 (0.0%)
    BLACK OR AFRICAN AMERICAN 8 (9.3%) 9 (12.5%) 6 (6.3%)
    WHITE 78 (90.7%) 62 (86.1%) 90 (93.8%)

The gtsummary object is a gt_tbl; rtfreporter reads it exactly the same way – nothing about the call changes:

Show code
out_dm_gts <- tempfile(fileext = ".rtf")
rtf_document() |>
  rtf_section(page = 1,
    header = make_header("14.1.1b", "Demographic and Baseline Characteristics (gtsummary)"),
    footer = make_footer("gtsummary + cards")) |>
  rtf_tables(as_rtftables(dm_gts, blank_rows = "between_groups",
                          auto_width = TRUE, cell_format = fmt_count_paren)) |>
  generate_rtfreport(out_dm_gts, overwrite = TRUE)

tfrmt + cards (a gt_tbl)

Pharmaverse Example code (expand to copy)
Show code
library(tfrmt); library(cards); library(forcats)
ard <- ard_stack(
  adsl,
  ard_summary(
    variables = AGE,
    statistic = ~ continuous_summary_fns(c("N", "mean", "sd", "min", "max"))),
  ard_tabulate(variables = c(AGEGR1, SEX, RACE)),
  .by = ACTARM, .overall = TRUE, .total_n = TRUE)
ard_tbl <- ard |>
  shuffle_card(fill_overall = "Total") |>
  prep_big_n(vars = "ACTARM") |>
  prep_combine_vars(vars = c("AGE", "AGEGR1", "SEX", "RACE")) |>
  prep_label() |>
  group_by(ACTARM, stat_variable) |>
  mutate(across(c(variable_level, label), ~ ifelse(stat_name == "N", "n", .x))) |>
  ungroup() |> unique() |>
  mutate(
    ord1 = fct_inorder(stat_variable) |> fct_relevel("SEX", after = 0) |> as.numeric(),
    ord2 = ifelse(label == "n", 1, 2)) |>
  mutate(stat_variable = case_when(
    stat_variable == "AGE"    ~ "Age (YEARS) at First Dose",
    stat_variable == "AGEGR1" ~ "Age Group (YEARS) at First Dose",
    stat_variable == "SEX"    ~ "Sex",
    stat_variable == "RACE"   ~ "High Level Race", .default = stat_variable)) |>
  select(ACTARM, stat_variable, label, stat_name, stat, ord1, ord2) |> unique()
dm_tfrmt <- tfrmt(
  group = stat_variable, label = label, param = stat_name,
  value = stat, column = ACTARM, sorting_cols = c(ord1, ord2),
  body_plan = body_plan(
    frmt_structure(".default", ".default", frmt("xxx")),
    frmt_structure(".default", ".default",
      frmt_combine("{n} ({p}%)", n = frmt("xxx"),
                   p = frmt("xx", transform = ~ . * 100)))),
  big_n = big_n_structure(param_val = "bigN", n_frmt = frmt(" (N=xx)")),
  col_plan = col_plan(-starts_with("ord")),
  col_style_plan = col_style_plan(col_style_structure(
    col = c("Placebo", "Xanomeline High Dose", "Xanomeline Low Dose", "Total"),
    align = "left")),
  row_grp_plan = row_grp_plan(row_grp_structure(
    ".default", element_block(post_space = " ")))) |>
  print_to_gt(ard_tbl)
Placebo (N=86) Xanomeline High Dose (N=72) Xanomeline Low Dose (N=96) NA
Sex



  n  86  72  96 254
  FEMALE  53 (62%)  35 (49%)  55 (57%) 143 (56%)
  MALE  33 (38%)  37 (51%)  41 (43%) 111 (44%)
           
Age (YEARS) at First Dose



  n  86  72  96 254
  Mean  75  74  76  75
  SD   9   8   8   8
  Min  52  56  51  51
  Max  89  88  88  89
           
Age Group (YEARS) at First Dose



  n  86  72  96 254
  18-40   0 ( 0%)   0 ( 0%)   0 ( 0%)   0 ( 0%)
  41-64  14 (16%)  11 (15%)   8 ( 8%)  33 (13%)
  >=65  72 (84%)  61 (85%)  88 (92%) 221 (87%)
           
High Level Race



  n  86  72  96 254
  AMERICAN INDIAN OR ALASKA NATIVE   0 ( 0%)   1 ( 1%)   0 ( 0%)   1 ( 0%)
  BLACK OR AFRICAN AMERICAN   8 ( 9%)   9 (12%)   6 ( 6%)  23 ( 9%)
  WHITE  78 (91%)  62 (86%)  90 (94%) 230 (91%)

tfrmt also renders to a gt_tbl, so once more the rtfreporter call is the same (this example formats its statistics as integers; rtfreporter reproduces whatever the object contains):

Show code
out_dm_tfrmt <- tempfile(fileext = ".rtf")
rtf_document() |>
  rtf_section(page = 1,
    header = make_header("14.1.1c", "Demographic and Baseline Characteristics (tfrmt)"),
    footer = make_footer("tfrmt + cards")) |>
  rtf_tables(as_rtftables(dm_tfrmt, auto_width = TRUE,
                          cell_format = fmt_count_paren)) |>
  generate_rtfreport(out_dm_tfrmt, overwrite = TRUE)

Adverse events – paginated across many pages

The Pharmaverse adverse events example is a genuinely large table – 23 system-organ classes and 242 preferred terms – so it spans many pages. This is where the last mile is most visible: as_rtftables() paginates the object and repeats the column and running headers on every page. The example builds it two ways, which also shows the two pagination strategies:

  • split = "group_safe" (tern) never breaks an SOC across a page boundary.
  • split = "group_force" (tfrmt) fills each page to max_rows and repeats the SOC label with " (Cont.)" at the top of the next page when a group is split.

tern + rtables (a TableTree)

Pharmaverse Example code (expand to copy)
Show code
library(tern); library(dplyr)
adsl_ae <- pharmaverseadam::adsl |> df_explicit_na()
adae_ae <- pharmaverseadam::adae |> df_explicit_na() |>
  var_relabel(AEBODSYS = "MedDRA System Organ Class",
              AEDECOD  = "MedDRA Preferred Term") |>
  filter(SAFFL == "Y")
split_fun <- drop_split_levels
ae_lyt <- basic_table(show_colcounts = TRUE) |>
  split_cols_by(var = "ACTARM") |>
  add_overall_col(label = "All Patients") |>
  analyze_num_patients(
    vars = "USUBJID", .stats = c("unique", "nonunique"),
    .labels = c(
      unique    = "Total number of patients with at least one adverse event",
      nonunique = "Overall total number of events")) |>
  split_rows_by("AEBODSYS", child_labels = "visible", nested = FALSE,
                split_fun = split_fun, label_pos = "topleft",
                split_label = obj_label(adae_ae$AEBODSYS)) |>
  summarize_num_patients(
    var = "USUBJID", .stats = c("unique", "nonunique"),
    .labels = c(
      unique    = "Total number of patients with at least one adverse event",
      nonunique = "Total number of events")) |>
  count_occurrences(vars = "AEDECOD", .indent_mods = -1L) |>
  append_varlabels(adae_ae, "AEDECOD", indent = 1L)
ae_tern <- build_table(ae_lyt, df = adae_ae, alt_counts_df = adsl_ae)

The object is too large to print, so we go straight to the RTF. The rtfreporter call adds the deliverable touches: split = "group_safe" pagination, a wide first column so the long labels stay on one line, centred data columns, and fmt_count_paren to line the counts and percentages up:

Show code
# adverse-events column alignment: row label left, the data columns centred
ae_col_spec <- c(list(list(col = 1L, align = "left")),
                 lapply(2:5, function(j) list(col = j, align = "center")))
ae_tern_pages <- as_rtftables(ae_tern, split = "group_safe", max_rows = 36,
                              blank_rows = "between_groups",
                              cell_format = fmt_count_paren,
                              col_rel_width = c(0.50, 0.125, 0.125, 0.125, 0.125),
                              col_spec = ae_col_spec, row_height_twips = 200)
out_ae_tern <- tempfile(fileext = ".rtf")
rtf_document() |>
  rtf_section(page = 1,
    header = make_header("14.3.1a", "Adverse Events by System Organ Class and Preferred Term (tern)"),
    footer = make_footer("tern + rtables")) |>
  rtf_tables(ae_tern_pages) |>
  generate_rtfreport(out_ae_tern, overwrite = TRUE)

tfrmt + cards (a gt_tbl)

Pharmaverse Example code (expand to copy)
Show code
library(tfrmt); library(cards); library(dplyr)
# pharmaverse cards + tfrmt example: safety population, treatment-emergent events
adsl_ae_tf <- pharmaverseadam::adsl |> filter(SAFFL == "Y")
adae_ae_tf <- pharmaverseadam::adae |> filter(SAFFL == "Y" & TRTEMFL == "Y")
ae_ard <- ard_stack_hierarchical(
  data = adae_ae_tf, by = ACTARM, variables = c(AEBODSYS, AEDECOD),
  statistic = ~ c("n", "p"), denominator = adsl_ae_tf, id = USUBJID,
  over_variables = TRUE, overall = TRUE)
ae_tot <- ard_stack_hierarchical(
  data = mutate(adae_ae_tf, ACTARM = "All Patients"), by = ACTARM,
  variables = c(AEBODSYS, AEDECOD),
  denominator = mutate(adsl_ae_tf, ACTARM = "All Patients"),
  statistic = ~ c("n", "p"), id = USUBJID,
  over_variables = TRUE, overall = TRUE) |>
  filter(group2 == "ACTARM" | variable == "ACTARM")
ae_card <- bind_ard(ae_ard, ae_tot) |>
  shuffle_card(fill_hierarchical_overall = "ANY EVENT") |>
  prep_big_n(vars = "ACTARM") |>
  prep_hierarchical_fill(vars = c("AEBODSYS", "AEDECOD"), fill = "ANY EVENT") |>
  mutate(ACTARM = ifelse(ACTARM == "Overall ACTARM", "All Patients", ACTARM))
ord_soc <- ae_card |>
  filter(ACTARM == "All Patients", stat_name == "n", AEDECOD == "ANY EVENT") |>
  arrange(desc(stat)) |> mutate(ord1 = row_number()) |> select(AEBODSYS, ord1)
ord_pt <- ae_card |>
  filter(ACTARM == "All Patients", stat_name == "n") |>
  group_by(AEBODSYS) |> arrange(desc(stat)) |>
  mutate(ord2 = row_number()) |> select(AEBODSYS, AEDECOD, ord2)
ae_card <- ae_card |>
  full_join(ord_soc, by = "AEBODSYS") |>
  full_join(ord_pt, by = c("AEBODSYS", "AEDECOD")) |>
  select(AEBODSYS, AEDECOD, ord1, ord2, stat, stat_name, ACTARM)
ae_tfrmt <- tfrmt_n_pct(
  n = "n", pct = "p",
  pct_frmt_when = frmt_when(
    "==1" ~ frmt("(100%)"), ">=0.995" ~ frmt("(>99%)"), "==0" ~ frmt(""),
    "<=0.01" ~ frmt("(<1%)"), "TRUE" ~ frmt("(xx.x%)", transform = ~ . * 100))) |>
  tfrmt(
    group = AEBODSYS, label = AEDECOD, param = stat_name, value = stat,
    column = ACTARM, sorting_cols = c(ord1, ord2),
    col_plan = col_plan("System Organ Class / Preferred Term" = AEBODSYS,
                        Placebo, `Xanomeline High Dose`, `Xanomeline Low Dose`,
                        -ord1, -ord2),
    row_grp_plan = row_grp_plan(row_grp_structure(
      group_val = ".default", element_block(post_space = " "))),
    big_n = big_n_structure(param_val = "bigN", n_frmt = frmt(" (N=xx)"))) |>
  print_to_gt(ae_card)

This one renders to a gt_tbl, so we use split = "group_force" pagination, two/three-line column headers (so the data columns can be narrow), and fmt_count_paren(bare = TRUE) (which also lines up the bare 0 counts – the built-in cell format "count_paren_bare"):

Show code
ae_hdr <- c("System Organ Class /\nPreferred Term", "Placebo\n(N=86)",
            "Xanomeline\nHigh Dose\n(N=72)",
            "Xanomeline\nLow Dose\n(N=96)", "All Patients\n(N=254)")
ae_tfrmt_pages <- as_rtftables(ae_tfrmt, split = "group_force", max_rows = 36,
                               col_header = ae_hdr,
                               col_rel_width = c(0.50, 0.125, 0.125, 0.125, 0.125),
                               col_spec = ae_col_spec, cell_format = "count_paren_bare",
                               row_height_twips = 200)
out_ae_tfrmt <- tempfile(fileext = ".rtf")
rtf_document() |>
  rtf_section(page = 1,
    header = make_header("14.3.1b", "Adverse Events by System Organ Class and Preferred Term (tfrmt)"),
    footer = make_footer("tfrmt + cards")) |>
  rtf_tables(ae_tfrmt_pages) |>
  generate_rtfreport(out_ae_tfrmt, overwrite = TRUE)

Assembling a deliverable

The tables above, each in its own .rtf, become one deliverable – a cover page, a clickable table of contents and a bookmark per table – with assemble_rtf(): see Assembling reports into one document.

Using rtfreporter

The examples above kept the rtfreporter-specific code to a minimum. This section collects the pieces you can tune.

Headers and footers

rtf_header() / rtf_footer() take rows of left / centre / right cells (l / c / r). For “Page X of Y” there are two flavours of token:

  • {AUTO_PAGE} / {AUTO_TOTAL_PAGES} – dynamic RTF fields, recomputed by the viewer (and across assemble_rtf()), so they always show the live page numbers.
  • {PAGE} / {TOTAL_PAGES} – static numbers baked in at render time, so each table carries its own real Page X of Y text that every viewer shows identically and assemble_rtf() leaves untouched. The recipes above use these.

Pagination

as_rtftables() turns one table object into a list of one-page rtftables. The split argument controls how:

  • "group_safe" – never breaks a row group; an SOC that would overflow moves whole to the next page.
  • "group_force" – cuts strictly at max_rows, inserting a (Cont.) row so the reader knows the group continues.
  • "by_value" – one group per page.
  • "rows" – split at explicit row positions (split_rows).
  • "none" – a single page.

blank_rows = "between_groups" inserts a blank line between row groups, and align_count_pct = TRUE re-pads n (xx.x%) cells so the percentages line up in a monospaced renderer.

Cell formatting

Different table packages write count/percent cells in different notations, so rtfreporter does not hard-code one. Instead, as_rtftables(cell_format = ) takes a cell-format function that is applied column-by-column to the body just before pagination. rtfreporter ships a few:

  • fmt_count_paren() – the workhorse, used by both adverse-events tables. It scans the whole column, then right-justifies the integer count and right-justifies the number inside the parentheses, so a column mixing "4 ( 4.7%)", "10 (11.6%)", "3 (<1%)", "70 (100%)" and a lone "0" all share one width and line up on both the count digit and the percentage. (It also copes with the 4-digit event totals in the tern table, which the fixed-width helper below cannot.)
  • align_count_pct = TRUE (the cell format "count_pct") – the older fixed-width helper for plain "n (xx.x%)" cells; kept for back-compatibility.
  • fmt_right_align() – the simplest one: right-justify a column to a common width.

A cell-format function follows a small contract:

It takes one column – a character vector – and returns a character vector of the same length. Cells it should not touch are returned unchanged, and any padding uses the non-breaking space (U+00A0, written "\u00a0") so Word does not collapse it.

When none of the built-ins matches your data, write your own. fmt_right_align() is the template – here is the whole of it, which you can copy and adapt:

Show code
my_right_align <- function(x, nbsp = "\u00a0") {
  x  <- as.character(x)
  nz <- nzchar(trimws(x))                       # the non-empty cells
  w  <- max(nchar(x[nz]), 0L)                   # widest cell in the column
  x[nz] <- formatC(x[nz], width = w, flag = "") # right-justify to that width
  gsub(" ", nbsp, x, fixed = TRUE)              # spaces -> non-breaking spaces
}
my_right_align(c("12 (3.6%)", "0", "1 (0.5%)"))
#> [1] "12 (3.6%)" "        0" " 1 (0.5%)"

Pass it with cell_format = my_right_align (a single function applies to every data column; a list applies one function per column).

What carries across, by source

What does and does not survive the conversion (column headers, spanning, alignment, titles, footnotes) depends on the source object and is documented in the What is carried, by source table in ?as_rtftables.

Notes

  • The output is .rtf; open it in Word / LibreOffice (or batch-convert to PDF).
  • The pharmaverse example tables are reproduced here unchanged; only the final RTF-rendering step is rtfreporter’s.

Where next

The four recipes (?rtfreporter-recipes) are the same ground covered as runnable help-page examples: demographics, adverse events, PK and laboratory, each data-in to RTF-out.