r2rtf, Tplyr and related packages

Lesson 7 — Clinical Programming with R

Lesson 7 of 12 Advanced ~100 min

Learning objectives

  • Produce submission-quality RTF with r2rtf
  • Control pagination, headers, footnotes and column widths
  • Build layered summaries with Tplyr
  • Choose between the available table packages
  • Assemble a full TLF pipeline from data to delivered file

The table package landscape

Package Produces Best for
r2rtf RTF Submission tables — the de facto standard
Tplyr A summary data frame Building the numbers for a clinical table
gtsummary gt / flextable / kable Publications, internal reports, DSMB
gt HTML, LaTeX, RTF, Word Beautiful HTML; RTF support is newer
flextable Word, PowerPoint, HTML Word deliverables
rtables Structured table objects Complex nested layouts, Roche ecosystem
tfrmt JSON-driven formatting Metadata-driven table formatting
huxtable Many formats General-purpose

The two you will use most in a regulatory setting are Tplyr to compute and r2rtf to render. They are designed to work together.

NoteThere is now a Python counterpart

rtflite is a pharmaverse package providing the same capability in Python, written by r2rtf’s author and mirroring its component design — RTFTitle, RTFColumnHeader, RTFBody, RTFFootnote, RTFSource, and the same col_rel_width model.

If your team works across both languages, the two produce comparable output from comparable code. See the Python TLF lesson.

r2rtf

Developed at Merck, purpose-built for clinical RTF output. The API is a pipeline of rtf_* functions ending in rtf_encode() and write_rtf().

library(r2rtf)

table_data |>
  rtf_page(orientation = "portrait", nrow = 40) |>
  rtf_title(
    "Table 14.1.1",
    "Demographic and Baseline Characteristics",
    "Safety Analysis Set"
  ) |>
  rtf_colheader(
    "Characteristic | Placebo | Drug A | Drug B | Total",
    col_rel_width = c(4, 2, 2, 2, 2)
  ) |>
  rtf_colheader(
    " | (N=86) | (N=84) | (N=84) | (N=254)",
    col_rel_width = c(4, 2, 2, 2, 2),
    border_top = ""
  ) |>
  rtf_body(
    col_rel_width      = c(4, 2, 2, 2, 2),
    text_justification = c("l", "c", "c", "c", "c"),
    text_indent_first  = 0,
    text_indent_left   = 0
  ) |>
  rtf_footnote("Percentages are based on the number of subjects in the safety analysis set.") |>
  rtf_source("Program: t_14_1_1_demographics.R    Generated: 28JUL2026 14:32") |>
  rtf_encode() |>
  write_rtf("output/tables/t_14_1_1_demographics.rtf")

Page setup

rtf_page(
  orientation = "landscape",   # or "portrait"
  width       = 11,            # inches
  height      = 8.5,
  margin      = c(1, 1, 1, 1, 1, 1),   # l, r, t, b, header, footer
  nrow        = 30,            # body rows per page
  border_first = "double",
  border_last  = "double"
)

nrow controls pagination. Too high and rows spill; too low and you waste pages. For a landscape table with a two-line header, 25–30 is typical.

Titles and headers

rtf_title(
  title = c("Table 14.3.1",
            "Summary of Treatment-Emergent Adverse Events by System Organ Class",
            "and Preferred Term (Safety Analysis Set)"),
  text_format    = c("b", "", ""),      # bold the first line
  text_font_size = c(10, 10, 10),
  text_justification = "c"
)

Multi-row headers with spanning:

tbl |>
  rtf_colheader(
    " | Treatment Group | ",
    col_rel_width = c(4, 6, 0),
    border_bottom = c("", "single", "")
  ) |>
  rtf_colheader(
    "Characteristic | Placebo | Drug A | Drug B",
    col_rel_width = c(4, 2, 2, 2)
  )

The first rtf_colheader() creates a spanning header over the treatment columns; border_bottom draws the line only under the spanned portion.

Body formatting

rtf_body(
  col_rel_width      = c(5, 2, 2, 2, 2),
  text_justification = c("l", rep("c", 4)),
  text_format        = c("", "", "", "", ""),
  text_font_size     = 9,
  border_left        = "single",
  border_right       = "single",
  page_by            = "AEBODSYS",       # start a new page per SOC
  group_by           = "AEBODSYS",       # suppress repeated values
  new_page           = TRUE,
  pageby_header      = TRUE
)

group_by is the RTF equivalent of SAS PROC REPORT’s ORDER — repeated values in a column are printed only on the first row of the group, which is what makes a nested AE table readable.

