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.
# Install from GitHub (requires remotes or pak)
remotes::install_github("humanpred/writetfl")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
)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 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.
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.
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)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
)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)
)
)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."
)
)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.
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
)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.
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.
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.
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.
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.