30 Days of Pharmaverse
  • Week 1: SDTM Fundamentals
  • Week 2: Production SDTM
  • Week 3: ADaM Deep Dive
  • Week 4: Tables, Listings and Figures
  1. Day 26: flextable and officer - Word and RTF Clinical Tables
  • Day 22: Demography Table with gtsummary + gt
  • Day 23: ADCM and ADRS - Concomitant Meds and Oncology Response
  • Day 24: ARD-First Reporting with cards and cardx
  • Day 25: gtsummary and tfrmt - ARD-Backed Production Tables
  • Day 26: flextable and officer - Word and RTF Clinical Tables
  • Day 27: rtables, tern, and r2rtf - Structured Clinical Tables
  • Day 28: Tplyr - Declarative Clinical Table Programming
  • Day 29: ggsurvfit + gtsummary - Survival Plots and Clinical Figures
  • Day 30: Capstone - Full Clinical Reporting Workflow

On this page

  • 1 Overview
  • 2 Setup
  • 3 Table 1: Demographics
    • 3.1 Step 1: Build Summary Data
    • 3.2 Step 2: Build flextable
    • 3.3 Step 3: Export Demographics to Word
    • 3.4 Step 4: Export Demographics to RTF
  • 4 Table 2: AE Frequency by SOC
    • 4.1 Step 5: Build AE Summary Data
    • 4.2 Step 6: Build AE flextable
    • 4.3 Step 7: Export AE Table to Word
    • 4.4 Step 8: Export AE Table to RTF
  • 5 Table 3: Lab Shift Table
    • 5.1 Step 9: Build Lab Shift Data
    • 5.2 Step 10: Build Lab Shift flextable
    • 5.3 Step 11: Export Lab Shift to Word
    • 5.4 Step 12: Export Lab Shift to RTF
  • 6 Step 13: Multi-Table Word Document
  • 7 Validation Checks
  • 8 Key Takeaways
  • 9 Next Steps
  • 10 Resources

Day 26: flextable and officer - Word and RTF Clinical Tables

Formatted TLF output to Word and RTF

Back to Roadmap

1 Overview

flextable is the most flexible R package for creating formatted tables in Word, RTF, HTML, and PowerPoint – comparable in power to SAS PROC REPORT for clinical reporting. officer provides the Word document container.

Key rule: body_add_flextable() lives in the flextable package, not officer. Always call it as flextable::body_add_flextable().


2 Setup

library(flextable)
library(officer)
library(pharmaverseadam)
library(dplyr)
library(tidyr)

adsl <- pharmaverseadam::adsl
adae <- pharmaverseadam::adae
adlb <- pharmaverseadam::adlb

cat("ADSL columns:\n");       print(names(adsl))
ADSL columns:
 [1] "STUDYID"  "USUBJID"  "SUBJID"   "RFSTDTC"  "RFENDTC"  "RFXSTDTC"
 [7] "RFXENDTC" "RFICDTC"  "RFPENDTC" "DTHDTC"   "DTHFL"    "SITEID"  
[13] "AGE"      "AGEU"     "SEX"      "RACE"     "ETHNIC"   "ARMCD"   
[19] "ARM"      "ACTARMCD" "ACTARM"   "COUNTRY"  "DMDTC"    "DMDY"    
[25] "TRT01P"   "TRT01A"   "TRTSDTM"  "TRTSTMF"  "TRTEDTM"  "TRTETMF" 
[31] "TRTSDT"   "TRTEDT"   "TRTDURD"  "SCRFDT"   "EOSDT"    "EOSSTT"  
[37] "FRVDT"    "RANDDT"   "DTHDT"    "DTHDTF"   "DTHADY"   "LDDTHELD"
[43] "DTHCAUS"  "DTHDOM"   "DTHCGR1"  "LSTALVDT" "SAFFL"    "RACEGR1" 
[49] "AGEGR1"   "REGION1"  "LDDTHGR1" "DTH30FL"  "DTHA30FL" "DTHB30FL"
cat("\nADAE columns (first 20):\n"); print(head(names(adae), 20))

ADAE columns (first 20):
 [1] "STUDYID"  "DOMAIN"   "USUBJID"  "AESEQ"    "AESPID"   "AETERM"  
 [7] "AELLT"    "AELLTCD"  "AEDECOD"  "AEPTCD"   "AEHLT"    "AEHLTCD" 
[13] "AEHLGT"   "AEHLGTCD" "AEBODSYS" "AEBDSYCD" "AESOC"    "AESOCCD" 
[19] "AESEV"    "AESER"   
cat("\nADLB columns (first 20):\n"); print(head(names(adlb), 20))

