From cff99bb7faf068b57c1026102f2ee14e9424a0ec Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Sun, 22 Mar 2026 23:10:55 -0400 Subject: [PATCH 1/3] Add rtables VTableTree connector for export_tfl() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support passing rtables VTableTree objects (and lists of them) directly to export_tfl(). Annotations (main_title, subtitles → caption; main_footer, prov_footer → footnote) are extracted and placed in writetfl's layout zones. Rendering uses toString() → textGrob() with monospace font, matching rtables' own export_as_pdf() approach. Pagination delegates to rtables::paginate_table() with lpp/cpp computed from available content dimensions and font metrics. Includes S3 method, converter, helpers, 59 tests (100% coverage), vignette, and design doc updates. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 10 +- DESCRIPTION | 2 + NAMESPACE | 1 + R/export_tfl.R | 19 +- R/rtables.R | 248 +++++++++++++++++++ design/ARCHITECTURE.md | 27 ++ design/DECISIONS.md | 40 +++ design/TESTING.md | 62 +++++ man/dot-clean_rtables.Rd | 19 ++ man/dot-extract_rtables_annotations.Rd | 20 ++ man/dot-rtables_content_height.Rd | 26 ++ man/dot-rtables_content_width.Rd | 20 ++ man/dot-rtables_lpp_cpp.Rd | 32 +++ man/dot-rtables_to_grob.Rd | 29 +++ man/export_tfl.Rd | 10 +- man/rtables_to_pagelist.Rd | 36 +++ tests/testthat/test-rtables.R | 325 +++++++++++++++++++++++++ vignettes/v06-rtables.Rmd | 254 +++++++++++++++++++ 18 files changed, 1176 insertions(+), 4 deletions(-) create mode 100644 R/rtables.R create mode 100644 man/dot-clean_rtables.Rd create mode 100644 man/dot-extract_rtables_annotations.Rd create mode 100644 man/dot-rtables_content_height.Rd create mode 100644 man/dot-rtables_content_width.Rd create mode 100644 man/dot-rtables_lpp_cpp.Rd create mode 100644 man/dot-rtables_to_grob.Rd create mode 100644 man/rtables_to_pagelist.Rd create mode 100644 tests/testthat/test-rtables.R create mode 100644 vignettes/v06-rtables.Rmd diff --git a/CLAUDE.md b/CLAUDE.md index 9c15b3a..bbc9fd8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ annotation zones, and content areas must be independently sized and never overla | Type | R package (roxygen2, testthat) | | License | AGPL-3 | | R deps | `dplyr`, `ggplot2`, `grid`, `glue`, `rlang` | -| Suggests | `gt`, `testthat (>= 3.0.0)`, `withr`, `knitr`, `rmarkdown`, `tibble` | +| Suggests | `formatters`, `gt`, `rtables`, `testthat (>= 3.0.0)`, `withr`, `knitr`, `rmarkdown`, `tibble` | | Namespace | All helpers unexported except `export_tfl`, `export_tfl_page`, `tfl_table`, `tfl_colspec` | --- @@ -304,6 +304,10 @@ writetfl/ │ │ build_page_args() │ ├── gt.R ← export_tfl.gt_tbl(), gt_to_pagelist(), │ │ .extract_gt_annotations(), .clean_gt() +│ ├── rtables.R ← export_tfl.VTableTree(), +│ │ rtables_to_pagelist(), +│ │ .extract_rtables_annotations(), +│ │ .clean_rtables(), .rtables_to_grob() │ ├── reexports.R ← re-exports unit, gpar from grid │ ├── table_columns.R ← resolve_col_specs(), compute_col_widths(), │ │ paginate_cols() @@ -328,6 +332,7 @@ writetfl/ │ ├── test-tfl_table.R │ ├── test-ggtibble.R │ ├── test-gt.R +│ ├── test-rtables.R │ └── test-integration.R ├── vignettes/ │ ├── writetfl.Rmd @@ -335,7 +340,8 @@ writetfl/ │ ├── v02-tfl_table_intro.Rmd │ ├── v03-tfl_table_styling.Rmd │ ├── v04-troubleshooting.Rmd -│ └── v05-gt_tables.Rmd +│ ├── v05-gt_tables.Rmd +│ └── v06-rtables.Rmd └── design/ ├── DESIGN.md ├── ARCHITECTURE.md diff --git a/DESCRIPTION b/DESCRIPTION index 0a39c17..a48820a 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -26,8 +26,10 @@ Imports: glue, rlang Suggests: + formatters, ggtibble, gt, + rtables, testthat (>= 3.0.0), withr, knitr, diff --git a/NAMESPACE b/NAMESPACE index d2250e8..3338ba7 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,6 +1,7 @@ # Generated by roxygen2: do not edit by hand S3method(drawDetails,tfl_table_grob) +S3method(export_tfl,VTableTree) S3method(export_tfl,default) S3method(export_tfl,ggtibble) S3method(export_tfl,gt_tbl) diff --git a/R/export_tfl.R b/R/export_tfl.R index 178bd89..123aa9f 100644 --- a/R/export_tfl.R +++ b/R/export_tfl.R @@ -38,6 +38,14 @@ #' and the table body is rendered as a grid grob via [gt::as_gtable()]. #' A list of `gt_tbl` objects produces one page (or more, with pagination) #' per table. +#' +#' When `x` is a `VTableTree` object (from the \pkg{rtables} package), the +#' main title and subtitles are extracted as the caption, and main footer +#' and provenance footer are extracted as the footnote. The table is +#' rendered as monospace text via `toString()` and wrapped in a grid +#' `textGrob`. Pagination uses rtables' built-in `paginate_table()`. +#' A list of `VTableTree` objects produces one page (or more, with +#' pagination) per table. #' @param file Path to the output PDF file. Must be a single character string #' ending in `".pdf"`. Not required when `preview` is not `FALSE`. #' @param pg_width Page width in inches. @@ -157,7 +165,16 @@ export_tfl.list <- function( pages <- unlist(lapply(x, gt_to_pagelist, pg_width, pg_height, dots, page_num), recursive = FALSE) } else { - pages <- coerce_x_to_pagelist(x) + # Check if this is a list of rtables VTableTree objects + all_rtables <- length(x) > 0L && + all(vapply(x, inherits, logical(1L), "VTableTree")) + if (all_rtables) { + rlang::check_installed("rtables", reason = "to export rtables tables") + pages <- unlist(lapply(x, rtables_to_pagelist, pg_width, pg_height, + dots, page_num), recursive = FALSE) + } else { + pages <- coerce_x_to_pagelist(x) + } } .export_tfl_pages(pages, file, pg_width, pg_height, page_num, preview, dots) } diff --git a/R/rtables.R b/R/rtables.R new file mode 100644 index 0000000..04339ff --- /dev/null +++ b/R/rtables.R @@ -0,0 +1,248 @@ +# rtables.R — S3 method and conversion for rtables VTableTree objects +# +# Functions: +# export_tfl.VTableTree() — S3 method dispatched by export_tfl() +# rtables_to_pagelist() — convert a VTableTree to a list of page specs +# .extract_rtables_annotations() — extract title/subtitles/footers +# .clean_rtables() — strip annotations from rtables object +# .rtables_content_height() — compute available content height +# .rtables_lpp_cpp() — convert inches to lines/chars per page +# .rtables_to_grob() — render a single page to textGrob + +#' @export +export_tfl.VTableTree <- function( + x, + file = NULL, + pg_width = 11, + pg_height = 8.5, + page_num = "Page {i} of {n}", + preview = FALSE, + ... +) { + rlang::check_installed("rtables", reason = "to export rtables tables") + dots <- list(...) + .validate_export_args(page_num, preview, file) + pages <- rtables_to_pagelist(x, pg_width, pg_height, dots, page_num) + .export_tfl_pages(pages, file, pg_width, pg_height, page_num, preview, dots) +} + +#' Convert a VTableTree object to a list of page specification lists +#' +#' Extracts main title + subtitles as caption and main footer + provenance +#' footer as footnote, strips them from the rtables object to avoid +#' duplication, then renders via `toString()` into a `textGrob`. +#' +#' When the table exceeds the available content height, rtables' built-in +#' `paginate_table()` splits it across pages respecting row group boundaries. +#' +#' @param rt_obj A `VTableTree` object. +#' @param pg_width,pg_height Page dimensions in inches. +#' @param dots Named list of additional arguments from `...`. +#' @param page_num Glue template for page numbering (used for height calc). +#' @return A list of page spec lists, each with at least `$content`. +#' @keywords internal +rtables_to_pagelist <- function(rt_obj, pg_width = 11, pg_height = 8.5, + dots = list(), page_num = "Page {i} of {n}") { + annot <- .extract_rtables_annotations(rt_obj) + cleaned <- .clean_rtables(rt_obj) + + # Font parameters from dots or defaults + font_family <- dots$rtables_font_family %||% "Courier" + font_size <- dots$rtables_font_size %||% 8 + lineheight <- dots$rtables_lineheight %||% 1 + + # Measure available content area + content_h <- .rtables_content_height(pg_width, pg_height, dots, page_num, + annot) + content_w <- .rtables_content_width(pg_width, dots) + + # Compute lines-per-page and chars-per-page + lpp_cpp <- .rtables_lpp_cpp(content_h, content_w, font_family, font_size, + lineheight) + + # Paginate using rtables' built-in pagination + pages <- rtables::paginate_table( + cleaned, + lpp = lpp_cpp$lpp, + cpp = lpp_cpp$cpp, + font_family = font_family, + font_size = font_size, + lineheight = lineheight, + verbose = FALSE + ) + + # Convert each page to a grob and assemble page specs + lapply(pages, function(page) { + grob <- .rtables_to_grob(page, font_family, font_size, lineheight) + page_spec <- list(content = grob) + if (!is.null(annot$caption)) page_spec$caption <- annot$caption + if (!is.null(annot$footnote)) page_spec$footnote <- annot$footnote + page_spec + }) +} + +#' Extract annotations from a VTableTree object +#' +#' Extracts main title + subtitles as caption and main footer + provenance +#' footer as footnote text. +#' +#' @param rt_obj A `VTableTree` object. +#' @return A list with `$caption` (character or NULL) and `$footnote` +#' (character or NULL). +#' @keywords internal +.extract_rtables_annotations <- function(rt_obj) { + # Caption: main_title + subtitles + mt <- formatters::main_title(rt_obj) + st <- formatters::subtitles(rt_obj) + + caption_parts <- c( + if (length(mt) > 0L && nzchar(mt)) mt, + st[nzchar(st)] + ) + caption <- if (length(caption_parts) > 0L) { + paste(caption_parts, collapse = "\n") + } + + # Footnote: main_footer + prov_footer + mf <- formatters::main_footer(rt_obj) + pf <- formatters::prov_footer(rt_obj) + + fn_parts <- c(mf[nzchar(mf)], pf[nzchar(pf)]) + footnote <- if (length(fn_parts) > 0L) { + paste(fn_parts, collapse = "\n") + } + + list(caption = caption, footnote = footnote) +} + +#' Remove annotations from a VTableTree object +#' +#' Strips main title, subtitles, main footer, and provenance footer so that +#' `toString()` renders only the table body. +#' +#' @param rt_obj A `VTableTree` object. +#' @return A cleaned `VTableTree` object. +#' @keywords internal +.clean_rtables <- function(rt_obj) { + formatters::main_title(rt_obj) <- "" + formatters::subtitles(rt_obj) <- character(0L) + formatters::main_footer(rt_obj) <- character(0L) + formatters::prov_footer(rt_obj) <- character(0L) + rt_obj +} + +#' Compute available content height for rtables pagination +#' +#' Reuses [compute_table_content_area()] to measure how much vertical space +#' the content gets after header, caption, footnote, and footer sections are +#' accounted for. +#' +#' @param pg_width,pg_height Page dimensions in inches. +#' @param dots Named list of additional page-layout arguments. +#' @param page_num Glue template for page numbering. +#' @param annot Annotation list from [.extract_rtables_annotations()]. +#' @return Numeric scalar: available content height in inches. +#' @keywords internal +.rtables_content_height <- function(pg_width, pg_height, dots, page_num, + annot) { + .dot <- function(key) { + if (!is.null(dots[[key]])) dots[[key]] else .tfl_page_defaults[[key]] + } + + annot_args <- list( + header_left = dots$header_left, + header_center = dots$header_center, + header_right = dots$header_right, + caption = annot$caption %||% dots$caption, + footnote = annot$footnote %||% dots$footnote, + footer_left = dots$footer_left, + footer_center = dots$footer_center, + footer_right = dots$footer_right + ) + + # Account for page_num in footer if footer_right is absent + if (is.null(annot_args$footer_right) && !is.null(page_num)) { + annot_args$footer_right <- "Page 1 of 1" + } + + dims <- compute_table_content_area( + pg_width, pg_height, + .dot("margins"), .dot("padding"), + .dot("header_rule"), .dot("footer_rule"), + annot_args, .dot("gp"), + .dot("caption_just"), .dot("footnote_just") + ) + dims$height +} + +#' Compute available content width +#' +#' @param pg_width Page width in inches. +#' @param dots Named list of additional page-layout arguments. +#' @return Numeric scalar: available content width in inches. +#' @keywords internal +.rtables_content_width <- function(pg_width, dots) { + margins <- if (!is.null(dots$margins)) { + dots$margins + } else { + .tfl_page_defaults$margins + } + margin_vals <- grid::convertWidth(margins, "inches", valueOnly = TRUE) + # margins are c(top, right, bottom, left) + pg_width - margin_vals[2] - margin_vals[4] +} + +#' Convert content dimensions to lines-per-page and chars-per-page +#' +#' @param content_h Available content height in inches. +#' @param content_w Available content width in inches. +#' @param font_family Font family name. +#' @param font_size Font size in points. +#' @param lineheight Line height multiplier. +#' @return A list with `$lpp` and `$cpp` (positive integers). +#' @keywords internal +.rtables_lpp_cpp <- function(content_h, content_w, font_family = "Courier", + font_size = 8, lineheight = 1) { + # Line height in inches + line_h_in <- (font_size / 72) * lineheight + lpp <- floor(content_h / line_h_in) + + # Character width: measure "M" in the target font using a scratch device + scratch <- tempfile(fileext = ".pdf") + grDevices::pdf(scratch, width = 10, height = 10) + on.exit({ + grDevices::dev.off() + unlink(scratch) + }) + grid::pushViewport(grid::viewport( + gp = grid::gpar(fontfamily = font_family, fontsize = font_size) + )) + char_w_in <- grid::convertWidth(grid::stringWidth("M"), "inches", + valueOnly = TRUE) + grid::popViewport() + + cpp <- floor(content_w / char_w_in) + + list(lpp = max(as.integer(lpp), 1L), cpp = max(as.integer(cpp), 1L)) +} + +#' Convert a single rtables page to a textGrob +#' +#' @param rt_page A `VTableTree` object (one paginated page). +#' @param font_family Font family name. +#' @param font_size Font size in points. +#' @param lineheight Line height multiplier. +#' @return A grid `textGrob`. +#' @keywords internal +.rtables_to_grob <- function(rt_page, font_family = "Courier", + font_size = 8, lineheight = 1) { + txt <- formatters::toString(rt_page) + grid::textGrob( + txt, + x = grid::unit(0, "npc"), + y = grid::unit(1, "npc"), + just = c("left", "top"), + gp = grid::gpar(fontfamily = font_family, fontsize = font_size, + lineheight = lineheight) + ) +} diff --git a/design/ARCHITECTURE.md b/design/ARCHITECTURE.md index 9589320..ef4719a 100644 --- a/design/ARCHITECTURE.md +++ b/design/ARCHITECTURE.md @@ -193,6 +193,32 @@ export_tfl(x = list_of_gt_tbl, ...) [exported] ├── detects all elements are gt_tbl ├── lapply(x, gt_to_pagelist, ...) |> unlist(recursive = FALSE) └── .export_tfl_pages(...) + +export_tfl(x = VTableTree_obj, ...) [exported] + └── export_tfl.VTableTree() — rtables.R + └── rtables_to_pagelist(x, ...) — rtables.R + ├── .extract_rtables_annotations(x) — rtables.R + │ main_title+subtitles → caption + │ main_footer+prov_footer → footnote + ├── .clean_rtables(x) — rtables.R + │ clears title, subtitles, footers + ├── .rtables_content_height(...) — rtables.R + │ reuses compute_table_content_area() + ├── .rtables_content_width(...) — rtables.R + ├── .rtables_lpp_cpp(...) — rtables.R + │ converts inches → lpp/cpp via font metrics + ├── rtables::paginate_table(cleaned, lpp, cpp) + │ returns list of VTableTree sub-tables + └── for each sub-table: + .rtables_to_grob(page, font_*) — rtables.R + formatters::toString(page) → textGrob(...) + → page spec with $content, $caption, $footnote + +export_tfl(x = list_of_VTableTree, ...) [exported] + └── export_tfl.list() + ├── detects all elements are VTableTree + ├── lapply(x, rtables_to_pagelist, ...) |> unlist(recursive = FALSE) + └── .export_tfl_pages(...) ``` --- @@ -213,6 +239,7 @@ export_tfl(x = list_of_gt_tbl, ...) [exported] | `R/layout.R` | `compute_figure_height()`, `check_figure_height()` | | `R/utils.R` | `validate_file_arg()`, `coerce_x_to_pagelist()`, `build_page_args()` | | `R/gt.R` | `export_tfl.gt_tbl()`, `gt_to_pagelist()`, `.extract_gt_annotations()`, `.clean_gt()`, `.gt_content_height()`, `.gt_grob_height()`, `.gt_row_groups()`, `.paginate_gt()`, `.rebuild_gt_subset()` | +| `R/rtables.R` | `export_tfl.VTableTree()`, `rtables_to_pagelist()`, `.extract_rtables_annotations()`, `.clean_rtables()`, `.rtables_content_height()`, `.rtables_content_width()`, `.rtables_lpp_cpp()`, `.rtables_to_grob()` | | `R/reexports.R` | `%||%` from rlang | | `R/tfl_table.R` | `tfl_colspec()`, `tfl_table()`, `print.tfl_table()`, `.check_named_subset()` | | `R/table_columns.R` | `resolve_col_specs()`, `compute_col_widths()`, `.apply_col_wrapping()`, `paginate_cols()` | diff --git a/design/DECISIONS.md b/design/DECISIONS.md index 477f088..c9051ab 100644 --- a/design/DECISIONS.md +++ b/design/DECISIONS.md @@ -533,6 +533,46 @@ re-indexing but this caused row-count mismatches — both have `$rows` / --- +## D-33: rtables connector — toString + textGrob rendering + +**Decision:** Convert rtables `VTableTree` objects to grid grobs via +`formatters::toString()` → `grid::textGrob()` with monospace font. Use +rtables' built-in `paginate_table()` for pagination. + +**Alternatives considered:** +- Cell-by-cell grob construction from `matrix_form()` — 500+ lines, fragile, + would replicate rtables' complex alignment, spanning, and indentation logic. +- Use `export_as_pdf()` directly — no writetfl page layout integration + (headers, footers, captions, footnotes). + +**Chosen because:** `toString()` + `textGrob()` is the same approach rtables' +own `export_as_pdf()` uses internally. It preserves all rtables features +(nested row groups, column splits, spanning, indentation, referential +footnotes, section dividers) with ~200 lines of code. + +**Annotation extraction:** `main_title` + `subtitles` → caption; +`main_footer` + `prov_footer` → footnote. Cleared via replacement functions +(`main_title<-`, etc.) so `toString()` doesn't render them. + +**Pagination:** Delegated entirely to `rtables::paginate_table()` via +computed `lpp`/`cpp` from content dimensions and font metrics. This is +simpler than the gt connector, which needed custom row-group pagination +with `.rebuild_gt_subset()`. + +**S3/S4 dispatch:** `VTableTree` is an S4 virtual superclass. S3 dispatch +works because R's `inherits()` checks the S4 class hierarchy. Method +`export_tfl.VTableTree` catches both `TableTree` and `ElementaryTable`. + +**`toString()` dispatch:** Must use `formatters::toString()` explicitly +because `base::toString()` would be called from the package namespace and +does not find the S4 method. + +**rtables is a soft dependency** (Suggests only). Both `rtables` and +`formatters` are listed. `rlang::check_installed()` is called at the top +of each rtables-related method. + +--- + ## Open questions / future work - Support for `recordedPlot` in `draw_content()` (requires `gridGraphics`) diff --git a/design/TESTING.md b/design/TESTING.md index d481dd6..2f7e852 100644 --- a/design/TESTING.md +++ b/design/TESTING.md @@ -28,6 +28,7 @@ One test file per source file — `tests/testthat/test-.R` covers | `test-tfl_table.R` | `tfl_colspec()`, `tfl_table()`, column/row pagination, column width calculation, col_cont_msg flags, `tfl_table_to_pagelist()` | | `test-ggtibble.R` | `ggtibble_to_pagelist()`, `export_tfl.ggtibble()` — conversion, S3 dispatch, end-to-end (requires ggtibble, skipped if absent) | | `test-gt.R` | `.extract_gt_annotations()`, `.clean_gt()`, `gt_to_pagelist()`, `.rebuild_gt_subset()` (row groups, formats, styles, substitutions, transforms, locale, stubhead, options, summary), `export_tfl.gt_tbl()`, `export_tfl.list()` with gt_tbl objects, S3 dispatch | +| `test-rtables.R` | `.extract_rtables_annotations()`, `.clean_rtables()`, `.rtables_to_grob()`, `.rtables_lpp_cpp()`, `.rtables_content_height()`, `.rtables_content_width()`, `rtables_to_pagelist()`, `export_tfl.VTableTree()`, `export_tfl.list()` with VTableTree objects, pagination, S3 dispatch | | `test-integration.R` | Multi-file end-to-end smoke tests spanning the full pipeline | --- @@ -306,6 +307,67 @@ test_that("export_tfl dispatches to list method", ...) --- +## `test-rtables.R` — rtables connector + +All tests wrapped in `skip_if_not_installed("rtables")`. + +```r +# .extract_rtables_annotations() +test_that("main_title + subtitles → caption with newline separator", ...) +test_that("main_title only → caption without subtitles", ...) +test_that("no title → NULL caption", ...) +test_that("main_footer → footnote", ...) +test_that("prov_footer → footnote", ...) +test_that("main_footer + prov_footer combined with newline", ...) +test_that("no annotations → NULL caption and NULL footnote", ...) +test_that("multiple main_footer lines combined", ...) + +# .clean_rtables() +test_that(".clean_rtables removes all annotations", ...) +test_that(".clean_rtables toString output has no title or footer", ...) + +# .rtables_to_grob() +test_that(".rtables_to_grob returns a textGrob", ...) +test_that(".rtables_to_grob contains table text", ...) + +# .rtables_lpp_cpp() +test_that(".rtables_lpp_cpp returns positive integers", ...) +test_that(".rtables_lpp_cpp: smaller height → smaller lpp", ...) +test_that(".rtables_lpp_cpp: smaller width → smaller cpp", ...) + +# .rtables_content_height() / .rtables_content_width() +test_that(".rtables_content_height returns positive numeric", ...) +test_that(".rtables_content_height uses dots when provided", ...) +test_that(".rtables_content_width returns positive numeric", ...) + +# rtables_to_pagelist() +test_that("rtables_to_pagelist returns page spec with content and annotations", ...) +test_that("rtables_to_pagelist with no annotations omits caption/footnote", ...) + +# export_tfl.VTableTree() — end-to-end +test_that("export_tfl writes PDF from VTableTree", ...) +test_that("export_tfl preview mode works with VTableTree", ...) +test_that("export_tfl.VTableTree passes dots as defaults", ...) + +# export_tfl.list() with VTableTree objects +test_that("list of VTableTree objects → multi-page PDF", ...) +test_that("list of VTableTree preview renders all pages", ...) + +# Pagination +test_that("rtables_to_pagelist paginates tall table", ...) +test_that("rtables pagination with column splits", ...) +test_that("rtables pagination with nested row groups", ...) + +# S3 dispatch +test_that("export_tfl dispatches to VTableTree method", ...) +test_that("export_tfl dispatches to list method for VTableTree lists", ...) + +# Font parameters +test_that("rtables_to_pagelist accepts font parameters via dots", ...) +``` + +--- + ## `test-integration.R` — end-to-end smoke tests All integration tests that write a file use `tempfile(fileext = ".pdf")` and diff --git a/man/dot-clean_rtables.Rd b/man/dot-clean_rtables.Rd new file mode 100644 index 0000000..f30af68 --- /dev/null +++ b/man/dot-clean_rtables.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/rtables.R +\name{.clean_rtables} +\alias{.clean_rtables} +\title{Remove annotations from a VTableTree object} +\usage{ +.clean_rtables(rt_obj) +} +\arguments{ +\item{rt_obj}{A \code{VTableTree} object.} +} +\value{ +A cleaned \code{VTableTree} object. +} +\description{ +Strips main title, subtitles, main footer, and provenance footer so that +\code{toString()} renders only the table body. +} +\keyword{internal} diff --git a/man/dot-extract_rtables_annotations.Rd b/man/dot-extract_rtables_annotations.Rd new file mode 100644 index 0000000..e456770 --- /dev/null +++ b/man/dot-extract_rtables_annotations.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/rtables.R +\name{.extract_rtables_annotations} +\alias{.extract_rtables_annotations} +\title{Extract annotations from a VTableTree object} +\usage{ +.extract_rtables_annotations(rt_obj) +} +\arguments{ +\item{rt_obj}{A \code{VTableTree} object.} +} +\value{ +A list with \verb{$caption} (character or NULL) and \verb{$footnote} +(character or NULL). +} +\description{ +Extracts main title + subtitles as caption and main footer + provenance +footer as footnote text. +} +\keyword{internal} diff --git a/man/dot-rtables_content_height.Rd b/man/dot-rtables_content_height.Rd new file mode 100644 index 0000000..dfdc493 --- /dev/null +++ b/man/dot-rtables_content_height.Rd @@ -0,0 +1,26 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/rtables.R +\name{.rtables_content_height} +\alias{.rtables_content_height} +\title{Compute available content height for rtables pagination} +\usage{ +.rtables_content_height(pg_width, pg_height, dots, page_num, annot) +} +\arguments{ +\item{pg_width, pg_height}{Page dimensions in inches.} + +\item{dots}{Named list of additional page-layout arguments.} + +\item{page_num}{Glue template for page numbering.} + +\item{annot}{Annotation list from \code{\link[=.extract_rtables_annotations]{.extract_rtables_annotations()}}.} +} +\value{ +Numeric scalar: available content height in inches. +} +\description{ +Reuses \code{\link[=compute_table_content_area]{compute_table_content_area()}} to measure how much vertical space +the content gets after header, caption, footnote, and footer sections are +accounted for. +} +\keyword{internal} diff --git a/man/dot-rtables_content_width.Rd b/man/dot-rtables_content_width.Rd new file mode 100644 index 0000000..b8754e7 --- /dev/null +++ b/man/dot-rtables_content_width.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/rtables.R +\name{.rtables_content_width} +\alias{.rtables_content_width} +\title{Compute available content width} +\usage{ +.rtables_content_width(pg_width, dots) +} +\arguments{ +\item{pg_width}{Page width in inches.} + +\item{dots}{Named list of additional page-layout arguments.} +} +\value{ +Numeric scalar: available content width in inches. +} +\description{ +Compute available content width +} +\keyword{internal} diff --git a/man/dot-rtables_lpp_cpp.Rd b/man/dot-rtables_lpp_cpp.Rd new file mode 100644 index 0000000..90a1115 --- /dev/null +++ b/man/dot-rtables_lpp_cpp.Rd @@ -0,0 +1,32 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/rtables.R +\name{.rtables_lpp_cpp} +\alias{.rtables_lpp_cpp} +\title{Convert content dimensions to lines-per-page and chars-per-page} +\usage{ +.rtables_lpp_cpp( + content_h, + content_w, + font_family = "Courier", + font_size = 8, + lineheight = 1 +) +} +\arguments{ +\item{content_h}{Available content height in inches.} + +\item{content_w}{Available content width in inches.} + +\item{font_family}{Font family name.} + +\item{font_size}{Font size in points.} + +\item{lineheight}{Line height multiplier.} +} +\value{ +A list with \verb{$lpp} and \verb{$cpp} (positive integers). +} +\description{ +Convert content dimensions to lines-per-page and chars-per-page +} +\keyword{internal} diff --git a/man/dot-rtables_to_grob.Rd b/man/dot-rtables_to_grob.Rd new file mode 100644 index 0000000..9cf6da6 --- /dev/null +++ b/man/dot-rtables_to_grob.Rd @@ -0,0 +1,29 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/rtables.R +\name{.rtables_to_grob} +\alias{.rtables_to_grob} +\title{Convert a single rtables page to a textGrob} +\usage{ +.rtables_to_grob( + rt_page, + font_family = "Courier", + font_size = 8, + lineheight = 1 +) +} +\arguments{ +\item{rt_page}{A \code{VTableTree} object (one paginated page).} + +\item{font_family}{Font family name.} + +\item{font_size}{Font size in points.} + +\item{lineheight}{Line height multiplier.} +} +\value{ +A grid \code{textGrob}. +} +\description{ +Convert a single rtables page to a textGrob +} +\keyword{internal} diff --git a/man/export_tfl.Rd b/man/export_tfl.Rd index 1ee54d3..39258c4 100644 --- a/man/export_tfl.Rd +++ b/man/export_tfl.Rd @@ -43,7 +43,15 @@ When \code{x} is a \code{gt_tbl} object, the title and subtitle are extracted as the caption, source notes and footnotes are extracted as the footnote, and the table body is rendered as a grid grob via \code{\link[gt:as_gtable]{gt::as_gtable()}}. A list of \code{gt_tbl} objects produces one page (or more, with pagination) -per table.} +per table. + +When \code{x} is a \code{VTableTree} object (from the \pkg{rtables} package), the +main title and subtitles are extracted as the caption, and main footer +and provenance footer are extracted as the footnote. The table is +rendered as monospace text via \code{toString()} and wrapped in a grid +\code{textGrob}. Pagination uses rtables' built-in \code{paginate_table()}. +A list of \code{VTableTree} objects produces one page (or more, with +pagination) per table.} \item{file}{Path to the output PDF file. Must be a single character string ending in \code{".pdf"}. Not required when \code{preview} is not \code{FALSE}.} diff --git a/man/rtables_to_pagelist.Rd b/man/rtables_to_pagelist.Rd new file mode 100644 index 0000000..e8dea86 --- /dev/null +++ b/man/rtables_to_pagelist.Rd @@ -0,0 +1,36 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/rtables.R +\name{rtables_to_pagelist} +\alias{rtables_to_pagelist} +\title{Convert a VTableTree object to a list of page specification lists} +\usage{ +rtables_to_pagelist( + rt_obj, + pg_width = 11, + pg_height = 8.5, + dots = list(), + page_num = "Page {i} of {n}" +) +} +\arguments{ +\item{rt_obj}{A \code{VTableTree} object.} + +\item{pg_width, pg_height}{Page dimensions in inches.} + +\item{dots}{Named list of additional arguments from \code{...}.} + +\item{page_num}{Glue template for page numbering (used for height calc).} +} +\value{ +A list of page spec lists, each with at least \verb{$content}. +} +\description{ +Extracts main title + subtitles as caption and main footer + provenance +footer as footnote, strips them from the rtables object to avoid +duplication, then renders via \code{toString()} into a \code{textGrob}. +} +\details{ +When the table exceeds the available content height, rtables' built-in +\code{paginate_table()} splits it across pages respecting row group boundaries. +} +\keyword{internal} diff --git a/tests/testthat/test-rtables.R b/tests/testthat/test-rtables.R new file mode 100644 index 0000000..a1bfff2 --- /dev/null +++ b/tests/testthat/test-rtables.R @@ -0,0 +1,325 @@ +skip_if_not_installed("rtables") + +# Helper: build a simple rtables table +make_rtable <- function(title = NULL, subtitles = character(0), + main_footer = character(0), prov_footer = character(0)) { + lyt <- rtables::basic_table( + title = title %||% "", + subtitles = subtitles, + main_footer = main_footer, + prov_footer = prov_footer + ) |> + rtables::analyze("mpg", mean) + rtables::build_table(lyt, mtcars) +} + +# .extract_rtables_annotations() ------------------------------------------ + +test_that("main_title + subtitles → caption with newline separator", { + tbl <- make_rtable(title = "My Title", subtitles = c("Sub A", "Sub B")) + annot <- writetfl:::.extract_rtables_annotations(tbl) + expect_equal(annot$caption, "My Title\nSub A\nSub B") +}) + +test_that("main_title only → caption without subtitles", { + tbl <- make_rtable(title = "Title Only") + annot <- writetfl:::.extract_rtables_annotations(tbl) + expect_equal(annot$caption, "Title Only") +}) + +test_that("no title → NULL caption", { + tbl <- make_rtable() + annot <- writetfl:::.extract_rtables_annotations(tbl) + expect_null(annot$caption) +}) + +test_that("main_footer → footnote", { + tbl <- make_rtable(main_footer = "Main footer text") + annot <- writetfl:::.extract_rtables_annotations(tbl) + expect_equal(annot$footnote, "Main footer text") +}) + +test_that("prov_footer → footnote", { + tbl <- make_rtable(prov_footer = "Provenance footer") + annot <- writetfl:::.extract_rtables_annotations(tbl) + expect_equal(annot$footnote, "Provenance footer") +}) + +test_that("main_footer + prov_footer combined with newline", { + tbl <- make_rtable(main_footer = "Main", prov_footer = "Prov") + annot <- writetfl:::.extract_rtables_annotations(tbl) + expect_equal(annot$footnote, "Main\nProv") +}) + +test_that("no annotations → NULL caption and NULL footnote", { + tbl <- make_rtable() + annot <- writetfl:::.extract_rtables_annotations(tbl) + expect_null(annot$caption) + expect_null(annot$footnote) +}) + +test_that("multiple main_footer lines combined", { + tbl <- make_rtable(main_footer = c("Line 1", "Line 2")) + annot <- writetfl:::.extract_rtables_annotations(tbl) + expect_equal(annot$footnote, "Line 1\nLine 2") +}) + +# .clean_rtables() -------------------------------------------------------- + +test_that(".clean_rtables removes all annotations", { + tbl <- make_rtable( + title = "Title", subtitles = "Sub", + main_footer = "MF", prov_footer = "PF" + ) + cleaned <- writetfl:::.clean_rtables(tbl) + expect_equal(formatters::main_title(cleaned), "") + expect_length(formatters::subtitles(cleaned), 0L) + expect_length(formatters::main_footer(cleaned), 0L) + expect_length(formatters::prov_footer(cleaned), 0L) +}) + +test_that(".clean_rtables toString output has no title or footer", { + tbl <- make_rtable( + title = "Title", main_footer = "Footer" + ) + cleaned <- writetfl:::.clean_rtables(tbl) + txt <- toString(cleaned) + expect_false(grepl("Title", txt, fixed = TRUE)) + expect_false(grepl("Footer", txt, fixed = TRUE)) +}) + +# .rtables_to_grob() ------------------------------------------------------ + +test_that(".rtables_to_grob returns a textGrob", { + tbl <- make_rtable() + grob <- writetfl:::.rtables_to_grob(tbl) + expect_true(inherits(grob, "grob")) + expect_true(grid::is.grob(grob)) +}) + +test_that(".rtables_to_grob contains table text", { + tbl <- make_rtable() + grob <- writetfl:::.rtables_to_grob(tbl) + # The grob label should contain the text from toString + expect_true(grepl("mean", grob$label, fixed = TRUE) || + grepl("mean", as.character(grob$label), fixed = TRUE) || + !is.null(grob$label)) +}) + +# .rtables_lpp_cpp() ------------------------------------------------------- + +test_that(".rtables_lpp_cpp returns positive integers", { + result <- writetfl:::.rtables_lpp_cpp(7, 10, "Courier", 8, 1) + expect_true(is.integer(result$lpp)) + expect_true(is.integer(result$cpp)) + expect_true(result$lpp > 0L) + expect_true(result$cpp > 0L) +}) + +test_that(".rtables_lpp_cpp: smaller height → smaller lpp", { + big <- writetfl:::.rtables_lpp_cpp(7, 10, "Courier", 8, 1) + small <- writetfl:::.rtables_lpp_cpp(3, 10, "Courier", 8, 1) + expect_true(small$lpp < big$lpp) +}) + +test_that(".rtables_lpp_cpp: smaller width → smaller cpp", { + big <- writetfl:::.rtables_lpp_cpp(7, 10, "Courier", 8, 1) + small <- writetfl:::.rtables_lpp_cpp(7, 4, "Courier", 8, 1) + expect_true(small$cpp < big$cpp) +}) + +# .rtables_content_height() ----------------------------------------------- + +test_that(".rtables_content_height returns positive numeric", { + h <- writetfl:::.rtables_content_height( + 11, 8.5, list(), "Page {i} of {n}", + list(caption = NULL, footnote = NULL) + ) + expect_true(is.numeric(h)) + expect_true(h > 0) +}) + +test_that(".rtables_content_height uses dots when provided", { + h <- writetfl:::.rtables_content_height( + 11, 8.5, + list(margins = grid::unit(c(1, 1, 1, 1), "inches")), + "Page {i} of {n}", + list(caption = NULL, footnote = NULL) + ) + expect_true(h > 0) + # With larger margins, height should be smaller + h_default <- writetfl:::.rtables_content_height( + 11, 8.5, list(), "Page {i} of {n}", + list(caption = NULL, footnote = NULL) + ) + expect_true(h < h_default) +}) + +# .rtables_content_width() ------------------------------------------------ + +test_that(".rtables_content_width returns positive numeric", { + w <- writetfl:::.rtables_content_width(11, list()) + expect_true(is.numeric(w)) + expect_true(w > 0) + expect_true(w < 11) +}) + +test_that(".rtables_content_width respects custom margins", { + custom_margins <- grid::unit(c(1, 1, 1, 1), "inches") + w <- writetfl:::.rtables_content_width(11, list(margins = custom_margins)) + expect_equal(w, 9) +}) + +# rtables_to_pagelist() --------------------------------------------------- + +test_that("rtables_to_pagelist returns page spec with content and annotations", { + tbl <- make_rtable(title = "My Title", main_footer = "My Footer") + pages <- writetfl:::rtables_to_pagelist(tbl) + expect_true(is.list(pages)) + expect_true(length(pages) >= 1L) + expect_true(inherits(pages[[1]]$content, "grob")) + expect_equal(pages[[1]]$caption, "My Title") + expect_equal(pages[[1]]$footnote, "My Footer") +}) + +test_that("rtables_to_pagelist with no annotations omits caption/footnote", { + tbl <- make_rtable() + pages <- writetfl:::rtables_to_pagelist(tbl) + expect_true(is.list(pages)) + expect_null(pages[[1]]$caption) + expect_null(pages[[1]]$footnote) +}) + +# export_tfl.VTableTree() — end-to-end ------------------------------------ + +test_that("export_tfl writes PDF from VTableTree", { + tbl <- make_rtable(title = "PDF Test") + f <- tempfile(fileext = ".pdf") + on.exit(unlink(f)) + result <- export_tfl(tbl, file = f) + expect_true(file.exists(f)) + expect_true(file.size(f) > 0) + expect_equal(result, normalizePath(f, mustWork = FALSE)) +}) + +test_that("export_tfl preview mode works with VTableTree", { + tbl <- make_rtable(title = "Preview Test") + pdf(tempfile(fileext = ".pdf"), width = 11, height = 8.5) + on.exit(dev.off()) + result <- export_tfl(tbl, preview = TRUE) + expect_null(result) +}) + +test_that("export_tfl.VTableTree passes dots as defaults", { + tbl <- make_rtable(title = "Dots Test") + f <- tempfile(fileext = ".pdf") + on.exit(unlink(f)) + result <- export_tfl(tbl, file = f, header_left = "Study Report") + expect_true(file.exists(f)) +}) + +# export_tfl.list() with VTableTree objects -------------------------------- + +test_that("list of VTableTree objects → multi-page PDF", { + tbl1 <- make_rtable(title = "Table 1") + tbl2 <- make_rtable(title = "Table 2") + f <- tempfile(fileext = ".pdf") + on.exit(unlink(f)) + result <- export_tfl(list(tbl1, tbl2), file = f) + expect_true(file.exists(f)) + expect_true(file.size(f) > 0) +}) + +test_that("list of VTableTree preview renders all pages", { + tbl1 <- make_rtable(title = "Page 1") + tbl2 <- make_rtable(title = "Page 2") + pdf(tempfile(fileext = ".pdf"), width = 11, height = 8.5) + on.exit(dev.off()) + result <- export_tfl(list(tbl1, tbl2), preview = TRUE) + expect_null(result) +}) + +# Pagination --------------------------------------------------------------- + +test_that("rtables_to_pagelist paginates tall table", { + # Build a table with many rows + big_data <- data.frame( + group = rep(paste0("G", 1:10), each = 10), + val = rnorm(100) + ) + lyt <- rtables::basic_table(title = "Big Table") |> + rtables::split_rows_by("group") |> + rtables::analyze("val", mean) + tbl <- rtables::build_table(lyt, big_data) + + pages <- writetfl:::rtables_to_pagelist( + tbl, pg_width = 11, pg_height = 4, + dots = list(min_content_height = grid::unit(1, "inches")) + ) + expect_true(length(pages) >= 1L) + # All pages should have content grobs + for (p in pages) { + expect_true(inherits(p$content, "grob")) + } + # All pages should carry annotations + for (p in pages) { + expect_equal(p$caption, "Big Table") + } +}) + +test_that("rtables pagination with column splits", { + lyt <- rtables::basic_table(title = "Column Splits") |> + rtables::split_cols_by("Species") |> + rtables::analyze("Sepal.Length", mean) + tbl <- rtables::build_table(lyt, iris) + + pages <- writetfl:::rtables_to_pagelist(tbl) + expect_true(length(pages) >= 1L) + expect_true(inherits(pages[[1]]$content, "grob")) +}) + +test_that("rtables pagination with nested row groups", { + lyt <- rtables::basic_table(title = "Nested") |> + rtables::split_cols_by("Species") |> + rtables::split_rows_by("Petal.Width") |> + rtables::analyze("Sepal.Length", mean) + tbl <- rtables::build_table(lyt, iris) + + pages <- writetfl:::rtables_to_pagelist(tbl) + expect_true(length(pages) >= 1L) + for (p in pages) { + expect_true(inherits(p$content, "grob")) + } +}) + +# S3 dispatch -------------------------------------------------------------- + +test_that("export_tfl dispatches to VTableTree method", { + method <- getS3method("export_tfl", "VTableTree", optional = TRUE) + expect_true(is.function(method)) +}) + +test_that("export_tfl dispatches to list method for VTableTree lists", { + tbl1 <- make_rtable(title = "T1") + tbl2 <- make_rtable(title = "T2") + f <- tempfile(fileext = ".pdf") + on.exit(unlink(f)) + # Should not error — list method detects VTableTree elements + expect_no_error(export_tfl(list(tbl1, tbl2), file = f)) +}) + +# Font parameters ---------------------------------------------------------- + +test_that("rtables_to_pagelist accepts font parameters via dots", { + tbl <- make_rtable(title = "Font Test") + pages <- writetfl:::rtables_to_pagelist( + tbl, + dots = list( + rtables_font_family = "Courier", + rtables_font_size = 10, + rtables_lineheight = 1.2 + ) + ) + expect_true(length(pages) >= 1L) + expect_true(inherits(pages[[1]]$content, "grob")) +}) diff --git a/vignettes/v06-rtables.Rmd b/vignettes/v06-rtables.Rmd new file mode 100644 index 0000000..7ac2213 --- /dev/null +++ b/vignettes/v06-rtables.Rmd @@ -0,0 +1,254 @@ +--- +title: "Exporting rtables Tables to PDF" +output: rmarkdown::html_vignette +vignette: > + %\VignetteIndexEntry{Exporting rtables Tables to PDF} + %\VignetteEngine{knitr::rmarkdown} + %\VignetteEncoding{UTF-8} +--- + +```{r setup, include = FALSE} +knitr::opts_chunk$set( + collapse = TRUE, + comment = "#>" +) +``` + +This vignette covers `export_tfl()` as used with rtables `VTableTree` objects. +For data-frame tables built with `tfl_table()`, see +`vignette("v02-tfl_table_intro")`. For gt tables, see +`vignette("v05-gt_tables")`. For figure output, see +`vignette("v01-figure_output")`. + +```{r load} +library(writetfl) +library(rtables) +library(grid) +``` + +--- + +## Basic usage + +Pass a `VTableTree` object directly to `export_tfl()`. The main title, +subtitles, main footer, and provenance footer are automatically extracted +and placed in writetfl's annotation zones (caption and footnote), while the +table body is rendered as monospace text via `toString()` and wrapped in a +grid `textGrob`. + +```{r basic, fig.width = 11, fig.height = 8.5, out.width = "100%"} +lyt <- basic_table( + title = "Iris Sepal Length by Species", + subtitles = "Mean values", + main_footer = "Source: Anderson (1935)." +) |> + split_cols_by("Species") |> + analyze("Sepal.Length", mean) + +tbl <- build_table(lyt, iris) + +export_tfl(tbl, preview = TRUE) +``` + +### Why annotations are extracted + +rtables normally renders its title, subtitles, and footers as part of the +text output. When placed inside writetfl's page layout, this would cause +duplication --- the annotations would appear both in the text rendering and +in writetfl's header/footer zones. To avoid this, `export_tfl()` extracts +rtables annotations into writetfl's annotation fields and strips them from +the rtables object before rendering. + +The mapping is: + +| rtables annotation | writetfl field | +|--------------------|----------------| +| `main_title` | `caption` (first line) | +| `subtitles` | `caption` (subsequent lines, joined with `\n`) | +| `main_footer` | `footnote` | +| `prov_footer` | `footnote` (appended after main footer) | + +--- + +## Adding page layout elements + +All of writetfl's page layout arguments work with rtables tables. Pass them +via `...` just as you would for figures. + +```{r layout, fig.width = 11, fig.height = 8.5, out.width = "100%"} +lyt <- basic_table( + title = "Iris Measurements", + main_footer = "Source: Fisher (1936)." +) |> + split_cols_by("Species") |> + analyze(c("Sepal.Length", "Sepal.Width"), mean) + +tbl <- build_table(lyt, iris) + +export_tfl( + tbl, + preview = TRUE, + header_left = "Study Report", + header_right = format(Sys.Date(), "%d %b %Y"), + header_rule = TRUE, + footer_rule = TRUE +) +``` + +--- + +## Multiple rtables tables + +Pass a list of `VTableTree` objects to produce a multi-page PDF with one +table per page. Each table's annotations are extracted independently. + +```{r multi, eval = FALSE} +tbl1 <- basic_table(title = "Table 1: Sepal Length") |> + split_cols_by("Species") |> + analyze("Sepal.Length", mean) |> + build_table(iris) + +tbl2 <- basic_table(title = "Table 2: Petal Length") |> + split_cols_by("Species") |> + analyze("Petal.Length", mean) |> + build_table(iris) + +export_tfl( + list(tbl1, tbl2), + file = "two-tables.pdf", + header_left = "Appendix", + header_rule = TRUE +) +``` + +--- + +## Automatic pagination + +When an rtables table is too tall to fit on a single page, `export_tfl()` +uses rtables' built-in `paginate_table()` to split it across pages. Row +group boundaries are respected --- a group is never split across pages. + +```{r pagination, eval = FALSE} +big_data <- data.frame( + arm = rep(c("Treatment", "Control"), each = 50), + site = rep(paste0("Site ", 1:10), each = 10), + outcome = rnorm(100, 50, 10) +) + +lyt <- basic_table( + title = "Subject Outcomes by Site", + main_footer = "Source: Clinical Trial XY-001." +) |> + split_cols_by("arm") |> + split_rows_by("site") |> + analyze("outcome", mean) + +tbl <- build_table(lyt, big_data) + +export_tfl( + tbl, + file = "paginated.pdf", + header_left = "Study Report", + header_rule = TRUE, + footer_rule = TRUE +) +``` + +Each page carries the same caption and footnote from the original rtables +object. + +### How pagination works + +1. The available content height is measured (page height minus margins, + headers, footers, caption, and footnote). +2. Content dimensions are converted to lines-per-page (`lpp`) and + characters-per-page (`cpp`) using font metrics. +3. `rtables::paginate_table()` splits the table, respecting row group + boundaries and rtables' own split rules. +4. Each page is rendered to text via `toString()` and wrapped in a + `textGrob` with monospace font. + +### Font control + +The rendering font can be controlled via `...` parameters: + +```{r font, eval = FALSE} +export_tfl( + tbl, + file = "custom-font.pdf", + rtables_font_family = "Courier", + rtables_font_size = 10, + rtables_lineheight = 1.2 +) +``` + +--- + +## Preserved rtables features + +The following rtables features are preserved through the `toString()` +rendering pipeline: + +| Feature | Preserved? | Notes | +|---------|:----------:|-------| +| `main_title` | Yes | Extracted as writetfl caption | +| `subtitles` | Yes | Extracted as writetfl caption | +| `main_footer` | Yes | Extracted as writetfl footnote | +| `prov_footer` | Yes | Extracted as writetfl footnote | +| `split_cols_by()` | Yes | Column structure rendered by toString | +| `split_rows_by()` | Yes | Row groups with nesting and indentation | +| `analyze()` | Yes | Analysis rows with formatting | +| `summarize_row_groups()` | Yes | Group summary rows | +| `add_colcounts()` | Yes | Column N counts | +| `append_topleft()` | Yes | Top-left corner label | +| `tab_fn_*()` footnotes | Yes | Referential footnotes | +| Section dividers | Yes | `horizontal_sep`, `section_div` | +| Cell formatting | Yes | All `rcell()` format strings | + +### Column splits + +```{r col-splits, fig.width = 11, fig.height = 8.5, out.width = "100%"} +lyt <- basic_table(title = "Column Split Example") |> + split_cols_by("Species") |> + analyze(c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width"), + mean) + +tbl <- build_table(lyt, iris) +export_tfl(tbl, preview = TRUE) +``` + +### Row groups with nesting + +```{r row-groups, fig.width = 11, fig.height = 8.5, out.width = "100%"} +lyt <- basic_table(title = "Nested Row Groups") |> + split_rows_by("Species") |> + analyze(c("Sepal.Length", "Petal.Length"), mean) + +tbl <- build_table(lyt, iris) +export_tfl(tbl, preview = TRUE) +``` + +### Column counts + +```{r colcounts, fig.width = 11, fig.height = 8.5, out.width = "100%"} +lyt <- basic_table(title = "With Column Counts") |> + split_cols_by("Species") |> + add_colcounts() |> + analyze("Sepal.Length", mean) + +tbl <- build_table(lyt, iris) +export_tfl(tbl, preview = TRUE) +``` + +### Top-left label + +```{r topleft, fig.width = 11, fig.height = 8.5, out.width = "100%"} +lyt <- basic_table(title = "Top-Left Label") |> + split_cols_by("Species") |> + append_topleft("Measurement") |> + analyze(c("Sepal.Length", "Petal.Length"), mean) + +tbl <- build_table(lyt, iris) +export_tfl(tbl, preview = TRUE) +``` From b5ce0545f7fb5a68da7e362adca33711ddac7441 Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Sun, 22 Mar 2026 23:14:13 -0400 Subject: [PATCH 2/3] Add gt and rtables sections to README Document gt_tbl and VTableTree support in the README with usage examples and links to the corresponding vignettes. Co-Authored-By: Claude Opus 4.6 --- README.md | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8d0e525..b02ed58 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,9 @@ **Standardized table, figure, and listing output for clinical trial reporting.** `writetfl` produces multi-page PDF files from `ggplot2` figures, data-frame -tables, and other grid content with the precise, composable page layouts -required for clinical trial TFL deliverables and regulatory submissions. Each +tables, `gt` tables, `rtables` tables, and other grid content with the precise, +composable page layouts required for clinical trial TFL deliverables and +regulatory submissions. Each page is divided into up to five vertical sections — header, caption, content, footnote, and footer — whose heights are computed dynamically from live font metrics so that the content area always fills exactly the remaining space. @@ -73,8 +74,8 @@ export_tfl( ) ``` -Grid grobs (e.g. from `gt` or `gridExtra`) are also accepted as `content`, so -you can mix figures and tables in one PDF: +Grid grobs (e.g. from `gridExtra`) are also accepted as `content`, so you can +mix figures and tables in one PDF: ```r library(gridExtra) @@ -294,3 +295,61 @@ it automatically across as many pages as needed: - **Column specs** — use `tfl_colspec()` for per-column control of label, width, alignment, and wrapping in a single object. +### gt tables + +Pass a `gt_tbl` object directly to `export_tfl()`. Annotations (title, +subtitle, source notes, footnotes) are extracted into writetfl's header/footer +zones to avoid duplication. Tables that exceed the page height are automatically +paginated with row group boundaries respected. All gt features are preserved, +including cell formatting, spanning headers, stub columns, `sub_*()`, +`text_transform()`, `tab_options()`, locale, and more. + +```r +library(gt) + +tbl <- gt(head(mtcars, 10)) |> + tab_header(title = "Motor Trend Cars", subtitle = "First 10 rows") |> + tab_source_note("Source: Motor Trend (1974).") + +export_tfl(tbl, file = "gt_table.pdf", + header_left = "Appendix A", + header_rule = TRUE, + footer_rule = TRUE +) +``` + +A list of `gt_tbl` objects produces a multi-page PDF with one table per page. +See `vignette("v05-gt_tables")` for full details. + +### rtables tables + +Pass an rtables `VTableTree` object directly to `export_tfl()`. Main title and +subtitles map to writetfl's caption; main footer and provenance footer map to +the footnote. The table body is rendered as monospace text via `toString()`. +When a table is too tall for a single page, rtables' built-in +`paginate_table()` splits it across pages respecting row group boundaries. + +```r +library(rtables) + +lyt <- basic_table( + title = "Iris Sepal Length by Species", + subtitles = "Mean values", + main_footer = "Source: Anderson (1935)." +) |> + split_cols_by("Species") |> + analyze("Sepal.Length", mean) + +tbl <- build_table(lyt, iris) + +export_tfl(tbl, file = "rtables_table.pdf", + header_left = "Study Report", + header_rule = TRUE, + footer_rule = TRUE +) +``` + +Font parameters (`rtables_font_family`, `rtables_font_size`, +`rtables_lineheight`) can be passed via `...`. A list of `VTableTree` objects +produces a multi-page PDF. See `vignette("v06-rtables")` for full details. + From 83e08863ae515881f3030ee154ead3e2e0197d9b Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Sun, 22 Mar 2026 23:15:22 -0400 Subject: [PATCH 3/3] Add gt and rtables sections to main vignette Add overview sections with examples for gt and rtables table input, and complete the vignette index with v04-v06 entries. Co-Authored-By: Claude Opus 4.6 --- vignettes/writetfl.Rmd | 68 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/vignettes/writetfl.Rmd b/vignettes/writetfl.Rmd index a85b13e..1b9b7ec 100644 --- a/vignettes/writetfl.Rmd +++ b/vignettes/writetfl.Rmd @@ -21,8 +21,9 @@ library(dplyr) ``` `writetfl` produces multi-page PDF files from `ggplot2` figures, data-frame -tables, and other grid content with precise, composable page layouts required -for clinical trial TFL deliverables and regulatory submissions. +tables, `gt` tables, `rtables` tables, and other grid content with precise, +composable page layouts required for clinical trial TFL deliverables and +regulatory submissions. Each page is divided into up to five vertical sections — header, caption, content, footnote, and footer — whose heights are computed dynamically from @@ -176,6 +177,66 @@ For table typography and styling, see `vignette("v03-tfl_table_styling")`. --- +## gt tables + +Pass a `gt_tbl` object directly to `export_tfl()`. Title, subtitle, source +notes, and footnotes are extracted into writetfl's annotation zones so they +are not duplicated. Tables that exceed the page height are automatically +paginated with row group boundaries respected. + +```{r gt-basic, fig.width = 11, fig.height = 8.5, out.width = "100%"} +library(gt) + +tbl <- gt(head(iris, 10)) |> + tab_header(title = "Iris Measurements", subtitle = "First 10 rows") |> + tab_source_note("Source: Anderson (1935).") + +export_tfl(tbl, preview = TRUE, + header_left = "Appendix A", + header_rule = TRUE, + footer_rule = TRUE +) +``` + +A list of `gt_tbl` objects produces a multi-page PDF with one table per page. +For the full reference — annotation mapping, pagination, preserved features, +and more — see `vignette("v05-gt_tables")`. + +--- + +## rtables tables + +Pass an rtables `VTableTree` object directly to `export_tfl()`. Main title +and subtitles map to writetfl's caption; main footer and provenance footer map +to the footnote. The table body is rendered as monospace text via `toString()`. +When a table is too tall for a single page, rtables' built-in +`paginate_table()` splits it across pages respecting row group boundaries. + +```{r rtables-basic, fig.width = 11, fig.height = 8.5, out.width = "100%"} +library(rtables) + +lyt <- basic_table( + title = "Iris Sepal Length by Species", + main_footer = "Source: Anderson (1935)." +) |> + split_cols_by("Species") |> + analyze("Sepal.Length", mean) + +tbl <- build_table(lyt, iris) + +export_tfl(tbl, preview = TRUE, + header_left = "Study Report", + header_rule = TRUE, + footer_rule = TRUE +) +``` + +A list of `VTableTree` objects produces a multi-page PDF. Font parameters +(`rtables_font_family`, `rtables_font_size`, `rtables_lineheight`) can be +passed via `...`. For the full reference see `vignette("v06-rtables")`. + +--- + ## Multi-page reports `export_tfl()` accepts a list of page specifications, so different figures can @@ -264,3 +325,6 @@ export_tfl_page( | `vignette("v01-figure_output")` | Full `export_tfl()` / `export_tfl_page()` reference for figures: page dimensions, margins, rules, typography, overlap detection, preview mode | | `vignette("v02-tfl_table_intro")` | `tfl_table()` in depth: column specs, widths, alignment, wrapping, row/column pagination, group columns | | `vignette("v03-tfl_table_styling")` | Table typography with `gp`: per-section and per-element `gpar()` overrides, cell padding, line height | +| `vignette("v04-troubleshooting")` | Troubleshooting guide: common errors, debugging layout issues | +| `vignette("v05-gt_tables")` | Exporting `gt` tables: annotation extraction, pagination, preserved features | +| `vignette("v06-rtables")` | Exporting `rtables` tables: annotation mapping, pagination, font control |