Skip to contents
library(writetfl)
library(dplyr)   # for group_by()
#> 
#> Attaching package: 'dplyr'
#> The following objects are masked from 'package:stats':
#> 
#>     filter, lag
#> The following objects are masked from 'package:base':
#> 
#>     intersect, setdiff, setequal, union
# Clinical data and column spec used throughout this vignette.
# treatment is the first column so it can serve as the group column.
clinical <- data.frame(
  treatment  = c(rep("Active (n=120)", 3),       rep("Placebo (n=118)", 3)),
  subgroup   = c("All patients", "Age < 65",    "Age ≥ 65",
                 "All patients", "Age < 65",    "Age ≥ 65"),
  n          = c(120L,  74L,  46L,  118L,  71L,  47L),
  responders = c( 68L,  44L,  24L,   31L,  18L,  13L),
  rate_pct   = c(56.7, 59.5, 52.2,  26.3, 25.4, 27.7),
  stringsAsFactors = FALSE
)

col_spec <- list(
  tfl_colspec("treatment",  label = "Treatment Arm",  width = unit(1.3, "inches")),
  tfl_colspec("subgroup",   label = "Subgroup",       width = unit(1.4, "inches")),
  tfl_colspec("n",          label = "N",              width = unit(0.4, "inches")),
  tfl_colspec("responders", label = "Resp.",          width = unit(0.5, "inches")),
  tfl_colspec("rate_pct",   label = "Rate (%)",       width = unit(0.65, "inches"))
)

Overview

tfl_table() accepts a gp argument that is a named list of gpar() objects. Each key targets a specific visual element of the rendered table. Keys that are not supplied fall back to sensible clinical defaults; you only need to specify the elements you want to change.

The full set of recognized keys is:

Key Targets Default
gp$table Base font for all table text gpar(fontsize = 9, fontfamily = "sans")
gp$header_row Column header row text; fill sets background color gpar(fontface = "bold") (inherits gp$table)
gp$data_row Data cell text; fill sets background (vector alternates) inherits gp$table
gp$group_col Row-header column text inherits gp$table
gp$continued Continuation marker text gpar(fontface = "italic") (inherits gp$table)
gp$col_header_rule Rule drawn under column header row gpar(lwd = 1)
gp$group_rule Rules drawn between groups gpar(lwd = 0.5, lty = "dotted")
gp$row_rule Rules drawn between data rows gpar(lwd = 0.5)
gp$row_header_sep Vertical rule after row-header columns gpar(lwd = 0.5)

Inheritance is cascading: gp$data_row starts from gp$table, so setting gp$table = gpar(fontsize = 8) automatically shrinks data cells unless you explicitly override gp$data_row.

Page-level typography — the text in the page header, caption, footnote, and footer zones — is controlled by the gp argument of export_tfl_page() / export_tfl(), not by tfl_table()’s gp.

This vignette covers the styling arguments grouped into:


Typography

Base font — gp$table

gp$table is the typographic root for the whole table. Every other gp key inherits from it unless overridden.

tbl <- tfl_table(
  clinical,
  gp = list(
    table = gpar(fontsize = 8, fontfamily = "serif")
  )
)

export_tfl(tbl, preview = TRUE, header_left = "Base font: serif 8pt")

Changing gp$table propagates to all rows and rules unless you selectively override a more specific key.

Column header row — gp$header_row and show_col_names

The column header row renders the column names (or labels supplied via tfl_colspec()). By default headers are bold at the base font size.

# Custom header: slightly larger, italic instead of bold
tbl <- tfl_table(
  clinical,
  gp = list(
    table      = gpar(fontsize = 9),
    header_row = gpar(fontface = "italic", fontsize = 10)
  )
)

export_tfl(tbl, preview = TRUE, header_left = "Header row: italic 10pt")

Set show_col_names = FALSE to suppress the header row entirely — useful when you are stacking multiple tfl_table objects on one page and only the first needs column labels.

tbl_no_header <- tfl_table(
  clinical,
  show_col_names = FALSE
)

export_tfl(tbl_no_header, preview = TRUE,
           header_left = "show_col_names = FALSE")

Data row — gp$data_row

gp$data_row controls the appearance of every non-header, non-group-column cell. It inherits gp$table automatically.

tbl <- tfl_table(
  clinical,
  gp = list(
    table    = gpar(fontsize = 9),
    data_row = gpar(col = "grey30")
  )
)

