Clinical Display Conventions

Introduction

The two companion vignettes cover how a number becomes a string: vignette("format_strings") for the grammar and vignette("precision_alignment") for widths and delimiters. This one covers the rules that come from somewhere else entirely – a statistical analysis plan, a sponsor’s mock shell, a regulatory reviewing convention. They are not properties of the data. They are decisions about what the table is allowed to say.

Most of them are layer_settings() arguments, and most are one line each:

Convention Setting
Report small percentages as <1% pct_lt
Report large percentages as >99% pct_gt
Suppress the (0.0%) on zero counts zero_count_display
Show only selected levels of a variable keep_levels
Add a Missing row, or a Total that excludes it missing_count, total_row_count_missings
Put each statistic in its own column stat_columns, stats_as_columns
Change what a shift-table percent is out of shift_denom, denoms_by
Show the shift denominator as an n row denom_row, denom_row_format
Indent and wrap nested terms collapse_row_labels(), str_indent_wrap()
Anything else, applied after the build apply_conditional_format()

Small and Large Percentages

One subject out of an arm of 300 is 0.33%, and xxx.x% renders it as 0.3%. Many reviewing conventions object: the reader is meant to see that the event is rare, not to read a spuriously precise decimal. The convention is <1%, with a matching >99% at the top of the scale.

pct_lt and pct_gt implement both. Here is a two-arm safety population of 300 subjects per arm, carrying one rare event and one nearly universal one:

set.seed(7)
n_arm <- 300
subj <- data.frame(
  USUBJID = sprintf("S%04d", seq_len(2 * n_arm)),
  TRTA    = rep(c("Placebo", "Drug"), each = n_arm)
)

incidence <- list(
  "HEADACHE"                = c(Placebo = 0.31,   Drug = 0.44),
  "NAUSEA"                  = c(Placebo = 0.12,   Drug = 0.19),
  "INJECTION SITE REACTION" = c(Placebo = 0.995,  Drug = 1.00),
  "ANAPHYLACTIC REACTION"   = c(Placebo = 1/300,  Drug = 2/300)
)

ae <- do.call(rbind, lapply(names(incidence), function(term) {
  do.call(rbind, lapply(c("Placebo", "Drug"), function(arm) {
    pool <- subj$USUBJID[subj$TRTA == arm]
    k <- round(incidence[[term]][[arm]] * length(pool))
    if (k == 0) return(NULL)
    data.frame(USUBJID = pool[seq_len(k)], TRTA = arm, AEDECOD = term)
  }))
}))

ae_spec <- function(...) {
  tplyr_spec(
    cols = "TRTA",
    pop_data = pop_data(cols = c("TRTA" = "TRTA")),
    layers = tplyr_layers(
      group_count("AEDECOD",
        settings = layer_settings(
          distinct_by = "USUBJID", ...,
          format_strings = list(
            n_counts = f_str("xxx (xxx.x%)", "distinct_n", "distinct_pct")
          )
        )
      )
    )
  )
}

Without the conventions:

show_table(tplyr_build(ae_spec(), ae, pop_data = subj))
rowlabel1 res1 res2
ANAPHYLACTIC REACTION   2 (  0.7%)   1 (  0.3%)
HEADACHE 132 ( 44.0%)  93 ( 31.0%)
INJECTION SITE REACTION 300 (100.0%) 298 ( 99.3%)
NAUSEA  57 ( 19.0%)  36 ( 12.0%)

With them:

show_table(tplyr_build(ae_spec(pct_lt = 1, pct_gt = 99), ae, pop_data = subj))
rowlabel1 res1 res2
ANAPHYLACTIC REACTION   2 ( <1.0%)   1 ( <1.0%)
HEADACHE 132 ( 44.0%)  93 ( 31.0%)
INJECTION SITE REACTION 300 (100.0%) 298 (>99.0%)
NAUSEA  57 ( 19.0%)  36 ( 12.0%)

The anaphylaxis row becomes <1.0% in both arms, and the 99.3% injection-site reaction becomes >99.0%. Three behaviors are worth knowing.

