Skip to contents

Standardized table, figure, and listing output for clinical trial reporting.

writetfl produces multi-page PDF files from ggplot2 figures, data-frame tables, gt tables, rtables tables, flextable tables, table1 tables, and other grid content with the precise, composable page layouts required for clinical trial TFL deliverables and regulatory submissions. Each page is divided into up to five vertical sections — header, caption, content, footnote, and footer — whose heights are computed dynamically from live font metrics so that the content area always fills exactly the remaining space. Nothing ever overlaps.

The package is designed for clinical, regulatory, and technical reporting contexts where outer margins, annotation zones, and content areas must be independently sized and reproducible across many pages. It is equally suitable for any setting that demands consistent, pixel-precise page layout.


Installation

# Install from GitHub (requires remotes or pak)
remotes::install_github("humanpred/writetfl")

Quick start

Figures

library(writetfl)
library(ggplot2)

p <- ggplot(mtcars, aes(wt, mpg)) +
  geom_point() +
  labs(x = "Weight (1000 lb)", y = "Miles per gallon")

# Single figure — "Page 1 of 1" added automatically
export_tfl(p, file = "figure.pdf")

A multi-page report with a shared header and per-page captions:

pages <- list(
  list(
    content  = ggplot(mtcars, aes(wt, mpg)) + geom_point(),
    caption  = "Figure 1. Weight is negatively associated with fuel efficiency.",
    footnote = "n = 32 vehicles."
  ),
  list(
    content  = ggplot(mtcars, aes(hp, mpg)) + geom_point(),
    caption  = "Figure 2. Higher horsepower predicts lower fuel efficiency.",
    footnote = "Pearson r = -0.78."
  )
)

export_tfl(
  pages,
  file         = "report.pdf",
  header_left  = "Fuel Economy Analysis",
  header_right = format(Sys.Date(), "%d %b %Y"),
  header_rule  = TRUE,
  footer_rule  = TRUE
)

Grid grobs (e.g. from gridExtra) are also accepted as content, so you can mix figures and tables in one PDF:

library(gridExtra)

export_tfl(
  list(
    list(content = tableGrob(head(mtcars[, 1:5])),
         caption = "Table 1. Selected variables."),
    list(content = p,
         caption = "Figure 1. Weight vs MPG.")
  ),
  file        = "report.pdf",
  header_left = "Analysis Report",
  header_rule = TRUE
)

Data-frame tables

tfl_table() converts a data frame into a paginated table grob with automatic column-width sizing, word-wrapping, row and column pagination, and group-aware page breaks:

library(writetfl)
library(dplyr)

ae_summary <- data.frame(
  system_organ_class = c("Gastrointestinal", "Nervous system", "Skin"),
  n_subjects         = c(12L, 7L, 4L),
  pct                = c(24.0, 14.0, 8.0)
)

tbl <- tfl_table(
  ae_summary,
  col_labels = c(system_organ_class = "System Organ Class",
                 n_subjects = "n", pct = "(%)"),
  col_align  = c(system_organ_class = "left",
                 n_subjects = "right", pct = "right")
)

export_tfl(tbl,
  file        = "ae_summary.pdf",
  header_left = "Table 1. Adverse Events by System Organ Class",
  footnote    = "Percentages are based on the safety population (N = 50)."
)

Use dplyr::group_by() to designate row-header columns that repeat on every column-split page and suppress repeated values in consecutive rows:

pk_data |>
  group_by(visit) |>
  tfl_table(
    col_labels = c(visit = "Visit", treatment = "Treatment",
                   n = "n", mean_auc = "Mean AUC\n(ng·h/mL)")
  ) |>
  export_tfl(file = "pk_summary.pdf",
             header_left = "Table 2. PK Summary by Visit")

Page layout