export_tfl(tbl, preview = TRUE, header_left = "Data row: grey text")

Group column — gp$group_col and per-column override

Row-header (group) columns — those designated via dplyr::group_by() — receive their own style key, gp$group_col, which also inherits gp$table.

# Bold group column to distinguish it from data columns
tbl <- clinical |>
  group_by(treatment) |>
  tfl_table(
    cols = list(
      tfl_colspec("treatment",  label = "Treatment Arm", width = unit(1.3, "inches")),
      tfl_colspec("subgroup",   label = "Subgroup",      width = unit(1.4, "inches")),
      tfl_colspec("n",          label = "N",             width = unit(0.4, "inches")),
      tfl_colspec("rate_pct",   label = "Rate (%)",      width = unit(0.65, "inches"))
    ),
    gp = list(
      group_col = gpar(fontface = "bold")
    )
  )

export_tfl(tbl, preview = TRUE, header_left = "Group column: bold")

To override a single group column without touching the others, pass gp directly to tfl_colspec():

# The treatment group column gets bold via its tfl_colspec gp;
# any other group columns would stay at the gp$group_col default
tbl <- clinical |>
  group_by(treatment) |>
  tfl_table(
    cols = list(
      tfl_colspec("treatment",  label = "Treatment Arm", width = unit(1.3, "inches"),
                  gp = gpar(fontface = "bold")),
      tfl_colspec("subgroup",   label = "Subgroup",      width = unit(1.4, "inches")),
      tfl_colspec("n",          label = "N",             width = unit(0.4, "inches")),
      tfl_colspec("rate_pct",   label = "Rate (%)",      width = unit(0.65, "inches"))
    )
  )

export_tfl(tbl, preview = TRUE,
           header_left = "Per-colspec gp overrides group_col gp")

The gp on tfl_colspec() takes precedence over gp$group_col for that specific column; all other group columns still inherit gp$group_col.

Continuation marker — gp$continued and row_cont_msg

When a table spans multiple pages, tfl_table() injects a continuation marker at the bottom of each non-final page. By default the marker text is "(continued)" and is rendered in italic.

# Smaller continuation marker, explicit message
tbl <- tfl_table(
  clinical,
  row_cont_msg = "(table continues on next page)",
  gp = list(
    continued = gpar(fontface = "italic", fontsize = 7, col = "grey50")
  )
)

row_cont_msg replaces the default "(continued)" string. gp$continued controls the visual rendering of whatever text row_cont_msg provides. The continuation marker only appears on tables with more rows than fit on one page; see vignette("v02-tfl_table_intro") for an example.


Rules and separators

Three boolean arguments switch horizontal rules on or off; their corresponding gp keys control line appearance. A vertical separator between row-headers and data columns is also available.

Column header rule — col_header_rule

A horizontal rule drawn immediately below the column header row.

# Thicker header rule
tbl <- tfl_table(
  clinical,
  col_header_rule = TRUE,
  gp = list(
    col_header_rule = gpar(lwd = 1.5)
  )
)

export_tfl(tbl, preview = TRUE, header_left = "Header rule: lwd = 1.5")


# No header rule at all
tbl_no_rule <- tfl_table(
  clinical,
  col_header_rule = FALSE
)

export_tfl(tbl_no_rule, preview = TRUE,
           header_left = "col_header_rule = FALSE")

Group rules — group_rule, group_rule_after_last

A rule drawn at the boundary between adjacent groups (defined by changes in any group column). group_rule_after_last controls whether a rule also appears after the final group.

The line is drawn as a partial width: it starts at the column whose value actually changed at this transition and extends to the right edge of the table. So with nested groups group_vars = c("Cohort", "Visit"):

Transition Outermost change Rule columns
Visit changes within the same Cohort Visit (level 2) Visit + data columns
Cohort changes Cohort (level 1) Cohort + Visit + data columns

This keeps the rule from visually slicing through an outer group’s label that is flowing across multiple rows (see Multi-line group labels and rowspan flow in the intro vignette).

# Solid thin rules between groups, including after the last one
tbl <- clinical |>
  group_by(treatment) |>
  tfl_table(
    cols                  = col_spec,
    group_rule            = TRUE,
    group_rule_after_last = TRUE,
    gp = list(
      group_rule = gpar(lwd = 0.5, lty = "solid")
    )
  )

export_tfl(tbl, preview = TRUE,
           header_left = "Group rules: solid, including after last")