The token is rendered at the format group’s own decimal width. xxx.x% produces <1.0, not <1. If the mock calls for <1, use an integer percent field:

int_spec <- tplyr_spec(
  cols = "TRTA",
  pop_data = pop_data(cols = c("TRTA" = "TRTA")),
  layers = tplyr_layers(
    group_count("AEDECOD",
      settings = layer_settings(
        distinct_by = "USUBJID",
        pct_lt = 1, pct_gt = 99,
        format_strings = list(
          n_counts = f_str("xxx (xxx%)", "distinct_n", "distinct_pct")
        )
      )
    )
  )
)

show_table(tplyr_build(int_spec, ae, pop_data = subj))
rowlabel1 res1 res2
ANAPHYLACTIC REACTION   2 (  1%)   1 ( <1%)
HEADACHE 132 ( 44%)  93 ( 31%)
INJECTION SITE REACTION 300 (100%) 298 ( 99%)
NAUSEA  57 ( 19%)  36 ( 12%)

The threshold is compared against the rounded display value, not the raw percentage. In the integer table above, the Drug arm reported 2 of 300 subjects and shows 1% rather than <1% – 0.67% rounds to 1, and 1 is not below the threshold. The rule is deliberate: a cell never claims to be below a number it visibly displays. The Placebo arm’s single subject is 0.33%, which rounds to 0, so it does get the token. And 99.3% rounds to 99, which is not above 99, so the integer table leaves it as 99.

Exactly 0% and exactly 100% are never rewritten. A zero count is zero, not “fewer than one percent”, and a universal event is 100%, not “more than ninety-nine”. The injection-site reaction stays a true 100% in the Drug arm in every table above – 100.0% where the field carries a decimal, 100% in the integer table.

Both settings target the first pct or distinct_pct group in the format string, so a cell that displays two percentages only has the leading one rewritten. Both also apply to shift layers, where the same reasoning holds.

Zero Counts

A count layer fills in every level for every column, so a category no subject fell into gets a real row rather than a gap. What that row should say is a convention. zero_count_display offers three answers:

zero_spec <- function(z) {
  tplyr_spec(
    cols = "TRT01P",
    layers = tplyr_layers(
      group_count("RACE",
        settings = layer_settings(
          zero_count_display = z,
          format_strings = list(n_counts = f_str("xx (xx.x%)", "n", "pct"))
        )
      )
    )
  )
}

show_table(tplyr_build(zero_spec("full"), tplyr_adsl),
           caption = 'zero_count_display = "full" (the default)')
zero_count_display = “full” (the default)
rowlabel1 res1 res2 res3
WHITE 78 (90.7%) 74 (88.1%) 78 (92.9%)
BLACK OR AFRICAN AMERICAN  8 ( 9.3%)  9 (10.7%)  6 ( 7.1%)
AMERICAN INDIAN OR ALASKA NATIVE  0 ( 0.0%)  1 ( 1.2%)  0 ( 0.0%)
show_table(tplyr_build(zero_spec("count_only"), tplyr_adsl),
           caption = 'zero_count_display = "count_only"')
zero_count_display = “count_only”
rowlabel1 res1 res2 res3
WHITE 78 (90.7%) 74 (88.1%) 78 (92.9%)
BLACK OR AFRICAN AMERICAN  8 ( 9.3%)  9 (10.7%)  6 ( 7.1%)
AMERICAN INDIAN OR ALASKA NATIVE  0  1 ( 1.2%)  0
show_table(tplyr_build(zero_spec("blank"), tplyr_adsl),
           caption = 'zero_count_display = "blank"')
zero_count_display = “blank”
rowlabel1 res1 res2 res3
WHITE 78 (90.7%) 74 (88.1%) 78 (92.9%)
BLACK OR AFRICAN AMERICAN  8 ( 9.3%)  9 (10.7%)  6 ( 7.1%)
AMERICAN INDIAN OR ALASKA NATIVE  1 ( 1.2%)

"full" leaves the cell alone – a full-width ` 0 ( 0.0%)`, leading pad space and all. "count_only" keeps the count field and drops everything after it, so the digit still sits under the counts above it. "blank" empties the cell.

