Creating Tables with tfl_table
v02-tfl_table_intro.Rmdwritetfl can render data frames as paginated,
publication-quality tables inside multi-page PDFs. The two key functions
are tfl_table(), which builds a table configuration object
from a data frame, and export_tfl(), which renders it to a
PDF file.
If you are working with gt tables, see
vignette("v05-gt_tables") for direct gt integration via
export_tfl().
library(writetfl)
library(dplyr) # for group_by() examples
#>
#> Attaching package: 'dplyr'
#> The following objects are masked from 'package:stats':
#>
#> filter, lag
#> The following objects are masked from 'package:base':
#>
#> intersect, setdiff, setequal, unionBasic usage
Pass any data frame to tfl_table(), then hand the result
to export_tfl(). Page annotations, page dimensions, and the
output file all belong to export_tfl() — not to
tfl_table().
tbl <- tfl_table(head(mtcars, 20))
export_tfl(
tbl,
preview = TRUE,
header_left = "Table 1. Motor Trend Car Road Tests (first 20 rows)",
footer_left = "Source: Motor Trend (1974)",
header_rule = TRUE
)
tfl_table() returns a table configuration object. It
does not draw anything or open any device. All drawing happens inside
export_tfl().
Column labels
By default, column names are used as column headers. Supply
col_labels to override them — either as a named character
vector (match by column name) or as a positional vector the same length
as cols.
# Subset columns and rename them for the report
tbl <- tfl_table(
head(mtcars, 20)[, c("mpg", "cyl", "hp", "wt")],
col_labels = c(
mpg = "Miles/Gallon",
cyl = "Cylinders",
hp = "Horsepower",
wt = "Weight\n(1000 lb)" # \n produces a two-line header
)
)
export_tfl(
tbl,
preview = TRUE,
header_left = "Table 1. Selected Performance Metrics"
)
Embedding \n in a label creates a multi-line column
header. The header row height is sized automatically to fit the tallest
label.
Column widths
Three width modes are available and can be mixed freely within the same table.
| Mode | How to specify | Effect |
|---|---|---|
| Fixed | unit(1.5, "inches") |
Always exactly that width |
| Relative | Plain numeric, e.g. 2
|
Width proportional to remaining space after fixed columns are placed |
| Auto |
NULL (the default) |
Sized to the widest content, up to a per-column maximum |
tbl <- tfl_table(
head(mtcars, 20)[, c("mpg", "cyl", "hp", "wt", "gear")],
col_widths = list(
mpg = unit(1.2, "inches"), # fixed
cyl = 1, # relative (equal share of remaining space)
hp = 1, # relative (equal share)
wt = unit(1.4, "inches"), # fixed
gear = NULL # auto: sized to content
)
)
export_tfl(tbl, file = "column_widths.pdf")When relative widths are used, they are scaled proportionally among themselves after all fixed and auto columns have claimed their space.
Column alignment
Numeric columns default to right-aligned; character columns default
to left-aligned. Override per column with col_align.
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),
stringsAsFactors = FALSE
)
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,
preview = TRUE,
header_left = "Table 2. Adverse Events by System Organ Class",
footnote = "Percentages are based on the safety population (N = 50)."
)
Valid alignment values are "left", "right",
and "center".
Row grouping
Mark grouping columns with dplyr::group_by() before
passing the data to tfl_table(). Group columns must appear
first in the column list; they act as row headers and their values are
suppressed on repeated consecutive rows, giving the indented-group
appearance common in clinical tables.
# Demographic summary with visit and treatment group as row headers
pk_data <- data.frame(
visit = rep(c("Week 4", "Week 8", "Week 12"), each = 4),
treatment = rep(c("Placebo", "Active 10 mg", "Active 20 mg", "Active 40 mg"), 3),
n = c(48L, 50L, 49L, 51L, 45L, 47L, 48L, 50L, 41L, 43L, 44L, 46L),
mean_auc = c(120.4, 145.2, 178.9, 201.3,
118.7, 148.6, 185.2, 219.4,
115.1, 152.3, 191.7, 228.6),
sd_auc = c(18.2, 22.4, 27.6, 31.1,
17.9, 23.1, 28.4, 32.7,
17.1, 24.0, 29.2, 34.3),
stringsAsFactors = FALSE
)
tbl <- pk_data |>
group_by(visit) |>
tfl_table(
col_labels = c(
visit = "Visit",
treatment = "Treatment",
n = "n",
mean_auc = "Mean AUC\n(ng\u00b7h/mL)",
sd_auc = "SD"
),
col_widths = list(
visit = unit(1.0, "inches"),
treatment = unit(1.5, "inches"),
n = NULL,
mean_auc = unit(1.4, "inches"),
sd_auc = unit(0.8, "inches")
)
)
export_tfl(
tbl,
preview = TRUE,
header_left = "Table 3. PK Summary by Visit and Treatment",
footnote = "AUC = area under the concentration-time curve."
)
The suppress_repeated_groups argument (default
TRUE) controls whether repeated group values are hidden.
Set it to FALSE to show every row’s group value
explicitly.
Group values within a block are kept together on the same page wherever possible (see Multi-page tables below).
Multi-page tables
Row pagination
When a table has more rows than fit on one page,
tfl_table paginates automatically. Groups are kept
together: if the rows belonging to one group value do not fit on the
current page, the whole group moves to the next page.
Continuation markers are appended to the last column header on intermediate pages and to the first data row of a continuation page so the reader can follow the table across page breaks.
tbl <- iris |>
relocate(Species) |>
group_by(Species) |>
tfl_table(
col_labels = c(
Species = "Species",
Sepal.Length = "Sepal\nLength",
Sepal.Width = "Sepal\nWidth",
Petal.Length = "Petal\nLength",
Petal.Width = "Petal\nWidth"
),
row_cont_msg = c("(continued from previous page)", "(continued on next page)"),
col_cont_msg = "(continued)"
)
export_tfl(
tbl,
preview = c(1, 2),
header_left = "Table 4. Iris Measurements by Species",
header_rule = TRUE,
footer_rule = TRUE
)
#> Warning: Row 33 belongs to a group that spans more than one page. A
#> '(continued)' marker will be added at the boundary.
#> Warning: Row 64 belongs to a group that spans more than one page. A
#> '(continued)' marker will be added at the boundary.
#> Warning: Row 95 belongs to a group that spans more than one page. A
#> '(continued)' marker will be added at the boundary.
#> Warning: Row 126 belongs to a group that spans more than one page. A
#> '(continued)' marker will be added at the boundary.

