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:
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_mmmillimetres (default 2) trigger a warning; setoverlap_warn_mm = NULLto disable. -
Minimum content height — if the content area would be squeezed below
min_content_height(defaultunit(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 viamin_col_width. -
Word wrapping — set
wrap_colsto a column name (orTRUEfor 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 = TRUEto distribute columns evenly rather than packing left-to-right. -
Group columns — use
dplyr::group_by()before passing totfl_table(). Group columns repeat as row headers on every column-split page; repeated values in consecutive rows are suppressed by default. -
Typography and spacing —
cell_paddingcontrols space inside each cell (vertical and horizontal independently);line_heightcontrols inter-line spacing in wrapped cells. Both can be overridden per section viagp. -
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.