The two suppressing modes are not interchangeable. "count_only" asserts we looked and found none; "blank" asserts nothing at all, which is why it is usually reserved for cells that are structurally inapplicable rather than merely empty.

Both shorten the string rather than padding it out, which matters if anything downstream assumes a fixed cell width:

nchar(tplyr_build(zero_spec("full"), tplyr_adsl)$res1)
#> [1] 10 10 10
nchar(tplyr_build(zero_spec("count_only"), tplyr_adsl)$res1)
#> [1] 10 10  2
nchar(tplyr_build(zero_spec("blank"), tplyr_adsl)$res1)
#> [1] 10 10  0

"blank" produces a genuinely zero-length string, not spaces – so nzchar() and trimws() checks behave as you would want, but a monospace renderer that relies on the string’s own width needs "count_only" instead.

The setting keys off the first count statistic in the format string – n or distinct_n – so on a subject-level AE table it triggers when no subject reported the term, which is the intent. A format string that references neither leaves the setting inert. It applies to shift layers too.

Showing Only Selected Levels

A flag variable summarized as a count layer produces a row for every level, including the "N" one nobody wants to print. keep_levels restricts the output to the levels you name:

spec <- tplyr_spec(
  cols = "TRT01P",
  layers = tplyr_layers(
    group_count("RACE",
      settings = layer_settings(
        keep_levels = c("WHITE", "BLACK OR AFRICAN AMERICAN"),
        format_strings = list(n_counts = f_str("xx (xx.x%)", "n", "pct"))
      )
    )
  )
)

show_table(tplyr_build(spec, tplyr_adsl))
rowlabel1 res1 res2 res3
WHITE 78 (90.7%) 74 (88.1%) 78 (92.9%)
BLACK OR AFRICAN AMERICAN  8 ( 9.3%)  9 (10.7%)  6 ( 7.1%)

keep_levels filters rows after the denominators are computed, so the percentages do not re-base. The dropped level’s subjects are still in every denominator: in the Xanomeline High Dose arm the two surviving rows sum to 98.8%, not 100%, because the one American Indian or Alaska Native subject is still counted below the line.

That is usually the correct behavior – for a flag variable, n (%) of “Yes” should be out of the whole arm. When you do want the kept levels to sum to 100%, remove the dropped subjects from the denominator too with denom_where or denom_ignore; see vignette("denom").

Missing and Total Rows

A Missing row and a Total row are display elements as much as statistics – their presence, label, and position are shell decisions.

total_row = TRUE appends a total, with total_row_label naming it. total_row_count_missings (default TRUE) decides whether missing values are included in that total. missing_count adds the missing row, keyed by label:

dat <- data.frame(
  TRT  = rep(c("Placebo", "Drug"), each = 20),
  RESP = c(rep("CR", 6), rep("PR", 5), rep("SD", 7), NA, NA,
           rep("CR", 9), rep("PR", 4), rep("SD", 6), NA)
)

spec <- tplyr_spec(
  cols = "TRT",
  layers = tplyr_layers(
    group_count("RESP",
      settings = layer_settings(
        missing_count   = list(label = "Not evaluable"),
        total_row       = TRUE,
        total_row_label = "Total evaluated",
        total_row_count_missings = FALSE,
        format_strings = list(n_counts = f_str("xx (xx.x%)", "n", "pct"))
      )
    )
  )
)

show_table(tplyr_build(spec, dat))
rowlabel1 res1 res2
CR  9 (45.0%)  6 (30.0%)
PR  4 (20.0%)  5 (25.0%)
SD  6 (30.0%)  7 (35.0%)
Not evaluable  1 ( 5.0%)  2 (10.0%)
Total evaluated 19 (95.0%) 18 (90.0%)

The missing row is always emitted once missing_count is set, and is zero-filled across every column and by group, so its presence does not depend on whether a particular arm happens to have gaps. sort_value controls where it lands relative to the category rows; it defaults to Inf, which puts it last.

