Skip to contents

Clinical tables live and die by their rules: a top and bottom line around the column header, a group underline beneath a spanning label, an occasional rule under the last data row. rtfreporter gives you three levels of control, from a one-word preset down to a single cell. This article walks through all three, with runnable code.

The border objects are plain S3 records (no reference semantics), so a border you build can be reused across tables safely. The vocabulary is small:

rtf_border(all, top, bottom, left, right, inside_h, inside_v,
           style, width, color, from)

top/bottom/left/right are the selection’s outer edges and all is the four of them at once; inside_h and inside_v are the rules between its rows and between its cells. Each takes TRUE for a rule, FALSE or "none" for none, or a style name such as "double". style/width/colour describe the line those sides draw, and from layers a border onto an existing one.

That is the whole vocabulary – one function. There is no separate table-border type and no per-edge constructor: what a border applies to is decided by where you attach it, the same way Word’s border dialog acts on whatever is selected.

Attach it to The selection is
rtftable(border = ) the whole table
style_zone(header = , body = , ...) that kind of row
col_cell(border = ), cell_styles one cell
rtf_header(border = ) / rtf_footer(border = ) that block

An edge always means the outer edge of the selection, and an absent inside_h / inside_v always means “no rule there” – the reading never depends on which other arguments are present.

Level 1 – a preset

The border argument of rtftable() (and rtf_tables(), as_rtftables()) accepts a shorthand string:

df <- data.frame(Parameter = c("Age (years)", "  Mean", "  SD"),
                 Value     = c("", "75.1", "8.2"), stringsAsFactors = FALSE)

tbl <- rtftable(df, border = "tfl")   # clinical TFL preset

  • "tfl" – the clinical preset: a rule on top of the first header row and below the last header row, a multi-column spanning cell gets an automatic group underline where the grouping changes below it (the next row subdivides the span), and the data area has no borders. This is the default.
  • "none" (or NULL) – no borders anywhere.

The same rules are available as a reusable value from rtf_table_style_tfl(), which is what "tfl" resolves to:

rtf_table_style_tfl()
#> <rtf_table_style>
#>   borders:
#>     header    : <rtf_border>
#>     spanning  : none
#>     body      : none
#>     first_row : none
#>     last_row  : none
#>   header_align : (inherit align)
#>   header_bold  : FALSE
#>   align        : (per column)
#>   bold         : FALSE
#>   cell_padding : L=(default) R=(default)

Level 2 – the whole table

Attach an rtf_border() to rtftable(border = ) and it describes the table:

Show code
line <- TRUE        # single, 15 twips (~0.5pt), black

# The listing look: a frame plus a rule under every row, no vertical rules.
tbl <- rtftable(df,
  border = rtf_border(top = line, bottom = line, left = line, right = line,
                      inside_h = line))

Add inside_v = line for a full grid; leave both out for a bare frame.

Level 3 – one kind of row

style_zone() aims a border at one of the five row kinds:

Zone Applies to
header column-header label rows (top on first, bottom on last)
spanning spanning-header rows (falls back to header when NULL)
body every data row
first_row the first data row (merged on top of body)
last_row the last data row (merged on top of body)

Each zone takes an rtf_border(), read against that zone as the selection. A header boxed top and bottom plus a thin rule under the last data row (a common “close the table” look):

Show code
tbl <- rtftable(df, border = "none") |>
  style_zone(
    header   = rtf_border(top = line, bottom = line),
    last_row = rtf_border(bottom = line)
  )

style, width and color describe the line that the sides named in the same call draw:

rtf_border(top = rtf_border_line("double", 30L, "#003366"))
#> <rtf_border>
#>   top     : double, 30 twips, color=#003366
#>   bottom  : none
#>   left    : none
#>   right   : none

TRUE and a bare style name are shorthands for the same value, so rtf_border(top = "double") and rtf_border(top = rtf_border_line("double")) agree. style is one of "single", "double", "thick", "dash", "dot" – and "none", which we meet below. Because each side holds its own line, sides that differ still fit in one call:

rtf_border(top    = rtf_border_line("double", 30L),
           bottom = rtf_border_line(color = "#C9372C"))
#> <rtf_border>
#>   top     : double, 30 twips
#>   bottom  : single, 15 twips, color=#C9372C
#>   left    : none
#>   right   : none

Body (data-area) borders