Column pagination
If the total column width exceeds the printable area,
tfl_table splits the columns across additional pages.
Row-header columns (i.e., the grouped columns) are repeated at the left
of every column page so the reader always knows which group they are
in.
# Lab safety panel: 6 parameters × 13 timepoints — too wide for one page.
# Group by parameter so the parameter column repeats as a row-header on each
# column-split page.
lab_wide <- data.frame(
parameter = c("ALT (U/L)", "AST (U/L)", "ALP (U/L)",
"Total Bilirubin (mg/dL)", "Creatinine (mg/dL)", "eGFR (mL/min)"),
scr = c(28, 22, 74, 0.6, 0.92, 88),
bl = c(30, 24, 76, 0.7, 0.93, 87),
wk2 = c(33, 26, 79, 0.7, 0.95, 85),
wk4 = c(35, 28, 81, 0.8, 0.97, 83),
wk6 = c(36, 29, 83, 0.8, 0.99, 81),
wk8 = c(38, 30, 84, 0.9, 1.01, 79),
wk12 = c(36, 28, 82, 0.8, 1.02, 78),
wk16 = c(34, 27, 80, 0.8, 1.04, 77),
wk20 = c(33, 26, 78, 0.7, 1.05, 76),
wk24 = c(31, 25, 76, 0.7, 1.07, 75),
wk28 = c(30, 24, 75, 0.6, 1.08, 74),
wk32 = c(29, 23, 74, 0.6, 1.09, 73),
eot = c(28, 22, 73, 0.6, 1.10, 72),
stringsAsFactors = FALSE
)
tbl <- lab_wide |>
group_by(parameter) |>
tfl_table(
col_labels = c(
parameter = "Lab Parameter",
scr = "Screen-\ning",
bl = "Base-\nline",
wk2 = "Week 2",
wk4 = "Week 4",
wk6 = "Week 6",
wk8 = "Week 8",
wk12 = "Week 12",
wk16 = "Week 16",
wk20 = "Week 20",
wk24 = "Week 24",
wk28 = "Week 28",
wk32 = "Week 32",
eot = "End of\nTreatment"
)
)
export_tfl(
tbl,
preview = c(1, 2),
header_left = "Table 5. Mean Lab Safety Values by Timepoint",
header_rule = TRUE
)

