---
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)
```

<img src="figures/ksTFL-logo.svg" alt="ksTFL logo" width="700"/>

## 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
   <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
```

### 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<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"
  )
```

### Render

```{r example_01_render, eval = FALSE}
create_report(spec) %>% write_doc("example_01")
```

### Rendered output

[Open example_01.pdf](example_01.pdf)

<iframe src="example_01.pdf" title="example_01.pdf preview" width="100%" height="600px" style="border: 1px solid #ccc;"></iframe>

### 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)

<iframe src="example_01_navy.pdf" title="example_01_navy.pdf preview" width="100%" height="600px" style="border: 1px solid #ccc;"></iframe>

**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
   <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
```

### 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 = 'Параметр<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")
```

### Rendered output

[Open example_02_demog.pdf](example_02_demog.pdf)

<iframe src="example_02_demog.pdf" title="example_02_demog.pdf preview" width="100%" height="600px" style="border: 1px solid #ccc;"></iframe>

## 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
   <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         
```

### 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<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")
```

### Rendered output

[Open example_03_ae.pdf](example_03_ae.pdf)

<iframe src="example_03_ae.pdf" title="example_03_ae.pdf preview" width="100%" height="600px" style="border: 1px solid #ccc;"></iframe>

## 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   <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 = '<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)

```

### Rendered output

[Open example_04_list.pdf](example_04_list.pdf)

<iframe src="example_04_list.pdf" title="example_04_list.pdf preview" width="100%" height="600px" style="border: 1px solid #ccc;"></iframe>

### 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)

<iframe src="example_04_list_colbr.pdf" title="example_04_list_colbr.pdf preview" width="100%" height="600px" style="border: 1px solid #ccc;"></iframe>

## 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)

<iframe src="example_05_figures_single_doc_toc.pdf" title="example_05_figures_single_doc_toc.pdf preview" width="100%" height="600px" style="border: 1px solid #ccc;"></iframe>

## 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
   <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
```

### 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)

<iframe src="example_03.1_table_under_figure.pdf" title="table_under_figure.pdf preview" width="100%" height="600px" style="border: 1px solid #ccc;"></iframe>

### 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)

<iframe src="spanning_headers_gap.pdf" title="spanning_headers_gap.pdf preview" width="100%" height="600px" style="border: 1px solid #ccc;"></iframe>