body selects every data row, so its top/bottom are the top and bottom of the block and inside_h is the rule between its rows. Likewise left and right are the outer columns and inside_v the rules between the cells. A grid around every data cell therefore needs all six:

Show code
tbl <- rtftable(
  data.frame(A = c("1", "2"), B = c("x", "y"), stringsAsFactors = FALSE),
  border = "none"
) |>
  style_zone(body = rtf_border(top = line, bottom = line,
                               left = line, right = line,
                               inside_h = line, inside_v = line))

Before 0.5.0 an edge on a zone was uniform – left/right were drawn on every cell and body’s top/bottom on every row – so rtf_border(all = TRUE) alone produced this grid. See Before and after 0.5.0 at the end of this article for each case written both ways.

Level 3 – single columns and single cells

The header is where fine control matters most. Two mechanisms reach inside it.

A whole column, with col_spec

A per-column border in col_spec is merged on top of the header zone for the header cell(s) in that column – e.g. a thicker rule under just column 2:

Show code
tbl <- rtftable(
  df,
  border   = "tfl",
  col_spec = list(
    list(col = "Value",
         border = rtf_border(bottom = "thick"))
  )
)

A single header cell, with col_cell(border = )

For a multi-row header, the unit of control is one cell. Build the header with rtf_col_header() / col_cell() and give the cell its own border. Because a per-cell border overrides everything – the zone border and the automatic group underline – this is how you add or remove an individual rule.

Take a 5-column header with two treatment groups. By default each spanning cell (“Drug A”, “Drug B”) is underlined:

Show code
df5 <- data.frame(Item = "Age", A_N = 30L, A_Mean = 45.2,
                  B_N = 30L, B_Mean = 46.1, stringsAsFactors = FALSE)

hdr <- rtf_col_header(
  list(col_cell(1, ""),
       col_cell(c(2, 3), "Drug A"),
       col_cell(c(4, 5), "Drug B")),
  c("Item", "N", "Mean", "N", "Mean")
)
tbl <- rtftable(df5, col_header = hdr, border = "tfl")

To remove the underline beneath “Drug A” only, give that one cell a "none" bottom. Unlike NULL (which just leaves a side unset and so inherits the automatic rule), a "none" side is an explicit “no line” that wins on merge:

Show code
hdr <- rtf_col_header(
  list(col_cell(1, ""),
       col_cell(c(2, 3), "Drug A",
                border = rtf_border(bottom = "none")),
       col_cell(c(4, 5), "Drug B")),
  c("Item", "N", "Mean", "N", "Mean")
)
tbl <- rtftable(df5, col_header = hdr, border = "tfl")

The same field can add a rule to a single cell (e.g. a thick bottom under “Drug B” only) – pass an rtf_border() with the sides you want instead of "none".

How a cell’s final border is decided

For a header cell the renderer merges, in order of increasing precedence:

  1. the zone border (header / spanning, reduced to top-on-first / bottom-on-last by the outer frame);
  2. the automatic group underline (a multi-column spanning cell, drawn only where the column grouping changes below it – i.e. the next header row subdivides the span);
  3. col_spec[[col]]$border for the cell’s leftmost column;
  4. col_cell(border = ) on the cell itself.

Later steps override earlier ones side-by-side, so a "none" side at step 3 or 4 erases a line drawn by step 1 or 2, and a real line at step 4 replaces one from step 3. That single rule – last writer wins, per side – is all you need to reason about any combination.

Level 4 – restyling an existing table: the styling verbs

Everything above configures a table you are building. When the table already exists – typically because as_rtftables(x, read_meta = TRUE) extracted the labels, alignment and spanning from a gt / gtsummary / rtables object and only one rule needs to change – reach for the post-hoc styling verbs instead of rebuilding the header:

Show code
df <- data.frame(Item = c("Age", "Sex"), A = c("1", "2"),
                 B = c("3", "4"), C = c("5", "6"))
hdr <- rtf_col_header(
  list(col_cell(1, ""), col_cell(c(2, 4), "Treatment Group")),
  list(col_cell(1, ""), col_cell(c(2, 4), "(N = 254)")),
  c("Item", "Placebo", "Drug A", "Drug B")
)
tbl <- rtftable(df, col_header = hdr, border = "tfl")