By default allow_col_split = TRUE. Set it to
FALSE if you want an error rather than an automatic split —
useful during development to confirm that your column widths fit within
the target page dimensions.
# This will error if the columns are too wide for the page
tbl_no_split <- tfl_table(
mtcars,
allow_col_split = FALSE
)
export_tfl(tbl_no_split, preview = TRUE)
Balancing columns across pages
By default the greedy algorithm packs as many columns as possible
onto each page, which can leave the last page looking sparse — for
example, 8 columns on page 1 and only 2 on page 2. Set
balance_col_pages = TRUE to redistribute the data columns
so that each page carries approximately the same number.
The greedy pass still runs first to determine the minimum number of pages required. The data columns are then divided as evenly as possible across those pages (pages that cannot be divided exactly get one extra column on the earlier pages). Each candidate balanced group is verified to fit within the available width; if any group would overflow, the greedy layout is used as a fallback.
tbl_balanced <- lab_wide |>
group_by(parameter) |>
tfl_table(
col_labels = c(
parameter = "Lab Parameter",
scr = "Screen-\ning",
bl = "Base-\nline",
wk2 = "Week 2",
wk4 = "Week 4",
wk6 = "Week 6",
wk8 = "Week 8",
wk12 = "Week 12",
wk16 = "Week 16",
wk20 = "Week 20",
wk24 = "Week 24",
wk28 = "Week 28",
wk32 = "Week 32",
eot = "End of\nTreatment"
),
balance_col_pages = TRUE
)
export_tfl(
tbl_balanced,
preview = c(1, 2),
header_left = "Table 5b. Mean Lab Safety Values by Timepoint (balanced columns)",
header_rule = TRUE
)

