r2rtf, Tplyr and related packages
Lesson 7 — Clinical Programming with R
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.
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)
tblrtables 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 withborder_bottom = ""on the first andborder_top = ""on the second. A single call with\linealso works but gives less control over the border. col_rel_widthis 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 = 32must 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; adjustnrowor insert apage_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"Recap
r2rtfis the standard for submission RTF;Tplyrbuilds the numbersrtf_page(nrow=)controls pagination — always check it against real outputset_pop_data()gives the correct big-N denominator;set_distinct_by()gives subject incidencegroup_byinrtf_body()suppresses repeated values for nested tables- Special characters need
\\ucodes;\\linefor a break within a cell - Compute first, format second; save the numeric result for QC
gtsummaryfor publications,rtables+ternfor the NEST ecosystem,tfrmtfor metadata-driven shells
Next: Validation and testing.