ADLB columns (first 20):
 [1] "STUDYID"  "DOMAIN"   "USUBJID"  "LBSEQ"    "LBTESTCD" "LBTEST"  
 [7] "LBCAT"    "LBORRES"  "LBORRESU" "LBORNRLO" "LBORNRHI" "LBSTRESC"
[13] "LBSTRESN" "LBSTRESU" "LBSTNRLO" "LBSTNRHI" "LBNRIND"  "LBBLFL"  
[19] "VISITNUM" "VISIT"   

3 Table 1: Demographics

3.1 Step 1: Build Summary Data

adsl_safe <- adsl |> dplyr::filter(SAFFL == "Y")

demo_sum <- adsl_safe |>
  dplyr::group_by(TRT01A) |>
  dplyr::summarise(
    N          = dplyr::n(),
    AGE_MEAN   = sprintf("%.1f (%.2f)", mean(AGE, na.rm = TRUE), sd(AGE, na.rm = TRUE)),
    FEMALE_PCT = sprintf("%d (%.1f%%)", sum(SEX == "F", na.rm = TRUE),
                         mean(SEX == "F", na.rm = TRUE) * 100),
    .groups = "drop"
  )

print(demo_sum)
# A tibble: 3 × 4
  TRT01A                   N AGE_MEAN    FEMALE_PCT
  <chr>                <int> <chr>       <chr>     
1 Placebo                 86 75.2 (8.59) 53 (61.6%)
2 Xanomeline High Dose    72 73.8 (7.94) 35 (48.6%)
3 Xanomeline Low Dose     96 76.0 (8.11) 55 (57.3%)

3.2 Step 2: Build flextable

ft_demo <- flextable::flextable(demo_sum) |>
  flextable::set_header_labels(
    TRT01A     = "Treatment",
    N          = "N",
    AGE_MEAN   = "Age, Mean (SD)",
    FEMALE_PCT = "Female, n (%)"
  ) |>
  flextable::theme_booktabs() |>
  flextable::align(align = "center", part = "all") |>
  flextable::align(j = 1, align = "left", part = "all") |>
  flextable::bold(part = "header") |>
  flextable::hline_top(border = officer::fp_border(width = 2), part = "header") |>
  flextable::hline_bottom(border = officer::fp_border(width = 2), part = "header") |>
  flextable::hline_bottom(border = officer::fp_border(width = 1.5), part = "body") |>
  flextable::add_header_lines("Table 14.1.1 -- Subject Demographics (Safety Population)") |>
  flextable::add_footer_lines("SD = standard deviation") |>
  flextable::autofit()

ft_demo

Table 14.1.1 -- Subject Demographics (Safety Population)

Treatment

N

Age, Mean (SD)

Female, n (%)

Placebo

86

75.2 (8.59)

53 (61.6%)

Xanomeline High Dose

72

73.8 (7.94)

35 (48.6%)

Xanomeline Low Dose

96

76.0 (8.11)

55 (57.3%)

SD = standard deviation


3.3 Step 3: Export Demographics to Word

# body_add_flextable() is in flextable, NOT officer
doc_demo <- officer::read_docx()
doc_demo <- officer::body_add_par(doc_demo,
                                   "Table 14.1.1 Demographics",
                                   style = "heading 1")
doc_demo <- flextable::body_add_flextable(doc_demo, value = ft_demo)
doc_demo <- officer::body_add_par(doc_demo, "", style = "Normal")
print(doc_demo, target = "table14_1_1_demographics.docx")
cat("Exported: table14_1_1_demographics.docx\n")
Exported: table14_1_1_demographics.docx

3.4 Step 4: Export Demographics to RTF

flextable::save_as_rtf(ft_demo, path = "table14_1_1_demographics.rtf")
cat("Exported: table14_1_1_demographics.rtf\n")
Exported: table14_1_1_demographics.rtf

4 Table 2: AE Frequency by SOC

4.1 Step 5: Build AE Summary Data

adae_te <- adae |>
  dplyr::filter(TRTEMFL == "Y", SAFFL == "Y")

n_per_arm <- adsl_safe |>
  dplyr::count(TRT01A, name = "ARM_N")