Special characters

RTF uses \ as an escape character, so literal backslashes and braces need care. r2rtf handles most of this, but Unicode needs explicit encoding:

# Superscripts, subscripts and symbols
"Cmax{\\super a}"          # superscript a
"AUC{\\sub 0-24}"          # subscript
"\\u945"                   # alpha
"\\u8805"                  # >=
"\\u177"                   # plus-minus

# Line break within a cell
"Placebo\\line(N=86)"

A helper is worth having:

rtf_symbol <- c(
  alpha = "\\u945", beta = "\\u946", mu = "\\u956",
  ge = "\\u8805", le = "\\u8804", pm = "\\u177",
  degree = "\\u176", dash = "\\u8211"
)

Multiple tables in one file

list(table1_encoded, table2_encoded, table3_encoded) |>
  write_rtf("output/tables/all_demographics.rtf")

Useful for a set of related tables that reviewers open together.

Tplyr

Tplyr (from Atorus) builds the numbers for a clinical table declaratively. Rather than writing the count/percentage/pivot pipeline by hand, you describe the layers.

library(Tplyr)

t <- tplyr_table(adsl, TRT01A, where = SAFFL == "Y") |>
  # Continuous layer
  add_layer(
    group_desc(AGE, by = "Age (years)") |>
      set_format_strings(
        "n"         = f_str("xx",         n),
        "Mean (SD)" = f_str("xx.x (xx.xx)", mean, sd),
        "Median"    = f_str("xx.x",       median),
        "Min, Max"  = f_str("xx, xx",     min, max)
      )
  ) |>
  # Categorical layers
  add_layer(
    group_count(AGEGR1, by = "Age group") |>
      set_format_strings(f_str("xx (xx.x%)", n, pct))
  ) |>
  add_layer(
    group_count(SEX, by = "Sex") |>
      set_format_strings(f_str("xx (xx.x%)", n, pct))
  ) |>
  add_total_group()

result <- build(t)

f_str() is Tplyr’s format specification: "xx.x (xx.xx)" describes the literal layout, and the arguments name which statistics fill the placeholders. The x count sets the field width, so columns align without manual padding.

Denominators

tplyr_table(adae, TRT01A) |>
  set_pop_data(adsl) |>
  set_pop_treat_var(TRT01A) |>
  set_pop_where(SAFFL == "Y") |>
  add_layer(
    group_count(vars(AEBODSYS, AEDECOD)) |>
      set_distinct_by(USUBJID) |>            # subject incidence
      set_denoms_by(TRT01A)                  # percentage of the population
  )

set_pop_data() is the important one: it tells Tplyr that the denominators come from ADSL, not from the AE dataset. This is the big-N problem from TLF generation, solved declaratively.

set_distinct_by(USUBJID) gives subject incidence rather than event counts.

Nested counts

add_layer(
  group_count(vars(AEBODSYS, AEDECOD)) |>
    set_distinct_by(USUBJID) |>
    set_nest_count(TRUE) |>
    set_indentation("    ") |>
    set_order_count_method("bycount") |>
    set_ordering_cols("Xanomeline High Dose")
)

set_order_count_method("bycount") orders terms by frequency; set_ordering_cols() says which column’s frequency drives the order — usually the highest-dose arm or the total.

Risk difference

add_layer(
  group_count(AEDECOD) |>
    set_distinct_by(USUBJID) |>
    add_risk_diff(
      c("Xanomeline High Dose", "Placebo"),
      c("Xanomeline Low Dose",  "Placebo")
    )
)

Getting the numeric data back

built <- build(t, metadata = TRUE)

# Trace a formatted cell back to the subjects behind it
get_meta_subjects(t, "d1_1", "var1_Placebo")
#> [1] "01-701-1015" "01-701-1023" ...

get_meta_result(t, "d1_1", "var1_Placebo")

This traceability is genuinely useful in a QC review: a reviewer questioning a cell can get the exact subject list that produced it, without reverse-engineering the code.

A full pipeline

#-------------------------------------------------------------------------------
# Program: t_14_3_1_ae_summary.R
# Purpose: Table 14.3.1 TEAEs by System Organ Class and Preferred Term
#-------------------------------------------------------------------------------

library(Tplyr); library(r2rtf); library(dplyr); library(here)

adsl <- readRDS(here("data", "adam", "adsl.rds"))
adae <- readRDS(here("data", "adam", "adae.rds"))

