Skip to contents

The second path. This article is about bringing a table you already have. When the statistics are in a cards / cardx ARD, the recommended route is a table plan – see Get started and Tables from an ARD.

The gt package by Posit gives users a friendly grammar for display tables – cols_label(), tab_header(), tab_spanner(), cols_align(), tab_source_note(), tab_footnote(), … Clinical summary tables are very often produced by gtsummary (tbl_summary(), tbl_regression(), …) or tfrmt, both of which ultimately render to a gt_tbl. When you move from the HTML world to the clinical TFL world, you want that same metadata to flow into the RTF document – not be re-stated in a different vocabulary.

[as_rtftables()] is the single entry point. It takes a table object (gt_tbl, a gtsummary table, a data.frame/tibble, or a list of these), reads all of the table’s metadata, paginates the body, and returns a list of ready-to-render [rtftable()] objects – one per page:

  gt / gtsummary / tfrmt          as_rtftables()             rtf_tables()
  ------------------------  ->  read metadata + paginate  ->  add to doc  ->  generate_rtfreport()
        (a gt_tbl)               (list of rtftable pages)      (titles /
                                                                footnotes
                                                                flow through)
Show code
library(gt)

g <- gt(head(mtcars, 3)) |>
  cols_label(mpg = "MPG", cyl = "Cyl", disp = "Disp", hp = "HP",
             drat = "Drat", wt = "WT", qsec = "QSec",
             vs = "VS", am = "AM", gear = "Gear", carb = "Carb") |>
  tab_header(
    title    = "Table 14.1.1",
    subtitle = "Demographics and baseline characteristics"
  ) |>
  tab_source_note("Source: built-in mtcars dataset.")

pages <- as_rtftables(g)          # read = TRUE by default
length(pages)                      # one page here
#> [1] 1
class(pages[[1]])
#> [1] "rtftable"

Hand the pages straight to [rtf_tables()]. The page-level title and source notes travel with each page (as rtf_titles / rtf_footnotes attributes) and are picked up automatically – no read_gt flag needed:

Show code
doc <- rtf_document() |>
  rtf_section(page = 1, header = NULL, footer = NULL) |>
  rtf_tables(pages)

doc$titles[[1]]        # title + subtitle, read from the gt object
#> [1] "Table 14.1.1"                             
#> [2] "Demographics and baseline characteristics"
doc$footnotes[[1]]     # source note
#> [1] "Source: built-in mtcars dataset."

For a single page you can use the convenience wrapper [as_rtftable()], which returns one rtftable instead of a list:

tbl <- as_rtftable(g)   # == as_rtftables(g, split = "none")[[1]]
class(tbl)
#> [1] "rtftable"

gtsummary in one line

A gtsummary table is converted to a gt_tbl automatically (via gtsummary::as_gt()), so the workflow is identical:

Show code
library(gtsummary)

s <- tbl_summary(
  trial[c("age", "grade", "response", "trt")],
  by = trt
)

pages <- as_rtftables(s)

doc <- rtf_document() |>
  rtf_section(page = 1, header = NULL, footer = NULL) |>
  rtf_tables(pages)

out <- tempfile(fileext = ".rtf")
generate_rtfreport(doc, out, overwrite = TRUE)
file.exists(out)
#> [1] TRUE

gtsummary applies row indentation and bold group-header rows through HTML/CSS styling that RTF cannot reproduce; those visual details are dropped (see Limitations below). Everything structural – column labels, spanning headers, the stub / group rows, footnote texts and marks – comes through.

Splitting a long table across pages

as_rtftables() splits a table that is too tall for one page; pass split and max_rows:

Show code
long <- data.frame(
  Parameter = sprintf("Row %02d", 1:8),
  Value     = round(rnorm(8), 2),
  stringsAsFactors = FALSE
)
pages <- as_rtftables(long, split = "group_force", max_rows = 4,
                      group_col = "Parameter")