missing_values handles the other kind of missing: sentinel codes such as "UNK" or "NOT DONE" that are present in the data but mean no result. Naming them folds their records into the missing row and removes their own category row, so nothing is counted twice:

dat$RESP[c(3, 25)] <- "UNK"

spec <- tplyr_spec(
  cols = "TRT",
  layers = tplyr_layers(
    group_count("RESP",
      settings = layer_settings(
        missing_count = list(label = "Not evaluable", missing_values = "UNK"),
        format_strings = list(n_counts = f_str("xx (xx.x%)", "n", "pct"))
      )
    )
  )
)

show_table(tplyr_build(spec, dat))
rowlabel1 res1 res2
CR  8 (40.0%)  5 (25.0%)
PR  4 (20.0%)  5 (25.0%)
SD  6 (30.0%)  7 (35.0%)
Not evaluable  2 (10.0%)  3 (15.0%)

There is no UNK row, and “Not evaluable” carries both the NA records and the UNK ones. On a nested layer, naming an outer-level value removes its inner rows along with it.

One Statistic Per Column

Everything so far packs several numbers into one cell. Some shells want them separated: an n (%) column and an E column under each treatment arm, or a demographics block laid out with the statistics running across the page.

Count layers: stat_columns

Replace format_strings with stat_columns, a named list of format strings. Each entry becomes its own result column within every treatment group, and the entry name becomes the column’s sub-label:

spec <- tplyr_spec(
  cols = "TRTA",
  pop_data = pop_data(cols = c("TRTA" = "TRT01A")),
  layers = tplyr_layers(
    group_count("AEBODSYS",
      settings = layer_settings(
        distinct_by = "USUBJID",
        stat_columns = list(
          "n (%)" = f_str("xxx (xx.x%)", "distinct_n", "distinct_pct"),
          "E"     = f_str("xxx", "n")
        )
      )
    )
  )
)

result <- tplyr_build(spec, tplyr_adae, pop_data = tplyr_adsl)
show_table(head(as_display(result), 6))
rowlabel1 res1 res2 res3 res4 res5 res6
CARDIAC DISORDERS   4 ( 4.7%)   5   6 ( 7.1%)   6   5 ( 6.0%)   6
CONGENITAL, FAMILIAL AND GENETIC DISORDERS   0 ( 0.0%)   0   1 ( 1.2%)   1   0 ( 0.0%)   0
GASTROINTESTINAL DISORDERS   6 ( 7.0%)   6   4 ( 4.8%)   6   3 ( 3.6%)   3
GENERAL DISORDERS AND ADMINISTRATION SITE CONDITIONS   9 (10.5%)  11  15 (17.9%)  21  18 (21.4%)  21
IMMUNE SYSTEM DISORDERS   0 ( 0.0%)   0   0 ( 0.0%)   0   1 ( 1.2%)   1
INFECTIONS AND INFESTATIONS   5 ( 5.8%)   5   4 ( 4.8%)   4   3 ( 3.6%)   3

Columns interleave arm-major, so each arm’s statistics sit adjacent. Which column is which lives on the label attributes, in the form "<arm> (N=n) | <stat>":

vapply(grep("^res", names(result), value = TRUE),
       function(nm) attr(result[[nm]], "label"), character(1))
#>                                  res1                                  res2 
#>              "Placebo (N=86) | n (%)"                  "Placebo (N=86) | E" 
#>                                  res3                                  res4 
#> "Xanomeline High Dose (N=84) | n (%)"     "Xanomeline High Dose (N=84) | E" 
#>                                  res5                                  res6 
#>  "Xanomeline Low Dose (N=84) | n (%)"      "Xanomeline Low Dose (N=84) | E"

A renderer splits those on " | " to build a two-level header spanning each arm over its sub-columns. Stat names may not themselves contain " | " or "(N=", which the label grammar reserves. See vignette("count") for more.

Descriptive layers: stats_as_columns

The desc-layer equivalent is a logical switch, stats_as_columns = TRUE. The statistics named in format_strings become columns instead of rows, so the by groups run down the page:

spec <- tplyr_spec(
  cols = "TRT01P",
  layers = tplyr_layers(
    group_desc("AGE",
      by = "AGEGR1",
      settings = layer_settings(
        stats_as_columns = TRUE,
        format_strings = list(
          "n"         = f_str("xx", "n"),
          "Mean (SD)" = f_str("xx.x (xx.xx)", "mean", "sd"),
          "Min, Max"  = f_str("xx, xx", "min", "max")
        )
      )
    )
  )
)

result <- tplyr_build(spec, tplyr_adsl)
show_table(result)
rowlabel1 res1 res2 res3 res4 res5 res6 res7 res8 res9
<65 14 61.1 ( 3.51) 52, 64 11 59.1 ( 2.91) 56, 63  8 57.1 ( 3.72) 51, 62
65-80 42 73.6 ( 4.17) 65, 80 55 74.5 ( 4.19) 65, 80 47 74.0 ( 4.34) 65, 80
>80 30 84.0 ( 2.51) 81, 89 18 83.4 ( 2.12) 81, 88 29 83.4 ( 2.08) 81, 88

One row per age group, and one column per arm-by-statistic, labelled with the same "<arm> (N=n) | <stat>" grammar as stat_columns:

vapply(grep("^res", names(result), value = TRUE),
       function(nm) attr(result[[nm]], "label"), character(1))
#>                                      res1 
#>                      "Placebo (N=86) | n" 
#>                                      res2 
#>              "Placebo (N=86) | Mean (SD)" 
#>                                      res3 
#>               "Placebo (N=86) | Min, Max" 
#>                                      res4 
#>         "Xanomeline High Dose (N=84) | n" 
#>                                      res5 
#> "Xanomeline High Dose (N=84) | Mean (SD)" 
#>                                      res6 
#>  "Xanomeline High Dose (N=84) | Min, Max" 
#>                                      res7 
#>          "Xanomeline Low Dose (N=84) | n" 
#>                                      res8 
#>  "Xanomeline Low Dose (N=84) | Mean (SD)" 
#>                                      res9 
#>   "Xanomeline Low Dose (N=84) | Min, Max"

This is the layout for a summary that reads across – a lab panel by visit, an exposure table by duration category – where the row dimension is the grouping and the statistics belong in the header.

Note that stats_as_columns produces a different shape when there is no by variable: the treatment groups become the rows and the result columns are named for the statistics directly rather than res1, res2, …, in the order the format strings declared them. as_display() handles that shape – it drops the internal ordering columns and keeps everything else – but the "<arm> | <stat>" label grammar does not apply, since the column names are the statistics. Reach for this form when you want a compact arm-by-statistic grid; for a two-level header, give the layer a by.

spec <- tplyr_spec(
  cols = "TRT01P",
  layers = tplyr_layers(
    group_desc("AGE",
      settings = layer_settings(
        stats_as_columns = TRUE,
        format_strings = list(
          "n"         = f_str("xx", "n"),
          "Mean (SD)" = f_str("xx.x (xx.xx)", "mean", "sd"),
          "Min, Max"  = f_str("xx, xx", "min", "max")
        )
      )
    )
  )
)

show_table(tplyr_build(spec, tplyr_adsl))
rowlabel1 n Mean (SD) Min, Max
Placebo (N=86) 86 75.2 ( 8.59) 52, 89
Xanomeline High Dose (N=84) 84 74.4 ( 7.89) 56, 88
Xanomeline Low Dose (N=84) 84 75.7 ( 8.29) 51, 88

Shift Table Denominators

A shift table cross-tabulates baseline against post-baseline, so the percent in each cell can mean several different things depending on what sits underneath it. Which one the table wants is a convention.

shift_denom = "total", the default, divides by the whole treatment arm – every cell is a share of all subjects, and the matrix sums to 100% per arm:

