This article covers arframe’s internal architecture for developers who want to understand the render pipeline, write custom backends, or contribute.
Three-layer architecture
+----------------------------------------------------------------------+
| API Layer (13 files) |
| fr_table -> fr_cols -> fr_header -> fr_titles -> ... -> fr_render |
| Each verb: validate -> modify fr_spec -> return fr_spec |
+----------------------------------------------------------------------+
| Infrastructure Layer (9 files) |
| classes.R S3 constructors (fr_spec, fr_col, fr_rule, ...) |
| columns.R Width estimation, AFM metrics, auto-distribution |
| config.R YAML discovery, loading, merging |
| constants.R fr_env: fonts, paper sizes, presets, colors |
| helpers.R Error conditions, text normalisation |
| markup.R Inline rich text: fr_super() -> sentinel tokens |
| tokens.R {thepage}, {program}, {datetime} resolution |
| units.R Twips, half-points, row height calculation |
| validate.R Type-safe validation helpers |
+----------------------------------------------------------------------+
| Render Layer (8 files) |
| render.R Dispatch: finalize -> prepare_pages -> backend |
| render-common.R Shared: cell grid, borders, escaping, colors |
| render-rtf.R RTF 1.9.1 backend |
| render-latex.R tabularray/XeLaTeX backend |
| render-pdf.R PDF wrapper (generates .tex, compiles twice) |
| render-figure.R Figure rendering (single + multi-page) |
| decimal.R Stat-display decimal alignment engine |
| paginate.R R-side pagination (group-aware page breaks) |
+----------------------------------------------------------------------+
The fr_spec object
Every verb reads and writes a single S3 object:
| Field | Type | Set by |
|---|---|---|
$data |
data.frame | fr_table() |
$meta$titles |
list | fr_titles() |
$meta$footnotes |
list | fr_footnotes() |
$columns |
named list of fr_col
|
fr_cols() |
$columns_meta |
list | fr_cols() |
$header |
list | fr_header() |
$body |
list | fr_rows() |
$rules |
list of fr_rule
|
fr_hlines(), fr_vlines()
|
$cell_styles |
list | fr_styles() |
$page |
list | fr_page() |
$pagehead |
list | fr_pagehead() |
$pagefoot |
list | fr_pagefoot() |
$spacing |
list | fr_spacing() |
$decimal_geometry |
list or NULL | finalize_spec() |
$plots |
list or NULL |
fr_figure() (multi-page) |
$figure_meta |
data.frame or NULL |
fr_figure() (per-page tokens) |
The render pipeline
fr_spec
|
v
finalize_spec() <- Resolve all deferred computations
| - Build default columns for unmentioned columns
| - Distribute widths (auto/fit/equal/percent)
| - Resolve N-count header labels
| - Inject group_label header rows (if set)
| - Insert blank_after rows
| - Apply indent_by offsets (single or multi-level)
| - Compute decimal geometry
v
prepare_pages() <- Split data into page groups
| - Split by page_by values (preserving data order)
| - Each group: {label, data, n_counts}
v
calculate_col_panels() <- Handle wide tables
| - If .split=TRUE and width > page: split into panels
| - Stub columns repeat in each panel
v
render_<format>() <- Backend-specific rendering
RTF: render_rtf(spec, page_groups, col_panels, path)
LaTeX: render_latex(spec, page_groups, col_panels, path)
PDF: render_pdf() -> render_latex() + xelatex x2
finalize_spec()
Key operations:
-
Width distribution:
"auto"measures content with AFM font metrics. If total exceeds printable area, columns scale proportionally. -
N-count labels:
.n+.n_formatonfr_cols()resolve into final column labels. -
Group label injection:
group_labelinserts header rows at group boundaries, placing the group value in the target column. Style indices are remapped to account for injected rows. -
Blank rows:
blank_afterinserts empty rows at group boundaries. -
Indent:
indent_byapplies indentation — either single-level (character vector) or multi-level (named list with key/col/levels). - Decimal geometry: 15-type stat display engine analyses cell values, computes space-padded alignment offsets.
Writing a custom backend
Any backend must implement one function:
| Argument | Type | Description |
|---|---|---|
spec |
Finalised fr_spec
|
All deferred computations resolved |
page_groups |
List of lists | Each: $label, $data,
$n_counts
|
col_panels |
List of char vectors | Column names per panel |
path |
Character | Output file path |
Register it:
render_html <- function(spec, page_groups, col_panels, path) {
# ... generate HTML ...
writeLines(html, path)
path
}
fr_register_backend("html", extensions = "html", render_fn = render_html)
# Now this works:
spec |> fr_render("output.html")Shared utilities
render-common.R provides backend-agnostic helpers:
-
build_cell_grid()— data frame to rendered cell matrix -
build_header_cell_grid()— header row with resolved N-counts -
resolve_borders()— rules list to per-cell border spec -
collect_colors()— scan styles for color table -
rtf_escape_and_resolve()/latex_escape_and_resolve()— escape special chars and resolve markup sentinels
The sentinel system
Inline markup survives string operations via sentinel tokens:
User writes: "P{fr_super('a')}-value < 0.05"
|
glue evaluation: "P\x01SUPER:a\x02-value < 0.05"
|
RTF backend: "P{\super a}-value < 0.05"
LaTeX backend: "P\textsuperscript{a}-value < 0.05"
Sentinel types: SUPER, SUB,
BOLD, ITALIC, UNDERLINE,
NEWLINE, UNICODE.
AFM font metrics
Pre-parsed Adobe Font Metrics for 12 font variants in
R/sysdata.rda:
| Family | Variants |
|---|---|
| Helvetica | Regular, Bold, Oblique, BoldOblique |
| Times | Roman, Bold, Italic, BoldItalic |
| Courier | Regular, Bold, Oblique, BoldOblique |
Helvetica ‘M’ = 833 units vs ‘i’ = 222 units (3.75x). Courier is
fixed at 600 units. estimate_col_width() uses these for
accurate auto-widths.
Available backends
fr_backends()
#> format extensions description
#> 1 rtf rtf, doc Rich Text Format
#> 2 latex tex LaTeX source (tabularray)
#> 3 pdf pdf PDF via XeLaTeX