┌─────────────────────────────────────────────────┐  ← page edge
│              (outer margin)                     │
│  ┌───────────────────────────────────────────┐  │
│  │  header_left  header_center  header_right │  │  header
│  │  ---------------------------------------- │  │  ← header_rule (optional)
│  │  caption                                  │  │  caption
│  │                                           │  │
│  │         content (fills remainder)         │  │  content
│  │                                           │  │
│  │  footnote                                 │  │  footnote
│  │  ---------------------------------------- │  │  ← footer_rule (optional)
│  │  footer_left  footer_center  footer_right │  │  footer
│  └───────────────────────────────────────────┘  │
│              (outer margin)                     │
└─────────────────────────────────────────────────┘

Absent sections and their padding gaps are suppressed entirely — no blank space is reserved for them.


Key features

Shared vs per-page arguments

Arguments passed via ... to export_tfl() apply to every page. An element in a page’s list always wins over the shared default.

export_tfl(
  pages,
  file        = "report.pdf",
  header_left = "Shared title",     # applies to all pages ...
  # page list can override:  list(content = p, header_left = "Override")
)

Priority order (highest first): page list element → ... argument → function default.

Automatic page numbering

page_num (default "Page {i} of {n}") populates footer_right unless a footer_right value is already set. Use a glue template or set to NULL to disable.

export_tfl(plots, file = "report.pdf", page_num = "{i} / {n}")
export_tfl(plots, file = "report.pdf", page_num = NULL)

Separator rules

header_rule and footer_rule draw a line inside the padding gap between sections. They accept FALSE (off), TRUE (full-width), a numeric fraction of viewport width, or a custom linesGrob.

export_tfl(p, file = "ruled.pdf",
  header_left = "Title",
  header_rule = TRUE,
  footer_rule = 0.5          # half-width, centred
)

Typography

Pass a single gpar() to style all annotation text, or a named list for section- or element-level control. Resolution priority: element > section > global.

export_tfl(
  p,
  file        = "styled.pdf",
  header_left = "Protocol XY-001",
  caption     = "Figure 1. Results.",
  gp = list(
    header       = grid::gpar(fontsize = 11, fontface = "bold"),
    header_right = grid::gpar(fontsize =  9, col = "gray50"),
    caption      = grid::gpar(fontsize =  9, fontface = "italic"),
    footer       = grid::gpar(fontsize =  8)
  )
)

Multi-line text

Any text argument accepts a character vector (joined with "\n") or a string with embedded newlines. Section height adjusts automatically.

export_tfl(p, file = "multiline.pdf",
  caption = c(
    "Figure 1. Fuel efficiency declines with vehicle weight.",
    "Data: Motor Trend (1974). Points represent individual models."
  )
)

Layout safety checks

Before any drawing occurs, writetfl validates the layout and reports all problems at once:

  • Overlap detection — if left and right header or footer text collide, the call errors. Near-misses within overlap_warn_mm millimetres (default 2) trigger a warning; set overlap_warn_mm = NULL to disable.
  • Minimum content height — if the content area would be squeezed below min_content_height (default unit(3, "inches")), the call errors with the computed and minimum heights.

Preview mode

export_tfl_page(..., preview = TRUE) draws to the currently open device without opening or closing a PDF. Use this in RStudio or Positron to iterate on layout interactively before writing the final file.

library(grid)
export_tfl_page(
  x           = list(content = p),
  header_left = "Draft",
  caption     = "Figure 1.",
  header_rule = TRUE,
  preview     = TRUE
)

Paginated data-frame tables

tfl_table() builds a table configuration object and export_tfl() paginates it automatically across as many pages as needed:

  • Column widths — auto-sized from content, fixed (unit()), or relative-weight numeric. A floor is applied via min_col_width.
  • Word wrapping — set wrap_cols to a column name (or TRUE for all data columns) to reflow long text within a fixed column width.
  • Row pagination — rows are split across pages with optional continuation markers (row_cont_msg). Groups are kept together where possible; a warning is issued when a group must be split.
  • Column pagination — if total column width exceeds the page, columns are split across pages. Set balance_col_pages = TRUE to distribute columns evenly rather than packing left-to-right.
  • Group columns — use dplyr::group_by() before passing to tfl_table(). Group columns repeat as row headers on every column-split page; repeated values in consecutive rows are suppressed by default.
  • Typography and spacingcell_padding controls space inside each cell (vertical and horizontal independently); line_height controls inter-line spacing in wrapped cells. Both can be overridden per section via gp.
  • Column specs — use tfl_colspec() for per-column control of label, width, alignment, and wrapping in a single object.

gt tables

Pass a gt_tbl object directly to export_tfl(). Annotations (title, subtitle, source notes, footnotes) are extracted into writetfl’s header/footer zones to avoid duplication. Tables that exceed the page height are automatically paginated with row group boundaries respected. All gt features are preserved, including cell formatting, spanning headers, stub columns, sub_*(), text_transform(), tab_options(), locale, and more.

library(gt)

tbl <- gt(head(mtcars, 10)) |>
  tab_header(title = "Motor Trend Cars", subtitle = "First 10 rows") |>
  tab_source_note("Source: Motor Trend (1974).")

export_tfl(tbl, file = "gt_table.pdf",
  header_left = "Appendix A",
  header_rule  = TRUE,
  footer_rule  = TRUE
)

A list of gt_tbl objects produces a multi-page PDF with one table per page. See vignette("v05-gt_tables") for full details.

rtables tables

Pass an rtables VTableTree object directly to export_tfl(). Main title and subtitles map to writetfl’s caption; main footer and provenance footer map to the footnote. The table body is rendered as monospace text via toString(). When a table is too tall for a single page, rtables’ built-in paginate_table() splits it across pages respecting row group boundaries.

library(rtables)

lyt <- basic_table(
  title       = "Iris Sepal Length by Species",
  subtitles   = "Mean values",
  main_footer = "Source: Anderson (1935)."
) |>
  split_cols_by("Species") |>
  analyze("Sepal.Length", mean)

tbl <- build_table(lyt, iris)

export_tfl(tbl, file = "rtables_table.pdf",
  header_left = "Study Report",
  header_rule = TRUE,
  footer_rule = TRUE
)

Font parameters (rtables_font_family, rtables_font_size, rtables_lineheight) can be passed via .... A list of VTableTree objects produces a multi-page PDF. See vignette("v06-rtables") for full details.

flextable tables

Pass a flextable object directly to export_tfl(). Captions (from set_caption()) are extracted into writetfl’s caption zone. Footer rows (from footnote() or add_footer_lines()) are extracted into writetfl’s footnote zone. The table is rendered via gen_grob() with all formatting preserved — borders, merged cells, colours, themes, and more.

library(flextable)

ft <- flextable(head(iris, 10)) |>
  set_caption("Iris Measurements") |>
  add_footer_lines("Source: Anderson (1935).")

export_tfl(ft, file = "flextable_table.pdf",
  header_left = "Study Report",
  header_rule = TRUE,
  footer_rule = TRUE
)

A list of flextable objects produces a multi-page PDF. See vignette("v07-flextable") for full details.

table1 tables

Pass a table1 object directly to export_tfl(). Column labels, bold variable names, indented summary statistics, and stratification headers are preserved via t1flex() conversion. Caption and footnote are extracted into writetfl’s annotation zones. Pagination is group-aware: variable labels and their summary rows are kept together across page breaks.

library(table1)

dat <- data.frame(
  age = rnorm(100, 50, 10),
  sex = sample(c("Male", "Female"), 100, replace = TRUE),
  trt = rep(c("Treatment", "Placebo"), each = 50)
)
label(dat$age) <- "Age (years)"
label(dat$sex) <- "Sex"

tbl <- table1(~ age + sex | trt, data = dat,
              caption = "Table 1. Baseline Demographics",
              footnote = "ITT Population")

export_tfl(tbl, file = "table1.pdf",
  header_left = "Study Report",
  header_rule = TRUE,
  footer_rule = TRUE
)

A list of table1 objects produces a multi-page PDF. See vignette("v08-table1") for full details.