tbl <- tbl |>
  # solid rule above -- and no rule below -- the "(N = 254)" span only
  style_header(row = 2, cols = 2:4,
               border = rtf_border(top    = TRUE,
                                   bottom = "none")) |>
  # rule under one BODY row (a summary/total row, selected by predicate)
  style_body(rows = ~ Item == "Sex",
             border = rtf_border(bottom = TRUE)) |>
  # double rule under the whole table
  style_zone(last_row = rtf_border(bottom = "double"))

Body borders follow the same two-step chain as everything else: the row’s zone border (body, crossed with first_row / last_row) first, then the per-cell override from style_body(border = ) – last writer wins, per side. Restrict cols to rule only part of a row, or pass an "none" side to erase a zone rule on selected cells.

style_header(row =, cols =) addresses header cells by header-row index and data-column position; style_cols(), style_body() and style_zone() cover the column, body-cell and zone layers the same way, and add_header_row() inserts a header row after the fact. Every verb also accepts the page list returned by as_rtftables() (the edit is applied to every page), so a converted multi-page table can be restyled in one pipe:

pages <- as_rtftables(gt_obj, read_meta = TRUE) |>
  style_header(row = 2, cols = 2:4,
               border = rtf_border(top    = TRUE,
                                   bottom = "none"))

The merge semantics are the ones this article already taught: last writer wins, per side. See ?style_header for the full family, including how a character label row is promoted to cells when it needs a per-cell border.

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.

Before and after 0.5.0

Two things changed at 0.5.0, and they compound, so here is every common case written both ways. s is an TRUE throughout.

First, rtf_table_border() is deprecated: a border is aimed with rtftable(border = ) for the whole table, or style_zone() for one kind of row. Second, an edge now always means the outer edge of the selection, so the rules inside it are asked for by name – inside_h between the rows, inside_v between the cells.

What you want Before (≤ 0.4.x) Now (0.5.0)
Rules above and below the column header rtf_table_border(header = rtf_border(top = s, bottom = s)) style_zone(header = rtf_border(top = s, bottom = s))
A rule under the last data row rtf_table_border(last_row = rtf_border(bottom = s)) style_zone(last_row = rtf_border(bottom = s))
A rule under every data row rtf_table_border(body = rtf_border(bottom = s)) style_zone(body = rtf_border(inside_h = s))
A vertical rule at every column boundary rtf_table_border(body = rtf_border(left = s, right = s)) style_zone(body = rtf_border(left = s, right = s, inside_v = s))
A grid around every data cell rtf_table_border(body = rtf_border_box()) style_zone(body = rtf_border(top = s, bottom = s, left = s, right = s, inside_h = s, inside_v = s))
An outer frame only (four style_header() / style_body() calls on the edge columns) rtftable(border = rtf_border(top = s, bottom = s, left = s, right = s))
Frame plus a rule under every row (same four calls, plus body) rtftable(border = rtf_border(top = s, bottom = s, left = s, right = s, inside_h = s))
The clinical TFL preset border = "tfl" border = "tfl" – unchanged
A border on one cell col_cell(border = rtf_border(bottom = s)) unchanged – a cell has no inside

And the constructors that folded into rtf_border() itself:

Before (≤ 0.5.x) Now
rtf_border_none() rtf_border()
rtf_border_top() rtf_border(top = TRUE)
rtf_border_bottom() rtf_border(bottom = TRUE)
rtf_border_box() rtf_border(all = TRUE)
rtf_border_with(b, bottom = x) layer at the attach point, e.g. a second style_zone()
rtf_border_tfl() border = "tfl", or rtf_table_style_tfl()

All of these still work and warn once per session. They are removed in 0.9.0.

The first two rows change spelling but not meaning. Rows three to five change meaning as well: those are the ones where an edge used to be uniform.

Show code
s <- TRUE

# was: rtf_table_border(body = rtf_border(bottom = s))
tbl <- rtftable(df, border = "none") |>
  style_zone(body = rtf_border(inside_h = s))

# was: rtf_table_border(body = rtf_border_box())
tbl <- rtftable(df, border = "none") |>
  style_zone(body = rtf_border(top = s, bottom = s, left = s, right = s,
                               inside_h = s, inside_v = s))

Because the two readings are spelled identically, rtfreporter cannot rewrite your code for you – but it can tell you where to look. It warns once per session when it meets a border that would have rendered differently before, and naming inside_h / inside_v says which reading you mean and silences it. Use "none" when the answer is “no rule there”.