length(pages)                       # 2 pages
#> [1] 2
vapply(pages, function(p) nrow(p$data), integer(1))
#> [1] 4 4

Per-cell styles read from a gt object are sliced to match each page automatically.

What gets read

read = TRUE (the default) is shorthand for a set of independent tokens, grouped into phases. Pass read = c("col_header", "titles") to opt in selectively.

  • Phase A (shape-preserving, “safe”)
    • "col_header" – cols_label() -> the rtftable’s column header
    • "alignment" – cols_align() -> per-column col_spec[[j]]$align
    • "titles" – tab_header(title, subtitle) -> the page titles[[i]] block
    • "source_notes" – tab_source_note() -> the page footnotes[[i]] block
  • Phase B (structural, modifies the column space)
    • "spanning" – tab_spanner() -> multi-row col_header with spanning cells
    • "widths" – cols_width() -> column_widths_twips ("NNpx") or col_rel_width ("NN%")
    • "hidden" – cols_hide() -> drop the column before rendering
  • Phase C (structural, modifies the row space + extra notes)
    • "footnotes" – tab_footnote() (any anchor) -> appended to the page footnotes[[i]] block
    • "stub" – groupname_col = ..., rowname_col = ..., tab_stubhead() -> group-transition rows and a stubhead-labelled stub column
  • Phase D (per-cell formatting)
    • "styles" – tab_style(cell_text(...)) -> per-cell bold / italic / underline / indent, carried on the rtftable’s cell_styles field
    • "footnote_marks" – the in-cell footnote marks gt renders as <sup>N</sup> -> rtfreporter ^{N} superscript markup (matching the texts read by "footnotes")
    • "strip_html" – remove stray HTML left in cell values (<br> -> line break; other tags dropped)

Anything listed in read = c(...) is read; anything left out falls back to the rtfreporter default (e.g. column names from names(data)).

End-to-end example: a TFL-style adverse-events table

This is the kind of table you would normally build by hand:

Show code
ae <- data.frame(
  SOC = c("Nervous system disorders",
          "Nervous system disorders",
          "Nervous system disorders",
          "Gastrointestinal disorders",
          "Gastrointestinal disorders"),
  PT  = c("Headache", "Dizziness", "Insomnia",
          "Nausea",   "Diarrhoea"),
  Placebo_n = c("10 (33.3)", "2 ( 6.7)", "3 (10.0)",
                "6 (20.0)",  "4 (13.3)"),
  Active_n  = c("13 (43.3)", "2 ( 6.7)", "4 (13.3)",
                "7 (23.3)",  "5 (16.7)"),
  Total_n   = c("23 (38.3)", "4 ( 6.7)", "7 (11.7)",
                "13 (21.7)", "9 (15.0)"),
  stringsAsFactors = FALSE
)

ae_gt <-
  gt(ae, groupname_col = "SOC", rowname_col = "PT") |>
  cols_label(PT = "Preferred Term",
             Placebo_n = "Placebo",
             Active_n  = "Active",
             Total_n   = "Total") |>
  tab_stubhead(label = "SOC / PT") |>
  cols_align("right", columns = c(Placebo_n, Active_n, Total_n)) |>
  tab_spanner(label = "Treatment Arm",
              columns = c(Placebo_n, Active_n, Total_n)) |>
  tab_header(title    = "Table 14.2.1",
             subtitle = "Adverse Events Summary (Safety Population)") |>
  tab_source_note("Source: ADaM ADAE.") |>
  tab_footnote("Percentages are based on column N.")

Convert it with as_rtftables() and add the pages to a document:

Show code
doc <- rtf_document() |>
  rtf_section(
    page   = 1,
    header = rtf_header(rows = list(c(l = "Protocol RTF-101",
                                       r = "Page {AUTO_PAGE}"))),
    footer = NULL
  ) |>
  rtf_tables(as_rtftables(ae_gt))

doc$contents[[1L]] now has:

tbl <- doc$contents[[1L]]
nrow(tbl$data)          # 5 PT rows + 2 SOC group rows
#> [1] 7
tbl$data$rowname[1:3]   # the SOC's heading row, then its first two PTs, indented
#> [1] "Nervous system disorders" "  Headache"              
#> [3] "  Dizziness"
tbl$col_header          # spanner row + stubhead-labelled bottom row
#> [[1]]
#> [[1]][[1]]
#> [[1]][[1]]$from
#> [1] 1
#> 
#> [[1]][[1]]$to
#> [1] 1
#> 
#> [[1]][[1]]$label
#> [1] ""
#> 
#> 
#> [[1]][[2]]
#> [[1]][[2]]$from
#> [1] 2
#> 
#> [[1]][[2]]$to
#> [1] 4
#> 
#> [[1]][[2]]$label
#> [1] "Treatment Arm"
#> 
#> 
#> 
#> [[2]]
#> [1] "SOC / PT" "Placebo"  "Active"   "Total"

Page-level blocks:

doc$titles[[1L]]        # title + subtitle
#> [1] "Table 14.2.1"                              
#> [2] "Adverse Events Summary (Safety Population)"
doc$footnotes[[1L]]     # footnote text + source note (in that order)
#> [1] "Percentages are based on column N." "Source: ADaM ADAE."

Render to RTF:

out <- tempfile(fileext = ".rtf")
generate_rtfreport(doc, out, overwrite = TRUE)
file.exists(out)
#> [1] TRUE

Precedence: explicit arguments beat gt-extracted values

The translation honours the documented precedence chain. If you specify col_header = c("A", "B", ...) explicitly, gt’s cols_label() is ignored for that table:

Show code
g <- gt(head(mtcars, 1)[, "mpg", drop = FALSE]) |>
  cols_label(mpg = "MPG")
tbl <- as_rtftable(g, read = TRUE, col_header = "Override")
tbl$col_header[[1L]]
#> [1] "Override"

The same applies to:

Slot Explicit arg
Column header col_header
Per-column alignment col_spec[[j]]$align
Spanner stack (passed via col_header)
Absolute widths column_widths_twips
Relative widths col_rel_width
Page titles rtf_titles()
Page footnotes rtf_footnotes()

Per-column col_spec entries are deep-merged – a user-supplied align = "left" for column 1 only knocks out gt’s column-1 alignment, the other columns keep gt’s values.

Limitations

The following gt / gtsummary features depend on HTML/CSS rendering that RTF cannot reproduce, so they are intentionally not carried through:

  • Row indentation. gtsummary nests variable levels by indenting them with CSS padding-left; the rows render flat in RTF.
  • Bold group-header rows. Applied by gtsummary through tab_style() in a way that the style reader does not currently map to a whole-row attribute. (Cell-level tab_style(cell_text(...)) is read – see the "styles" token.)
  • Cell background colour / fill. Still gt-only.
  • Markdown inside titles / labels. md("**bold**") is flattened to the raw string "**bold**"; rtfreporter has no markdown subsystem yet.
  • column_width strings other than "NNpx" or "NN%". Mixed-unit widths bail out to NULL and the renderer falls back to its default proportional split.

What is now supported (and was not in earlier releases): per-cell bold / italic / underline / indent via tab_style(cell_text(...)), and in-cell footnote marks rendered as ^{N} superscripts.

Deprecated: rtf_tables(read_gt = ...)

Before as_rtftables() existed, a raw gt_tbl could be handed directly to rtf_tables() with read_gt = TRUE to read its metadata. That argument is deprecated (it warns, and is removed in 0.9.0): pass the table through as_rtftables(), the one path that both paginates and reads metadata, and that keeps rtf_tables() free of any gt knowledge.

See also

Where next

The four recipes (?rtfreporter-recipes) are the same ground covered as runnable help-page examples: demographics, adverse events, PK and laboratory, each data-in to RTF-out.