# No between-group rules
tbl_no_grp <- clinical |>
  group_by(treatment) |>
  tfl_table(
    cols       = col_spec,
    group_rule = FALSE
  )

export_tfl(tbl_no_grp, preview = TRUE,
           header_left = "group_rule = FALSE")

The default gp$group_rule is gpar(lwd = 0.5, lty = "dotted"). Any valid lty value accepted by grid (e.g. "dashed", "solid", "dotted") works here.

Row rules — row_rule

A horizontal rule drawn between every pair of consecutive data rows. Enabled with row_rule = TRUE. Unlike group_rule (which only fires at group boundaries), row_rule draws after every row except the last — unless the row below it is part of a multi-row group span. A line that would slice through a label flowing downward through suppressed cells is automatically suppressed, the same way HTML rowspan cells have no internal borders.

tbl <- tfl_table(
  clinical,
  row_rule = TRUE,
  gp = list(
    row_rule = gpar(lwd = 0.3, col = "grey70")
  )
)

export_tfl(tbl, preview = TRUE,
           header_left = "row_rule = TRUE: lines between rows")

The default gp$row_rule is gpar(lwd = 0.5). Set row_rule = FALSE (the default) to suppress inter-row rules entirely.

Vertical row-header separator — row_header_sep

A vertical rule drawn to the right of the last row-header (group) column, separating the row labels from the data columns. Enabled with row_header_sep = TRUE.

# Thin solid vertical separator after the group column
tbl <- clinical |>
  group_by(treatment) |>
  tfl_table(
    cols = list(
      tfl_colspec("treatment",  label = "Treatment Arm", width = unit(1.3, "inches")),
      tfl_colspec("subgroup",   label = "Subgroup",      width = unit(1.4, "inches")),
      tfl_colspec("n",          label = "N",             width = unit(0.4, "inches")),
      tfl_colspec("rate_pct",   label = "Rate (%)",      width = unit(0.65, "inches"))
    ),
    row_header_sep = TRUE,
    gp = list(
      row_header_sep = gpar(lwd = 0.75, col = "grey40")
    )
  )

export_tfl(tbl, preview = TRUE,
           header_left = "Row header separator")


# Suppress the separator (default)
tbl_no_sep <- clinical |>
  group_by(treatment) |>
  tfl_table(
    cols           = col_spec,
    row_header_sep = FALSE
  )

export_tfl(tbl_no_sep, preview = TRUE,
           header_left = "row_header_sep = FALSE (default)")


Cell padding

cell_padding is a grid::unit object that controls the whitespace between cell content and cell boundaries. It accepts two forms:

Scalar — the same padding is applied on all four sides:

tbl <- tfl_table(
  clinical,
  cell_padding = unit(0.15, "lines")
)

export_tfl(tbl, preview = TRUE, header_left = "Uniform padding: 0.15 lines")

Two-element vector — separate vertical and horizontal padding. Use this when you want tighter horizontal spacing but more vertical breathing room:

tbl <- tfl_table(
  clinical,
  cell_padding = unit(c(0.3, 0.1), "lines")   # [1] = vertical, [2] = horizontal
)

export_tfl(tbl, preview = TRUE,
           header_left = "Asymmetric padding: 0.3v / 0.1h lines")

The first element controls top and bottom padding; the second controls left and right. Reducing horizontal padding allows more columns to fit on a page without reducing font size.


Cell background shading

Background colors for the header row and data rows are controlled through the fill field in existing gp keys.

Header row background

tbl <- tfl_table(
  clinical,
  gp = list(
    header_row = gpar(fontface = "bold", fill = "lightblue")
  )
)

export_tfl(tbl, preview = TRUE,
           header_left = "Header row with fill = 'lightblue'")

Alternating row colors (zebra striping)

Pass a vector of colors to gp$data_row$fill to alternate between them:

tbl <- tfl_table(
  clinical,
  gp = list(
    header_row = gpar(fontface = "bold", fill = "steelblue4", col = "white"),
    data_row   = gpar(fill = c("grey95", "white"))
  )
)

export_tfl(tbl, preview = TRUE,
           header_left = "Alternating row colors")

Alternating by group — fill_by = "group"

By default, fill_by = "row" cycles through the color vector for each data row. Setting fill_by = "group" advances the color index only at group boundaries, so all rows in the same group share one background color.

tbl <- clinical |>
  group_by(treatment) |>
  tfl_table(
    cols    = col_spec,
    fill_by = "group",
    gp = list(
      data_row = gpar(fill = c("grey95", "white"))
    )
  )

