“I can make one table. How do I make 50?”
Four-tier defaults
arframe resolves settings from four tiers, lowest to highest priority:
+----------------------------------------------------------------------+
| 4. Per-table verbs fr_page(font_size = 10) <- wins |
| 3. Session theme fr_theme(font_size = 9) |
| 2. Project config _arframe.yml |
| 1. Package defaults inst/defaults/_arframe.yml <- lowest|
+----------------------------------------------------------------------+
| Tier | Scope | Set by | Survives restart |
|---|---|---|---|
| Package defaults | All users | Package author | Always |
_arframe.yml |
Project/study | Lead programmer | Yes (file) |
fr_theme() |
R session | Programmer | No |
| Per-table verbs | One table | Programmer | No |
SAS: Tier 2 =
autoexec.sas+PROC TEMPLATE. Tier 3 =OPTIONS. Tier 4 = inlineSTYLE()=.
YAML configuration
Place _arframe.yml at your project root. Every table
inherits these settings:
page:
paper: letter
orientation: landscape
margins: [1.0, 0.75, 1.0, 0.75]
font_family: "Courier New"
font_size: 9
continuation: "(continued)"
header:
bold: false
valign: bottom
n_format: "{label}\n(N={n})"
pagehead:
left: "Protocol TFRM-2024-001"
right: "CONFIDENTIAL"
pagefoot:
left: "{program}"
right: "Page {thepage} of {total_pages}"
rules:
hlines: header
footnotes:
separator: false
spacing:
titles_after: 1
footnotes_before: 1
tokens:
study: "TFRM-2024-001"
cutoff: "15MAR2025"
fr_config()
cfg <- fr_config_get()
cfg$page$font_size
#> [1] 9
fr_config_reset()Custom tokens defined in YAML resolve in fr_pagehead()
and fr_pagefoot().
Session theme
fr_theme() sets session-level defaults. Call it once at
the top of your program:
fr_theme(
font_size = 9,
font_family = "Courier New",
orientation = "landscape",
hlines = "header",
pagehead = list(left = "TFRM-2024-001", right = "CONFIDENTIAL"),
pagefoot = list(left = "{program}",
right = "Page {thepage} of {total_pages}")
)Every table now inherits the theme:
spec1 <- tbl_demog |> fr_table() |> fr_titles("Table 14.1.1", "Demographics")
spec2 <- tbl_disp |> fr_table() |> fr_titles("Table 14.1.4", "Disposition")
fr_get_page(spec1)$font_size
#> [1] 9
fr_get_page(spec2)$font_size
#> [1] 9Override per-table:
spec3 <- tbl_ae_soc |>
fr_table() |>
fr_page(orientation = "portrait")
fr_get_page(spec3)$orientation
#> [1] "portrait"
fr_get_page(spec3)$font_size
#> [1] 9Recipes
Recipes capture verb chains as portable, composable objects:
company_layout <- fr_recipe(
fr_page(orientation = "landscape", font_size = 9),
fr_pagehead(left = "TFRM-2024-001", right = "CONFIDENTIAL"),
fr_pagefoot(left = "{program}",
right = "Page {thepage} of {total_pages}"),
fr_hlines("header")
)
company_layout
#>
#> ── fr_recipe (4 verbs)
#> 1. fr_page(orientation="landscape", font_size=9)
#> 2. fr_pagehead(left="TFRM-2024-001", right="CONFIDENTIAL")
#> 3. fr_pagefoot(left="{program}", right="Page {thepage} of {tot...)
#> 4. fr_hlines("header")
spec <- tbl_demog |>
fr_table() |>
fr_apply(company_layout) |>
fr_titles("Table 14.1.1", "Demographics")
fr_get_page(spec)$font_size
#> [1] 9Composing recipes
c() merges recipes. Later recipes override for replacing
verbs; appending verbs (fr_spans, fr_styles)
accumulate:
ae_style <- fr_recipe(
fr_header(bold = TRUE, align = "center"),
fr_page(continuation = "(continued)")
)
ae_combined <- c(company_layout, ae_style)
spec <- tbl_ae_soc |>
fr_table() |>
fr_apply(ae_combined) |>
fr_titles("Table 14.3.1", "AE by SOC/PT")
fr_get_page(spec)$continuation
#> [1] "(continued)"Three-layer recipe pattern
# ── Population N-counts (define once) ──
n_itt <- c(placebo = 45, zom_50mg = 45, zom_100mg = 45, total = 135)
# Layer 1: Company standard
company <- fr_recipe(
fr_page(orientation = "landscape", font_family = "Courier New", font_size = 9),
fr_pagehead(right = "CONFIDENTIAL"),
fr_pagefoot(left = "{program}", right = "Page {thepage} of {total_pages}"),
fr_hlines("header"),
fr_header(bold = TRUE, align = "center")
)
# Layer 2: Study-specific
study <- fr_recipe(
fr_pagehead(left = "Protocol TFRM-2024-001"),
fr_page(tokens = list(study = "TFRM-2024-001", cutoff = "15MAR2025"))
)
# Layer 3: Table-type (only what varies by table type)
demog_recipe <- fr_recipe(
fr_cols(.n = n_itt, .n_format = "{label}\n(N={n})"),
fr_footnotes("Percentages based on N per treatment arm.")
)
# Usage: only titles and columns are unique per table
tbl_demog |>
fr_table() |>
fr_apply(c(company, study, demog_recipe)) |>
fr_titles("Table 14.1.1", "Demographics") |>
fr_cols(group = fr_col(visible = FALSE)) |>
fr_render("output/Table_14_1_1.rtf")Batch rendering
Combine a metadata table with recipes and a loop:
metadata <- data.frame(
table_id = c("14.1.1", "14.1.4", "14.3.1", "14.2.1"),
title1 = c("Demographics", "Subject Disposition",
"AE by SOC/PT", "Time to Study Withdrawal"),
title2 = c("ITT Population", "All Randomized",
"Safety Population", "ITT Population"),
data_name = c("tbl_demog", "tbl_disp", "tbl_ae_soc", "tbl_tte"),
recipe = c("demog", "demog", "ae", "tte"),
stringsAsFactors = FALSE
)
metadata
#> table_id title1 title2 data_name recipe
#> 1 14.1.1 Demographics ITT Population tbl_demog demog
#> 2 14.1.4 Subject Disposition All Randomized tbl_disp demog
#> 3 14.3.1 AE by SOC/PT Safety Population tbl_ae_soc ae
#> 4 14.2.1 Time to Study Withdrawal ITT Population tbl_tte tte
for (i in seq_len(nrow(metadata))) {
row <- metadata[i, ]
data <- get(row$data_name)
recipe <- recipes[[row$recipe]]
data |>
fr_table() |>
fr_apply(c(company, study, recipe)) |>
fr_titles(paste("Table", row$table_id), row$title1, row$title2) |>
fr_render(sprintf("output/Table_%s.rtf", gsub("\\.", "_", row$table_id)))
}SAS: This replaces the macro library +
%includepattern. The metadata CSV is your macro call dataset, recipes are your macros, and the loop is your%DOblock.
RTF vs PDF
| Feature | RTF | |
|---|---|---|
| Engine | Direct RTF 1.9.1 | tabularray + XeLaTeX |
Footnote "last"
|
Repeats every page | Works (lastfoot) |
| Page numbers |
\chpgn fields |
\thepage / \pageref
|
| Decimal alignment | \ql\li<offset> |
\hspace{pt} + ~
|
| Fonts | Referenced by name | Embedded via fontspec |
| Group pagination | R-side pagination | longtblr |
Linux font fallback: On systems without Microsoft fonts, PDF rendering automatically falls back to Latin Modern fonts (built into tinytex/texlive). No extra font installation needed.
Custom font directory: Set the
ARFRAME_FONT_DIR environment variable to a directory
containing .ttf or .otf files. arframe passes
this to XeLaTeX via OSFONTDIR, making fonts discoverable by
family name — no system-wide installation required. This is ideal for
Docker containers, CI pipelines, or project-local font directories:
# Shell: export ARFRAME_FONT_DIR=/path/to/project/fonts
# R:
Sys.setenv(ARFRAME_FONT_DIR = "/path/to/project/fonts")
spec |> fr_page(font_family = "Courier New") |> fr_render("output.pdf")
install.packages("tinytex")
tinytex::install_tinytex()Validation and QC
Pre-render validation
spec <- tbl_demog |>
fr_table() |>
fr_titles("Table 14.1.1", "Demographics") |>
fr_cols(group = fr_col(visible = FALSE))
fr_validate(spec)fr_validate() checks columns, widths, styles, spans,
fonts, and row references. Use strict = TRUE in CI
pipelines.
Accessor-based QC
spec <- tbl_demog |>
fr_table() |>
fr_titles("Table 14.1.1", "Demographics") |>
fr_footnotes("Source: ADSL.") |>
fr_cols(group = fr_col(visible = FALSE))
stopifnot(length(fr_get_titles(spec)) == 2)
stopifnot(grepl("ADSL", fr_get_footnotes(spec)[[1]]$content))
stopifnot(fr_get_page(spec)$orientation == "landscape")Double-programming workflow
# Programmer 1
spec1 <- tbl_demog |> fr_table() |>
fr_titles("Table 14.1.1", "Demographics") |>
fr_render("output/t_14_1_1_prod.rtf")
# Programmer 2 (QC)
spec2 <- tbl_demog |> fr_table() |>
fr_titles("Table 14.1.1", "Demographics") |>
fr_render("output/t_14_1_1_qc.rtf")
# Same data + same verbs = identical outputCI/CD and Docker
# render_all.R
library(arframe)
fr_config("_arframe.yml")
metadata <- read.csv("metadata.csv", stringsAsFactors = FALSE)
for (i in seq_len(nrow(metadata))) {
row <- metadata[i, ]
data <- get(row$data_name)
recipe <- readRDS(sprintf("recipes/%s.rds", row$recipe))
data |>
fr_table() |>
fr_apply(recipe) |>
fr_titles(paste("Table", row$table_id), row$title1) |>
fr_render(sprintf("output/Table_%s.rtf", gsub("\\.", "_", row$table_id)))
}FROM rocker/r-ver:4.3.3
RUN apt-get update && apt-get install -y libxml2-dev libcurl4-openssl-dev
RUN R -e "install.packages(c('cli','glue','rlang','tidyselect','vctrs','yaml','stringi'))"
RUN R -e "install.packages('tinytex'); tinytex::install_tinytex()"
# Latin Modern fonts are built into tinytex --- no Microsoft fonts needed
# Optional: bundle project-specific fonts (e.g., Courier New)
COPY fonts/ /opt/fonts/
ENV ARFRAME_FONT_DIR=/opt/fonts