set.seed(99)
n <- 120
shift_data <- data.frame(
  USUBJID = sprintf("S%03d", seq_len(n)),
  TRTA    = rep(c("Placebo", "Drug"), each = n / 2),
  BNRIND  = factor(sample(c("LOW", "NORMAL", "HIGH"), n, TRUE, c(.15, .7, .15)),
                   levels = c("LOW", "NORMAL", "HIGH")),
  ANRIND  = factor(sample(c("LOW", "NORMAL", "HIGH"), n, TRUE, c(.2, .6, .2)),
                   levels = c("LOW", "NORMAL", "HIGH"))
)

shift_spec <- function(...) {
  tplyr_spec(
    cols = "TRTA",
    layers = tplyr_layers(
      group_shift(c(row = "BNRIND", column = "ANRIND"),
        settings = layer_settings(
          distinct_by = "USUBJID", ...,
          format_strings = list(n_counts = f_str("xx (xx.x%)", "n", "pct"))
        )
      )
    )
  )
}

show_table(tplyr_build(shift_spec(), shift_data),
           caption = 'shift_denom = "total" (the default)')
shift_denom = “total” (the default)
rowlabel1 res1 res2 res3 res4 res5 res6
LOW  0 ( 0.0%)  2 ( 3.3%)  3 ( 5.0%)  1 ( 1.7%)  6 (10.0%)  2 ( 3.3%)
NORMAL  5 ( 8.3%) 32 (53.3%) 10 (16.7%) 11 (18.3%) 24 (40.0%)  8 (13.3%)
HIGH  2 ( 3.3%)  3 ( 5.0%)  3 ( 5.0%)  0 ( 0.0%)  5 ( 8.3%)  3 ( 5.0%)

shift_denom = "column" divides by the result column group instead – the treatment arm crossed with the post-baseline category. Each result column then sums to 100%, and a cell reads “of the subjects who ended up here, this share started there”:

result <- tplyr_build(shift_spec(shift_denom = "column"), shift_data)
show_table(result, caption = 'shift_denom = "column"')
shift_denom = “column”
rowlabel1 res1 res2 res3 res4 res5 res6
LOW  0 ( 0.0%)  2 ( 5.4%)  3 (18.8%)  1 ( 8.3%)  6 (17.1%)  2 (15.4%)
NORMAL  5 (71.4%) 32 (86.5%) 10 (62.5%) 11 (91.7%) 24 (68.6%)  8 (61.5%)
HIGH  2 (28.6%)  3 ( 8.1%)  3 (18.8%)  0 ( 0.0%)  5 (14.3%)  3 (23.1%)

The (N=) values on the column labels change with it – each is now the count of subjects in that post-baseline category rather than the arm total:

vapply(grep("^res", names(result), value = TRUE),
       function(nm) attr(result[[nm]], "label"), character(1))
#>                      res1                      res2                      res3 
#>        "Drug | LOW (N=7)"    "Drug | NORMAL (N=37)"      "Drug | HIGH (N=16)" 
#>                      res4                      res5                      res6 
#>    "Placebo | LOW (N=12)" "Placebo | NORMAL (N=35)"   "Placebo | HIGH (N=13)"

The mirror-image convention – each baseline group summing to 100%, so a cell reads “of the subjects who started here, this share ended up there” – is not a shift_denom value. Name the denominator groups directly with denoms_by, listing the column variable(s) and the baseline row variable:

show_table(
  tplyr_build(shift_spec(denoms_by = c("TRTA", "BNRIND")), shift_data),
  caption = 'denoms_by = c("TRTA", "BNRIND") -- row-wise percentages'
)
denoms_by = c(“TRTA”, “BNRIND”) – row-wise percentages
rowlabel1 res1 res2 res3 res4 res5 res6
LOW  0 ( 0.0%)  2 (40.0%)  3 (60.0%)  1 (11.1%)  6 (66.7%)  2 (22.2%)
NORMAL  5 (10.6%) 32 (68.1%) 10 (21.3%) 11 (25.6%) 24 (55.8%)  8 (18.6%)
HIGH  2 (25.0%)  3 (37.5%)  3 (37.5%)  0 ( 0.0%)  5 (62.5%)  3 (37.5%)

An explicit denoms_by overrides shift_denom, and must list every variable that scopes the denominator (including any by variables) or the groups pool together. See vignette("denom").

