This vignette presents real-world clinical reporting examples built with ksTFL. Each example walks through the complete pipeline — from raw input data, through the R code that constructs the specification object, to the final rendered document — so you can see exactly what goes in and what comes out.
The examples are ordered by complexity: we start with a demographics table that introduces core concepts (invisible columns, conditional formatting, section headers), then move on to multi-language support, complex spanning headers, data listings with grouping, and finally figure output.
All code chunks use eval = FALSE; the rendered PDFs were
pre-generated and are embedded below. To reproduce an example locally,
run the shown code in an R session with ksTFL loaded.
Every example follows the same structure:
create_table() or create_figure()A shared session setup block at the top configures page headers, footers, and output defaults so the examples can stay focused on their unique logic.
Related reading:
Demographics tables are among the most common deliverables in clinical reporting. They summarise baseline patient characteristics by treatment arm and typically include section headers, summary statistics, and inferential test results.
This example shows how ksTFL handles all of that declaratively. The key techniques demonstrated here are:
SECTION,
SECTION_ID, and MODELVAL are kept out of the
rendered document but drive conditional logic via
compute_cols(). This is a core ksTFL pattern: your data
frame can carry metadata columns that the rendering engine never prints
but uses to control formatting.c_addrow() — inserts bold section
headers (Age, Sex, Race) above the first row of each group, pulling
values directly from the hidden SECTION column.c_merge() + c_glue() — on
p-value rows the treatment columns are merged into one cell and the
p-value is appended, creating the typical “p = 0.041” display.c_pageBreak() — forces a page break
before the “Race” section for readability.c_addrow("below") with
row_h4 adds visual separation between sections.The data frame has three demographic sections (Age, Sex, Race), each
containing summary statistics and an optional p-value stored in the
MODELVAL column. Note that MODELVAL is only
populated on the p-value rows — it is NA everywhere else,
which is how we target those rows in compute_cols(). The
SECTION_ID column is derived from the grouping and used for
spacing and page-break logic:
raw <- tibble(
SECTION = c(
rep("Age (years)", 6),
rep("Sex", 3),
rep("Race", 4)
),
STAT = c(
"n", "Mean (SD)", "Median", "Q1; Q3", "Min; Max", "p-value (ANOVA)",
"Female", "Male", "p-value (Fisher)",
"White", "Asian", "Black", "p-value (Fisher)"
),
DRUGX = c("160", "55.2 (12.4)", "54.0", "47.0; 64.0", "18; 82", "",
"88 (55.0%)", "72 (45.0%)", "",
"130 (81.2%)", "18 (11.2%)", "12 (7.5%)", ""),
PLCB = c("158", "56.0 (11.9)", "55.0", "48.0; 63.0", "20; 81", "",
"90 (57.0%)", "68 (43.0%)", "",
"124 (78.5%)", "22 (13.9%)", "12 (7.6%)", ""),
TOTAL = c("318", "55.6 (12.1)", "54.0", "47.5; 63.5", "18; 82", "",
"178 (56.0%)", "140 (44.0%)", "",
"254 (79.9%)", "40 (12.6%)", "24 (7.5%)", ""),
MODELVAL = c(NA, NA, NA, NA, NA, "0.041",
NA, NA, ">0.999",
NA, NA, NA, "0.772")
) %>%
group_by(SECTION) %>%
mutate(SECTION_ID = cur_group_id()) %>%
ungroup()The first few rows of raw:
# A tibble: 13 × 7
SECTION STAT DRUGX PLCB TOTAL MODELVAL SECTION_ID
<chr> <chr> <chr> <chr> <chr> <chr> <int>
1 Age (years) n 160 158 318 NA 1
2 Age (years) Mean (SD) 55.2 (12.4) 56.0 (11.9) 55.6 (12.1) NA 1
3 Age (years) Median 54.0 55.0 54.0 NA 1
4 Age (years) Q1; Q3 47.0; 64.0 48.0; 63.0 47.5; 63.5 NA 1
5 Age (years) Min; Max 18; 82 20; 81 18; 82 NA 1
6 Age (years) p-value (ANOVA) 0.041 1
7 Sex Female 88 (55.0%) 90 (57.0%) 178 (56.0%) NA 2
8 Sex Male 72 (45.0%) 68 (43.0%) 140 (44.0%) NA 2
9 Sex p-value (Fisher) >0.999 2
10 Race White 130 (81.2%) 124 (78.5%) 254 (79.9%) NA 3
11 Race Asian 18 (11.2%) 22 (13.9%) 40 (12.6%) NA 3
12 Race Black 12 (7.5%) 12 (7.6%) 24 (7.5%) NA 3
13 Race p-value (Fisher) 0.772 3
spec <- create_table(raw) %>%
# --- Titles and footnotes ---
# Multi-line title: table number on first line, description on second.
# toclevel = 1 makes this entry appear in the Table of Contents.
add_title(
c("Table S1",
"Demographic and Baseline Characteristics"),
toclevel = 1
) %>%
# Secondary title in italics for the analysis population label
add_title("Full Analysis Set", styleRef = "font_italic") %>%
add_footnote(c(
"Values are shown as n (%), mean (SD), median, or quartiles.",
"P-values shown for section-level inferential tests."
)) %>%
# --- Column definitions ---
# Hide helper columns: they won't appear in the document, but remain
# available for compute_cols() conditions. This is the "invisible column"
# pattern — SECTION drives group headers, SECTION_ID drives spacing and
# page breaks, MODELVAL carries p-values for conditional formatting.
define_cols(c(SECTION, SECTION_ID, MODELVAL), isVisible = FALSE) %>%
# The statistics column: left-aligned header, values indented one level
# to sit beneath the bold section headers inserted by c_addrow() below.
define_cols(STAT,
label = "Parameter<br> Statistic",
labelStyleRef = "text_left",
valueStyleRef = "indent_1"
) %>%
# Treatment-arm columns: centred values, equal width, with N in the header.
define_cols(c(DRUGX, PLCB, TOTAL),
label = c("DrugX<br>(N=160)", "Placebo<br>(N=158)", "Total<br>(N=318)"),
valueStyleRef = "text_center",
colWidth = "16%"
) %>%
# --- Conditional row actions (compute_cols) ---
# 1. Section headers: for the first row of each SECTION
# group, insert a bold header row above it, pulling the
# text from the hidden SECTION column.
compute_cols(
firstOf(SECTION),
c_addrow("above", value_from = SECTION, styleRef = "font_bold")
) %>%
# 2. P-value rows: when MODELVAL is not NA, merge the DrugX and Placebo
# cells into one (with a thin top border and centred italic text),
# italicise the STAT label, and append the p-value after the merged cell.
compute_cols(
!is.na(MODELVAL),
c_merge(c(DRUGX, PLCB), styleRef = f_combine("text_center", "bt_th", "i")),
c_style(STAT, "i"),
c_glue(DRUGX, "after", glue_col = MODELVAL)
) %>%
# 3. Spacers: insert a thin empty row below each section for visual separation
compute_cols(
lastOf(SECTION_ID),
c_addrow("below", styleRef = "row_h4")
) %>%
# 4. Page break: force a new page before the Race section (group 3)
compute_cols(
SECTION_ID == 3 & firstOf(SECTION_ID),
c_pageBreak()
) %>%
# Document-level settings: narrow content area, no extra spacing
set_document(
contentWidth = "75%",
topEmptyLine = "0pt",
bottomEmptyLine = "0pt"
)The example above uses the default embedded style template. One of ksTFL’s design principles is that content and styling are separate: you can switch to a completely different visual theme by changing a single parameter on an already-built spec, without touching any of the data or conditional logic:
spec <- set_document(spec, docTemplate = 'Navy_Pro')
create_report(spec) %>% write_doc("example_01_navy")The same table is now rendered with the “Navy_Pro” template — different fonts, colours, and border styles, but identical content and structure:
Key take-aways from this example:
SECTION,
SECTION_ID, and MODELVAL never appear in the
document, yet they drive all conditional logic. This is the core
“metadata column” pattern.c_addrow(value_from = ...) pulls text
from a hidden column into a dynamically inserted header row — no manual
string duplication needed.c_merge() + c_glue()
combine cells and append content from another column in a single
compute_cols() call, producing the merged p-value
display.c_pageBreak() gives fine-grained
control over pagination.set_document(docTemplate = ...) re-skins the entire output
without modifying the spec pipeline.Clinical trials run in many countries, and regulatory submissions often require documents in local languages. ksTFL has full UTF-8 support — titles, footnotes, column labels, and data values can all contain non-Latin characters without any special configuration.
This example reproduces the same demographics table structure from Example 1, but entirely in Russian. The code also serves as a detailed walkthrough of the spec-building pipeline, with comments explaining every step.
# A tibble: 32 × 7
CAT1 CAT2 RPH104 PLCB TOTAL modelval SECTORD1
<chr> <chr> <chr> <chr> <chr> <chr> <dbl>
1 Возраст (лет) n "16" "17" "33" NA 1
2 Возраст (лет) Сред. (СО) "30.9 (11.3)" "41.6 (11.9)" "36.4 (12.6)" NA 1
3 Возраст (лет) Медиана "28.0" "40.0" "33.0" NA 1
4 Возраст (лет) Q1; Q3 "22.5; 36.5" "33.0; 48.0" "26.0; 45.0" NA 1
5 Возраст (лет) Мин.; Макс. "18; 57" "21; 59" "18; 59" NA 1
6 Возраст (лет) p-величина (ANOVA μ₁=μ₂) "" "" "" 0.013 1
7 Пол Женский " 6 ( 37.5%)" " 7 ( 41.2%)" "13 ( 39.4%)" NA 2
8 Пол Мужской "10 ( 62.5%)" "10 ( 58.8%)" "20 ( 60.6%)" NA 2
9 Пол p-величина (Фишер) "" "" "" >0.999 2
10 Раса Белые "16 (100.0%)" "17 (100.0%)" "33 (100.0%)" NA 3
The pipeline follows the same pattern as Example 1: create table → titles/footnotes → hide helper columns → define visible columns → conditional row transforms → document settings. Reading the inline comments below alongside Example 1 will help you see the one-to-one correspondence:
### Build the specification object
drg_N <- 16
plcb_N <- 17
total_N <- 33
spec_dm_01 <- create_table(data) %>%
add_title(
c("Таблица 1.2",
"Демографические и другие исходные характеристики"),
toclevel = 1
) %>%
add_title("Популяция FAS", styleRef = 'font_italic') %>%
add_footnote('Источник: Перечень 16.3; Перечень 16.33') %>%
# The CAT1 variable holds the category name. We want those
# names to appear in the CAT2 column as section headers,
# so we hide CAT1. We also hide the helper columns SECTORD1
# and modelval (whose values we place into other cells):
define_cols(c(CAT1, SECTORD1, modelval), isVisible = F) %>%
# First value of each CAT1 group becomes an extra header row:
compute_cols(
# built-in helper: first value within a group
# (can also define composite groups from several variables)
firstOf(CAT1),
# Insert a bold row above with the value from CAT1
c_addrow('above',
value_from = CAT1,
styleRef = 'font_bold')
) %>%
## Indent CAT2 values to the right relative to the header.
## This can be done in two ways...
# Method 1: via a per-row rule:
# compute_cols(!is.na(CAT2), c_style(CAT2, 'indent_1'))
# Method 2: by setting the column style directly.
# Method 2 is preferable here because we apply the same
# style to every value in the column; doing it per-row via
# compute_cols creates unnecessary rendering overhead.
# We also define the column label and header style:
define_cols(CAT2,
label = 'Параметр<br> Статистика',
valueStyleRef = 'indent_1',
labelStyleRef = 'text_left') %>%
# Labels for the remaining treatment-arm columns:
define_cols(c(RPH104, PLCB, TOTAL),
label = c(
paste0('Drug-001', '<br>(N=', drg_N, ')'),
paste0('Плацебо', '<br>(N=', plcb_N, ')'),
paste0('Всего', '<br>(N=', total_N, ')')
),
# indent numbers from cell borders
valueStyleRef = 'indent_1',
colWidth = '15%'
) %>%
define_cols(everything(), valueStyleRef = 'fs_8') %>%
# Display modelval in a merged cell spanning RPH104 + PLCB:
compute_cols(
!is.na(modelval),
# Merge cells; centre the value and draw a thin top border
# to show it relates to both treatment columns:
c_merge(c(RPH104, PLCB),
styleRef = f_combine('text_center', 'bt_th', 'i')),
c_style(CAT2, 'i'),
# Append modelval into the merged cell
c_glue(RPH104, 'after', glue_col = modelval)
) %>%
# Separators between groups
compute_cols(
lastOf(SECTORD1),
c_addrow('below', styleRef = 'row_h4')
) %>%
# Manual page break: first 3 categories on page one,
# the rest on page two:
compute_cols(
SECTORD1 == 4 & firstOf(SECTORD1),
c_pageBreak()
) %>%
set_document(
contentWidth = '75%',
# Place footnotes in the document footer section
footnotePlace = 'doc_footer',
# Use an alternative embedded template
docTemplate = 'Classic_landscape_times'
)
create_report(spec_dm_01) %>% write_doc("example_02_demog")Adverse Event (AE) frequency tables are a staple of clinical safety reporting. They often have many columns (treatment periods, follow-up windows, overall totals) arranged under multi-level spanning headers.
This example demonstrates:
add_span_header() with stubOrder to stack
period sub-headers, a follow-up banner, and a top-level drug-arm
header.isID = TRUE — marks columns that
should repeat on every page when the table spans multiple pages.# A tibble: 18 × 17
SOC_GROUP SOC_PT SEVERITY TRT_N TRT_E FU_0_6_N FU_0_6_E FU_GT6_N FU_GT6_E FU_0_8_N FU_0_8_E FU_GT8_N FU_GT8_E FU_TOTAL_N FU_TOTAL_E
<chr> <chr> <chr> <chr> <chr> <chr> <chr> <chr> <chr> <chr> <chr> <chr> <chr> <chr> <chr>
1 Any AE "Any A… "" 142 … 327 56 (31.… 71 93 (51.… 122 77 (42.… 103 75 (41.… 90 121 (67.2) 193
2 Any AE "" "1: Mil… 110 … 159 31 (17.… 34 59 (32.… 67 42 (23.… 49 46 (25.… 52 84 (46.7) 101
3 Any AE "" "2: Mod… 75 (… 94 17 (9.4) 18 25 (13.… 26 24 (13.… 25 18 (10.… 19 38 (21.1) 44
4 Any AE "" "3: Sev… 48 (… 51 12 (6.7) 12 17 (9.4) 18 20 (11.… 20 10 (5.6) 10 27 (15.0) 30
5 Any AE "" "4: Lif… 12 (… 13 4 (2.2) 4 7 (3.9) 7 5 (2.8) 5 6 (3.3) 6 11 (6.1) 11
6 Any AE "" "5: Dea… 9 (5… 10 3 (1.7) 3 4 (2.2) 4 4 (2.2) 4 3 (1.7) 3 7 (3.9) 7
7 SOC1 "" "" 117 … 206 34 (18.… 35 60 (33.… 71 47 (26.… 55 46 (25.… 51 82 (45.6) 106
8 SOC1 "" "1: Mil… 74 (… 101 18 (10.… 18 37 (20.… 39 23 (12.… 25 30 (16.… 32 52 (28.9) 57
9 SOC1 "" "2: Mod… 50 (… 57 9 (5.0) 9 15 (8.3) 15 15 (8.3) 15 9 (5.0) 9 23 (12.8) 24
10 SOC1 "" "3: Sev… 31 (… 33 6 (3.3) 6 11 (6.1) 12 13 (7.2) 13 5 (2.8) 5 16 (8.9) 18
11 SOC1 "" "4: Lif… 8 (4… 8 0 (0.0) 0 3 (1.7) 3 0 (0.0) 0 3 (1.7) 3 3 (1.7) 3
12 SOC1 "" "5: Dea… 7 (3… 7 2 (1.1) 2 2 (1.1) 2 2 (1.1) 2 2 (1.1) 2 4 (2.2) 4
13 SOC2 "" "" 89 (… 121 31 (17.… 36 44 (24.… 51 40 (22.… 48 34 (18.… 39 68 (37.8) 87
14 SOC2 "" "1: Mil… 54 (… 58 13 (7.2) 16 26 (14.… 28 20 (11.… 24 18 (10.… 20 38 (21.1) 44
15 SOC2 "" "2: Mod… 32 (… 37 9 (5.0) 9 10 (5.6) 11 10 (5.6) 10 9 (5.0) 10 18 (10.0) 20
16 SOC2 "" "3: Sev… 18 (… 18 6 (3.3) 6 6 (3.3) 6 7 (3.9) 7 5 (2.8) 5 11 (6.1) 12
17 SOC2 "" "4: Lif… 5 (2… 5 4 (2.2) 4 4 (2.2) 4 5 (2.8) 5 3 (1.7) 3 8 (4.4) 8
18 SOC2 "" "5: Dea… 2 (1… 3 1 (0.6) 1 2 (1.1) 2 2 (1.1) 2 1 (0.6) 1 3 (1.7) 3
# --- Build the table specification ---
spec <- create_table(tbl) %>%
# --- Custom styles ---
# Define a smaller font size for the dense numeric columns,
# and extra spacing after the title block for readability.
add_style("font_small", s_font(font_size = "8pt")) %>%
add_style("spacing_after10",
s_paragraph(spacing = s_spacing(after = "10pt"))
) %>%
# --- Title block ---
# Three-line title: table number, full description, and population label.
# toclevel = 1 adds it to the Table of Contents.
add_title(c(
"Table 11.32",
"Frequency of AEs by System Organ Class, and Severity.",
"SS Sub-population, Primary Enrolment into OLE"
), toclevel = 1, styleRef = "spacing_after10") %>%
# --- Page layout ---
# Dynamic page numbering in the footer
add_footer("", "Page {PAGE} of {NUMPAGES}", "") %>%
# Landscape A4 with tight margins — necessary to fit 16 columns
set_page_style(
page = p_page(
size = "A4",
orientation = "landscape",
margins = p_margins(
top = "12mm",
bottom = "12mm",
left = "5mm",
right = "5mm",
header = "8mm",
footer = "8mm"
)
)
) %>%
# --- Column definitions ---
# Hide SOC_GROUP — it is used only by compute_cols() to insert
# bold SOC header rows and trigger page breaks between groups.
define_cols(SOC_GROUP, isVisible = FALSE) %>%
# SOC / Preferred Term: left-aligned, repeats on page breaks (isID = TRUE)
# so readers always know which SOC they are looking at.
define_cols(SOC_PT,
label = "MedDRA SOC",
isID = TRUE,
labelStyleRef = "text_left",
valueStyleRef = f_combine("text_left", "font_small"),
colWidth = "18%"
) %>%
# Severity column: also repeats on continuation pages.
define_cols(SEVERITY,
label = "Severity",
isID = TRUE,
labelStyleRef = "text_left",
valueStyleRef = f_combine("text_left", "font_small"),
colWidth = "13%"
) %>%
# All 14 numeric columns share the same layout: centred, small font,
# equal width. The label vector alternates "n (%)" and "E" (events).
define_cols(
c(
TRT_N, TRT_E,
FU_0_6_N, FU_0_6_E,
FU_GT6_N, FU_GT6_E,
FU_0_8_N, FU_0_8_E,
FU_GT8_N, FU_GT8_E,
FU_TOTAL_N, FU_TOTAL_E,
GRAND_N, GRAND_E
),
label = rep(c("n (%)", "E"), 7),
#labelStyleRef = f_combine("text_center","to_90"),
labelStyleRef = "text_center",
valueStyleRef = f_combine("text_center", "font_small"),
colWidth = "4.3%"
) %>%
# --- Multi-level spanning headers (3 tiers) ---
# Spanning headers group columns visually. stubOrder controls the
# vertical stacking order (1 = closest to column labels, 3 = top).
# Tier 1 (stubOrder = 1): individual period sub-headers
add_span_header(
cols = c(TRT_N, TRT_E),
label = "Treatment Period<br>n (%)",
stubOrder = 1
) %>%
add_span_header(
cols = c(GRAND_N, GRAND_E),
label = "Overall<br>n (%)",
stubOrder = 1
) %>%
add_span_header(
cols = c(FU_0_6_N, FU_0_6_E),
label = "0-6 wks after<br>last dose",
stubOrder = 1
) %>%
add_span_header(
cols = c(FU_GT6_N, FU_GT6_E),
label = ">6 wks after<br>last dose",
stubOrder = 1
) %>%
add_span_header(
cols = c(FU_0_8_N, FU_0_8_E),
label = "0-8 wks after<br>last dose",
stubOrder = 1
) %>%
add_span_header(
cols = c(FU_GT8_N, FU_GT8_E),
label = ">8 wks after<br>last dose",
stubOrder = 1
) %>%
add_span_header(
cols = c(FU_TOTAL_N, FU_TOTAL_E),
label = "Total",
stubOrder = 1
) %>%
# Tier 2 (stubOrder = 2): groups all follow-up sub-periods under one
# banner, making it clear that the five sub-columns all belong to
# the Safety Follow-up Period.
add_span_header(
cols = c(FU_0_6_N, FU_0_6_E, FU_GT6_N, FU_GT6_E,
FU_0_8_N, FU_0_8_E, FU_GT8_N, FU_GT8_E, FU_TOTAL_N, FU_TOTAL_E),
label = "Safety Follow-up Period<br>n (%)",
stubOrder = 2
) %>%
# Tier 3 (stubOrder = 3): top-level drug arm header spanning all
# numeric columns. The label is a two-element vector (drug name + N).
add_span_header(
cols = c(
TRT_N, TRT_E,
FU_0_6_N, FU_0_6_E, FU_GT6_N, FU_GT6_E,
FU_0_8_N, FU_0_8_E, FU_GT8_N, FU_GT8_E,
FU_TOTAL_N, FU_TOTAL_E,
GRAND_N, GRAND_E
),
label = c("DrugX", sprintf("N=%d", N)),
stubOrder = 3) %>%
# --- Conditional row actions ---
# Insert a bold SOC header row above the first row of each SOC group.
# The "Any AE" group already has its own label in the data, so we skip it.
compute_cols(
firstOf(SOC_GROUP) & SOC_GROUP != "Any AE",
c_addrow("above", value_from = SOC_GROUP, styleRef = "font_bold")
) %>%
# Force a page break before SOC1 so the "Any AE" summary stands alone
# on the first page and per-SOC detail starts on a fresh page.
compute_cols(
SOC_GROUP == "SOC1" & firstOf(SOC_GROUP),
c_pageBreak()
) %>%
# Bold the SOC-level totals: rows where SEVERITY is blank represent
# the overall count for that SOC (not broken down by severity grade).
compute_cols(
SEVERITY == "",
c_style(c(SOC_PT, SEVERITY), styleRef = "font_bold")
) %>%
# Use a regulatory-style template (Arial font, conservative borders)
set_document(docTemplate = 'Regulatory_Arial')
## Write the document
create_report(spec) %>% write_doc("example_03_ae")Data listings present individual patient records with minimal aggregation. They often run to hundreds of pages, so navigation features become essential. This example demonstrates:
isGrouping = TRUE — marks columns as
grouping variables. When grouping columns change value, ksTFL inserts a
page break and generates a new sub-entry in the Table of Contents.#ByGroup1,
#ByGroup2, etc. are placeholders that get replaced with the
current group values, producing “Subject: 01001, Sex: M, Age (years):
26” automatically.compute_cols() flags pH values above 5.5 in red with a
checkmark symbol.to_90 rotates
headers 90° to save horizontal space for the many narrow lab-parameter
columns.# A tibble: 550 × 16
ID AGE SEX TRT VISIT DATE ALT ALTNR AST ASTNR BILI BILINR HGB HGBNR PH WBCU
1 SUBJ001 45 M Drug A Baseline 2024-08-02 162.6 high 62.9 high 0.78 normal 14.1 normal 6.6 -
2 SUBJ001 45 M Drug A Visit 1 2024-08-14 89.2 high 153.5 high 1.12 normal 13.7 normal 6.4 +++
3 SUBJ001 45 M Drug A Visit 2 2024-08-30 154.3 high 110.7 high 0.97 normal 13.9 normal 6.8 -
4 SUBJ001 45 M Drug A Visit 3 2024-09-12 171.4 high 116.1 high 0.69 normal NA <NA> 5.5 ++
5 SUBJ001 45 M Drug A Visit 4 2024-09-29 203.1 high 69.0 high 0.37 normal 12.9 normal 6.2 -
6 SUBJ001 45 M Drug A Visit 5 2024-10-22 231.4 high 65.0 high 0.54 normal 13.9 normal 6.4 +
7 SUBJ001 45 M Drug A Visit 6 2024-11-25 82.3 high 104.7 high 1.91 high 11.9 low 6.6 -
8 SUBJ001 45 M Drug A Visit 7 2024-12-22 145.7 high 64.9 high 0.80 normal 13.5 normal 5.5 -
9 SUBJ001 45 M Drug A Visit 8 2025-01-16 69.8 high 64.4 high 0.96 normal 14.1 normal 5.1 -
10 SUBJ001 45 M Drug A Visit 9 2025-02-16 189.4 high 58.3 high 0.68 normal 15.3 normal 6.9 +
11 SUBJ001 45 M Drug A Visit 10 2025-03-16 167.7 high 99.8 high 1.34 high 13.7 normal 5.5 -
12 SUBJ002 39 F Drug A Baseline 2024-01-28 34.7 normal 24.5 normal 0.80 normal 17.4 high 6.4 -
13 SUBJ002 39 F Drug A Visit 1 2024-02-13 17.1 normal 22.0 normal 0.50 normal 12.4 normal 5.6 -
14 SUBJ002 39 F Drug A Visit 2 2024-02-26 31.5 normal 19.3 normal 0.89 normal 13.4 normal 6.3 +
15 SUBJ002 39 F Drug A Visit 3 2024-03-10 42.0 high 11.9 normal 0.52 normal 13.0 normal 6.8 -
spec_lbl_01 <- create_table(lab_listing) %>%
# --- Title and dynamic subtitle ---
# toclevel = 1 creates the top-level TOC entry for this listing.
add_title(c("Listing 16.1", "Laboratory Data"), toclevel = 1) %>%
# Dynamic subtitle: #ByGroup1/2/3 are replaced at render time with the
# current values of the grouping columns (ID, AGE, SEX).
# toclevel = 2 creates a nested TOC entry per subject.
add_subtitle(
"Subject: #ByGroup1, Sex: #ByGroup3, Age (years): #ByGroup2",
toclevel = 2
) %>%
add_footnote('H, L - Value Outside Normal Ranges') %>%
# --- Column definitions ---
# Hide subject-level columns and mark them as grouping variables.
# isGrouping = TRUE triggers automatic page breaks when any of these
# columns change value, and populates the #ByGroupN placeholders.
define_cols(
c(ID, SEX, AGE),
isVisible = FALSE,
isGrouping = TRUE
) %>%
# Lab parameter columns: rotated 90° headers (to_90), left-aligned,
# top-aligned (va_t).
define_cols(
c(-TRT, -VISIT, -DATE),
labelStyleRef = f_combine(
'al', 'va_t', 'to_90'
)
) %>%
# Identifier columns: Treatment, Visit
define_cols(c(TRT, VISIT, DATE),
label = c('Treatment', 'Visit', 'Date'),
valueStyleRef = 'i' # Italicise Treatment and Visit values for visual distinction
) %>%
#Hide Normal Ranges columns - we will use them to mark the lab value itself
define_cols( ends_with('NR'), isVisible = FALSE ) %>%
# Lab parameter columns (positions 7-15): narrow equal
# widths, descriptive labels
define_cols(c(ALT, AST, BILI, HGB, PH, WBCU),
colWidth = '7%', #set equal width for all result columns
label = c('ALT (U/L)', 'AST (U/L)',
'Bilirubin (g/dL)', 'Hemoglobin (g/dL)',
'pH', 'Leukocytes, urine (/HPF)'
),
missings = 'NC' #defines how to report NA values
) %>%
# --- Conditional formatting ---
# Flag abnormal ALT values: if ALTNR is Low append 'L' to the value,
# if is High append 'H' to the value
compute_cols(
(ALTNR == 'low') %>% replace_na(FALSE),
c_style(ALT, styleRef = 'fc_green'),
c_glue(ALT, 'after', text = '<sup>L</sup>')
) %>%
compute_cols(
(ALTNR == 'high') %>% replace_na(FALSE),
c_style(ALT, styleRef = 'fc_red'),
c_glue(ALT, 'after', text = '<sup>H</sup>')
) %>%
# Repeat the same trick with other result
compute_cols(
(ASTNR == 'low') %>% replace_na(FALSE),
c_style(AST, styleRef = 'fc_green'),
c_glue(AST, 'after', text = '<sup>L</sup>')
) %>%
compute_cols(
(ASTNR == 'high') %>% replace_na(FALSE),
c_style(AST, styleRef = 'fc_red'),
c_glue(AST, 'after', text = '<sup>H</sup>')
) %>%
compute_cols(
(HGBNR == 'low') %>% replace_na(FALSE),
c_style(HGB, styleRef = 'fc_green'),
c_glue(HGB, 'after', text = '<sup>L</sup>')
) %>%
compute_cols(
(HGBNR == 'high') %>% replace_na(FALSE),
c_style(HGB, styleRef = 'fc_red'),
c_glue(HGB, 'after', text = '<sup>H</sup>')
) %>%
compute_cols(
(BILINR == 'low') %>% replace_na(FALSE),
c_style(BILI, styleRef = 'fc_green'),
c_glue(BILI, 'after', text = '<sup>L</sup>')
) %>%
compute_cols(
(BILINR == 'high') %>% replace_na(FALSE),
c_style(BILI, styleRef = 'fc_red'),
c_glue(BILI, 'after', text = '<sup>H</sup>')
) %>%
# --- Document settings ---
set_document(
docTemplate = 'Default',
topEmptyLine = '6pt',
bottomEmptyLine = '6pt',
)
## Write the document with a Table of Contents page
create_report(spec_lbl_01) %>% write_doc("example_04_list", toc = TRUE)When a table has too many columns to fit on one page, ksTFL can
automatically split the columns across multiple pages. The
isColBreak parameter on define_cols() tells
the engine where to start a new column page. Columns marked with
isID = TRUE repeat on every column-page, ensuring the
reader always sees the identifying context.
For example, adding a column break at PH splits the
listing into two column groups — Bilirubin through Nitrites on the first
page, and pH through White Blood Cells on the second:
ksTFL is not limited to tables. The create_figure()
function wraps a ggplot2 object (or an image file path) into a
TFL_spec, which can then receive titles, subtitles,
footnotes, and document settings just like a table.
The real power shows when you combine multiple specs into a single
document. create_report() accepts any number of
TFL_spec objects — tables, figures, and text — and merges
them into one report. When write_doc() is called with
toc = TRUE, a Table of Contents is generated automatically
from the toclevel values set in titles.
This example creates three ggplot2 figures and writes them into a single landscape document with a TOC page.
library(ggplot2)
# --- Figure 1: Fuel efficiency scatter plot from mtcars ---
# A straightforward scatter plot coloured by cylinder count.
t.fig <- ggplot(mtcars, aes(x = wt, y = mpg, colour = factor(cyl))) +
geom_point(size = 3, alpha = 0.8) +
scale_colour_manual(
name = "Cylinders",
values = c("4" = "#2166AC", "6" = "#F4A582", "8" = "#D6604D")
) +
labs(
x = "Weight (1000 lbs)",
y = "Miles per Gallon"
) +
theme_bw(base_size = 11) +
theme(legend.position = "bottom")
# Wrap the ggplot in a figure spec, then add titles and footnotes.
# toclevel = 1 adds this figure to the TOC.
t.fig.spec <- t.fig %>% create_figure() %>%
add_title(
c("Study Motor Trend",
"Figure 1: Fuel Efficiency by Vehicle Weight"),
toclevel = 1
) %>%
add_subtitle("All vehicles, 1974") |>
add_footnote("Source: 1974 Motor Trend US magazine (n = 32 vehicles).")
# figureScaleMode = "fitPage" scales the image to fill the available area.
t.fig.spec <- t.fig.spec %>%
set_document(figureScaleMode = "fitPage",
docTemplate = "Classic_landscape_times")
# --- Figure 2: Iris petal dimensions by species (violin + jitter) ---
# Violin plots show the distribution shape; jittered points show
# individual observations. Legend is turned off because species
# identity is clear from the x-axis labels.
t.fig2 <- ggplot(iris, aes(x = Species, y = Petal.Length, fill = Species)) +
geom_violin(alpha = 0.4, colour = NA) +
geom_jitter(aes(colour = Species), width = 0.15, size = 1.5, alpha = 0.7) +
scale_fill_manual(values = c("setosa" = "#66C2A5", "versicolor" = "#FC8D62",
"virginica" = "#8DA0CB")) +
scale_colour_manual(values = c("setosa" = "#66C2A5", "versicolor" = "#FC8D62",
"virginica" = "#8DA0CB")) +
labs(x = NULL, y = "Petal Length (cm)") +
theme_minimal(base_size = 11) +
theme(legend.position = "none")
t.fig.spec2 <- t.fig2 %>% create_figure() %>%
add_title(
c("Study Iris",
"Figure 2: Petal Length Distribution by Species"),
toclevel = 1
) %>%
add_subtitle("Anderson's Iris data set (n = 150)") |>
add_footnote(
"Each point represents one flower. Violin width shows density."
) %>%
set_document(figureScaleMode = "fitPage",
docTemplate = "Classic_landscape_times")
# --- Figure 3: Displacement vs horsepower (bubble + LOESS smooth) ---
# Bubble size encodes quarter-mile time; fill colour encodes transmission
# type (automatic vs manual). A LOESS curve with 95% CI ribbon shows
# the overall trend.
t.fig3 <- ggplot(mtcars, aes(x = disp, y = hp)) +
geom_smooth(method = "loess", formula = y ~ x,
se = TRUE, colour = "#B2182B", fill = "#FDDBC7", alpha = 0.3) +
geom_point(aes(size = qsec, fill = factor(am)),
shape = 21, alpha = 0.75, colour = "grey30") +
scale_fill_manual(name = "Transmission",
values = c("0" = "#4393C3", "1" = "#D6604D"),
labels = c("0" = "Automatic", "1" = "Manual")) +
scale_size_continuous(name = "1/4 Mile Time (s)", range = c(2, 8)) +
labs(x = "Displacement (cu. in.)", y = "Horsepower") +
theme_bw(base_size = 11) +
theme(legend.position = "bottom",
legend.box = "vertical",
legend.margin = margin(t = 2, b = 2),
legend.spacing.y = unit(2, "pt"))
t.fig.spec3 <- t.fig3 %>% create_figure() %>%
add_title(c("Study Motor Trend",
"Figure 3: Displacement vs Horsepower"), toclevel = 1) %>%
add_subtitle("Bubble size = quarter-mile time; colour = transmission type") |>
add_footnote(
paste("LOESS curve with 95% CI shown in red.",
"Source: 1974 Motor Trend US magazine.")
) %>%
set_document(figureScaleMode = "fitPage",
docTemplate = "Classic_landscape_times")
# --- Combine all three figures into a single document ---
# create_report() accepts any number of TFL_spec objects.
# write_doc() with toc = TRUE generates a Table of Contents from
# the toclevel values set in each spec's title.
t.fig.report <- create_report(t.fig.spec, t.fig.spec2, t.fig.spec3)
write_doc(t.fig.report, "example_05_figures_single_doc_toc", toc = TRUE)The resulting document contains a TOC page listing all three figures,
followed by one page per figure. Each figure fills the landscape page
thanks to figureScaleMode = "fitPage".
In clinical reporting, you often need to place a summary table
directly under a figure (e.g., a PK concentration-time plot followed by
a summary statistics table). This pattern uses two specs — one for the
figure and one for the table — combined in a single report with
continuousSection = TRUE on both to suppress page breaks
between them.
Key design principles:
continuousSection = TRUE to flow
together without a page break.figureHeight parameter to adjust the
plot size if needed.> pk
# A tibble: 21 × 3
TIME TRT CONC
<dbl> <chr> <dbl>
1 0 Placebo 0
2 0.5 Placebo 14
3 1 Placebo 25
4 2 Placebo 20
5 4 Placebo 11
6 8 Placebo 6
7 12 Placebo 3
8 0 Low Dose 0
9 0.5 Low Dose 22
10 1 Low Dose 39
# ℹ 11 more rows
> summary_tbl
# A tibble: 3 × 3
TRT Cmax Tmax
<chr> <dbl> <dbl>
1 High Dose 52 1
2 Low Dose 39 1
3 Placebo 25 1
p <- ggplot2::ggplot(pk, ggplot2::aes(TIME, CONC, colour = TRT, shape = TRT)) +
ggplot2::geom_line(linewidth = 0.9) +
ggplot2::geom_point(size = 2.8) +
ggplot2::scale_x_continuous(breaks = c(0, 0.5, 1, 2, 4, 8, 12)) +
ggplot2::labs(x = "Time (h)", y = "Concentration (ng/mL)") +
ggplot2::theme_bw(base_size = 11) +
ggplot2::theme(legend.position = "bottom")
spec_fig <- create_figure(p) %>%
add_title(c("Figure S3.1", "Mean PK Concentration–Time Profile"), toclevel = 1) %>%
add_subtitle("PK Analysis Set") %>%
set_document(continuousSection = TRUE, figureHeight = '3in')
spec_tbl <- create_table(summary_tbl) %>%
define_cols(TRT, label = "Treatment", isID = TRUE, colWidth = "40%") %>%
define_cols(Cmax, label = "C[max]", type = "numeric", format = "%.1f", valueStyleRef = "text_center") %>%
define_cols(Tmax, label = "T[max] (h)", type = "numeric", format = "%.1f", valueStyleRef = "text_center") %>%
add_footnote("C[max] = maximum concentration; T[max] = time of C[max].") %>%
set_document(continuousSection = TRUE)
create_report(spec_fig, spec_tbl) %>%
write_doc("table_under_figure", toc = TRUE)If you want the table to have its own title (in addition to the figure), simply add a title to the table spec:
spec_tbl_titled <- create_table(summary_tbl) %>%
add_title(c("Table S3.1", "PK Summary Parameters"), toclevel = 1) %>%
define_cols(TRT, label = "Treatment", isID = TRUE, colWidth = "40%") %>%
define_cols(Cmax, label = "C[max]", type = "numeric", format = "%.1f", valueStyleRef = "text_center") %>%
define_cols(Tmax, label = "T[max] (h)", type = "numeric", format = "%.1f", valueStyleRef = "text_center") %>%
add_footnote("C[max] = maximum concentration; T[max] = time of C[max].") %>%
set_document(continuousSection = TRUE)
create_report(spec_fig, spec_tbl_titled) %>%
write_doc("table_under_figure_titled", toc = TRUE)This creates two separate entries in the Table of Contents: one for the figure and one for the table.
If the combined height of the figure, table, and all titles/footnotes
exceeds the page height, Word will force an automatic page break between
them. To keep them together on one page, reduce the
figureHeight:
spec_fig_compact <- create_figure(p) %>%
add_title(c("Figure S3.1", "Mean PK Concentration–Time Profile"), toclevel = 1) %>%
add_subtitle("PK Analysis Set") %>%
# Reduce from 3in to 2in to leave more room for the table on the same page
set_document(continuousSection = TRUE, figureHeight = '2in')
create_report(spec_fig_compact, spec_tbl) %>%
write_doc("table_under_figure_compact", toc = TRUE)Alternatively, use figureScaleMode = "fitWidth" to scale
the figure proportionally to the available width while respecting a
height constraint.
Sometimes it is necessary to include a visual gap between spanning column groups so it is clear which columns belong to which header. This can be achieved by adding empty dummy columns to the dataset, but it can also be done by using a combination of built-in atomic styles. Consider the following dataset:
PARAM STAT TRT_A1 TRT_B1 TRT_A2 TRT_B2
1 Age (years) Mean (SD) 45.2 (12.1) 46.8 (11.5) 45.2 (12.1) 46.8 (11.5)
2 Age (years) Median [Min, Max] 44.0 [22, 71] 46.0 [21, 69] 44.0 [22, 71] 46.0 [21, 69]
3 Weight (kg) Mean (SD) 78.3 (15.4) 80.1 (14.8) 78.3 (15.4) 80.1 (14.8)
4 Weight (kg) Median [Min, Max] 76.5 [48, 120] 79.0 [50, 118] 76.5 [48, 120] 79.0 [50, 118]
5 Height (cm) Mean (SD) 172.1 (9.8) 173.5 (10.2) 172.1 (9.8) 173.5 (10.2)
6 Height (cm) Median [Min, Max] 171.0 [150, 195] 173.0 [152, 198] 171.0 [150, 195] 173.0 [152, 198]
7 BMI (kg/m²) Mean (SD) 26.4 (4.2) 26.6 (3.9) 26.4 (4.2) 26.6 (3.9)
8 BMI (kg/m²) Median [Min, Max] 25.8 [18, 38] 26.1 [19, 37] 25.8 [18, 38] 26.1 [19, 37]
We want to add spanning headers ‘Group 1’ covering
TRT_A1 and TRT_B1, and ‘Group 2’ covering
TRT_A2 and TRT_B2.
If we do this in the usual way:
spec <- create_table(demo_data) |>
add_title("Table 14.1.1") |>
add_title("Summary of Demographic and Baseline Characteristics") |>
add_footer("Source: ADSL") |>
add_footnote("SD = Standard Deviation; BMI = Body Mass Index") %>%
define_cols(
c(PARAM, STAT, TRT_A1, TRT_B1, TRT_A2, TRT_B2),
label = c('Parameter', 'Statistics', 'Drug A', 'Drug B', 'Drug A', 'Drug B')
) %>%
define_cols(PARAM, dedupe = T) %>%
### Spanning headers
add_span_header(c(TRT_A1, TRT_B1), 'Group 1') %>%
add_span_header(c(TRT_A2, TRT_B2), 'Group 2', stubOrder = 1) The bottom border of the spanning header row will be a solid line, making it difficult to see which columns actually belong to which group:
Instead of adding a dummy column to the input dataframe between
TRT_B1 and TRT_A2 to separate them visually,
we can use built-in atomic styles to replace the cell bottom border with
a paragraph bottom border. The paragraph border only underlines the text
of each spanning header individually, creating a visible gap between the
two groups:
spec <- create_table(demo_data) |>
add_title("Table 14.1.1") |>
add_title("Summary of Demographic and Baseline Characteristics") |>
add_footer("Source: ADSL") |>
add_footnote("SD = Standard Deviation; BMI = Body Mass Index") %>%
define_cols(
c(PARAM, STAT, TRT_A1, TRT_B1, TRT_A2, TRT_B2),
label = c('Parameter', 'Statistics', 'Drug A', 'Drug B', 'Drug A', 'Drug B'),
labelStyleRef = 'bc_white' # drop all cell borders from column header row
) %>%
define_cols(PARAM, dedupe = T) %>%
add_span_header(c(TRT_A1, TRT_B1), 'Group 1',
# pb - paragraph bottom border (underlines the text only)
# bc_white - suppress cell borders (white, 0pt)
# brw_thick - 4pt white right border to create a gap before the next group
labelStyleRef = f_combine("pb", 'bc_white', 'brw_thick')
) %>%
add_span_header(c(TRT_A2, TRT_B2), 'Group 2', stubOrder = 1,
# same as above, but no thick right border needed on the last group
labelStyleRef = f_combine("pb", 'bc_white')
)With this approach the groups are visually separated from each other: