Skip to content

Commit f3ec7f7

Browse files
authored
Merge pull request #20 from humanpred/feature/table1-connector
Add table1 connector for export_tfl()
2 parents 9d72f39 + 6897d1a commit f3ec7f7

18 files changed

Lines changed: 1268 additions & 7 deletions

CLAUDE.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ annotation zones, and content areas must be independently sized and never overla
5050
| Type | R package (roxygen2, testthat) |
5151
| License | AGPL-3 |
5252
| R deps | `dplyr`, `ggplot2`, `grid`, `glue`, `rlang` |
53-
| Suggests | `flextable`, `formatters`, `gt`, `rtables`, `testthat (>= 3.0.0)`, `withr`, `knitr`, `rmarkdown`, `tibble` |
53+
| Suggests | `flextable`, `formatters`, `gt`, `rtables`, `table1`, `testthat (>= 3.0.0)`, `withr`, `knitr`, `rmarkdown`, `tibble` |
5454
| Namespace | All helpers unexported except `export_tfl`, `export_tfl_page`, `tfl_table`, `tfl_colspec` |
5555

5656
---
@@ -314,6 +314,12 @@ writetfl/
314314
│ │ .clean_flextable(),
315315
│ │ .flextable_to_grob(),
316316
│ │ .paginate_flextable()
317+
│ ├── table1.R ← export_tfl.table1(),
318+
│ │ table1_to_pagelist(),
319+
│ │ .extract_table1_annotations(),
320+
│ │ .table1_variable_groups(),
321+
│ │ .paginate_table1(),
322+
│ │ .paginate_oversized_group()
317323
│ ├── reexports.R ← re-exports unit, gpar from grid
318324
│ ├── table_columns.R ← resolve_col_specs(), compute_col_widths(),
319325
│ │ paginate_cols()
@@ -340,6 +346,7 @@ writetfl/
340346
│ ├── test-gt.R
341347
│ ├── test-rtables.R
342348
│ ├── test-flextable.R
349+
│ ├── test-table1.R
343350
│ └── test-integration.R
344351
├── vignettes/
345352
│ ├── writetfl.Rmd
@@ -349,7 +356,8 @@ writetfl/
349356
│ ├── v04-troubleshooting.Rmd
350357
│ ├── v05-gt_tables.Rmd
351358
│ ├── v06-rtables.Rmd
352-
│ └── v07-flextable.Rmd
359+
│ ├── v07-flextable.Rmd
360+
│ └── v08-table1.Rmd
353361
└── design/
354362
├── DESIGN.md
355363
├── ARCHITECTURE.md

DESCRIPTION

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Suggests:
3131
ggtibble,
3232
gt,
3333
rtables,
34+
table1,
3435
testthat (>= 3.0.0),
3536
withr,
3637
knitr,

NAMESPACE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ S3method(export_tfl,flextable)
77
S3method(export_tfl,ggtibble)
88
S3method(export_tfl,gt_tbl)
99
S3method(export_tfl,list)
10+
S3method(export_tfl,table1)
1011
S3method(export_tfl,tfl_table)
1112
S3method(print,tfl_table)
1213
export(export_tfl)

R/export_tfl.R

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@
5353
#' [flextable::add_footer_lines()]) are extracted as the footnote. The
5454
#' table is rendered via [flextable::gen_grob()]. A list of `flextable`
5555
#' objects produces one page (or more, with pagination) per table.
56+
#'
57+
#' When `x` is a `table1` object (from the \pkg{table1} package), the
58+
#' caption and footnote are extracted from the table1 object's internal
59+
#' structure. The table is converted to a flextable via [table1::t1flex()],
60+
#' preserving column labels, bold variable names, and indented summary
61+
#' statistics. Pagination is group-aware: page breaks fall between
62+
#' variable groups (label + summary rows) rather than splitting a group
63+
#' mid-way. A list of `table1` objects produces one page (or more, with
64+
#' pagination) per table.
5665
#' @param file Path to the output PDF file. Must be a single character string
5766
#' ending in `".pdf"`. Not required when `preview` is not `FALSE`.
5867
#' @param pg_width Page width in inches.
@@ -189,7 +198,19 @@ export_tfl.list <- function(
189198
pages <- unlist(lapply(x, flextable_to_pagelist, pg_width, pg_height,
190199
dots, page_num), recursive = FALSE)
191200
} else {
192-
pages <- coerce_x_to_pagelist(x)
201+
# Check if this is a list of table1 objects
202+
all_table1 <- length(x) > 0L &&
203+
all(vapply(x, inherits, logical(1L), "table1"))
204+
if (all_table1) {
205+
rlang::check_installed("table1",
206+
reason = "to export table1 tables")
207+
rlang::check_installed("flextable",
208+
reason = "to export table1 tables")
209+
pages <- unlist(lapply(x, table1_to_pagelist, pg_width, pg_height,
210+
dots, page_num), recursive = FALSE)
211+
} else {
212+
pages <- coerce_x_to_pagelist(x)
213+
}
193214
}
194215
}
195216
}