# --- 1. Build the numbers ---------------------------------------------------
t <- tplyr_table(adae, TRT01A, where = SAFFL == "Y" & TRTEMFL == "Y") |>
  set_pop_data(adsl) |>
  set_pop_treat_var(TRT01A) |>
  set_pop_where(SAFFL == "Y") |>
  add_total_group() |>
  add_layer(
    group_count("Subjects with any TEAE") |>
      set_distinct_by(USUBJID) |>
      set_format_strings(f_str("xx (xx.x%)", distinct_n, distinct_pct))
  ) |>
  add_layer(
    group_count(vars(AEBODSYS, AEDECOD)) |>
      set_distinct_by(USUBJID) |>
      set_nest_count(TRUE) |>
      set_indentation("    ") |>
      set_order_count_method("bycount") |>
      set_ordering_cols("Total") |>
      set_format_strings(f_str("xx (xx.x%)", distinct_n, distinct_pct))
  )

built <- build(t)

# --- 2. Arrange for output --------------------------------------------------
big_n <- header_n(t)

final <- built |>
  arrange(ord_layer_index, ord_layer_1, ord_layer_2) |>
  select(row_label1, starts_with("var1_")) |>
  mutate(across(everything(), ~ tidyr::replace_na(.x, "0")))

# --- 3. Render --------------------------------------------------------------
header_line <- paste0(
  "System Organ Class\\line    Preferred Term | ",
  paste(sprintf("%s\\line(N=%d)", big_n$TRT01A, big_n$n), collapse = " | ")
)

final |>
  rtf_page(orientation = "landscape", nrow = 28) |>
  rtf_title(
    c("Table 14.3.1",
      "Summary of Treatment-Emergent Adverse Events by System Organ Class and Preferred Term",
      "Safety Analysis Set")
  ) |>
  rtf_colheader(header_line, col_rel_width = c(6, 2, 2, 2, 2)) |>
  rtf_body(
    col_rel_width      = c(6, 2, 2, 2, 2),
    text_justification = c("l", rep("c", 4)),
    text_font_size     = 9
  ) |>
  rtf_footnote(c(
    "A treatment-emergent adverse event is one with onset on or after the first dose of study",
    "drug and no later than 30 days after the last dose.",
    "Subjects are counted once per system organ class and once per preferred term.",
    "Percentages are based on the number of subjects in the safety analysis set."
  )) |>
  rtf_source(sprintf("Program: t_14_3_1_ae_summary.R    Generated: %s",
                     format(Sys.time(), "%d%b%Y %H:%M"))) |>
  rtf_encode() |>
  write_rtf(here("output", "tables", "t_14_3_1_ae_summary.rtf"))

# --- 4. Save the numbers for QC ---------------------------------------------
saveRDS(built, here("output", "tables", "t_14_3_1_data.rds"))

rtables

Roche’s approach: build a layout object, then apply it to data. Powerful for deeply nested tables.

library(rtables)

lyt <- basic_table(show_colcounts = TRUE) |>
  split_cols_by("TRT01A") |>
  add_overall_col("All Patients") |>
  split_rows_by("AEBODSYS", split_fun = drop_split_levels) |>
  summarize_row_groups() |>
  count_occurrences(vars = "AEDECOD")

tbl <- build_table(lyt, adae, alt_counts_df = adsl)
tbl

rtables integrates with tern (statistical outputs) and teal (interactive review apps) in the NEST ecosystem. It is a coherent, well-engineered stack; the learning curve is steeper than Tplyr + r2rtf, and the choice between them is largely which ecosystem your organisation has adopted.

tfrmt

Separates the formatting of a table from its data, driven by JSON metadata:

library(tfrmt)

tfrmt(
  group  = c(AEBODSYS),
  label  = AEDECOD,
  column = TRT01A,
  param  = param,
  value  = value,
  body_plan = body_plan(
    frmt_structure(group_val = ".default", label_val = ".default",
                   frmt_combine("{n} ({pct}%)",
                                n   = frmt("xx"),
                                pct = frmt("xx.x")))
  )
) |>
  print_to_gt(ae_data)

This is the metadata-driven idea from lesson 5 applied to table formatting: the shell is a JSON artefact, reviewable and reusable across studies, separate from the code that computes the numbers.

Choosing

Submission RTF?
├── Yes ──▶ r2rtf for rendering
│           └── Numbers: Tplyr (declarative) or dplyr (full control)
└── No ───▶ What is the deliverable?
            ├── Word ──▶ flextable, or gtsummary |> as_flex_table()
            ├── HTML ──▶ gt, gtsummary, reactable
            ├── Publication ──▶ gtsummary
            └── Complex nesting, NEST ecosystem ──▶ rtables + tern