Word-wrapping columns
wrap_cols accepts a named numeric vector of column
indices or names, specifying the maximum number of characters before
wrapping. This is useful for free-text columns (narrative descriptions,
verbatim terms) that would otherwise force very wide pages or illegible
small fonts.
ae_verbatim <- data.frame(
subject_id = c("001-001", "001-002", "001-003", "002-001", "002-002"),
ae_term = c(
"Nausea and vomiting, mild, considered possibly related to study treatment",
"Headache, moderate, considered unlikely related",
"Fatigue, mild, relationship to study drug uncertain",
"Abdominal pain, moderate, considered probably related",
"Dizziness, mild, considered possibly related"
),
onset_day = c(3L, 7L, 2L, 14L, 5L),
stringsAsFactors = FALSE
)
tbl <- tfl_table(
ae_verbatim,
col_labels = c(
subject_id = "Subject ID",
ae_term = "Adverse Event (Verbatim)",
onset_day = "Onset\n(Day)"
),
col_widths = list(
subject_id = unit(0.8, "inches"),
ae_term = unit(3.5, "inches"),
onset_day = NULL
),
wrap_cols = "ae_term"
)
export_tfl(
tbl,
preview = TRUE,
header_left = "Listing 1. Adverse Event Verbatim Terms",
header_rule = TRUE
)
Handling missing values
na_string controls how NA values are
displayed in the table. The default is "" (an empty cell).
Supply any character string to substitute a visible token.
labs_data <- data.frame(
subject_id = c("001", "001", "002", "002", "003"),
visit = c("Baseline", "Week 4", "Baseline", "Week 4", "Baseline"),
ALT = c(28, 31, NA, 45, 22),
AST = c(19, NA, 24, 38, 17),
stringsAsFactors = FALSE
)
tbl <- labs_data |>
group_by(subject_id) |>
tfl_table(
col_labels = c(
subject_id = "Subject",
visit = "Visit",
ALT = "ALT\n(U/L)",
AST = "AST\n(U/L)"
),
na_string = "NC" # NC = not collected
)
export_tfl(
tbl,
preview = TRUE,
header_left = "Table 6. Laboratory Values",
footnote = "NC = not collected."
)
Per-column specification with tfl_colspec()
For complex tables it can be cleaner to specify each column
separately using tfl_colspec() and collect the results into
a list. This avoids long parallel vectors for labels, widths, and
alignments.
pk_summary <- data.frame(
param = rep(c("Cmax", "AUC0-inf", "t1/2"), each = 3),
treatment = rep(c("Placebo", "Active 10 mg", "Active 20 mg"), 3),
geo_mean = c(0.00, 145.2, 210.8, 0.00, 4820, 7340, 0.00, 8.4, 9.1),
cv_pct = c(NA, 28.4, 31.2, NA, 22.7, 25.8, NA, 15.3, 17.9),
stringsAsFactors = FALSE
)
tbl <- pk_summary |>
group_by(param) |>
tfl_table(
cols = list(
tfl_colspec("param", label = "Parameter", width = unit(1.2, "inches"), align = "left"),
tfl_colspec("treatment", label = "Treatment", width = unit(1.5, "inches"), align = "left"),
tfl_colspec("geo_mean", label = "Geometric\nMean", width = unit(1.2, "inches"), align = "right"),
tfl_colspec("cv_pct", label = "CV%", width = unit(0.8, "inches"), align = "right")
),
na_string = "--"
)
export_tfl(
tbl,
preview = TRUE,
header_left = "Table 7. PK Parameters — Geometric Mean (CV%)",
footnote = c(
"CV% = coefficient of variation.",
"-- = not applicable (placebo)."
)
)
tfl_colspec() accepts col,
label, width, align,
wrap, and gp. It provides no functionality
beyond what the parallel-vector approach offers; the choice is
stylistic.
Typography with gp
The gp argument to tfl_table() controls
cell typography. Pass a single gpar() for a uniform style,
or a named list for per-section control. For a full reference of all
gp keys and their effects, see
vignette("v03-tfl_table_styling").
tbl <- tfl_table(
head(mtcars, 15)[, c("mpg", "cyl", "hp", "wt")],
col_labels = c(
mpg = "MPG",
cyl = "Cylinders",
hp = "Horsepower",
wt = "Weight"
),
gp = list(
header = gpar(fontsize = 9, fontface = "bold"),
body = gpar(fontsize = 9)
)
)
export_tfl(
tbl,
file = "typed_table.pdf",
gp = gpar(fontsize = 9) # page annotation text
)Page layout and annotations
Page dimensions, margins, header/footer text, separator rules, and
page numbering are all arguments to export_tfl(), not
tfl_table(). This keeps the table structure independent
from the output format, allowing the same tfl_table object
to be used with different page layouts.
tbl <- tfl_table(
head(iris, 30),
col_labels = c(
Species = "Species",
Sepal.Length = "Sepal\nLength",
Sepal.Width = "Sepal\nWidth",
Petal.Length = "Petal\nLength",
Petal.Width = "Petal\nWidth"
)
)
export_tfl(
tbl,
preview = TRUE,
pg_width = 8.5,
pg_height = 11,
margins = unit(c(t = 1, r = 0.75, b = 1, l = 0.75), "inches"),
header_left = "Protocol XY-001\nDraft — Not for Distribution",
header_center = "CONFIDENTIAL",
header_right = format(Sys.Date(), "%d %b %Y"),
caption = "Table 8. Iris Sepal and Petal Measurements.",
footnote = "Data: Fisher (1936). All measurements in centimetres.",
footer_left = "Department of Statistics",
header_rule = TRUE,
footer_rule = TRUE
)
See vignette("v01-figure_output") for a full reference
of all export_tfl() layout arguments, including typography
(gp), padding, rules, and overlap detection.
Summary of tfl_table() arguments
| Argument | Default | Purpose |
|---|---|---|
x |
— | Data frame or grouped tibble |
cols |
NULL (all columns) |
NULL or a list of tfl_colspec() objects.
To display a column subset, pre-select columns in x before
passing to tfl_table(). |
col_widths |
NULL (auto) |
Named list of unit(), plain numeric, or
NULL per column |
col_labels |
column names | Named character vector of header labels; supports
\n
|
col_align |
type-based | Named vector: "left", "right", or
"center"
|
wrap_cols |
NULL |
Names of columns to word-wrap |
min_col_width |
unit(0.5, "inches") |
Floor applied to auto-sized columns |
allow_col_split |
TRUE |
If FALSE, error when columns exceed page width |
balance_col_pages |
FALSE |
If TRUE, redistribute columns evenly across
column-split pages instead of packing left-to-right |
suppress_repeated_groups |
TRUE |
Hide repeated group values in consecutive rows |
col_cont_msg |
"Columns continue on other pages" |
Rotated side-label text on column-split pages: clockwise 90° to the right when columns continue on a later page; counter-clockwise 90° to the left when columns continue from a prior page |
row_cont_msg |
c("(continued)", "(continued on next page)") |
[1] shown at top of continuation page; [2]
shown at bottom of page before continuation |
show_col_names |
TRUE |
Whether to render the column header row at all |
col_header_rule |
TRUE |
Rule below column headers |
group_rule |
TRUE |
Rule above each new group block |
group_rule_after_last |
FALSE |
Rule after the last group block |
row_header_sep |
FALSE |
Vertical rule after row-header columns |
na_string |
"" |
Replacement for NA values |
gp |
list() |
Typography for headers and body cells |
cell_padding |
unit(c(0.2, 0.5), "lines") |
Vertical and horizontal padding inside each cell |
line_height |
1.05 |
Inter-line spacing multiplier for word-wrapped cells |
max_measure_rows |
Inf |
Number of rows sampled when measuring auto column widths |