R/table1.R

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
# table1.R — S3 method and conversion for table1 objects
2+
#
3+
# Functions:
4+
# export_tfl.table1() — S3 method dispatched by export_tfl()
5+
# table1_to_pagelist() — convert a table1 to a list of page specs
6+
# .extract_table1_annotations() — extract caption and footnote
7+
# .table1_variable_groups() — identify variable-group row boundaries
8+
# .paginate_table1() — group-aware greedy pagination
9+
10+
#' @export
11+
export_tfl.table1 <- function(
12+
x,
13+
file = NULL,
14+
pg_width = 11,
15+
pg_height = 8.5,
16+
page_num = "Page {i} of {n}",
17+
preview = FALSE,
18+
...
19+
) {
20+
rlang::check_installed("table1", reason = "to export table1 tables")
21+
rlang::check_installed("flextable", reason = "to export table1 tables")
22+
dots <- list(...)
23+
.validate_export_args(page_num, preview, file)
24+
pages <- table1_to_pagelist(x, pg_width, pg_height, dots, page_num)
25+
.export_tfl_pages(pages, file, pg_width, pg_height, page_num, preview, dots)
26+
}
27+
28+
#' Convert a table1 object to a list of page specification lists
29+
#'
30+
#' Extracts caption and footnote from the table1 object's internal structure,
31+
#' converts to a flextable via [table1::t1flex()], then renders via
32+
#' [flextable::gen_grob()]. When the rendered table exceeds the available
33+
#' content height, rows are split across multiple pages using group-aware
34+
#' pagination that keeps each variable's label and summary statistics together.
35+
#'
36+
#' @param t1_obj A `table1` object.
37+
#' @param pg_width,pg_height Page dimensions in inches.
38+
#' @param dots Named list of additional arguments from `...`.
39+
#' @param page_num Glue template for page numbering (used for height calc).
40+
#' @return A list of page spec lists, each with at least `$content`.
41+
#' @keywords internal
42+
table1_to_pagelist <- function(t1_obj, pg_width = 11, pg_height = 8.5,
43+
dots = list(), page_num = "Page {i} of {n}") {
44+
annot <- .extract_table1_annotations(t1_obj)
45+
groups <- .table1_variable_groups(t1_obj)
46+
47+
# Convert to flextable — t1flex() preserves bold labels, indentation, etc.
48+
ft <- table1::t1flex(t1_obj)
49+
50+
# Clean: remove footer rows (we already extracted footnote)
51+
ft <- .clean_flextable(ft)
52+
# Clear caption (we already extracted it)
53+
ft$caption <- list(value = NULL)
54+
55+
# Measure available content area
56+
content_h <- .flextable_content_height(pg_width, pg_height, dots, page_num,
57+
annot)
58+
content_w <- .flextable_content_width(pg_width, dots)
59+
60+
# Convert to grob and measure height
61+
grob <- .flextable_to_grob(ft, content_w)
62+
grob_h <- .flextable_grob_height(grob)
63+
64+
# If the table fits on a single page, return immediately
65+
if (grob_h <= content_h) {
66+
page_spec <- list(content = grob)
67+
if (!is.null(annot$caption)) page_spec$caption <- annot$caption
68+
if (!is.null(annot$footnote)) page_spec$footnote <- annot$footnote
69+
return(list(page_spec))
70+
}
71+
72+
# Paginate: group-aware splitting
73+
ft_pages <- .paginate_table1(ft, groups, content_h, content_w)
74+
75+
lapply(ft_pages, function(ft_page) {
76+
page_grob <- .flextable_to_grob(ft_page, content_w)
77+
page_spec <- list(content = page_grob)
78+
if (!is.null(annot$caption)) page_spec$caption <- annot$caption
79+
if (!is.null(annot$footnote)) page_spec$footnote <- annot$footnote
80+
page_spec
81+
})
82+
}
83+
84+
#' Extract annotations from a table1 object
85+
#'
86+
#' Extracts caption and footnote from the internal `"obj"` attribute of a
87+
#' table1 object.
88+
#'
89+
#' @param t1_obj A `table1` object.
90+
#' @return A list with `$caption` (character or NULL) and `$footnote`
91+
#' (character or NULL).
92+
#' @keywords internal
93+
.extract_table1_annotations <- function(t1_obj) {
94+
obj <- attr(t1_obj, "obj", exact = TRUE)
95+
96+
caption <- obj$caption
97+
if (!is.null(caption) && (!nzchar(caption) || all(is.na(caption)))) {
98+
caption <- NULL
99+
}
100+
101+
footnote <- obj$footnote
102+
if (!is.null(footnote)) {
103+
footnote <- footnote[nzchar(footnote) & !is.na(footnote)]
104+
if (length(footnote) == 0L) {
105+
footnote <- NULL
106+
} else {
107+
footnote <- paste(footnote, collapse = "\n")
108+
}
109+
}
110+
111+
list(caption = caption, footnote = footnote)
112+
}
113+
114+
#' Identify variable-group row boundaries in a table1 object
115+
#'
116+
#' Each variable in a table1 output forms a "group" consisting of a bold
117+
#' variable-label row followed by indented summary-statistic rows. This
118+
#' function returns the flextable body row indices for each group, derived
119+
#' from the `contents` matrices in the table1 object's internal structure.
120+
#'
121+
#' @param t1_obj A `table1` object.
122+
#' @return A list of integer vectors, each containing the body row indices
123+
#' for one variable group (label row + summary rows).
124+
#' @keywords internal
125+
.table1_variable_groups <- function(t1_obj) {
126+
obj <- attr(t1_obj, "obj", exact = TRUE)
127+
contents <- obj$contents
128+
129+
groups <- list()
130+
cumrow <- 0L
131+
for (i in seq_along(contents)) {
132+
nr <- nrow(contents[[i]])
133+
rows <- seq(cumrow + 1L, cumrow + nr)
134+
groups <- c(groups, list(rows))
135+
cumrow <- cumrow + nr
136+
}
137+
groups
138+
}
139+
140+
#' Group-aware greedy pagination for table1 flextables
141+
#'
142+
#' Splits a table1-derived flextable across pages, keeping each variable's
143+
#' label and summary statistic rows together. If a single variable group
144+
#' exceeds the page height, falls back to row-by-row splitting within that
145+
#' group.
146+
#'
147+
#' @param ft_obj A cleaned `flextable` (converted from table1, no footer rows).
148+
#' @param groups List of integer vectors from [.table1_variable_groups()].
149+
#' @param content_h Available content height in inches.
150+
#' @param content_w Available content width in inches.
151+
#' @return A list of `flextable` objects (one per page).
152+
#' @keywords internal
153+
.paginate_table1 <- function(ft_obj, groups, content_h, content_w) {
154+
pages <- list()
155+
current_rows <- integer(0L)
156+
157+
for (grp_idx in seq_along(groups)) {
158+
candidate_rows <- c(current_rows, groups[[grp_idx]])
159+
sub_ft <- .rebuild_flextable_subset(ft_obj, candidate_rows)
160+
sub_grob <- .flextable_to_grob(sub_ft, content_w)
161+
h <- .flextable_grob_height(sub_grob)
162+
163+
if (h > content_h && length(current_rows) > 0L) {
164+
# Current group doesn't fit — finalize current page
165+
pages <- c(pages, list(.rebuild_flextable_subset(ft_obj, current_rows)))
166+
# Try the group alone
167+
grp_ft <- .rebuild_flextable_subset(ft_obj, groups[[grp_idx]])
168+
grp_grob <- .flextable_to_grob(grp_ft, content_w)
169+
grp_h <- .flextable_grob_height(grp_grob)
170+
171+
if (grp_h > content_h) {
172+
# Oversized group: fall back to row-by-row within this group
173+
row_pages <- .paginate_oversized_group(ft_obj, groups[[grp_idx]],
174+
content_h, content_w)
175+
# All but the last sub-page are complete pages
176+
for (rp_idx in seq_along(row_pages)) {
177+
if (rp_idx < length(row_pages)) {
178+
pages <- c(pages, list(row_pages[[rp_idx]]))
179+
} else {
180+
# Last sub-page becomes the start of the next accumulation
181+
current_rows <- row_pages[[rp_idx]]$body_rows
182+
}
183+
}
184+
} else {
185+
current_rows <- groups[[grp_idx]]
186+
}
187+
} else if (h > content_h && length(current_rows) == 0L) {
188+
# First group on an empty page and it still doesn't fit
189+
row_pages <- .paginate_oversized_group(ft_obj, groups[[grp_idx]],
190+
content_h, content_w)
191+
for (rp_idx in seq_along(row_pages)) {
192+
if (rp_idx < length(row_pages)) {
193+
pages <- c(pages, list(row_pages[[rp_idx]]))
194+
} else {
195+
current_rows <- row_pages[[rp_idx]]$body_rows
196+
}
197+
}
198+
} else {
199+
current_rows <- candidate_rows
200+
}
201+
}
202+
203+
if (length(current_rows) > 0L) {
204+
pages <- c(pages, list(.rebuild_flextable_subset(ft_obj, current_rows)))
205+
}
206+
207+
pages
208+
}
209+
210+
#' Paginate an oversized variable group row-by-row
211+
#'
212+
#' When a single variable group (label + summary rows) exceeds the available
213+
#' content height, falls back to row-by-row greedy splitting.
214+
#'
215+
#' @param ft_obj The full flextable object.
216+
#' @param grp_rows Integer vector of body row indices for the oversized group.
217+
#' @param content_h Available content height in inches.
218+
#' @param content_w Available content width in inches.
219+
#' @return A list of objects. Complete sub-pages are `flextable` objects.
220+
#' The last element is a list with `$body_rows` (integer vector of remaining
221+
#' row indices) for further accumulation.
222+
#' @keywords internal
223+
.paginate_oversized_group <- function(ft_obj, grp_rows, content_h, content_w) {
224+
results <- list()
225+
current_rows <- integer(0L)
226+
227+
for (row_idx in grp_rows) {
228+
candidate <- c(current_rows, row_idx)
229+
sub_ft <- .rebuild_flextable_subset(ft_obj, candidate)
230+
sub_grob <- .flextable_to_grob(sub_ft, content_w)
231+
h <- .flextable_grob_height(sub_grob)
232+
233+
if (h > content_h && length(current_rows) > 0L) {
234+
results <- c(results, list(.rebuild_flextable_subset(ft_obj,
235+
current_rows)))
236+
current_rows <- row_idx
237+
} else {
238+
current_rows <- candidate
239+
}
240+
}
241+
242+
# Last batch: return as a list with body_rows for further accumulation
243+
if (length(current_rows) > 0L) {
244+
results <- c(results, list(list(body_rows = current_rows)))
245+
}
246+
247+
results
248+
}