export_tfl(tbl, preview = TRUE,
           header_left = "fill_by = 'group': banded groups")


Word wrapping — wrap_cols, wrap_breaks

wrap_cols and wrap_breaks control text wrapping inside columns — the table reflows long cell text and column-header labels onto multiple lines so a column can be narrower. Distinct from page-column-split, which handles a table that is too wide as a whole by spreading its columns over more than one PDF page. The two run in order: text-wrap first; then page-column-split as a fallback if the table still doesn’t fit.

Default behaviour — wrap_cols = "auto"

Out of the box, every non-group column whose cells or header contain a break character (whitespace by default) is eligible for wrapping. Numeric columns and single-token strings are skipped because no amount of narrowing can make them break. The wrap pass only runs when the natural column widths exceed the available page width.

notes_df <- data.frame(
  visit = c("Baseline", "Week 4", "Week 12"),
  notes = c("Patient enrolled and signed informed consent",
            "Mild headache reported, resolved within 24 hours",
            "All scheduled assessments completed without issue")
)
tbl <- tfl_table(notes_df)   # wrap_cols = "auto" by default

Disabling wrap entirely

Pass wrap_cols = FALSE. The wrap module is bypassed; if the table is wider than the page it will fall through to page-column-split (or error when allow_col_split = FALSE).

tbl <- tfl_table(notes_df, wrap_cols = FALSE)

Per-column control

tfl_colspec(wrap = ...) overrides the table-level setting:

  • wrap = TRUE — always eligible, even when no break character is present.
  • wrap = FALSE — never eligible, even when wrap_cols = "auto" would mark it.
  • wrap = NA (default) — inherit from wrap_cols.
tbl <- tfl_table(
  notes_df,
  cols = list(
    tfl_colspec("notes", width = unit(2, "inches"), wrap = TRUE)
  )
)

Custom break characters — wrap_breaks()

By default the wrap module breaks on whitespace (space, tab) and consumes the whitespace at the break point. Pass a wrap_breaks() object to configure additional break characters:

  • drop — characters consumed at the break (whitespace; the default).
  • keep_before — characters that stay on the left of the break, with the next character starting the new line. Useful for hyphenated terms or path-like strings.
# Break after "-" (hyphenated terms)
tbl <- tfl_table(
  hyphen_df,
  wrap_breaks = wrap_breaks(drop = " ", keep_before = "-")
)

# Break after "/" or "-" (path-like content)
tbl <- tfl_table(
  path_df,
  wrap_breaks = wrap_breaks(drop = " ", keep_before = c("-", "/"))
)

Algorithm in one paragraph

The wrap module computes a floor per wrap-eligible column equal to the larger of min_col_width and the rendered width of that column’s longest unbreakable token. It then runs a “water-from-top” pass: at each step it finds the widest set of wrap-eligible columns above their floor and shrinks them together until they meet the next-widest column or hit a floor — repeating until the total fits or every wrap-eligible column has hit its floor. Deterministic and bounded.

Optimising for height — wrap_balance = "height"

The default narrowing pass (wrap_balance = "width", water-from-top) balances the widths of wrap-eligible columns. That’s content-blind and fast, but on tables with very different content density per column it can leave one column wrapping to many more lines than its neighbour. When the goal is to fit more rows per page, opt in to wrap_balance = "height":

tbl <- tfl_table(my_df, wrap_balance = "height")

The height pass runs after water-fill, takes width away from columns whose cells have slack (short content) and gives it to columns whose cells are the bottleneck (long content). It accepts a move only if the resulting total table height is smaller, so the result is never worse than the default. The pass is time-budgeted at ~1 second; on any error or budget overrun the result silently falls back to water-fill widths.

Use the height pass when:

  • You have multiple wrap-eligible string columns of obviously different content density.
  • The default wrap_balance = "width" is producing one column with many wrapped lines next to a column with one line of content.
  • You’d rather fit more rows on a page than have visually balanced column widths.

The default stays "width" because it’s deterministic, fast, visually tidy, and produces good results on most clinical TFLs.

Balanced split-then-wrap — col_split_strategy

When a table is too wide for a single page, two interacting decisions have to be made: which columns go on which page (page-column-split) and how aggressively each remaining column should wrap. The default col_split_strategy = "balanced" does the page-split first using each column’s minimum survivable width, then water-fills each page’s columns within that page’s actual horizontal slack. The legacy "wrap_first" strategy does whole-table wrapping first and uses the resulting widths to drive the split.