The denominator row

Once the percentages are relative to a column group, the table has to state what each column’s denominator was. denom_row = TRUE emits those counts as an integer row above the baseline rows rather than making you recompute them. It reports the shift_denom = "column" denominator, so it pairs with that setting rather than with a row-wise denoms_by:

show_table(tplyr_build(
  shift_spec(shift_denom = "column", denom_row = TRUE),
  shift_data
))
rowlabel1 res1 res2 res3 res4 res5 res6
n          7         37         16         12         35         13
LOW  0 ( 0.0%)  2 ( 5.4%)  3 (18.8%)  1 ( 8.3%)  6 (17.1%)  2 (15.4%)
NORMAL  5 (71.4%) 32 (86.5%) 10 (62.5%) 11 (91.7%) 24 (68.6%)  8 (61.5%)
HIGH  2 (28.6%)  3 ( 8.1%)  3 (18.8%)  0 ( 0.0%)  5 (14.3%)  3 (23.1%)

denom_row_label renames it (the default is "n"), and denom_row_format gives it its own f_str so the count field can be sized independently of the shift cells:

show_table(tplyr_build(
  shift_spec(
    shift_denom     = "column",
    denom_row       = TRUE,
    denom_row_label = "n",
    denom_row_format = f_str("xxx", "n")
  ),
  shift_data
))
rowlabel1 res1 res2 res3 res4 res5 res6
n   7  37  16  12  35  13
LOW  0 ( 0.0%)  2 ( 5.4%)  3 (18.8%)  1 ( 8.3%)  6 (17.1%)  2 (15.4%)
NORMAL  5 (71.4%) 32 (86.5%) 10 (62.5%) 11 (91.7%) 24 (68.6%)  8 (61.5%)
HIGH  2 (28.6%)  3 ( 8.1%)  3 (18.8%)  0 ( 0.0%)  5 (14.3%)  3 (23.1%)

Without denom_row_format the integer is right-aligned to the full width of a formatted shift cell – ten characters for xx (xx.x%) – which leaves a wide gap in front of a two-digit count. denom_row_format takes a format string with a single numeric field and sizes the row independently, usually narrower.

Indenting and Wrapping Nested Terms

An adverse event table is hierarchical – system organ class over preferred term – and the display convention is a single row-label column with the preferred terms indented under their class. collapse_row_labels() does the merge:

spec <- tplyr_spec(
  cols = "TRTA",
  pop_data = pop_data(cols = c("TRTA" = "TRT01A")),
  layers = tplyr_layers(
    group_count(c("AEBODSYS", "AEDECOD"),
      settings = layer_settings(
        distinct_by = "USUBJID",
        zero_count_display = "count_only",
        format_strings = list(
          n_counts = f_str("xx (xx.x%)", "distinct_n", "distinct_pct")
        )
      )
    )
  )
)

result <- tplyr_build(spec, tplyr_adae, pop_data = tplyr_adsl)
collapsed <- collapse_row_labels(result, "rowlabel1", "rowlabel2",
                                 nest = TRUE, indent = "   ")
show_table(head(collapsed[, c("row_label", "res1", "res2", "res3")], 10))
row_label res1 res2 res3
CARDIAC DISORDERS  4 ( 4.7%)  6 ( 7.1%)  5 ( 6.0%)
   ATRIAL FIBRILLATION  0  0  1 ( 1.2%)
   ATRIAL FLUTTER  0  1 ( 1.2%)  0
   ATRIAL HYPERTROPHY  1 ( 1.2%)  0  0
   BUNDLE BRANCH BLOCK RIGHT  1 ( 1.2%)  0  0
   CARDIAC FAILURE CONGESTIVE  1 ( 1.2%)  0  0
   MYOCARDIAL INFARCTION  0  1 ( 1.2%)  2 ( 2.4%)
   SINUS BRADYCARDIA  0  3 ( 3.6%)  1 ( 1.2%)
   SUPRAVENTRICULAR EXTRASYSTOLES  1 ( 1.2%)  0  1 ( 1.2%)
   SUPRAVENTRICULAR TACHYCARDIA  0  0  1 ( 1.2%)