Common mistakes

Mistake Consequence Fix
nrow too high in rtf_page() Rows spill off the page Test the pagination on real data
Forgetting set_pop_data() Percentages use the wrong denominator Always set it for incidence tables
No set_distinct_by(USUBJID) Counts events, not subjects Set it
Column widths not summing sensibly Squashed or overflowing columns col_rel_width proportional
Unescaped special characters Corrupt RTF Use \\u codes
Formatting inside the computation Untestable numbers Compute, then format
Not saving the numeric result QC has to read RTF saveRDS() alongside
Assuming RTF renders identically everywhere Word and LibreOffice differ Check in the target application

Exercise 7.1 — Demographics table with r2rtf

Take the demographics summary from lesson 6 and render it as a submission-quality RTF with correct titles, a two-line column header including big N, footnotes and a source line.

Show solution
library(r2rtf); library(dplyr); library(here)

# `final` and `big_n` from the previous lesson's exercise

# --- Column widths ----------------------------------------------------------
# Label column wider; treatment columns equal. Relative, not absolute.
widths <- c(4.5, rep(1.8, nrow(big_n)))

# --- Header lines -----------------------------------------------------------
# Two separate rtf_colheader() calls give a clean two-row header where the
# second row has no top border, so it reads as a continuation.
hdr1 <- paste0("Characteristic | ", paste(big_n$TRT01A, collapse = " | "))
hdr2 <- paste0(" | ", paste(sprintf("(N=%d)", big_n$N), collapse = " | "))

# --- Render -----------------------------------------------------------------
final |>
  rtf_page(
    orientation = "portrait",
    nrow        = 32,
    margin      = c(1, 1, 1, 1, 0.5, 0.5)
  ) |>
  rtf_title(
    title = c(
      "Table 14.1.1",
      "Demographic and Baseline Characteristics",
      "Safety Analysis Set"
    ),
    text_format        = c("b", "b", ""),
    text_font_size     = c(10, 10, 10),
    text_justification = "c"
  ) |>
  rtf_colheader(
    hdr1,
    col_rel_width      = widths,
    text_justification = c("l", rep("c", nrow(big_n))),
    text_format        = "b",
    border_bottom      = ""
  ) |>
  rtf_colheader(
    hdr2,
    col_rel_width      = widths,
    text_justification = c("l", rep("c", nrow(big_n))),
    text_format        = "b",
    border_top         = ""
  ) |>
  rtf_body(
    col_rel_width      = widths,
    text_justification = c("l", rep("c", nrow(big_n))),
    text_font_size     = 9,
    text_space_before  = 15,
    text_space_after   = 15
  ) |>
  rtf_footnote(
    c(
      "N = number of subjects in the safety analysis set; n = number of subjects with non-missing data.",
      "Percentages are based on N.",
      "SD = standard deviation."
    ),
    text_font_size = 8,
    text_justification = "l"
  ) |>
  rtf_source(
    sprintf("Program: t_14_1_1_demographics.R     Data cut: %s     Generated: %s",
            format(as.Date("2026-07-15"), "%d%b%Y"),
            format(Sys.time(), "%d%b%Y %H:%M")),
    text_font_size = 8
  ) |>
  rtf_encode() |>
  write_rtf(here("output", "tables", "t_14_1_1_demographics.rtf"))

Details that separate a usable RTF from a nearly-usable one:

  • Two rtf_colheader() calls with border_bottom = "" on the first and border_top = "" on the second. A single call with \line also works but gives less control over the border.
  • col_rel_width is relative, not absolute — the values are normalised to the page width. c(4.5, 1.8, 1.8, 1.8, 1.8) gives the label column about 38% of the width regardless of orientation.
  • nrow = 32 must be checked against the actual output. A table that paginates mid-block (the “Age group” heading on one page and its categories on the next) reads badly; adjust nrow or insert a page_by.
  • The data cut date in the source line. “Generated” tells you when the program ran; “Data cut” tells you what it ran on. Reviewers need the second one more often.

Verify the output opens correctly:

# Convert to PDF for a quick visual check (requires LibreOffice)
system("soffice --headless --convert-to pdf --outdir output/tables output/tables/t_14_1_1_demographics.rtf")

Exercise 7.2 — AE table with Tplyr

Build the AE incidence table from lesson 6 using Tplyr instead of hand-written dplyr, with nesting, a 5% threshold and ordering by frequency. Compare the two approaches.

Show solution
library(Tplyr); library(dplyr); library(r2rtf); library(here)