README.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
**Standardized table, figure, and listing output for clinical trial reporting.**
99

1010
`writetfl` produces multi-page PDF files from `ggplot2` figures, data-frame
11-
tables, `gt` tables, `rtables` tables, `flextable` tables, and other grid content with the precise,
11+
tables, `gt` tables, `rtables` tables, `flextable` tables, `table1` tables,
12+
and other grid content with the precise,
1213
composable page layouts required for clinical trial TFL deliverables and
1314
regulatory submissions. Each
1415
page is divided into up to five vertical sections — header, caption, content,
@@ -378,3 +379,36 @@ export_tfl(ft, file = "flextable_table.pdf",
378379
A list of `flextable` objects produces a multi-page PDF. See
379380
`vignette("v07-flextable")` for full details.
380381

382+
### table1 tables
383+
384+
Pass a `table1` object directly to `export_tfl()`. Column labels, bold
385+
variable names, indented summary statistics, and stratification headers are
386+
preserved via `t1flex()` conversion. Caption and footnote are extracted into
387+
writetfl's annotation zones. Pagination is group-aware: variable labels and
388+
their summary rows are kept together across page breaks.
389+
390+
```r
391+
library(table1)
392+
393+
dat <- data.frame(
394+
age = rnorm(100, 50, 10),
395+
sex = sample(c("Male", "Female"), 100, replace = TRUE),
396+
trt = rep(c("Treatment", "Placebo"), each = 50)
397+
)
398+
label(dat$age) <- "Age (years)"
399+
label(dat$sex) <- "Sex"
400+
401+
tbl <- table1(~ age + sex | trt, data = dat,
402+
caption = "Table 1. Baseline Demographics",
403+
footnote = "ITT Population")
404+
405+
export_tfl(tbl, file = "table1.pdf",
406+
header_left = "Study Report",
407+
header_rule = TRUE,
408+
footer_rule = TRUE
409+
)
410+
```
411+
412+
A list of `table1` objects produces a multi-page PDF. See
413+
`vignette("v08-table1")` for full details.
414+

0 commit comments

Comments
 (0)