--- title: "Real Examples of Clinical Outputs using ksTFL" author: "ksTFL Team" output: rmarkdown::html_vignette: dev: pdf css: ksTFL-vignette.css resource_files: - example_01.pdf - example_01_navy.pdf - example_02_demog.pdf - example_03_ae.pdf - example_04_list.pdf - example_04_list_colbr.pdf - example_05_figures_single_doc_toc.pdf - example_03.1_table_under_figure.pdf - spanning_headers_gap.pdf vignette: > %\VignetteIndexEntry{Real Examples of Clinical Outputs using ksTFL} %\VignetteEncoding{UTF-8} %\VignetteEngine{knitr::rmarkdown} editor_options: markdown: wrap: 80 --- ```{r setup, include=FALSE} knitr::opts_chunk$set( comment = "", collapse = TRUE, eval = FALSE, dev = "png", dev.args = if (capabilities("cairo")) list(type = "cairo") else NULL ) options(pkgdown.internet = FALSE) ``` ksTFL logo ## Purpose 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. ## How examples are organised Every example follows the same structure: 1. **Input data** — the data frame that feeds `create_table()` or `create_figure()` 2. **Spec-building pipeline** — the ksTFL calls that define the document (with inline comments explaining each step) 3. **Rendered output** — the resulting document embedded as PDF 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: - [Getting Started](Getting_Started_with_ksTFL.html) for the pipeline and core concepts. - [Reporting Examples](Reporting_Examples_with_ksTFL.html) for smaller, feature-by-feature patterns. - [Styling Guide](Styling_Guide_with_ksTFL.html) and [Advanced StyleRows](Advanced_StyleRows.html) when you need styling or row action details. -------------------------------------------------------------------------------- ## Session setup (shared across examples) Every example in this vignette assumes the following session-wide settings are in place. They register default headers, footers, output paths, and footnote behavior once so the individual examples do not need to repeat them: ```{r session_setup, eval = FALSE} library(ksTFL) library(dplyr) tfl_reset_options() tfl_set_options( add_header(c("CRO Example LLC.", "CONFIDENTIAL", "Page {PAGE} of {NUMPAGES}")), add_header("Study: Miracle Drug 001"), add_footer(c("Showcase examples", "Program: inst/examples/showcase")), output_directory = '.', footnotePlace = "repeated" ) ``` -------------------------------------------------------------------------------- ## Example 1 — Demographics table with conditional formatting {#example-01} 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: - **Invisible helper columns** — `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. - **Spacer rows** — `c_addrow("below")` with `row_h4` adds visual separation between sections. ### Input data 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: ```{r example_01_data, eval = FALSE} 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 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 ``` ### Building the specification ```{r example_01_spec, eval = FALSE} 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
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
(N=160)", "Placebo
(N=158)", "Total
(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" ) ``` ### Render ```{r example_01_render, eval = FALSE} create_report(spec) %>% write_doc("example_01") ``` ### Rendered output [Open example_01.pdf](example_01.pdf) ### Switching style templates 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: ```{r example_01_navy, eval = FALSE} 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: [Open example_01_navy.pdf](example_01_navy.pdf) **Key take-aways from this example:** - **Invisible columns** — `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. - **Template switching** — `set_document(docTemplate = ...)` re-skins the entire output without modifying the spec pipeline. ## Example 2 — Demographics table (UTF-8 encoding support) 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. ### Input data ``` # A tibble: 32 × 7 CAT1 CAT2 RPH104 PLCB TOTAL modelval SECTORD1 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 ``` ### Code 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: ```{r example_02_render, eval = FALSE} ### 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 = 'Параметр
Статистика', valueStyleRef = 'indent_1', labelStyleRef = 'text_left') %>% # Labels for the remaining treatment-arm columns: define_cols(c(RPH104, PLCB, TOTAL), label = c( paste0('Drug-001', '
(N=', drg_N, ')'), paste0('Плацебо', '
(N=', plcb_N, ')'), paste0('Всего', '
(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") ``` ### Rendered output [Open example_02_demog.pdf](example_02_demog.pdf) ## Example 3 — Adverse Events table with complex spanning headers 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: - **Three-tier spanning headers** via `add_span_header()` with `stubOrder` to stack period sub-headers, a follow-up banner, and a top-level drug-arm header. - **Custom page layout** — landscape A4 with tight margins to fit 14 numeric columns plus two identifier columns. - **`isID = TRUE`** — marks columns that should repeat on every page when the table spans multiple pages. - **Conditional formatting** — bold SOC-level totals, inserted header rows per System Organ Class, and a forced page break between the "Any AE" summary and per-SOC detail. ### Input data ``` # 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 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 ``` ### Code ```{r example_03_render, eval = FALSE} # --- 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
n (%)", stubOrder = 1 ) %>% add_span_header( cols = c(GRAND_N, GRAND_E), label = "Overall
n (%)", stubOrder = 1 ) %>% add_span_header( cols = c(FU_0_6_N, FU_0_6_E), label = "0-6 wks after
last dose", stubOrder = 1 ) %>% add_span_header( cols = c(FU_GT6_N, FU_GT6_E), label = ">6 wks after
last dose", stubOrder = 1 ) %>% add_span_header( cols = c(FU_0_8_N, FU_0_8_E), label = "0-8 wks after
last dose", stubOrder = 1 ) %>% add_span_header( cols = c(FU_GT8_N, FU_GT8_E), label = ">8 wks after
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
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") ``` ### Rendered output [Open example_03_ae.pdf](example_03_ae.pdf) ## Example 4 — Data listing with automatic two-level TOC 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. - **Dynamic subtitles** — `#ByGroup1`, `#ByGroup2`, etc. are placeholders that get replaced with the current group values, producing "Subject: 01001, Sex: M, Age (years): 26" automatically. - **Two-level TOC** — the title and subtitle together create a nested TOC: listing title at level 1, per-subject entries at level 2. - **Value-dependent formatting** — `compute_cols()` flags pH values above 5.5 in red with a checkmark symbol. - **Rotated column labels** — `to_90` rotates headers 90° to save horizontal space for the many narrow lab-parameter columns. ### Input data ``` # 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 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 - ``` ### Code ```{r example_04_render, eval = FALSE} 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 = 'L') ) %>% compute_cols( (ALTNR == 'high') %>% replace_na(FALSE), c_style(ALT, styleRef = 'fc_red'), c_glue(ALT, 'after', text = 'H') ) %>% # 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 = 'L') ) %>% compute_cols( (ASTNR == 'high') %>% replace_na(FALSE), c_style(AST, styleRef = 'fc_red'), c_glue(AST, 'after', text = 'H') ) %>% compute_cols( (HGBNR == 'low') %>% replace_na(FALSE), c_style(HGB, styleRef = 'fc_green'), c_glue(HGB, 'after', text = 'L') ) %>% compute_cols( (HGBNR == 'high') %>% replace_na(FALSE), c_style(HGB, styleRef = 'fc_red'), c_glue(HGB, 'after', text = 'H') ) %>% compute_cols( (BILINR == 'low') %>% replace_na(FALSE), c_style(BILI, styleRef = 'fc_green'), c_glue(BILI, 'after', text = 'L') ) %>% compute_cols( (BILINR == 'high') %>% replace_na(FALSE), c_style(BILI, styleRef = 'fc_red'), c_glue(BILI, 'after', text = 'H') ) %>% # --- 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) ``` ### Rendered output [Open example_04_list.pdf](example_04_list.pdf) ### Splitting long tables across pages 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: ```{r example_04_colbreak, eval = FALSE} spec_lbl_02 <- spec_lbl_01 %>% # Add a column break at PH — all columns from PH onward move to a new page. # The Treatment, Visit, and Date columns (isID = TRUE) repeat automatically. define_cols(PH, isColBreak = TRUE) create_report(spec_lbl_02) %>% write_doc("example_04_list_colbr", toc = TRUE) ``` ### Split rendered output [Open example_04_list_colbr.pdf](example_04_list_colbr.pdf) ## Example 5 — Figures and combined multi-spec reports with TOC 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. ### Code ```{r example_05_render, eval = FALSE} 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) ``` ### Rendered output 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"`. [Open example_05_figures_single_doc_toc.pdf](example_05_figures_single_doc_toc.pdf) ## Example 6 - Table under the figure 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:** - **Figure subtitle** — by default now appears **above** the figure image, between the title and the plot. You can override this with template-level configuration or per-spec settings if needed. - **Continuous section breaks** — both the figure and table specs must have `continuousSection = TRUE` to flow together without a page break. - **Spacing and sizing** — you are responsible for ensuring the combined height of figure + table + titles/footnotes fits within the page. Use `figureHeight` parameter to adjust the plot size if needed. - **Table title** — you can omit the table title (letting the figure subtitle serve as visual context) or add an explicit title to the table spec for clarity. ### Data ``` > pk # A tibble: 21 × 3 TIME TRT CONC 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 1 High Dose 52 1 2 Low Dose 39 1 3 Placebo 25 1 ``` ### Code ```{r example03.1, eval=FALSE} 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) ``` ### Rendered output [Open table_under_figure.pdf](example_03.1_table_under_figure.pdf) ### Variation: adding an explicit table title If you want the table to have its own title (in addition to the figure), simply add a title to the table spec: ```{r example06_with_title, eval = FALSE} 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. ### Troubleshooting: When figure and table don't fit 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`: ```{r example06_smaller_figure, eval = FALSE} 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. ## Example 7 - Gap between Spanning Header Lines 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: ```{r example_07_basic, eval = FALSE} 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: ![Table with a solid bottom border on spanning header row, making group boundaries ambiguous](images/spanning_headers_gap.png) 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: ```{r example_07_styled, eval = FALSE} 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: [Open spanning_headers_gap.pdf](spanning_headers_gap.pdf)