adsl <- readRDS(here("data", "adam", "adsl.rds"))
adae <- readRDS(here("data", "adam", "adae.rds"))

t <- tplyr_table(adae, TRT01A, where = TRTEMFL == "Y") |>

  # Denominators come from ADSL, not from the AE data
  set_pop_data(adsl) |>
  set_pop_treat_var(TRT01A) |>
  set_pop_where(SAFFL == "Y") |>
  add_total_group() |>

  # Layer 1: any TEAE
  add_layer(
    group_count("Subjects with any TEAE") |>
      set_distinct_by(USUBJID) |>
      set_format_strings(f_str("xx (xx.x%)", distinct_n, distinct_pct))
  ) |>

  # Layer 2: nested SOC / PT
  add_layer(
    group_count(vars(AEBODSYS, AEDECOD)) |>
      set_distinct_by(USUBJID) |>
      set_nest_count(TRUE) |>
      set_indentation("    ") |>
      set_order_count_method("bycount") |>
      set_ordering_cols("Total") |>
      set_format_strings(f_str("xx (xx.x%)", distinct_n, distinct_pct))
  )

built <- build(t)

# --- Apply the 5% threshold -------------------------------------------------
# Tplyr has no built-in threshold, so filter the built result using the
# numeric columns it retains.
numeric_pcts <- build(t, metadata = FALSE) |>
  select(starts_with("ord_"), row_label1, row_label2, starts_with("var1_"))

# Determine which PTs qualify, from the underlying data
qualifying <- adae |>
  filter(TRTEMFL == "Y", USUBJID %in% adsl$USUBJID[adsl$SAFFL == "Y"]) |>
  distinct(USUBJID, TRT01A, AEBODSYS, AEDECOD) |>
  count(TRT01A, AEBODSYS, AEDECOD) |>
  left_join(count(filter(adsl, SAFFL == "Y"), TRT01A, name = "N"), by = "TRT01A") |>
  mutate(pct = 100 * n / N) |>
  summarise(max_pct = max(pct), .by = c(AEBODSYS, AEDECOD)) |>
  filter(max_pct >= 5)

final <- built |>
  filter(
    row_label1 == "Subjects with any TEAE" |          # keep the any-TEAE row
    row_label2 == "" |                                 # keep SOC rows
    trimws(row_label2) %in% qualifying$AEDECOD         # keep qualifying PTs
  ) |>
  arrange(ord_layer_index, ord_layer_1, ord_layer_2)

Comparison

Hand-written dplyr Tplyr
Lines of code ~60 ~25
Denominator handling Manual join, easy to get wrong set_pop_data(), declarative
Subject incidence distinct() or AOCC* flags set_distinct_by()
Nesting and indentation Manual paste0(" ", ...) set_nest_count()
Ordering Manual join and arrange() set_order_count_method()
Zero counts values_fill = "0" Automatic
Traceability Write it yourself get_meta_subjects()
Non-standard requirements Straightforward Sometimes awkward
Reviewability Every step visible Requires knowing Tplyr

Tplyr is clearly better for standard tables — it encodes the conventions (denominators, distinct counts, nesting, zero fill) that hand-written code gets wrong. The 5% threshold above is where it becomes awkward: Tplyr has no built-in threshold, so the filter has to be computed separately from the source data and applied to the built result, which is less elegant than the dplyr version.

That is the general pattern. Use Tplyr for the 80% of tables that follow the standard shapes, and drop to dplyr for the ones with study-specific logic. Mixing the two in one study is fine, provided the derivations (which subjects, which events) live in ADaM where both approaches read them consistently.

A practical note: Tplyr’s metadata feature is a strong argument in its favour for QC.

built_meta <- build(t, metadata = TRUE)
get_meta_subjects(t, "d2_15", "var1_Placebo")
#> [1] "01-701-1015" "01-701-1047" "01-702-1082"
A QC programmer questioning a cell gets the subject list directly, which collapses a half-day investigation into a minute.

Recap

  • r2rtf is the standard for submission RTF; Tplyr builds the numbers
  • rtf_page(nrow=) controls pagination — always check it against real output
  • set_pop_data() gives the correct big-N denominator; set_distinct_by() gives subject incidence
  • group_by in rtf_body() suppresses repeated values for nested tables
  • Special characters need \\u codes; \\line for a break within a cell
  • Compute first, format second; save the numeric result for QC
  • gtsummary for publications, rtables+tern for the NEST ecosystem, tfrmt for metadata-driven shells

Next: Validation and testing.

Back to top