Real preferred terms then overflow the column width the shell allows. str_indent_wrap() wraps them to a width while preserving the indentation on continuation lines, and hyphenates any single word at least as long as the width:

collapsed$row_label <- str_indent_wrap(collapsed$row_label, width = 28)
cat(head(collapsed$row_label, 9), sep = "\n")
#> CARDIAC DISORDERS
#>    ATRIAL FIBRILLATION
#>    ATRIAL FLUTTER
#>    ATRIAL HYPERTROPHY
#>    BUNDLE BRANCH BLOCK
#>    RIGHT
#>    CARDIAC FAILURE
#>    CONGESTIVE
#>    MYOCARDIAL INFARCTION
#>    SINUS BRADYCARDIA
#>    SUPRAVENTRICULAR
#>    EXTRASYSTOLES

BUNDLE BRANCH BLOCK RIGHT breaks after BLOCK and its continuation line keeps the three-space preferred-term indent, so the hierarchy survives the wrap. That is why str_indent_wrap() exists rather than plain stringr::str_wrap(): it detects the existing indentation and re-applies it to every continuation line.

One quirk to know when picking width: the indent is charged against it twice, so an indented label wraps at width minus its own indent – a three-space indent at width = 28 fills only 25 characters. Set width a few characters above the stub width if you want the indented rows to reach it.

Conventions a Format String Cannot Express

Everything above is declared in the spec and applied at build time, which is where you want a display rule: the numeric data behind the cell stays intact, and the convention travels with the spec through serialization, metadata, and ARD conversion. For the rules that have no setting, apply_conditional_format() rewrites a built column based on a number found inside it.

You pick a format group – the nth number in the string, counting from the left – give a condition on it using the variable name x, and supply a replacement:

res <- tplyr_build(zero_spec("full"), tplyr_adsl)

# flag any row where the percentage clears 90%
apply_conditional_format(res$res1, format_group = 2, x > 90,
                         replacement = "(>90%)")
#> [1] "78  (>90%)" " 8 ( 9.3%)" " 0 ( 0.0%)"

With full_string = FALSE (the default) the replacement refills just that format group and is padded to preserve the original alignment. With full_string = TRUE it replaces the whole cell:

apply_conditional_format(res$res1, 2, x == 0, "    -     ", full_string = TRUE)
#> [1] "78 (90.7%)" " 8 ( 9.3%)" "    -     "

Because the condition reads the displayed number, the same function works on externally bound statistics and on risk-difference columns. str_extract_num() pulls a format group out as a number when you want to compute with it rather than replace it. Both are covered in vignette("post_processing").

Getting the Table Out

Two last conventions belong to the hand-off rather than the build.

as_display() drops the internal ordering columns and returns just what belongs on the page. labels = TRUE renames the result columns to their column-group headers:

result <- tplyr_build(zero_spec("full"), tplyr_adsl)
kable(as_display(result, labels = TRUE))
rowlabel1 Placebo (N=86) Xanomeline High Dose (N=84) Xanomeline Low Dose (N=84)
WHITE 78 (90.7%) 74 (88.1%) 78 (92.9%)
BLACK OR AFRICAN AMERICAN 8 ( 9.3%) 9 (10.7%) 6 ( 7.1%)
AMERICAN INDIAN OR ALASKA NATIVE 0 ( 0.0%) 1 ( 1.2%) 0 ( 0.0%)

And the padding that every format string in these vignettes produces is real whitespace, which HTML collapses. replace_leading_whitespace() converts leading spaces to non-breaking spaces so indentation and alignment survive a web or RTF renderer:

indented <- c("CARDIAC DISORDERS", "   ATRIAL FIBRILLATION")
nchar(indented)
#> [1] 17 22
nchar(replace_leading_whitespace(indented))
#> [1] 17 22

The character count is unchanged – each leading space became one non-breaking space. This vignette’s own tables use the same trick on their numeric columns, which is why the alignment above is visible at all.

Where to Go From Here