The practical difference shows up on multi-page tables:

Strategy Page-1 column behavior
"balanced" (default) Each column gets the slack of the page it lands on. Strings on a 3-column page have ~3× more room than under "wrap_first".
"wrap_first" Every column is wrapped tightly enough to fit all columns on one page width, even though most end up on different pages anyway.
# Default: balanced.  Per-page widths.
tbl <- tfl_table(wide_clinical_df)

# Legacy: whole-table wrap first.
tbl <- tfl_table(wide_clinical_df, col_split_strategy = "wrap_first")

Group columns are pinned at their minimum width on every page under "balanced" (data columns absorb the per-page slack). Together with the HTML-rowspan-style group-label flow this keeps group cells compact without wasting page real estate.

When a row’s wrapped height genuinely exceeds the page, the balanced strategy iterates: it raises the bottleneck column’s minimum by 0.25 inches and re-runs the width pipeline, up to row_overflow_max_retries times (default 5L). After the cap the existing overflow_action path fires. Set row_overflow_max_retries = 0L to disable the loop entirely.

Visual gap between adjacent multi-line cells — wrap_extra_padding

When two consecutive rows both contain wrapped (multi-line) cells, the bottom of one row’s wrapped text can sit visually flush against the top of the next row’s wrapped text — making it hard to see where one row ends and the next begins. wrap_extra_padding adds a configurable amount of vertical space only at the bottom of multi-line cells.

# Default: 0.5 lines of extra space below every multi-line cell.
tbl <- tfl_table(notes_df)

# Disable: pack rows tightly even when cells wrap.
tbl <- tfl_table(notes_df, wrap_extra_padding = unit(0, "lines"))

# More breathing room.
tbl <- tfl_table(notes_df, wrap_extra_padding = unit(0.5, "lines"))

The extra applies to any cell whose displayed text spans more than one line — whether the lines come from the wrap algorithm or from explicit \n in the data. Single-line cells are unaffected, so the padding does not inflate compact tables.

Failsafe — row-overflow guard

A row whose wrapped height exceeds one page is almost always a sign of input that needs to change (e.g. a 5,000-character cell forced into a 0.5-inch column). paginate_rows() raises an error in this case via the same overflow_action switch as the column-overflow guard:

# Raise error (default)
export_tfl(tbl, file = "out.pdf")

# Downgrade to a warning and still produce diagnostic output
export_tfl(tbl, file = "out.pdf", overflow_action = "warn")

Multi-page accessories

Column continuation message — col_cont_msg

When the table has more columns than fit on one page, tfl_table() splits across multiple column-pages. col_cont_msg is a character string displayed as rotated side labels:

  • Clockwise 90° (reading downward) to the right of the table on pages where columns continue on a subsequent page.
  • Counter-clockwise 90° (reading upward) to the left of the full table (including row-label columns) on pages where columns continue from a prior page.

One line-height of spacing separates the table edge from the text. Set col_cont_msg = NULL to suppress the labels entirely.

# Default message
tbl <- tfl_table(
  clinical,
  col_cont_msg = "Columns continue on next page"
)

# Suppress
tbl_no_msg <- tfl_table(
  clinical,
  col_cont_msg = NULL
)

The corresponding row_cont_msg for row-pagination markers is covered under Continuation marker — gp$continued and row_cont_msg in the typography section above.


Sub-tables — sub_tfl

sub_tfl splits a single tfl_table into one self-identifying sub-table per unique combination of values in the named columns. The columns named in sub_tfl are removed from the rendered table body and instead appear in the caption as "label: value; label: value".

This is the idiomatic way to produce by-group tables (e.g. one table per treatment arm, per visit) without manually splitting the data and stitching the page lists together.

tbl_by_arm <- tfl_table(
  clinical,
  sub_tfl = "treatment",
  cols    = col_spec
)

export_tfl(
  tbl_by_arm,
  caption = "Table 1. Response by Subgroup",
  preview = 1L
)

The first preview page shows Table 1. Response by Subgroup followed on a new line by Treatment Arm: Active (n=120) (the colspec label is reused, not the raw column name). The treatment column itself is no longer in the table body. A second page contains the placebo arm.

Multiple sub_tfl columns

Naming more than one column produces the Cartesian product, with the first column varying outermost:

tbl <- tfl_table(
  clinical,
  sub_tfl = c("treatment", "subgroup")
)
# Page captions, in order:
#   "Treatment Arm: Active (n=120); Subgroup: All patients"
#   "Treatment Arm: Active (n=120); Subgroup: Age < 65"
#   ...

Formatting controls

Three formatting arguments mirror paste():

Argument Default Role
sub_tfl_sep ": " between each column’s label and value
sub_tfl_collapse "; " between successive label: value pairs
sub_tfl_prefix "\n" between the existing caption and the suffix
tfl_table(
  clinical,
  sub_tfl          = c("treatment", "subgroup"),
  sub_tfl_sep      = " = ",
  sub_tfl_collapse = " | ",
  sub_tfl_prefix   = " — "
)
# Caption per page: "Table 1 — Treatment Arm = Active (n=120) | Subgroup = ..."

When the global caption is NULL, the suffix becomes the entire caption (no leading prefix).

Ordering

Sub-tables are produced in this order:

  • Factor columns drive their own ordering by levels() (only levels that appear in the data are emitted). Use a factor when you need a clinically meaningful order such as Active before Placebo.
  • Character / numeric columns use first-appearance order in the data.

Overlap with group_vars()

When a column listed in sub_tfl is also a dplyr::group_by() variable (a row-header column), it is promoted to the caption — i.e. removed from both the rendered body and from group_vars. This is a common case:

clinical |>
  group_by(treatment) |>
  tfl_table(sub_tfl = "treatment")
# treatment is no longer a row-header column; instead each treatment arm
# is its own sub-table, with the arm name in the caption.

Sub-figures via ggtibble

export_tfl.ggtibble() accepts the same four arguments. The named columns are appended to each row’s caption (using the raw column names as labels, as ggtibbles have no colspec system). This is the recommended way to build by-group sub-figure decks.


Complete examples

The following pair of examples contrasts the out-of-the-box clinical appearance with a more compact publication-style variant. Both render using preview = TRUE.

Default clinical look

tbl_clinical <- clinical |>
  group_by(treatment) |>
  tfl_table(
    cols                  = col_spec,
    col_header_rule       = TRUE,
    group_rule            = TRUE,
    group_rule_after_last = FALSE,
    row_header_sep        = TRUE,
    cell_padding          = unit(0.2, "lines")
    # gp uses built-in defaults: bold headers, dotted group rules, etc.
  )

export_tfl(
  tbl_clinical,
  preview        = TRUE,
  pg_width       = 11,
  pg_height      = 8.5,
  header_left    = "Study XYZ-001",
  header_right   = "CONFIDENTIAL",
  caption        = "Table 1. Response rates by treatment arm and age subgroup.",
  footnote       = "Abbreviations: Resp. = responders; Rate = response rate.",
  footer_left    = "Program: t_resp.R",
  margins        = unit(c(t = 0.75, r = 0.75, b = 0.75, l = 0.75), "inches")
)

Publication style

tbl_publication <- clinical |>
  group_by(treatment) |>
  tfl_table(
    cols                  = col_spec,
    col_header_rule       = TRUE,
    group_rule            = FALSE,   # no between-group rules
    group_rule_after_last = FALSE,
    row_header_sep        = FALSE,   # no vertical separator
    cell_padding          = unit(c(0.25, 0.08), "lines"),
    gp = list(
      # smaller, serif base font
      table           = gpar(fontsize = 8, fontfamily = "serif"),
      # plain (not bold) column headers, slightly larger
      header_row      = gpar(fontface = "plain", fontsize = 9, fontfamily = "serif"),
      # italicized group column
      group_col       = gpar(fontface = "italic"),
      # heavier header rule
      col_header_rule = gpar(lwd = 1.5),
      # smaller, lighter continuation marker
      continued       = gpar(fontface = "italic", fontsize = 7, col = "grey60")
    )
  )

export_tfl(
  tbl_publication,
  preview        = TRUE,
  pg_width       = 8.5,
  pg_height      = 11,
  caption        = "Table 1. Response rates by treatment arm and age subgroup.",
  footnote       = "Resp. = responders; Rate (%) = response rate.",
  margins        = unit(c(t = 1, r = 1, b = 1, l = 1), "inches")
)

The two outputs differ visibly in:

  • Font family and weight (sans-serif bold headers vs. serif plain headers)
  • Presence of group rules and the vertical row-header separator
  • Cell padding (uniform vs. asymmetric v/h form)
  • Header rule weight
  • Continuation marker appearance