ae_sum <- adae_te |>
  dplyr::distinct(USUBJID, TRT01A, AEBODSYS, AEDECOD) |>
  dplyr::group_by(TRT01A, AEBODSYS, AEDECOD) |>
  dplyr::summarise(subj_n = dplyr::n_distinct(USUBJID), .groups = "drop") |>
  dplyr::left_join(n_per_arm, by = "TRT01A") |>
  dplyr::mutate(
    cell = sprintf("%d (%.1f%%)", subj_n, subj_n / ARM_N * 100)
  ) |>
  dplyr::select(AEBODSYS, AEDECOD, TRT01A, cell) |>
  tidyr::pivot_wider(names_from = TRT01A, values_from = cell,
                     values_fill = "0 (0.0%)") |>
  dplyr::arrange(AEBODSYS, AEDECOD) |>
  utils::head(15)

cat("AE summary rows:", nrow(ae_sum), "\n")
AE summary rows: 15 
print(head(ae_sum, 3))
# A tibble: 3 × 5
  AEBODSYS          AEDECOD Placebo `Xanomeline High Dose` `Xanomeline Low Dose`
  <chr>             <chr>   <chr>   <chr>                  <chr>                
1 CARDIAC DISORDERS ATRIAL… 1 (1.2… 2 (2.8%)               2 (2.1%)             
2 CARDIAC DISORDERS ATRIAL… 0 (0.0… 1 (1.4%)               1 (1.0%)             
3 CARDIAC DISORDERS ATRIAL… 1 (1.2… 0 (0.0%)               0 (0.0%)             

4.2 Step 6: Build AE flextable

ft_ae <- flextable::flextable(ae_sum) |>
  flextable::set_header_labels(
    AEBODSYS = "System Organ Class",
    AEDECOD  = "Preferred Term"
  ) |>
  flextable::theme_booktabs() |>
  flextable::bold(part = "header") |>
  flextable::align(align = "center", part = "all") |>
  flextable::align(j = c(1, 2), align = "left", part = "all") |>
  flextable::merge_v(j = "AEBODSYS") |>
  flextable::valign(j = "AEBODSYS", valign = "top", part = "body") |>
  flextable::hline_top(border = officer::fp_border(width = 2), part = "header") |>
  flextable::hline_bottom(border = officer::fp_border(width = 2), part = "header") |>
  flextable::hline_bottom(border = officer::fp_border(width = 1.5), part = "body") |>
  flextable::add_header_lines(
    "Table 14.3.1 -- Treatment-Emergent Adverse Events by SOC (Safety Population)"
  ) |>
  flextable::add_footer_lines(
    "n (%) = subjects with at least one event; TEAE = treatment-emergent adverse event"
  ) |>
  flextable::autofit()

ft_ae

Table 14.3.1 -- Treatment-Emergent Adverse Events by SOC (Safety Population)

System Organ Class

Preferred Term

Placebo

Xanomeline High Dose

Xanomeline Low Dose

CARDIAC DISORDERS

ATRIAL FIBRILLATION

1 (1.2%)

2 (2.8%)

2 (2.1%)

ATRIAL FLUTTER

0 (0.0%)

1 (1.4%)

1 (1.0%)

ATRIAL HYPERTROPHY

1 (1.2%)

0 (0.0%)

0 (0.0%)

ATRIOVENTRICULAR BLOCK FIRST DEGREE

1 (1.2%)

0 (0.0%)

1 (1.0%)

ATRIOVENTRICULAR BLOCK SECOND DEGREE

1 (1.2%)

0 (0.0%)

0 (0.0%)

BRADYCARDIA

1 (1.2%)

0 (0.0%)

0 (0.0%)

BUNDLE BRANCH BLOCK LEFT

1 (1.2%)

0 (0.0%)

0 (0.0%)

BUNDLE BRANCH BLOCK RIGHT

1 (1.2%)

0 (0.0%)

1 (1.0%)

CARDIAC DISORDER

0 (0.0%)

1 (1.4%)

0 (0.0%)

CARDIAC FAILURE CONGESTIVE

1 (1.2%)

0 (0.0%)

0 (0.0%)

MYOCARDIAL INFARCTION

4 (4.7%)

4 (5.6%)

2 (2.1%)

PALPITATIONS

0 (0.0%)

0 (0.0%)

2 (2.1%)

SINUS ARRHYTHMIA

1 (1.2%)

0 (0.0%)

0 (0.0%)

SINUS BRADYCARDIA

2 (2.3%)

8 (11.1%)

7 (7.3%)

SUPRAVENTRICULAR EXTRASYSTOLES

1 (1.2%)

1 (1.4%)

1 (1.0%)

n (%) = subjects with at least one event; TEAE = treatment-emergent adverse event


4.3 Step 7: Export AE Table to Word

doc_ae <- officer::read_docx()
doc_ae <- officer::body_add_par(doc_ae, "Table 14.3.1 TEAEs by SOC",
                                 style = "heading 1")
doc_ae <- flextable::body_add_flextable(doc_ae, value = ft_ae)
doc_ae <- officer::body_add_par(doc_ae, "", style = "Normal")
print(doc_ae, target = "table14_3_1_ae.docx")
cat("Exported: table14_3_1_ae.docx\n")
Exported: table14_3_1_ae.docx

4.4 Step 8: Export AE Table to RTF

flextable::save_as_rtf(ft_ae, path = "table14_3_1_ae.rtf")
cat("Exported: table14_3_1_ae.rtf\n")
Exported: table14_3_1_ae.rtf

5 Table 3: Lab Shift Table

5.1 Step 9: Build Lab Shift Data

adlb_safe <- adlb |>
  dplyr::filter(SAFFL == "Y", PARAMCD == "ALT", !is.na(AVAL))

cat("ADLB indicator columns present:\n")
ADLB indicator columns present:
print(intersect(names(adlb_safe), c("BNRIND", "ANRIND", "ABLFL", "ANL01FL")))
[1] "ANRIND"  "ABLFL"   "BNRIND"  "ANL01FL"
shift_data <- adlb_safe |>
  dplyr::filter(!is.na(BNRIND), !is.na(ANRIND), ANL01FL == "Y") |>
  dplyr::count(TRT01A, BNRIND, ANRIND, name = "n") |>
  dplyr::mutate(
    BNRIND = factor(BNRIND, levels = c("LOW", "NORMAL", "HIGH")),
    ANRIND = factor(ANRIND, levels = c("LOW", "NORMAL", "HIGH"))
  ) |>
  tidyr::pivot_wider(
    names_from  = ANRIND,
    values_from = n,
    values_fill = 0L
  ) |>
  dplyr::arrange(TRT01A, BNRIND)

cat("Lab shift rows:", nrow(shift_data), "\n")
Lab shift rows: 6 
print(shift_data)
# A tibble: 6 × 5
  TRT01A               BNRIND  HIGH NORMAL   LOW
  <chr>                <fct>  <int>  <int> <int>
1 Placebo              NORMAL    20    786     6
2 Placebo              HIGH      16     24     0
3 Xanomeline High Dose NORMAL    18    563     0
4 Xanomeline High Dose HIGH      15     22     0
5 Xanomeline Low Dose  NORMAL    23    577    10
6 Xanomeline Low Dose  HIGH       9      5     0

5.2 Step 10: Build Lab Shift flextable

ft_lab <- flextable::flextable(shift_data) |>
  flextable::set_header_labels(
    TRT01A = "Treatment",
    BNRIND = "Baseline"
  ) |>
  flextable::add_header_row(
    values     = c("", "", "Post-Baseline Reference Range"),
    colwidths  = c(1, 1, ncol(shift_data) - 2)
  ) |>
  flextable::theme_booktabs() |>
  flextable::bold(part = "header") |>
  flextable::align(align = "center", part = "all") |>
  flextable::align(j = c(1, 2), align = "left", part = "all") |>
  flextable::merge_v(j = "TRT01A") |>
  flextable::valign(j = "TRT01A", valign = "top", part = "body") |>
  flextable::hline_top(border = officer::fp_border(width = 2), part = "header") |>
  flextable::hline_bottom(border = officer::fp_border(width = 2), part = "header") |>
  flextable::hline_bottom(border = officer::fp_border(width = 1.5), part = "body") |>
  flextable::add_header_lines(
    "Table 14.4.1 -- ALT Shift Table: Baseline vs Post-Baseline (Safety Population)"
  ) |>
  flextable::add_footer_lines(
    "BNRIND = Baseline Reference Range Indicator; ANRIND = Post-Baseline Reference Range Indicator"
  ) |>
  flextable::autofit()

ft_lab

Table 14.4.1 -- ALT Shift Table: Baseline vs Post-Baseline (Safety Population)

Post-Baseline Reference Range

Treatment

Baseline

HIGH

NORMAL

LOW

Placebo

NORMAL

20

786

6

HIGH

16

24

0

Xanomeline High Dose

NORMAL

18

563

0

HIGH

15

22

0

Xanomeline Low Dose

NORMAL

23

577

10

HIGH

9

5

0

BNRIND = Baseline Reference Range Indicator; ANRIND = Post-Baseline Reference Range Indicator


5.3 Step 11: Export Lab Shift to Word

doc_lab <- officer::read_docx()
doc_lab <- officer::body_add_par(doc_lab, "Table 14.4.1 ALT Shift Table",
                                  style = "heading 1")
doc_lab <- flextable::body_add_flextable(doc_lab, value = ft_lab)
doc_lab <- officer::body_add_par(doc_lab, "", style = "Normal")
print(doc_lab, target = "table14_4_1_lab_shift.docx")
cat("Exported: table14_4_1_lab_shift.docx\n")
Exported: table14_4_1_lab_shift.docx

5.4 Step 12: Export Lab Shift to RTF

flextable::save_as_rtf(ft_lab, path = "table14_4_1_lab_shift.rtf")
cat("Exported: table14_4_1_lab_shift.rtf\n")
Exported: table14_4_1_lab_shift.rtf

6 Step 13: Multi-Table Word Document

doc_all <- officer::read_docx()
doc_all <- officer::body_add_par(doc_all,
                                  "Table 14.1.1 Demographics",
                                  style = "heading 1")
doc_all <- flextable::body_add_flextable(doc_all, value = ft_demo)
doc_all <- officer::body_add_break(doc_all)
doc_all <- officer::body_add_par(doc_all,
                                  "Table 14.3.1 Treatment-Emergent Adverse Events",
                                  style = "heading 1")
doc_all <- flextable::body_add_flextable(doc_all, value = ft_ae)
doc_all <- officer::body_add_break(doc_all)
doc_all <- officer::body_add_par(doc_all,
                                  "Table 14.4.1 ALT Shift Table",
                                  style = "heading 1")
doc_all <- flextable::body_add_flextable(doc_all, value = ft_lab)
doc_all <- officer::body_add_par(doc_all, "", style = "Normal")
print(doc_all, target = "tlf_package_day26.docx")
cat("Exported: tlf_package_day26.docx (all 3 tables)\n")
Exported: tlf_package_day26.docx (all 3 tables)

7 Validation Checks

cat("\n=== Day 26 Validation ===\n\n")

=== Day 26 Validation ===
cat("Check 1 - Treatment arms in demo summary:\n")
Check 1 - Treatment arms in demo summary:
print(demo_sum |> dplyr::select(TRT01A, N))
# A tibble: 3 × 2
  TRT01A                   N
  <chr>                <int>
1 Placebo                 86
2 Xanomeline High Dose    72
3 Xanomeline Low Dose     96
cat("\nCheck 2 - AE table columns:\n")

Check 2 - AE table columns:
print(names(ae_sum))
[1] "AEBODSYS"             "AEDECOD"              "Placebo"             
[4] "Xanomeline High Dose" "Xanomeline Low Dose" 
cat("\nCheck 3 - Lab shift rows:", nrow(shift_data), "\n")

Check 3 - Lab shift rows: 6 
files_out <- c(
  "table14_1_1_demographics.docx",
  "table14_1_1_demographics.rtf",
  "table14_3_1_ae.docx",
  "table14_3_1_ae.rtf",
  "table14_4_1_lab_shift.docx",
  "table14_4_1_lab_shift.rtf",
  "tlf_package_day26.docx"
)

cat("\nCheck 4 - Output files:\n")

Check 4 - Output files:
for (f in files_out) {
  cat(sprintf("  %s: %s\n", f,
              if (file.exists(f)) "EXISTS" else "MISSING"))
}
  table14_1_1_demographics.docx: EXISTS
  table14_1_1_demographics.rtf: EXISTS
  table14_3_1_ae.docx: EXISTS
  table14_3_1_ae.rtf: EXISTS
  table14_4_1_lab_shift.docx: EXISTS
  table14_4_1_lab_shift.rtf: EXISTS
  tlf_package_day26.docx: EXISTS
cat("\nValidation complete\n")

Validation complete

8 Key Takeaways

  1. body_add_flextable() is exported from flextable, not officer – always use flextable::body_add_flextable()
  2. Use base assignment (doc <- flextable::body_add_flextable(doc, ...)) not pipe chains for officer documents
  3. theme_booktabs() produces the standard clinical table look
  4. merge_v() + valign() handles SOC grouping rows in AE tables
  5. add_header_row() creates column spanners for shift tables
  6. save_as_rtf() exports directly; for Word use officer::read_docx() + print(target = ...)

9 Next Steps

Day 27: r2rtf – submission-ready RTF tables Day 28: rtables + tern – structured clinical tables


10 Resources

  • flextable documentation: https://davidgohel.github.io/flextable/
  • officer CRAN: https://cran.r-project.org/package=officer
  • r4csr.org: https://r4csr.org

End of Day 26

 

30 Days of Pharmaverse  ·  Disclaimer  ·  Indraneel Chakraborty  ·  © 2026