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().
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))
[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 (" \n ADAE columns (first 20): \n " ); print (head (names (adae), 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 (" \n ADLB columns (first 20): \n " ); print (head (names (adlb), 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"
Table 1: Demographics
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%)
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
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
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
Table 2: AE Frequency by SOC
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 " )
# 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%)
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
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
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
Table 3: Lab Shift Table
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 = 0 L
) |>
dplyr:: arrange (TRT01A, BNRIND)
cat ("Lab shift rows:" , nrow (shift_data), " \n " )
# 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
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
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
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
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)
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 (" \n Check 2 - AE table columns: \n " )
Check 2 - AE table columns:
[1] "AEBODSYS" "AEDECOD" "Placebo"
[4] "Xanomeline High Dose" "Xanomeline Low Dose"
cat (" \n Check 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 (" \n Check 4 - Output files: \n " )
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 (" \n Validation complete \n " )
Key Takeaways
body_add_flextable() is exported from flextable , not officer – always use flextable::body_add_flextable()
Use base assignment (doc <- flextable::body_add_flextable(doc, ...)) not pipe chains for officer documents
theme_booktabs() produces the standard clinical table look
merge_v() + valign() handles SOC grouping rows in AE tables
add_header_row() creates column spanners for shift tables
save_as_rtf() exports directly; for Word use officer::read_docx() + print(target = ...)
Next Steps
Day 27: r2rtf – submission-ready RTF tables Day 28: rtables + tern – structured clinical tables